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:
@@ -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..."
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user