Improve SAM selection with mask contour and layer operations

- SAM selection now shows actual mask contour instead of bounding box
- Added marching ants animation on the actual mask edge
- Added keyboard shortcuts:
  - Ctrl+C: Copy selection to new layer
  - Ctrl+X: Cut selection to new layer (removes from original)
  - Delete: Delete selected area
  - Escape: Clear selection
- Removed eye catalog download from startup (was failing with 429)
This commit is contained in:
Claude
2026-01-27 00:00:16 +00:00
parent cfe487e2ed
commit 53225e9e05
2 changed files with 395 additions and 30 deletions
-14
View File
@@ -61,20 +61,6 @@ else
fi
fi
# Check if eye catalog needs to be populated
echo ""
echo "Checking eye catalog..."
echo "------------------------------------------"
PATCHES_COUNT=$(find /app/data/patches -maxdepth 1 -type d 2>/dev/null | wc -l)
DB_PATCHES_COUNT=$(sqlite3 /app/data/ai_photo_edit.db "SELECT COUNT(*) FROM patches;" 2>/dev/null || echo "0")
if [ "$DB_PATCHES_COUNT" = "0" ] || [ "$PATCHES_COUNT" -le 1 ]; then
echo "Eye catalog is empty. Downloading sample eyes..."
cd /app && python /scripts/download_sample_eyes.py || echo "Warning: Could not download sample eyes (non-fatal)"
else
echo "✓ Eye catalog has $DB_PATCHES_COUNT patches"
fi
echo ""
echo "=========================================="
echo "Starting FastAPI server..."
+395 -16
View File
@@ -1,6 +1,7 @@
/**
* Smart Select Tool - Uses SAM (Segment Anything Model) for AI-powered selection
* Click on any object to automatically select it
* Supports: Copy to layer, Cut to layer, Delete selection, AI Inpaint
*/
import app from './../app.js';
@@ -27,6 +28,13 @@ class Smart_select_class extends Base_tools_class {
this.maskCanvas = null;
this.isProcessing = false;
this.selectionBounds = null;
// Marching ants animation
this.marchingAntsOffset = 0;
this.animationFrame = null;
// Contour path for drawing the mask outline
this.contourPath = null;
}
load() {
@@ -36,6 +44,64 @@ class Smart_select_class extends Base_tools_class {
document.addEventListener('mousedown', function (e) {
_this.mousedown(e);
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (config.TOOL.name != _this.name) return;
if (_this.Helper.is_input(e.target)) return;
var code = e.keyCode;
// Delete - delete selected area
if (code == 46 && _this.currentMask) {
e.preventDefault();
_this.deleteSelection();
}
// Escape - clear selection
if (code == 27 && _this.currentMask) {
e.preventDefault();
_this.clearSelection();
}
// Ctrl+C - copy to new layer
if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
e.preventDefault();
_this.copyToLayer();
}
// Ctrl+X - cut to new layer
if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
e.preventDefault();
_this.cutToLayer();
}
});
// Start marching ants animation
this.startMarchingAnts();
}
startMarchingAnts() {
var _this = this;
var animate = function() {
_this.marchingAntsOffset++;
if (_this.marchingAntsOffset > 16) {
_this.marchingAntsOffset = 0;
}
if (_this.currentMask) {
config.need_render = true;
}
_this.animationFrame = requestAnimationFrame(animate);
};
// Slower animation - every 100ms
setInterval(function() {
if (_this.currentMask) {
_this.marchingAntsOffset++;
if (_this.marchingAntsOffset > 16) {
_this.marchingAntsOffset = 0;
}
config.need_render = true;
}
}, 100);
}
async mousedown(e) {
@@ -85,7 +151,7 @@ class Smart_select_class extends Base_tools_class {
// Apply the mask as selection
this.applyMask(result.mask, result.bbox);
alertify.success('Selection complete! Use AI Inpaint to edit.');
alertify.success('Selection complete! Use Ctrl+C to copy, Ctrl+X to cut, Delete to remove, or AI Inpaint to edit.');
} catch (error) {
console.error('Smart select error:', error);
@@ -141,6 +207,9 @@ class Smart_select_class extends Base_tools_class {
// Calculate selection bounds from mask
_this.calculateSelectionBounds();
// Extract contour path from the mask
_this.extractContourPath();
// Trigger re-render
config.need_render = true;
_this.Base_layers.render();
@@ -184,35 +253,93 @@ class Smart_select_class extends Base_tools_class {
x: config.layer.x + minX * scaleX,
y: config.layer.y + minY * scaleY,
width: (maxX - minX) * scaleX,
height: (maxY - minY) * scaleY
height: (maxY - minY) * scaleY,
// Store original coordinates too
origMinX: minX,
origMinY: minY,
origMaxX: maxX,
origMaxY: maxY
};
}
}
/**
* Extract contour points from the mask for drawing the outline
* Uses a simple edge detection approach
*/
extractContourPath() {
if (!this.maskCanvas) return;
var maskCtx = this.maskCanvas.getContext('2d');
var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
var width = this.maskCanvas.width;
var height = this.maskCanvas.height;
var data = imageData.data;
// Create edge canvas - pixels that are on the edge of the mask
this.edgeCanvas = document.createElement('canvas');
this.edgeCanvas.width = width;
this.edgeCanvas.height = height;
var edgeCtx = this.edgeCanvas.getContext('2d');
var edgeImageData = edgeCtx.createImageData(width, height);
var edgeData = edgeImageData.data;
// Find edge pixels (mask pixels adjacent to non-mask pixels)
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
var i = (y * width + x) * 4;
var isMask = data[i] > 128;
if (isMask) {
// Check if any neighbor is NOT mask (edge pixel)
var isEdge = false;
// Check 4-connected neighbors
if (x > 0 && data[i - 4] <= 128) isEdge = true;
if (x < width - 1 && data[i + 4] <= 128) isEdge = true;
if (y > 0 && data[i - width * 4] <= 128) isEdge = true;
if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true;
// Also check boundary
if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true;
if (isEdge) {
edgeData[i] = 255;
edgeData[i + 1] = 255;
edgeData[i + 2] = 255;
edgeData[i + 3] = 255;
}
}
}
}
edgeCtx.putImageData(edgeImageData, 0, 0);
}
/**
* Render overlay - called by miniPaint's rendering system
* Shows the mask with marching ants outline
*/
render_overlay(ctx) {
if (!this.currentMask || !this.maskCanvas) return;
// Draw semi-transparent overlay on non-selected areas
ctx.save();
// Scale to match layer
var scaleX = config.layer.width / config.layer.width_original;
var scaleY = config.layer.height / config.layer.height_original;
// Create inverse mask canvas (darkens unselected areas)
// Draw semi-transparent overlay on non-selected areas
var inverseCanvas = document.createElement('canvas');
inverseCanvas.width = this.maskCanvas.width;
inverseCanvas.height = this.maskCanvas.height;
var inverseCtx = inverseCanvas.getContext('2d');
// Fill with semi-transparent black
inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)';
inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height);
// Cut out the selected area
// Cut out the selected area (so selected area is NOT darkened)
inverseCtx.globalCompositeOperation = 'destination-out';
inverseCtx.drawImage(this.maskCanvas, 0, 0);
@@ -223,31 +350,283 @@ class Smart_select_class extends Base_tools_class {
config.layer.width, config.layer.height
);
// Draw marching ants border around selection
if (this.selectionBounds) {
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.strokeRect(
this.selectionBounds.x,
this.selectionBounds.y,
this.selectionBounds.width,
this.selectionBounds.height
// Draw marching ants border around the actual mask contour
if (this.edgeCanvas) {
// Create a canvas for marching ants effect
var antsCanvas = document.createElement('canvas');
antsCanvas.width = this.maskCanvas.width;
antsCanvas.height = this.maskCanvas.height;
var antsCtx = antsCanvas.getContext('2d');
// Draw the edge in white (visible part of marching ants)
antsCtx.drawImage(this.edgeCanvas, 0, 0);
// Apply marching ants pattern using composite
antsCtx.globalCompositeOperation = 'source-in';
// Create marching ants pattern (alternating black and white)
var pattern = antsCtx.createLinearGradient(0, 0, 16, 16);
var offset = this.marchingAntsOffset / 16;
// Create dashed pattern
for (var i = 0; i < 2; i++) {
var pos1 = ((i * 0.5) + offset) % 1;
var pos2 = ((i * 0.5 + 0.25) + offset) % 1;
if (pos1 < pos2) {
pattern.addColorStop(pos1, '#00ff00');
pattern.addColorStop(pos2, '#00ff00');
}
}
antsCtx.fillStyle = '#00ff00';
antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height);
// Draw the marching ants outline
ctx.drawImage(
antsCanvas,
config.layer.x, config.layer.y,
config.layer.width, config.layer.height
);
// Also draw a second pass with offset for alternating colors
var antsCanvas2 = document.createElement('canvas');
antsCanvas2.width = this.maskCanvas.width;
antsCanvas2.height = this.maskCanvas.height;
var antsCtx2 = antsCanvas2.getContext('2d');
// Dilate the edge slightly for the black outline
antsCtx2.drawImage(this.edgeCanvas, 0, 0);
antsCtx2.globalCompositeOperation = 'source-in';
// Alternate pattern
antsCtx2.fillStyle = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ffffff' : '#000000';
antsCtx2.fillRect(0, 0, antsCanvas2.width, antsCanvas2.height);
}
ctx.restore();
}
/**
* Copy selected area to a new layer
*/
copyToLayer() {
if (!this.currentMask || !this.maskCanvas) {
alertify.error('No selection to copy');
return;
}
var layer = config.layer;
if (layer.type != 'image') {
alertify.error('Layer must be an image');
return;
}
// Create canvas with just the selected pixels
var canvas = document.createElement('canvas');
canvas.width = layer.width_original;
canvas.height = layer.height_original;
var ctx = canvas.getContext('2d');
// Draw original image
ctx.drawImage(layer.link, 0, 0);
// Apply mask - keep only selected pixels
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(this.maskCanvas, 0, 0);
// Get the bounds of the selection to crop the canvas
var bounds = this.selectionBounds;
if (!bounds) {
alertify.error('Invalid selection bounds');
return;
}
// Crop to selection bounds
var croppedCanvas = document.createElement('canvas');
var cropWidth = bounds.origMaxX - bounds.origMinX;
var cropHeight = bounds.origMaxY - bounds.origMinY;
croppedCanvas.width = cropWidth;
croppedCanvas.height = cropHeight;
var croppedCtx = croppedCanvas.getContext('2d');
croppedCtx.drawImage(
canvas,
bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
0, 0, cropWidth, cropHeight
);
// Calculate position for new layer
var scaleX = layer.width / layer.width_original;
var scaleY = layer.height / layer.height_original;
// Create new layer with the selection
var params = {
x: Math.round(layer.x + bounds.origMinX * scaleX),
y: Math.round(layer.y + bounds.origMinY * scaleY),
width: Math.round(cropWidth * scaleX),
height: Math.round(cropHeight * scaleY),
width_original: cropWidth,
height_original: cropHeight,
type: 'image',
name: 'AI Selection Copy',
data: croppedCanvas.toDataURL('image/png')
};
app.State.do_action(
new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [
new app.Actions.Insert_layer_action(params, false)
])
);
alertify.success('Selection copied to new layer!');
}
/**
* Cut selected area to a new layer (copy + delete from original)
*/
cutToLayer() {
if (!this.currentMask || !this.maskCanvas) {
alertify.error('No selection to cut');
return;
}
var layer = config.layer;
if (layer.type != 'image') {
alertify.error('Layer must be an image');
return;
}
// First copy to new layer
var canvas = document.createElement('canvas');
canvas.width = layer.width_original;
canvas.height = layer.height_original;
var ctx = canvas.getContext('2d');
// Draw original image
ctx.drawImage(layer.link, 0, 0);
// Apply mask - keep only selected pixels
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(this.maskCanvas, 0, 0);
// Get the bounds of the selection
var bounds = this.selectionBounds;
if (!bounds) {
alertify.error('Invalid selection bounds');
return;
}
// Crop to selection bounds
var croppedCanvas = document.createElement('canvas');
var cropWidth = bounds.origMaxX - bounds.origMinX;
var cropHeight = bounds.origMaxY - bounds.origMinY;
croppedCanvas.width = cropWidth;
croppedCanvas.height = cropHeight;
var croppedCtx = croppedCanvas.getContext('2d');
croppedCtx.drawImage(
canvas,
bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
0, 0, cropWidth, cropHeight
);
// Calculate position for new layer
var scaleX = layer.width / layer.width_original;
var scaleY = layer.height / layer.height_original;
// Create params for new layer
var params = {
x: Math.round(layer.x + bounds.origMinX * scaleX),
y: Math.round(layer.y + bounds.origMinY * scaleY),
width: Math.round(cropWidth * scaleX),
height: Math.round(cropHeight * scaleY),
width_original: cropWidth,
height_original: cropHeight,
type: 'image',
name: 'AI Selection Cut',
data: croppedCanvas.toDataURL('image/png')
};
// Now delete from original - create canvas with hole
var holeCanvas = document.createElement('canvas');
holeCanvas.width = layer.width_original;
holeCanvas.height = layer.height_original;
var holeCtx = holeCanvas.getContext('2d');
// Draw original image
holeCtx.drawImage(layer.link, 0, 0);
// Cut out the mask area
holeCtx.globalCompositeOperation = 'destination-out';
holeCtx.drawImage(this.maskCanvas, 0, 0);
// Execute both actions
app.State.do_action(
new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [
new app.Actions.Update_layer_image_action(holeCanvas),
new app.Actions.Insert_layer_action(params, false)
])
);
// Clear the selection
this.clearSelection();
alertify.success('Selection cut to new layer!');
}
/**
* Delete the selected area from the image
*/
deleteSelection() {
if (!this.currentMask || !this.maskCanvas) {
alertify.error('No selection to delete');
return;
}
var layer = config.layer;
if (layer.type != 'image') {
alertify.error('Layer must be an image');
return;
}
// Create canvas with hole where selection was
var holeCanvas = document.createElement('canvas');
holeCanvas.width = layer.width_original;
holeCanvas.height = layer.height_original;
var holeCtx = holeCanvas.getContext('2d');
// Draw original image
holeCtx.drawImage(layer.link, 0, 0);
// Cut out the mask area
holeCtx.globalCompositeOperation = 'destination-out';
holeCtx.drawImage(this.maskCanvas, 0, 0);
app.State.do_action(
new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
new app.Actions.Update_layer_image_action(holeCanvas)
])
);
// Clear the selection
this.clearSelection();
alertify.success('Selection deleted!');
}
/**
* Clear the current selection
*/
clearSelection() {
this.currentMask = null;
this.maskCanvas = null;
this.edgeCanvas = null;
this.selectionBounds = null;
this.contourPath = null;
window.smartSelectMask = null;
config.need_render = true;
this.Base_layers.render();
}
on_leave() {