Merge branch 'main' into claude/add-eye-detection-feature-69XOl
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
# 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_MODEL_OVERRIDE=true
|
||||
|
||||
@@ -6,6 +6,8 @@ from PIL import Image
|
||||
from io import BytesIO
|
||||
import numpy as np
|
||||
import json
|
||||
import base64
|
||||
import cv2
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
@@ -133,11 +135,12 @@ async def smart_select(
|
||||
project_id: int = Form(...),
|
||||
point_x: int = Form(...),
|
||||
point_y: int = Form(...),
|
||||
return_format: str = Form("json"), # "json" (default) or "image"
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
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.
|
||||
Falls back to simple flood-fill selection if SAM unavailable.
|
||||
@@ -163,13 +166,62 @@ async def smart_select(
|
||||
# Convert mask to PNG
|
||||
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()
|
||||
mask_img.save(buffer, format='PNG')
|
||||
mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="image/png"
|
||||
)
|
||||
return {
|
||||
"polygon": polygon,
|
||||
"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)
|
||||
@@ -387,11 +439,12 @@ async def color_select(
|
||||
color_g: int = Form(...),
|
||||
color_b: int = Form(...),
|
||||
tolerance: int = Form(30),
|
||||
return_format: str = Form("json"), # "json" (default) or "image"
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
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()
|
||||
if not project:
|
||||
@@ -412,18 +465,33 @@ async def color_select(
|
||||
distance = np.sum(diff, axis=2)
|
||||
|
||||
# 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
|
||||
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()
|
||||
mask_img.save(buffer, format='PNG')
|
||||
mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="image/png"
|
||||
)
|
||||
return {
|
||||
"polygon": polygon,
|
||||
"bbox": bbox,
|
||||
"mask_base64": mask_b64,
|
||||
"color": {"r": color_r, "g": color_g, "b": color_b},
|
||||
"tolerance": tolerance,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/extract-object")
|
||||
|
||||
+47
-24
@@ -3,9 +3,10 @@
|
||||
# AI Photo Edit - Container Startup Script
|
||||
# =============================================================================
|
||||
# This script runs when the container starts. It:
|
||||
# 1. Downloads sample eye images if the catalog is empty
|
||||
# 2. Ensures all directories exist
|
||||
# 3. Starts the FastAPI server
|
||||
# 1. Initializes the database
|
||||
# 2. Downloads SAM model automatically (can be disabled with AUTO_DOWNLOAD_SAM=false)
|
||||
# 3. Downloads sample eye images if the catalog is empty
|
||||
# 4. Starts the FastAPI server
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
@@ -18,18 +19,15 @@ echo "=========================================="
|
||||
mkdir -p /app/data/projects
|
||||
mkdir -p /app/data/patches
|
||||
mkdir -p /app/data/models
|
||||
mkdir -p /app/data/patch_library
|
||||
|
||||
# Check if eye catalog needs to be populated
|
||||
echo "Checking eye catalog..."
|
||||
PATCHES_COUNT=$(find /app/data/patches -maxdepth 1 -type d | wc -l)
|
||||
|
||||
if [ "$PATCHES_COUNT" -le 1 ]; then
|
||||
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
|
||||
# Initialize database FIRST (before eye import)
|
||||
echo ""
|
||||
echo "Initializing database..."
|
||||
echo "------------------------------------------"
|
||||
cd /app && python /scripts/init_database.py || echo "Warning: Database init failed (non-fatal)"
|
||||
|
||||
# Check and download SAM model automatically
|
||||
echo ""
|
||||
echo "Checking SAM model (Smart Select)..."
|
||||
echo "------------------------------------------"
|
||||
@@ -39,17 +37,42 @@ if [ -f "/app/data/models/sam_model.pth" ] || \
|
||||
[ -f "/app/data/models/sam_vit_h_4b8939.pth" ]; then
|
||||
echo "✓ SAM model found - Smart Select will use local AI (free, offline)"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠ SAM model not found"
|
||||
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 ""
|
||||
echo " Model sizes: vit_b (375MB), vit_l (1.2GB), vit_h (2.5GB)"
|
||||
echo " The model persists across container rebuilds."
|
||||
echo ""
|
||||
# Auto-download SAM unless explicitly disabled
|
||||
AUTO_DOWNLOAD_SAM="${AUTO_DOWNLOAD_SAM:-true}"
|
||||
if [ "$AUTO_DOWNLOAD_SAM" = "true" ]; then
|
||||
echo "SAM model not found. Downloading automatically..."
|
||||
echo "(This is a one-time ~375MB download that persists across rebuilds)"
|
||||
echo ""
|
||||
python /scripts/download_sam_model.py vit_b || {
|
||||
echo ""
|
||||
echo "⚠ SAM download failed (non-fatal)"
|
||||
echo " Smart Select will fall back to Replicate API (requires REPLICATE_API_KEY)"
|
||||
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
|
||||
|
||||
echo ""
|
||||
|
||||
+54
-3
@@ -26,6 +26,9 @@ function App() {
|
||||
const [layers, setLayers] = useState([]);
|
||||
const [activeLayer, setActiveLayer] = useState('background');
|
||||
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([]);
|
||||
|
||||
// Create project and upload image
|
||||
@@ -284,9 +287,49 @@ function App() {
|
||||
};
|
||||
|
||||
// Handle mask generation from smart select / color select
|
||||
const handleMaskGenerated = async (maskBlob, source) => {
|
||||
setGeneratedMask({ blob: maskBlob, source });
|
||||
// The mask can be used for various operations
|
||||
const handleMaskGenerated = async (maskData, source) => {
|
||||
setGeneratedMask({ data: maskData, source });
|
||||
// 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
|
||||
@@ -361,6 +404,11 @@ function App() {
|
||||
imageUrl={currentImageUrl}
|
||||
onSelectionChange={setSelection}
|
||||
selectionMode={selectionMode}
|
||||
advancedToolMode={advancedToolMode}
|
||||
onAdvancedToolClick={handleAdvancedToolClick}
|
||||
zoom={canvasZoom}
|
||||
onZoomChange={setCanvasZoom}
|
||||
externalSelection={externalSelection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -391,6 +439,8 @@ function App() {
|
||||
isProcessing={isProcessing}
|
||||
setIsProcessing={setIsProcessing}
|
||||
setError={setError}
|
||||
activeToolMode={advancedToolMode}
|
||||
setActiveToolMode={setAdvancedToolMode}
|
||||
/>
|
||||
|
||||
<Layers
|
||||
@@ -401,6 +451,7 @@ function App() {
|
||||
setActiveLayer={setActiveLayer}
|
||||
onFlatten={handleFlattenLayers}
|
||||
isProcessing={isProcessing}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
<EyeCatalog
|
||||
|
||||
@@ -11,8 +11,9 @@ const AdvancedTools = ({
|
||||
isProcessing,
|
||||
setIsProcessing,
|
||||
setError,
|
||||
activeToolMode,
|
||||
setActiveToolMode,
|
||||
}) => {
|
||||
const [activeToolMode, setActiveToolMode] = useState(null);
|
||||
const [colorTolerance, setColorTolerance] = useState(30);
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
|
||||
@@ -43,3 +43,66 @@
|
||||
z-index: 10;
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
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 fabricCanvasRef = useRef(null);
|
||||
const [currentSelection, setCurrentSelection] = useState(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [isTransformMode, setIsTransformMode] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(zoom);
|
||||
const lassoPoints = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,11 +60,36 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
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 () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
canvas.off('mouse:wheel', handleWheel);
|
||||
canvas.dispose();
|
||||
};
|
||||
}, []);
|
||||
}, [onZoomChange]);
|
||||
|
||||
// Load image when URL changes
|
||||
useEffect(() => {
|
||||
@@ -94,12 +129,128 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
}, { crossOrigin: 'anonymous' });
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Don't set up selection handlers if in advanced tool mode
|
||||
if (advancedToolMode) return;
|
||||
|
||||
// Clear previous selection when changing modes
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
@@ -123,7 +274,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
} else if (selectionMode === 'lasso') {
|
||||
setupLassoMode(canvas);
|
||||
}
|
||||
}, [selectionMode]);
|
||||
}, [selectionMode, advancedToolMode]);
|
||||
|
||||
const setupRectangleMode = (canvas) => {
|
||||
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 (
|
||||
<div className="canvas-container">
|
||||
<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">
|
||||
Click selection to move/resize/rotate
|
||||
|
||||
@@ -11,6 +11,7 @@ const Layers = ({
|
||||
onLayerVisibilityChange,
|
||||
onFlatten,
|
||||
isProcessing,
|
||||
onError,
|
||||
}) => {
|
||||
const [draggedLayer, setDraggedLayer] = useState(null);
|
||||
|
||||
@@ -69,6 +70,56 @@ const Layers = ({
|
||||
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 (
|
||||
<div className="layers-panel">
|
||||
<div className="layers-header">
|
||||
@@ -142,6 +193,7 @@ const Layers = ({
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Add new empty layer"
|
||||
onClick={handleNewLayer}
|
||||
>
|
||||
+ New Layer
|
||||
</button>
|
||||
@@ -149,6 +201,7 @@ const Layers = ({
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || activeLayer === 'background'}
|
||||
title="Delete selected layer"
|
||||
onClick={handleDeleteLayer}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -156,6 +209,7 @@ const Layers = ({
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || activeLayer === 'background'}
|
||||
title="Duplicate selected layer"
|
||||
onClick={handleDuplicateLayer}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
|
||||
@@ -171,20 +171,22 @@ export const toolsApi = {
|
||||
},
|
||||
|
||||
// Smart select object at point
|
||||
// Returns { polygon, bbox, mask_base64 }
|
||||
smartSelect: async (projectId, x, y) => {
|
||||
const formData = new FormData();
|
||||
formData.append('project_id', projectId);
|
||||
formData.append('point_x', x);
|
||||
formData.append('point_y', y);
|
||||
formData.append('return_format', 'json');
|
||||
|
||||
const response = await api.post('/tools/smart-select', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Select by color
|
||||
// Returns { polygon, bbox, mask_base64, color, tolerance }
|
||||
colorSelect: async (projectId, r, g, b, tolerance = 30) => {
|
||||
const formData = new FormData();
|
||||
formData.append('project_id', projectId);
|
||||
@@ -192,10 +194,10 @@ export const toolsApi = {
|
||||
formData.append('color_g', g);
|
||||
formData.append('color_b', b);
|
||||
formData.append('tolerance', tolerance);
|
||||
formData.append('return_format', 'json');
|
||||
|
||||
const response = await api.post('/tools/color-select', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -106,17 +106,34 @@ def import_eye_to_database(db_path: Path, eye_data: dict, patch_path: str, thumb
|
||||
return patch_id
|
||||
|
||||
def main():
|
||||
# Determine paths
|
||||
script_dir = Path(__file__).parent
|
||||
project_root = script_dir.parent
|
||||
data_dir = project_root / 'data'
|
||||
db_path = data_dir / 'photoedit.db'
|
||||
# Determine paths - handle both Docker and local environments
|
||||
# In Docker: script is at /scripts/, data is at /app/data/
|
||||
# Locally: script is at ./scripts/, data is at ./data/
|
||||
docker_data_dir = Path('/app/data')
|
||||
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
|
||||
if not db_path.exists():
|
||||
print(f"Database not found at {db_path}")
|
||||
print("Please start the backend first to initialize the database.")
|
||||
sys.exit(1)
|
||||
print("Attempting to initialize database...")
|
||||
# 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"Data directory: {data_dir}")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user