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:
Outis
2026-01-27 18:07:46 -05:00
committed by GitHub
6 changed files with 247 additions and 5 deletions
+43 -2
View File
@@ -203,6 +203,32 @@ async def remove_background_base64(request: RemoveBackgroundRequest):
_u2net_model = None _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: async def _remove_background_u2net(img: Image.Image) -> bytes:
""" """
Remove background using U2Net model directly. Remove background using U2Net model directly.
@@ -225,10 +251,25 @@ async def _remove_background_u2net(img: Image.Image) -> bytes:
u2net_path = alt_path u2net_path = alt_path
break 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(): if not u2net_path.exists():
raise FileNotFoundError( raise FileNotFoundError(
f"U2Net model not found at {u2net_path}. " "U2Net model not found. To fix this, run:\n"
"Download from: https://github.com/xuebinqin/U-2-Net" " 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 # Load model if not cached
+2
View File
@@ -83,8 +83,10 @@ config.TOOLS = [
{ {
name: 'select', name: 'select',
title: 'Select object tool', title: 'Select object tool',
on_activate: 'on_activate',
attributes: { attributes: {
auto_select: true, auto_select: true,
keep_ratio: true,
}, },
}, },
{ {
+67
View File
@@ -31,6 +31,7 @@ var template = `
<div class="row"> <div class="row">
<span class="trn label">Height:</span> <span class="trn label">Height:</span>
<input type="number" id="detail_height" step="any" /> <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;">&#128279;</button>
</div> </div>
<hr /> <hr />
<div class="row"> <div class="row">
@@ -124,6 +125,8 @@ class GUI_details_class {
this.Helper = new Helper_class(); this.Helper = new Helper_class();
this.layer_details_active = false; this.layer_details_active = false;
this.Tools_translate = new Tools_translate_class(); 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() { render_main_details() {
@@ -139,6 +142,7 @@ class GUI_details_class {
this.render_general('y', events); this.render_general('y', events);
this.render_general('width', events); this.render_general('width', events);
this.render_general('height', events); this.render_general('height', events);
this.render_aspect_lock(events);
this.render_general('rotate', events); this.render_general('rotate', events);
this.render_general('opacity', 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) { render_general_param(key, events) {
var layer = config.layer; var layer = config.layer;
+6 -2
View File
@@ -327,10 +327,14 @@ class Brush_select_class extends Base_tools_class {
canvas.width = config.layer.width_original; canvas.width = config.layer.width_original;
canvas.height = config.layer.height_original; canvas.height = config.layer.height_original;
var ctx = canvas.getContext('2d'); 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); 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; img.src = 'data:image/png;base64,' + maskBase64;
}); });
} }
+127
View File
@@ -5,6 +5,7 @@ import Base_layers_class from './../core/base-layers.js';
import Base_selection_class from './../core/base-selection.js'; import Base_selection_class from './../core/base-selection.js';
import Helper_class from './../libs/helpers.js'; import Helper_class from './../libs/helpers.js';
import Dialog_class from './../libs/popup.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 { 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); 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() { load() {
var _this = this; var _this = this;
+2 -1
View File
@@ -191,7 +191,8 @@ class Smart_select_class extends Base_tools_class {
newMaskCanvas.width = config.layer.width_original; newMaskCanvas.width = config.layer.width_original;
newMaskCanvas.height = config.layer.height_original; newMaskCanvas.height = config.layer.height_original;
var newMaskCtx = newMaskCanvas.getContext('2d'); 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 additive and we have an existing mask, combine them
if (isAdditive && _this.maskCanvas) { if (isAdditive && _this.maskCanvas) {