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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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