Merge branch 'main' into claude/add-eye-detection-feature-69XOl

This commit is contained in:
outis1one
2026-01-25 18:42:32 -05:00
committed by GitHub
11 changed files with 581 additions and 54 deletions
+6 -1
View File
@@ -116,7 +116,12 @@ SECRET_KEY=change-this-to-a-long-random-string-in-production
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3080,http://localhost CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3080,http://localhost
# Database path # Database path
DATABASE_URL=sqlite:///./data/photoedit.db DATABASE_URL=sqlite:///./data/ai_photo_edit.db
# Auto-download SAM model on startup (true/false)
# When true (default): Downloads SAM model (~375MB) on first startup for offline Smart Select
# When false: Skips download, Smart Select uses Replicate API (requires REPLICATE_API_KEY)
AUTO_DOWNLOAD_SAM=true
# Allow users to select model per-edit # Allow users to select model per-edit
ALLOW_MODEL_OVERRIDE=true ALLOW_MODEL_OVERRIDE=true
+80 -12
View File
@@ -6,6 +6,8 @@ from PIL import Image
from io import BytesIO from io import BytesIO
import numpy as np import numpy as np
import json import json
import base64
import cv2
from app.database import get_db from app.database import get_db
from app.models.project import Project from app.models.project import Project
@@ -133,11 +135,12 @@ async def smart_select(
project_id: int = Form(...), project_id: int = Form(...),
point_x: int = Form(...), point_x: int = Form(...),
point_y: int = Form(...), point_y: int = Form(...),
return_format: str = Form("json"), # "json" (default) or "image"
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
Use SAM (Segment Anything) to select object at given point. Use SAM (Segment Anything) to select object at given point.
Returns mask for the selected object. Returns mask and polygon data for the selected object.
Note: Requires SAM model to be downloaded. Note: Requires SAM model to be downloaded.
Falls back to simple flood-fill selection if SAM unavailable. Falls back to simple flood-fill selection if SAM unavailable.
@@ -163,13 +166,62 @@ async def smart_select(
# Convert mask to PNG # Convert mask to PNG
mask_img = Image.fromarray((mask * 255).astype(np.uint8), mode='L') mask_img = Image.fromarray((mask * 255).astype(np.uint8), mode='L')
if return_format == "image":
buffer = BytesIO()
mask_img.save(buffer, format='PNG')
return Response(
content=buffer.getvalue(),
media_type="image/png"
)
# Return JSON with polygon and bbox
polygon, bbox = _mask_to_polygon(mask)
# Also return mask as base64 for potential use
buffer = BytesIO() buffer = BytesIO()
mask_img.save(buffer, format='PNG') mask_img.save(buffer, format='PNG')
mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return Response( return {
content=buffer.getvalue(), "polygon": polygon,
media_type="image/png" "bbox": bbox,
) "mask_base64": mask_b64,
}
def _mask_to_polygon(mask: np.ndarray) -> tuple:
"""
Convert a binary mask to a simplified polygon and bounding box.
Returns:
(polygon, bbox) where:
- polygon: list of [x, y] points (simplified contour)
- bbox: dict with x, y, width, height
"""
# Ensure mask is binary uint8
mask_uint8 = (mask * 255).astype(np.uint8) if mask.max() <= 1 else mask.astype(np.uint8)
# Find contours
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return [], {"x": 0, "y": 0, "width": 0, "height": 0}
# Get largest contour
largest = max(contours, key=cv2.contourArea)
# Get bounding box
x, y, w, h = cv2.boundingRect(largest)
bbox = {"x": int(x), "y": int(y), "width": int(w), "height": int(h)}
# Simplify contour to reduce points (epsilon = 1% of arc length)
epsilon = 0.01 * cv2.arcLength(largest, True)
simplified = cv2.approxPolyDP(largest, epsilon, True)
# Convert to list of [x, y] points
polygon = [[int(pt[0][0]), int(pt[0][1])] for pt in simplified]
return polygon, bbox
# Global SAM model cache (loaded once, reused) # Global SAM model cache (loaded once, reused)
@@ -387,11 +439,12 @@ async def color_select(
color_g: int = Form(...), color_g: int = Form(...),
color_b: int = Form(...), color_b: int = Form(...),
tolerance: int = Form(30), tolerance: int = Form(30),
return_format: str = Form("json"), # "json" (default) or "image"
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
Select all pixels similar to the given color. Select all pixels similar to the given color.
Returns a mask of selected areas. Returns a mask and polygon data for selected areas.
""" """
project = db.query(Project).filter(Project.id == project_id).first() project = db.query(Project).filter(Project.id == project_id).first()
if not project: if not project:
@@ -412,18 +465,33 @@ async def color_select(
distance = np.sum(diff, axis=2) distance = np.sum(diff, axis=2)
# Create mask where distance is within tolerance # Create mask where distance is within tolerance
mask = (distance <= tolerance * 3).astype(np.uint8) * 255 mask = (distance <= tolerance * 3).astype(np.uint8)
# Convert to PNG # Convert to PNG
mask_img = Image.fromarray(mask, mode='L') mask_img = Image.fromarray(mask * 255, mode='L')
if return_format == "image":
buffer = BytesIO()
mask_img.save(buffer, format='PNG')
return Response(
content=buffer.getvalue(),
media_type="image/png"
)
# Return JSON with polygon and bbox
polygon, bbox = _mask_to_polygon(mask)
buffer = BytesIO() buffer = BytesIO()
mask_img.save(buffer, format='PNG') mask_img.save(buffer, format='PNG')
mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return Response( return {
content=buffer.getvalue(), "polygon": polygon,
media_type="image/png" "bbox": bbox,
) "mask_base64": mask_b64,
"color": {"r": color_r, "g": color_g, "b": color_b},
"tolerance": tolerance,
}
@router.post("/extract-object") @router.post("/extract-object")
+47 -24
View File
@@ -3,9 +3,10 @@
# AI Photo Edit - Container Startup Script # AI Photo Edit - Container Startup Script
# ============================================================================= # =============================================================================
# This script runs when the container starts. It: # This script runs when the container starts. It:
# 1. Downloads sample eye images if the catalog is empty # 1. Initializes the database
# 2. Ensures all directories exist # 2. Downloads SAM model automatically (can be disabled with AUTO_DOWNLOAD_SAM=false)
# 3. Starts the FastAPI server # 3. Downloads sample eye images if the catalog is empty
# 4. Starts the FastAPI server
# ============================================================================= # =============================================================================
set -e set -e
@@ -18,18 +19,15 @@ echo "=========================================="
mkdir -p /app/data/projects mkdir -p /app/data/projects
mkdir -p /app/data/patches mkdir -p /app/data/patches
mkdir -p /app/data/models mkdir -p /app/data/models
mkdir -p /app/data/patch_library
# Check if eye catalog needs to be populated # Initialize database FIRST (before eye import)
echo "Checking eye catalog..." echo ""
PATCHES_COUNT=$(find /app/data/patches -maxdepth 1 -type d | wc -l) echo "Initializing database..."
echo "------------------------------------------"
if [ "$PATCHES_COUNT" -le 1 ]; then cd /app && python /scripts/init_database.py || echo "Warning: Database init failed (non-fatal)"
echo "Eye catalog is empty. Downloading sample eyes..."
python /scripts/download_sample_eyes.py || echo "Warning: Could not download sample eyes (non-fatal)"
else
echo "Eye catalog has content, skipping download."
fi
# Check and download SAM model automatically
echo "" echo ""
echo "Checking SAM model (Smart Select)..." echo "Checking SAM model (Smart Select)..."
echo "------------------------------------------" echo "------------------------------------------"
@@ -39,17 +37,42 @@ if [ -f "/app/data/models/sam_model.pth" ] || \
[ -f "/app/data/models/sam_vit_h_4b8939.pth" ]; then [ -f "/app/data/models/sam_vit_h_4b8939.pth" ]; then
echo "✓ SAM model found - Smart Select will use local AI (free, offline)" echo "✓ SAM model found - Smart Select will use local AI (free, offline)"
else else
echo "" # Auto-download SAM unless explicitly disabled
echo "⚠ SAM model not found" AUTO_DOWNLOAD_SAM="${AUTO_DOWNLOAD_SAM:-true}"
echo "" if [ "$AUTO_DOWNLOAD_SAM" = "true" ]; then
echo " Smart Select will use Replicate API (requires REPLICATE_API_KEY)" echo "SAM model not found. Downloading automatically..."
echo "" echo "(This is a one-time ~375MB download that persists across rebuilds)"
echo " To enable FREE offline Smart Select, run:" echo ""
echo " docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py" python /scripts/download_sam_model.py vit_b || {
echo "" echo ""
echo " Model sizes: vit_b (375MB), vit_l (1.2GB), vit_h (2.5GB)" echo "⚠ SAM download failed (non-fatal)"
echo " The model persists across container rebuilds." echo " Smart Select will fall back to Replicate API (requires REPLICATE_API_KEY)"
echo "" echo " To retry later: docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py"
}
else
echo ""
echo "⚠ SAM model not found (AUTO_DOWNLOAD_SAM=false)"
echo ""
echo " Smart Select will use Replicate API (requires REPLICATE_API_KEY)"
echo ""
echo " To enable FREE offline Smart Select, run:"
echo " docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py"
echo ""
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 fi
echo "" echo ""
+54 -3
View File
@@ -26,6 +26,9 @@ function App() {
const [layers, setLayers] = useState([]); const [layers, setLayers] = useState([]);
const [activeLayer, setActiveLayer] = useState('background'); const [activeLayer, setActiveLayer] = useState('background');
const [generatedMask, setGeneratedMask] = useState(null); const [generatedMask, setGeneratedMask] = useState(null);
const [advancedToolMode, setAdvancedToolMode] = useState(null); // 'smart-select', 'color-select', 'object-remove'
const [canvasZoom, setCanvasZoom] = useState(1);
const [externalSelection, setExternalSelection] = useState(null); // For smart-select/color-select polygon results
const editsRef = useRef([]); const editsRef = useRef([]);
// Create project and upload image // Create project and upload image
@@ -284,9 +287,49 @@ function App() {
}; };
// Handle mask generation from smart select / color select // Handle mask generation from smart select / color select
const handleMaskGenerated = async (maskBlob, source) => { const handleMaskGenerated = async (maskData, source) => {
setGeneratedMask({ blob: maskBlob, source }); setGeneratedMask({ data: maskData, source });
// The mask can be used for various operations // Convert mask to selection if it contains polygon data
if (maskData && maskData.polygon && maskData.polygon.length > 0) {
// Set external selection for canvas to draw
setExternalSelection({
polygon: maskData.polygon,
bbox: maskData.bbox,
});
// Also set selection state for fix button
setSelection({
type: 'polygon',
bbox: maskData.bbox,
selectionData: { points: maskData.polygon },
});
}
// Reset tool mode after selection
setAdvancedToolMode(null);
};
// Handle canvas click for advanced tools (smart select, color select)
const handleAdvancedToolClick = async (x, y, color) => {
if (!project || !advancedToolMode) return;
try {
setIsProcessing(true);
setError(null);
if (advancedToolMode === 'smart-select') {
const result = await toolsApi.smartSelect(project.id, x, y);
handleMaskGenerated(result, 'smart-select');
} else if (advancedToolMode === 'color-select') {
// Color is passed from canvas click
if (color) {
const result = await toolsApi.colorSelect(project.id, color.r, color.g, color.b, 30);
handleMaskGenerated(result, 'color-select');
}
}
} catch (err) {
setError(`${advancedToolMode} failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
}; };
// Handle flatten layers // Handle flatten layers
@@ -361,6 +404,11 @@ function App() {
imageUrl={currentImageUrl} imageUrl={currentImageUrl}
onSelectionChange={setSelection} onSelectionChange={setSelection}
selectionMode={selectionMode} selectionMode={selectionMode}
advancedToolMode={advancedToolMode}
onAdvancedToolClick={handleAdvancedToolClick}
zoom={canvasZoom}
onZoomChange={setCanvasZoom}
externalSelection={externalSelection}
/> />
</div> </div>
@@ -391,6 +439,8 @@ function App() {
isProcessing={isProcessing} isProcessing={isProcessing}
setIsProcessing={setIsProcessing} setIsProcessing={setIsProcessing}
setError={setError} setError={setError}
activeToolMode={advancedToolMode}
setActiveToolMode={setAdvancedToolMode}
/> />
<Layers <Layers
@@ -401,6 +451,7 @@ function App() {
setActiveLayer={setActiveLayer} setActiveLayer={setActiveLayer}
onFlatten={handleFlattenLayers} onFlatten={handleFlattenLayers}
isProcessing={isProcessing} isProcessing={isProcessing}
onError={setError}
/> />
<EyeCatalog <EyeCatalog
+2 -1
View File
@@ -11,8 +11,9 @@ const AdvancedTools = ({
isProcessing, isProcessing,
setIsProcessing, setIsProcessing,
setError, setError,
activeToolMode,
setActiveToolMode,
}) => { }) => {
const [activeToolMode, setActiveToolMode] = useState(null);
const [colorTolerance, setColorTolerance] = useState(30); const [colorTolerance, setColorTolerance] = useState(30);
const handleRemoveBackground = async () => { const handleRemoveBackground = async () => {
+63
View File
@@ -43,3 +43,66 @@
z-index: 10; z-index: 10;
white-space: nowrap; white-space: nowrap;
} }
/* Zoom controls */
.zoom-controls {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 4px;
background-color: rgba(0, 0, 0, 0.8);
padding: 6px 10px;
border-radius: 4px;
z-index: 10;
}
.zoom-controls button {
width: 28px;
height: 28px;
padding: 0;
font-size: 18px;
font-weight: bold;
background-color: #444;
color: white;
border: 1px solid #666;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-controls button:hover {
background-color: #555;
}
.zoom-level {
color: white;
font-size: 12px;
min-width: 45px;
text-align: center;
}
/* Tool mode indicator */
.tool-mode-indicator {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 120, 255, 0.9);
color: white;
padding: 10px 20px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
z-index: 10;
white-space: nowrap;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
+202 -4
View File
@@ -2,12 +2,22 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { fabric } from 'fabric'; import { fabric } from 'fabric';
import './ImageCanvas.css'; import './ImageCanvas.css';
const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { const ImageCanvas = ({
imageUrl,
onSelectionChange,
selectionMode,
advancedToolMode,
onAdvancedToolClick,
zoom = 1,
onZoomChange,
externalSelection, // { polygon: [[x,y],...], bbox: {x,y,width,height} }
}) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const fabricCanvasRef = useRef(null); const fabricCanvasRef = useRef(null);
const [currentSelection, setCurrentSelection] = useState(null); const [currentSelection, setCurrentSelection] = useState(null);
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
const [isTransformMode, setIsTransformMode] = useState(false); const [isTransformMode, setIsTransformMode] = useState(false);
const [currentZoom, setCurrentZoom] = useState(zoom);
const lassoPoints = useRef([]); const lassoPoints = useRef([]);
useEffect(() => { useEffect(() => {
@@ -50,11 +60,36 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
handleResize(); handleResize();
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// Mouse wheel zoom
const handleWheel = (opt) => {
const e = opt.e;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY;
let newZoom = canvas.getZoom();
newZoom *= 0.999 ** delta;
// Clamp zoom between 0.1x and 10x
if (newZoom > 10) newZoom = 10;
if (newZoom < 0.1) newZoom = 0.1;
// Zoom to point under cursor
const pointer = canvas.getPointer(e, true);
canvas.zoomToPoint({ x: pointer.x, y: pointer.y }, newZoom);
setCurrentZoom(newZoom);
onZoomChange?.(newZoom);
};
canvas.on('mouse:wheel', handleWheel);
return () => { return () => {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
canvas.off('mouse:wheel', handleWheel);
canvas.dispose(); canvas.dispose();
}; };
}, []); }, [onZoomChange]);
// Load image when URL changes // Load image when URL changes
useEffect(() => { useEffect(() => {
@@ -94,12 +129,128 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}, { crossOrigin: 'anonymous' }); }, { crossOrigin: 'anonymous' });
}, [imageUrl]); }, [imageUrl]);
// Handle advanced tool mode clicks (smart-select, color-select)
useEffect(() => {
if (!fabricCanvasRef.current || !advancedToolMode) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
const handleAdvancedClick = async (e) => {
if (!bgImage || !onAdvancedToolClick) return;
const pointer = canvas.getPointer(e.e);
// Convert canvas coordinates to image coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const imgX = Math.round((pointer.x - imgLeft) / imgScale);
const imgY = Math.round((pointer.y - imgTop) / imgScale);
// Check if click is within image bounds
if (imgX < 0 || imgY < 0 || imgX > bgImage.width || imgY > bgImage.height) {
return;
}
if (advancedToolMode === 'color-select') {
// Get pixel color at click position
const ctx = canvas.getContext('2d');
const canvasX = pointer.x * canvas.getZoom();
const canvasY = pointer.y * canvas.getZoom();
// For color picking, we need to get the color from the image
// Create a temporary canvas to read pixel color
const tempCanvas = document.createElement('canvas');
tempCanvas.width = bgImage.width;
tempCanvas.height = bgImage.height;
const tempCtx = tempCanvas.getContext('2d');
// Draw the image element to temp canvas
const imgElement = bgImage.getElement();
tempCtx.drawImage(imgElement, 0, 0);
const pixelData = tempCtx.getImageData(imgX, imgY, 1, 1).data;
const color = { r: pixelData[0], g: pixelData[1], b: pixelData[2] };
onAdvancedToolClick(imgX, imgY, color);
} else {
onAdvancedToolClick(imgX, imgY, null);
}
};
canvas.on('mouse:down', handleAdvancedClick);
return () => {
canvas.off('mouse:down', handleAdvancedClick);
};
}, [advancedToolMode, onAdvancedToolClick]);
// Handle external selection (from smart-select or color-select)
useEffect(() => {
if (!fabricCanvasRef.current || !externalSelection?.polygon?.length) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
if (!bgImage) return;
// Clear previous selection
if (currentSelection) {
canvas.remove(currentSelection);
}
// Convert image coordinates to canvas coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const canvasPoints = externalSelection.polygon.map(([x, y]) => ({
x: x * imgScale + imgLeft,
y: y * imgScale + imgTop,
}));
// Create polygon selection
const polygon = new fabric.Polygon(canvasPoints, {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
});
canvas.add(polygon);
canvas.setActiveObject(polygon);
setCurrentSelection(polygon);
lassoPoints.current = canvasPoints;
// Notify parent of selection
onSelectionChange({
type: 'polygon',
bbox: externalSelection.bbox,
selectionData: { points: externalSelection.polygon },
});
canvas.renderAll();
}, [externalSelection]);
// Handle selection mode changes // Handle selection mode changes
useEffect(() => { useEffect(() => {
if (!fabricCanvasRef.current) return; if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current; const canvas = fabricCanvasRef.current;
// Don't set up selection handlers if in advanced tool mode
if (advancedToolMode) return;
// Clear previous selection when changing modes // Clear previous selection when changing modes
if (currentSelection) { if (currentSelection) {
canvas.remove(currentSelection); canvas.remove(currentSelection);
@@ -123,7 +274,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
} else if (selectionMode === 'lasso') { } else if (selectionMode === 'lasso') {
setupLassoMode(canvas); setupLassoMode(canvas);
} }
}, [selectionMode]); }, [selectionMode, advancedToolMode]);
const setupRectangleMode = (canvas) => { const setupRectangleMode = (canvas) => {
let rect, isDown, startX, startY; let rect, isDown, startX, startY;
@@ -497,10 +648,57 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
} }
}; };
const handleZoomIn = () => {
if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current;
let newZoom = canvas.getZoom() * 1.2;
if (newZoom > 10) newZoom = 10;
canvas.setZoom(newZoom);
setCurrentZoom(newZoom);
onZoomChange?.(newZoom);
};
const handleZoomOut = () => {
if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current;
let newZoom = canvas.getZoom() / 1.2;
if (newZoom < 0.1) newZoom = 0.1;
canvas.setZoom(newZoom);
setCurrentZoom(newZoom);
onZoomChange?.(newZoom);
};
const handleZoomReset = () => {
if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current;
canvas.setZoom(1);
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
setCurrentZoom(1);
onZoomChange?.(1);
};
return ( return (
<div className="canvas-container"> <div className="canvas-container">
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
{currentSelection && (
{/* Zoom controls */}
<div className="zoom-controls">
<button onClick={handleZoomOut} title="Zoom Out"></button>
<span className="zoom-level">{Math.round(currentZoom * 100)}%</span>
<button onClick={handleZoomIn} title="Zoom In">+</button>
<button onClick={handleZoomReset} title="Reset Zoom"></button>
</div>
{/* Advanced tool mode indicator */}
{advancedToolMode && (
<div className="tool-mode-indicator">
{advancedToolMode === 'smart-select' && 'Click on an object to select it'}
{advancedToolMode === 'color-select' && 'Click on a color to select similar pixels'}
{advancedToolMode === 'object-remove' && 'Click on an object to remove it'}
</div>
)}
{currentSelection && !advancedToolMode && (
<> <>
<div className="selection-hint"> <div className="selection-hint">
Click selection to move/resize/rotate Click selection to move/resize/rotate
+54
View File
@@ -11,6 +11,7 @@ const Layers = ({
onLayerVisibilityChange, onLayerVisibilityChange,
onFlatten, onFlatten,
isProcessing, isProcessing,
onError,
}) => { }) => {
const [draggedLayer, setDraggedLayer] = useState(null); const [draggedLayer, setDraggedLayer] = useState(null);
@@ -69,6 +70,56 @@ const Layers = ({
loadLayers(); loadLayers();
}; };
const handleNewLayer = async () => {
if (!projectId) return;
try {
// Create a new empty transparent layer
const newLayer = {
id: `layer-${Date.now()}`,
name: `Layer ${layers.length + 1}`,
visible: true,
thumbnail: null,
};
setLayers([...layers, newLayer]);
setActiveLayer(newLayer.id);
} catch (err) {
onError?.(`Failed to create layer: ${err.message}`);
}
};
const handleDeleteLayer = async () => {
if (!projectId || activeLayer === 'background') return;
try {
const updatedLayers = layers.filter((l) => l.id !== activeLayer);
setLayers(updatedLayers);
setActiveLayer(updatedLayers.length > 0 ? updatedLayers[updatedLayers.length - 1].id : 'background');
} catch (err) {
onError?.(`Failed to delete layer: ${err.message}`);
}
};
const handleDuplicateLayer = async () => {
if (!projectId || activeLayer === 'background') return;
try {
const layerToDuplicate = layers.find((l) => l.id === activeLayer);
if (!layerToDuplicate) return;
const newLayer = {
...layerToDuplicate,
id: `layer-${Date.now()}`,
name: `${layerToDuplicate.name} copy`,
};
const activeIndex = layers.findIndex((l) => l.id === activeLayer);
const updatedLayers = [...layers];
updatedLayers.splice(activeIndex + 1, 0, newLayer);
setLayers(updatedLayers);
setActiveLayer(newLayer.id);
} catch (err) {
onError?.(`Failed to duplicate layer: ${err.message}`);
}
};
return ( return (
<div className="layers-panel"> <div className="layers-panel">
<div className="layers-header"> <div className="layers-header">
@@ -142,6 +193,7 @@ const Layers = ({
className="layer-action-btn" className="layer-action-btn"
disabled={isProcessing || !projectId} disabled={isProcessing || !projectId}
title="Add new empty layer" title="Add new empty layer"
onClick={handleNewLayer}
> >
+ New Layer + New Layer
</button> </button>
@@ -149,6 +201,7 @@ const Layers = ({
className="layer-action-btn" className="layer-action-btn"
disabled={isProcessing || activeLayer === 'background'} disabled={isProcessing || activeLayer === 'background'}
title="Delete selected layer" title="Delete selected layer"
onClick={handleDeleteLayer}
> >
Delete Delete
</button> </button>
@@ -156,6 +209,7 @@ const Layers = ({
className="layer-action-btn" className="layer-action-btn"
disabled={isProcessing || activeLayer === 'background'} disabled={isProcessing || activeLayer === 'background'}
title="Duplicate selected layer" title="Duplicate selected layer"
onClick={handleDuplicateLayer}
> >
Duplicate Duplicate
</button> </button>
+4 -2
View File
@@ -171,20 +171,22 @@ export const toolsApi = {
}, },
// Smart select object at point // Smart select object at point
// Returns { polygon, bbox, mask_base64 }
smartSelect: async (projectId, x, y) => { smartSelect: async (projectId, x, y) => {
const formData = new FormData(); const formData = new FormData();
formData.append('project_id', projectId); formData.append('project_id', projectId);
formData.append('point_x', x); formData.append('point_x', x);
formData.append('point_y', y); formData.append('point_y', y);
formData.append('return_format', 'json');
const response = await api.post('/tools/smart-select', formData, { const response = await api.post('/tools/smart-select', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
}); });
return response.data; return response.data;
}, },
// Select by color // Select by color
// Returns { polygon, bbox, mask_base64, color, tolerance }
colorSelect: async (projectId, r, g, b, tolerance = 30) => { colorSelect: async (projectId, r, g, b, tolerance = 30) => {
const formData = new FormData(); const formData = new FormData();
formData.append('project_id', projectId); formData.append('project_id', projectId);
@@ -192,10 +194,10 @@ export const toolsApi = {
formData.append('color_g', g); formData.append('color_g', g);
formData.append('color_b', b); formData.append('color_b', b);
formData.append('tolerance', tolerance); formData.append('tolerance', tolerance);
formData.append('return_format', 'json');
const response = await api.post('/tools/color-select', formData, { const response = await api.post('/tools/color-select', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
}); });
return response.data; return response.data;
}, },
+24 -7
View File
@@ -106,17 +106,34 @@ def import_eye_to_database(db_path: Path, eye_data: dict, patch_path: str, thumb
return patch_id return patch_id
def main(): def main():
# Determine paths # Determine paths - handle both Docker and local environments
script_dir = Path(__file__).parent # In Docker: script is at /scripts/, data is at /app/data/
project_root = script_dir.parent # Locally: script is at ./scripts/, data is at ./data/
data_dir = project_root / 'data' docker_data_dir = Path('/app/data')
db_path = data_dir / 'photoedit.db' local_data_dir = Path(__file__).parent.parent / 'data'
if docker_data_dir.exists():
data_dir = docker_data_dir
else:
data_dir = local_data_dir
db_path = data_dir / 'ai_photo_edit.db'
# Check if database exists # Check if database exists
if not db_path.exists(): if not db_path.exists():
print(f"Database not found at {db_path}") print(f"Database not found at {db_path}")
print("Please start the backend first to initialize the database.") print("Attempting to initialize database...")
sys.exit(1) # Try to import and initialize database
try:
sys.path.insert(0, str(Path('/app')))
sys.path.insert(0, str(Path(__file__).parent.parent / 'backend'))
from app.database import init_db
init_db()
print("Database initialized successfully.")
except Exception as e:
print(f"Could not initialize database: {e}")
print("Please start the backend first to initialize the database.")
sys.exit(1)
print(f"Using database: {db_path}") print(f"Using database: {db_path}")
print(f"Data directory: {data_dir}") print(f"Data directory: {data_dir}")
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""
Initialize the database before other startup scripts run.
This ensures the database exists and has all required tables
before download_sample_eyes.py tries to use it.
"""
import os
import sys
from pathlib import Path
# Add backend to path - handle both Docker and local environments
# In Docker: backend is at /app/
# Locally: backend is at ./backend/
if Path('/app').exists():
sys.path.insert(0, '/app')
else:
sys.path.insert(0, str(Path(__file__).parent.parent / 'backend'))
def main():
# Import after path setup
from app.database import engine, Base, init_db
from app.models import project, user, patch
print("Initializing database...")
# Create all tables
init_db()
# Verify database was created - check both Docker and local paths
docker_db_path = Path('/app/data/ai_photo_edit.db')
local_db_path = Path('./data/ai_photo_edit.db')
if docker_db_path.exists():
print(f"✓ Database initialized at: {docker_db_path}")
elif local_db_path.exists():
print(f"✓ Database initialized at: {local_db_path}")
else:
print("⚠ Database file not found at expected locations, but tables may still be created")
print("Database initialization complete.")
if __name__ == '__main__':
main()