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
|
_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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;">🔗</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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user