diff --git a/backend/app/routers/tools.py b/backend/app/routers/tools.py index c6eed00..00d6f92 100644 --- a/backend/app/routers/tools.py +++ b/backend/app/routers/tools.py @@ -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 diff --git a/frontend/src/js/config.js b/frontend/src/js/config.js index 937ab97..077c1f7 100644 --- a/frontend/src/js/config.js +++ b/frontend/src/js/config.js @@ -83,8 +83,10 @@ config.TOOLS = [ { name: 'select', title: 'Select object tool', + on_activate: 'on_activate', attributes: { auto_select: true, + keep_ratio: true, }, }, { diff --git a/frontend/src/js/core/gui/gui-details.js b/frontend/src/js/core/gui/gui-details.js index fba4e62..f41860a 100644 --- a/frontend/src/js/core/gui/gui-details.js +++ b/frontend/src/js/core/gui/gui-details.js @@ -31,6 +31,7 @@ var template = `
Height: +

@@ -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; diff --git a/frontend/src/js/tools/brush_select.js b/frontend/src/js/tools/brush_select.js index f78f69e..b1522b9 100644 --- a/frontend/src/js/tools/brush_select.js +++ b/frontend/src/js/tools/brush_select.js @@ -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; }); } diff --git a/frontend/src/js/tools/select.js b/frontend/src/js/tools/select.js index 4adee00..dbcc923 100644 --- a/frontend/src/js/tools/select.js +++ b/frontend/src/js/tools/select.js @@ -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; diff --git a/frontend/src/js/tools/smart_select.js b/frontend/src/js/tools/smart_select.js index 8899e6d..643447c 100644 --- a/frontend/src/js/tools/smart_select.js +++ b/frontend/src/js/tools/smart_select.js @@ -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) {