Merge pull request #41 from outis1one/claude/fix-ai-paint-tool-yz6q8
Fix selection tools, add float selection, aspect ratio lock, U2net au…
This commit is contained in:
@@ -203,6 +203,32 @@ async def remove_background_base64(request: RemoveBackgroundRequest):
|
||||
_u2net_model = None
|
||||
|
||||
|
||||
async def _download_u2net_model(models_dir):
|
||||
"""Auto-download U2Net model (lightweight version ~4MB)"""
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
models_dir = Path(models_dir)
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download lightweight u2netp model (only 4MB)
|
||||
url = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx"
|
||||
dest_path = models_dir / "u2netp.onnx"
|
||||
|
||||
print(f"Downloading U2Net model from {url}...")
|
||||
|
||||
def download_progress(count, block_size, total_size):
|
||||
if total_size > 0:
|
||||
percent = min(100, count * block_size * 100 // total_size)
|
||||
if count % 100 == 0:
|
||||
print(f" Download progress: {percent}%")
|
||||
|
||||
urllib.request.urlretrieve(url, str(dest_path), download_progress)
|
||||
print(f"U2Net model downloaded to {dest_path}")
|
||||
|
||||
return dest_path
|
||||
|
||||
|
||||
async def _remove_background_u2net(img: Image.Image) -> bytes:
|
||||
"""
|
||||
Remove background using U2Net model directly.
|
||||
@@ -225,10 +251,25 @@ async def _remove_background_u2net(img: Image.Image) -> bytes:
|
||||
u2net_path = alt_path
|
||||
break
|
||||
|
||||
if not u2net_path.exists():
|
||||
# Try to auto-download the model
|
||||
print("U2Net model not found, attempting to download...")
|
||||
try:
|
||||
await _download_u2net_model(models_dir)
|
||||
# Check again
|
||||
for alt_name in ['u2net.onnx', 'u2netp.onnx', 'u2net.pth']:
|
||||
alt_path = models_dir / alt_name
|
||||
if alt_path.exists():
|
||||
u2net_path = alt_path
|
||||
break
|
||||
except Exception as download_error:
|
||||
print(f"Auto-download failed: {download_error}")
|
||||
|
||||
if not u2net_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"U2Net model not found at {u2net_path}. "
|
||||
"Download from: https://github.com/xuebinqin/U-2-Net"
|
||||
"U2Net model not found. To fix this, run:\n"
|
||||
" docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py\n"
|
||||
"Or manually download from: https://github.com/danielgatis/rembg/releases"
|
||||
)
|
||||
|
||||
# Load model if not cached
|
||||
|
||||
@@ -83,8 +83,10 @@ config.TOOLS = [
|
||||
{
|
||||
name: 'select',
|
||||
title: 'Select object tool',
|
||||
on_activate: 'on_activate',
|
||||
attributes: {
|
||||
auto_select: true,
|
||||
keep_ratio: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ var template = `
|
||||
<div class="row">
|
||||
<span class="trn label">Height:</span>
|
||||
<input type="number" id="detail_height" step="any" />
|
||||
<button class="extra trn" type="button" id="toggle_aspect_lock" title="Lock Aspect Ratio" style="font-size:16px;">🔗</button>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="row">
|
||||
@@ -124,6 +125,8 @@ class GUI_details_class {
|
||||
this.Helper = new Helper_class();
|
||||
this.layer_details_active = false;
|
||||
this.Tools_translate = new Tools_translate_class();
|
||||
this.aspect_locked = true; // Default to locked for image layers
|
||||
this.aspect_ratio = 1; // Will be calculated from layer dimensions
|
||||
}
|
||||
|
||||
render_main_details() {
|
||||
@@ -139,6 +142,7 @@ class GUI_details_class {
|
||||
this.render_general('y', events);
|
||||
this.render_general('width', events);
|
||||
this.render_general('height', events);
|
||||
this.render_aspect_lock(events);
|
||||
|
||||
this.render_general('rotate', events);
|
||||
this.render_general('opacity', events);
|
||||
@@ -287,6 +291,69 @@ class GUI_details_class {
|
||||
}
|
||||
}
|
||||
|
||||
render_aspect_lock(events) {
|
||||
var _this = this;
|
||||
var layer = config.layer;
|
||||
var lockBtn = document.getElementById('toggle_aspect_lock');
|
||||
|
||||
if (!lockBtn) return;
|
||||
|
||||
// Update aspect ratio from current layer dimensions
|
||||
if (layer && layer.width && layer.height) {
|
||||
this.aspect_ratio = layer.width / layer.height;
|
||||
}
|
||||
|
||||
// Update button appearance based on lock state
|
||||
if (this.aspect_locked) {
|
||||
lockBtn.style.background = '#4a4';
|
||||
lockBtn.title = 'Aspect Ratio Locked - Click to Unlock';
|
||||
} else {
|
||||
lockBtn.style.background = '';
|
||||
lockBtn.title = 'Aspect Ratio Unlocked - Click to Lock';
|
||||
}
|
||||
|
||||
if (events) {
|
||||
lockBtn.addEventListener('click', function() {
|
||||
_this.aspect_locked = !_this.aspect_locked;
|
||||
|
||||
// Update aspect ratio when locking
|
||||
if (_this.aspect_locked && config.layer) {
|
||||
_this.aspect_ratio = config.layer.width / config.layer.height;
|
||||
}
|
||||
|
||||
_this.render_aspect_lock(false);
|
||||
});
|
||||
|
||||
// Override width change to update height when locked
|
||||
var widthInput = document.getElementById('detail_width');
|
||||
var heightInput = document.getElementById('detail_height');
|
||||
|
||||
widthInput.addEventListener('input', function(e) {
|
||||
if (_this.aspect_locked && config.layer) {
|
||||
var units = _this.Tools_settings.get_setting('default_units');
|
||||
var resolution = _this.Tools_settings.get_setting('resolution');
|
||||
var newWidth = _this.Helper.get_internal_unit(this.value, units, resolution);
|
||||
var newHeight = newWidth / _this.aspect_ratio;
|
||||
|
||||
heightInput.value = _this.Helper.get_user_unit(newHeight, units, resolution);
|
||||
config.layer.height = newHeight;
|
||||
}
|
||||
});
|
||||
|
||||
heightInput.addEventListener('input', function(e) {
|
||||
if (_this.aspect_locked && config.layer) {
|
||||
var units = _this.Tools_settings.get_setting('default_units');
|
||||
var resolution = _this.Tools_settings.get_setting('resolution');
|
||||
var newHeight = _this.Helper.get_internal_unit(this.value, units, resolution);
|
||||
var newWidth = newHeight * _this.aspect_ratio;
|
||||
|
||||
widthInput.value = _this.Helper.get_user_unit(newWidth, units, resolution);
|
||||
config.layer.width = newWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render_general_param(key, events) {
|
||||
var layer = config.layer;
|
||||
|
||||
|
||||
@@ -327,10 +327,14 @@ class Brush_select_class extends Base_tools_class {
|
||||
canvas.width = config.layer.width_original;
|
||||
canvas.height = config.layer.height_original;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
// Scale the mask image to match the layer dimensions
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
resolve(canvas);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.onerror = function(e) {
|
||||
console.error('Failed to decode mask image:', e);
|
||||
reject(e);
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + maskBase64;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Base_layers_class from './../core/base-layers.js';
|
||||
import Base_selection_class from './../core/base-selection.js';
|
||||
import Helper_class from './../libs/helpers.js';
|
||||
import Dialog_class from './../libs/popup.js';
|
||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
|
||||
class Select_tool_class extends Base_tools_class {
|
||||
|
||||
@@ -37,6 +38,132 @@ class Select_tool_class extends Base_tools_class {
|
||||
this.Base_selection = new Base_selection_class(ctx, sel_config, this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the Select tool is activated
|
||||
* If there's an AI selection, offer to float it so it can be moved
|
||||
*/
|
||||
on_activate() {
|
||||
var _this = this;
|
||||
|
||||
// Check if there's an active AI selection (Smart Select, Brush Select, etc.)
|
||||
if (window.smartSelectMask && window.smartSelectMask.canvas) {
|
||||
// Ask user if they want to float the selection
|
||||
alertify.confirm(
|
||||
'Float Selection',
|
||||
'You have an active selection. Would you like to copy it to a new layer so you can move and scale it?',
|
||||
function() {
|
||||
// Yes - float the selection
|
||||
_this.floatSelection();
|
||||
},
|
||||
function() {
|
||||
// No - just clear the selection indicator
|
||||
alertify.message('Tip: Use Ctrl+C in selection tools to copy, or Ctrl+X to cut.');
|
||||
}
|
||||
).set('labels', {ok: 'Yes, Float It', cancel: 'No, Keep Selection'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Float the current selection to a new layer
|
||||
* This copies the selected pixels to a new layer that can be moved/scaled
|
||||
*/
|
||||
floatSelection() {
|
||||
var maskCanvas = window.smartSelectMask?.canvas;
|
||||
if (!maskCanvas) {
|
||||
alertify.error('No selection to float');
|
||||
return;
|
||||
}
|
||||
|
||||
var layer = config.layer;
|
||||
if (layer.type != 'image') {
|
||||
alertify.error('Please select an image layer first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get mask bounds
|
||||
var maskCtx = maskCanvas.getContext('2d');
|
||||
var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
var minX = maskCanvas.width, minY = maskCanvas.height;
|
||||
var maxX = 0, maxY = 0;
|
||||
var hasSelection = false;
|
||||
|
||||
for (var y = 0; y < maskCanvas.height; y++) {
|
||||
for (var x = 0; x < maskCanvas.width; x++) {
|
||||
var i = (y * maskCanvas.width + x) * 4;
|
||||
if (imageData.data[i] > 128) {
|
||||
hasSelection = true;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSelection || maxX <= minX || maxY <= minY) {
|
||||
alertify.error('Selection is empty or too small');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create masked image
|
||||
var maskedCanvas = document.createElement('canvas');
|
||||
maskedCanvas.width = layer.width_original;
|
||||
maskedCanvas.height = layer.height_original;
|
||||
var maskedCtx = maskedCanvas.getContext('2d');
|
||||
|
||||
maskedCtx.drawImage(layer.link, 0, 0);
|
||||
maskedCtx.globalCompositeOperation = 'destination-in';
|
||||
maskedCtx.drawImage(maskCanvas, 0, 0);
|
||||
|
||||
// Crop to selection bounds
|
||||
var cropWidth = maxX - minX + 1;
|
||||
var cropHeight = maxY - minY + 1;
|
||||
|
||||
var croppedCanvas = document.createElement('canvas');
|
||||
croppedCanvas.width = cropWidth;
|
||||
croppedCanvas.height = cropHeight;
|
||||
var croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
croppedCtx.drawImage(
|
||||
maskedCanvas,
|
||||
minX, minY, cropWidth, cropHeight,
|
||||
0, 0, cropWidth, cropHeight
|
||||
);
|
||||
|
||||
// Calculate position
|
||||
var scaleX = layer.width / layer.width_original;
|
||||
var scaleY = layer.height / layer.height_original;
|
||||
|
||||
var params = {
|
||||
x: Math.round(layer.x + minX * scaleX),
|
||||
y: Math.round(layer.y + minY * scaleY),
|
||||
width: Math.round(cropWidth * scaleX),
|
||||
height: Math.round(cropHeight * scaleY),
|
||||
width_original: cropWidth,
|
||||
height_original: cropHeight,
|
||||
type: 'image',
|
||||
name: layer.name + ' (Floated)',
|
||||
data: croppedCanvas.toDataURL('image/png')
|
||||
};
|
||||
|
||||
app.State.do_action(
|
||||
new app.Actions.Bundle_action('float_selection', 'Float Selection', [
|
||||
new app.Actions.Insert_layer_action(params)
|
||||
])
|
||||
);
|
||||
|
||||
// Clear the selection
|
||||
window.smartSelectMask = null;
|
||||
|
||||
// Enable transparency
|
||||
if (config.TRANSPARENCY == false) {
|
||||
config.TRANSPARENCY = true;
|
||||
this.Base_layers.render();
|
||||
}
|
||||
|
||||
alertify.success('Selection floated to new layer! You can now move and scale it.');
|
||||
}
|
||||
|
||||
load() {
|
||||
var _this = this;
|
||||
|
||||
|
||||
@@ -191,7 +191,8 @@ class Smart_select_class extends Base_tools_class {
|
||||
newMaskCanvas.width = config.layer.width_original;
|
||||
newMaskCanvas.height = config.layer.height_original;
|
||||
var newMaskCtx = newMaskCanvas.getContext('2d');
|
||||
newMaskCtx.drawImage(maskImage, 0, 0);
|
||||
// Scale mask to match layer dimensions
|
||||
newMaskCtx.drawImage(maskImage, 0, 0, newMaskCanvas.width, newMaskCanvas.height);
|
||||
|
||||
// If additive and we have an existing mask, combine them
|
||||
if (isAdditive && _this.maskCanvas) {
|
||||
|
||||
Reference in New Issue
Block a user