Merge pull request #11 from outis1one/claude/add-eye-detection-feature-69XOl

Claude/add eye detection feature 69 x ol
This commit is contained in:
outis1one
2026-01-25 12:01:10 -05:00
committed by GitHub
14 changed files with 1445 additions and 17 deletions
+11 -3
View File
@@ -2,10 +2,15 @@ FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
# Install system dependencies for OpenCV, rembg, and image processing
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
wget \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
@@ -14,11 +19,14 @@ COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Pre-download rembg model (u2net) to avoid first-run delay
RUN python -c "from rembg import remove; print('rembg model downloaded')" || true
# Copy application
COPY . .
# Create data directory
RUN mkdir -p /app/data/projects
# Create data directories
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
# Expose port
EXPOSE 8000
+2 -1
View File
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from app.config import settings
from app.database import init_db
from app.routers import projects, edits, images, patches, generate
from app.routers import projects, edits, images, patches, generate, tools
@asynccontextmanager
@@ -37,6 +37,7 @@ app.include_router(edits.router)
app.include_router(images.router)
app.include_router(patches.router)
app.include_router(generate.router)
app.include_router(tools.router)
@app.get("/")
+454
View File
@@ -0,0 +1,454 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import Response
from sqlalchemy.orm import Session
from typing import Optional
from PIL import Image
from io import BytesIO
import numpy as np
import json
from app.database import get_db
from app.models.project import Project
from app.schemas import StatusResponse
router = APIRouter(prefix="/tools", tags=["tools"])
@router.post("/remove-background")
async def remove_background(
project_id: Optional[int] = Form(None),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db)
):
"""
Remove background from an image using rembg.
Either provide project_id to use current project image,
or upload a file directly.
Returns PNG with transparent background.
"""
try:
from rembg import remove
except ImportError:
raise HTTPException(
status_code=500,
detail="rembg not installed. Run: pip install rembg"
)
# Get image bytes
if file:
image_bytes = await file.read()
elif project_id:
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
edit_service = EditService()
image_path = edit_service.get_current_image_path(project_id)
with open(image_path, 'rb') as f:
image_bytes = f.read()
else:
raise HTTPException(
status_code=400,
detail="Provide either project_id or file"
)
# Remove background
result_bytes = remove(image_bytes)
return Response(
content=result_bytes,
media_type="image/png",
headers={"Content-Disposition": "inline; filename=no-background.png"}
)
@router.post("/remove-background-to-layer")
async def remove_background_to_layer(
project_id: int = Form(...),
db: Session = Depends(get_db)
):
"""
Remove background and save as a new layer in the project.
Returns layer info that can be added to frontend layer system.
"""
try:
from rembg import remove
except ImportError:
raise HTTPException(
status_code=500,
detail="rembg not installed. Run: pip install rembg"
)
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
from pathlib import Path
edit_service = EditService()
image_path = edit_service.get_current_image_path(project_id)
with open(image_path, 'rb') as f:
image_bytes = f.read()
# Remove background
result_bytes = remove(image_bytes)
# Save as layer file
project_dir = edit_service.get_project_dir(project_id)
layers_dir = project_dir / 'layers'
layers_dir.mkdir(exist_ok=True)
# Find next layer number
existing_layers = list(layers_dir.glob('layer_*.png'))
layer_num = len(existing_layers) + 1
layer_path = layers_dir / f'layer_{layer_num}.png'
with open(layer_path, 'wb') as f:
f.write(result_bytes)
# Get dimensions
img = Image.open(BytesIO(result_bytes))
return {
"status": "success",
"layer": {
"id": layer_num,
"name": f"No Background {layer_num}",
"path": str(layer_path),
"width": img.width,
"height": img.height,
"type": "background_removed"
}
}
@router.post("/smart-select")
async def smart_select(
project_id: int = Form(...),
point_x: int = Form(...),
point_y: int = Form(...),
db: Session = Depends(get_db)
):
"""
Use SAM (Segment Anything) to select object at given point.
Returns mask for the selected object.
Note: Requires SAM model to be downloaded.
Falls back to simple flood-fill selection if SAM unavailable.
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
edit_service = EditService()
image_path = edit_service.get_current_image_path(project_id)
img = Image.open(image_path).convert('RGB')
img_array = np.array(img)
# Try SAM first, fall back to flood fill
try:
mask = await _sam_select(img_array, point_x, point_y)
except Exception as e:
print(f"SAM not available, using flood fill: {e}")
mask = _flood_fill_select(img_array, point_x, point_y)
# Convert mask to PNG
mask_img = Image.fromarray((mask * 255).astype(np.uint8), mode='L')
buffer = BytesIO()
mask_img.save(buffer, format='PNG')
return Response(
content=buffer.getvalue(),
media_type="image/png"
)
async def _sam_select(img_array: np.ndarray, x: int, y: int) -> np.ndarray:
"""Use Segment Anything Model for selection via Replicate API"""
import httpx
import base64
import asyncio
from app.config import settings
if not settings.replicate_api_key:
raise ValueError("REPLICATE_API_KEY not configured")
# Convert image to base64
img = Image.fromarray(img_array)
buffer = BytesIO()
img.save(buffer, format='PNG')
img_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
# Call SAM via Replicate
async with httpx.AsyncClient(timeout=120.0) as client:
# Use SAM model on Replicate
prediction_data = {
"version": "meta/sam-2-image:fe97b453d6525baeeb530595c74a3c4f567c1f655ee2a0fee11f76bd1d31e495",
"input": {
"image": f"data:image/png;base64,{img_b64}",
"point_coords": f"{x},{y}",
"point_labels": "1", # 1 = foreground point
}
}
headers = {
'Authorization': f'Bearer {settings.replicate_api_key}',
'Content-Type': 'application/json'
}
# Start prediction
response = await client.post(
"https://api.replicate.com/v1/predictions",
json=prediction_data,
headers=headers
)
if response.status_code != 201:
raise Exception(f"Replicate API error: {response.text}")
prediction = response.json()
# Poll for completion
prediction_url = prediction['urls']['get']
max_attempts = 60
attempt = 0
while attempt < max_attempts:
await asyncio.sleep(2)
status_response = await client.get(prediction_url, headers=headers)
status_data = status_response.json()
if status_data['status'] == 'succeeded':
# Download mask image
mask_url = status_data['output']
if isinstance(mask_url, list):
mask_url = mask_url[0]
mask_response = await client.get(mask_url)
mask_img = Image.open(BytesIO(mask_response.content)).convert('L')
# Resize if needed
if mask_img.size != (img_array.shape[1], img_array.shape[0]):
mask_img = mask_img.resize(
(img_array.shape[1], img_array.shape[0]),
Image.Resampling.LANCZOS
)
return np.array(mask_img) // 255 # Normalize to 0-1
elif status_data['status'] == 'failed':
raise Exception(f"SAM prediction failed: {status_data.get('error')}")
attempt += 1
raise Exception("SAM prediction timed out")
def _flood_fill_select(img_array: np.ndarray, x: int, y: int, tolerance: int = 32) -> np.ndarray:
"""Simple flood-fill based selection with color tolerance"""
import cv2
h, w = img_array.shape[:2]
# Ensure point is within bounds
x = max(0, min(x, w - 1))
y = max(0, min(y, h - 1))
# Create mask for flood fill (needs to be 2 pixels larger)
mask = np.zeros((h + 2, w + 2), np.uint8)
# Flood fill
cv2.floodFill(
img_array.copy(),
mask,
(x, y),
(255, 255, 255),
(tolerance, tolerance, tolerance),
(tolerance, tolerance, tolerance),
cv2.FLOODFILL_MASK_ONLY
)
# Extract the actual mask (remove padding)
return mask[1:-1, 1:-1]
@router.post("/color-select")
async def color_select(
project_id: int = Form(...),
color_r: int = Form(...),
color_g: int = Form(...),
color_b: int = Form(...),
tolerance: int = Form(30),
db: Session = Depends(get_db)
):
"""
Select all pixels similar to the given color.
Returns a mask of selected areas.
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
edit_service = EditService()
image_path = edit_service.get_current_image_path(project_id)
img = Image.open(image_path).convert('RGB')
img_array = np.array(img)
# Target color
target = np.array([color_r, color_g, color_b])
# Calculate color distance
diff = np.abs(img_array.astype(np.int16) - target.astype(np.int16))
distance = np.sum(diff, axis=2)
# Create mask where distance is within tolerance
mask = (distance <= tolerance * 3).astype(np.uint8) * 255
# Convert to PNG
mask_img = Image.fromarray(mask, mode='L')
buffer = BytesIO()
mask_img.save(buffer, format='PNG')
return Response(
content=buffer.getvalue(),
media_type="image/png"
)
@router.post("/extract-object")
async def extract_object(
project_id: int = Form(...),
mask: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""
Extract object using provided mask.
Returns PNG with transparent background containing only the masked area.
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
edit_service = EditService()
image_path = edit_service.get_current_image_path(project_id)
# Load image and mask
img = Image.open(image_path).convert('RGBA')
mask_bytes = await mask.read()
mask_img = Image.open(BytesIO(mask_bytes)).convert('L')
# Resize mask if needed
if mask_img.size != img.size:
mask_img = mask_img.resize(img.size, Image.Resampling.LANCZOS)
# Apply mask as alpha channel
img_array = np.array(img)
mask_array = np.array(mask_img)
# Set alpha channel based on mask
img_array[:, :, 3] = mask_array
result = Image.fromarray(img_array, mode='RGBA')
buffer = BytesIO()
result.save(buffer, format='PNG')
return Response(
content=buffer.getvalue(),
media_type="image/png"
)
@router.get("/layers/{project_id}")
async def list_layers(
project_id: int,
db: Session = Depends(get_db)
):
"""List all layers for a project"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
from pathlib import Path
edit_service = EditService()
project_dir = edit_service.get_project_dir(project_id)
layers_dir = project_dir / 'layers'
if not layers_dir.exists():
return {"layers": []}
layers = []
for layer_file in sorted(layers_dir.glob('layer_*.png')):
img = Image.open(layer_file)
layer_num = int(layer_file.stem.split('_')[1])
layers.append({
"id": layer_num,
"name": f"Layer {layer_num}",
"path": str(layer_file),
"width": img.width,
"height": img.height
})
return {"layers": layers}
@router.post("/flatten-layers")
async def flatten_layers(
project_id: int = Form(...),
layer_order: str = Form(...), # JSON array of layer IDs in order
db: Session = Depends(get_db)
):
"""
Flatten all layers into a single image and save as current.
layer_order is a JSON array like [1, 2, 3] from bottom to top.
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
from app.services.edit_service import EditService
from pathlib import Path
edit_service = EditService()
project_dir = edit_service.get_project_dir(project_id)
layers_dir = project_dir / 'layers'
order = json.loads(layer_order)
# Start with original image as base
base_path = edit_service.get_current_image_path(project_id)
result = Image.open(base_path).convert('RGBA')
# Composite layers in order
for layer_id in order:
layer_path = layers_dir / f'layer_{layer_id}.png'
if layer_path.exists():
layer = Image.open(layer_path).convert('RGBA')
# Resize if needed
if layer.size != result.size:
layer = layer.resize(result.size, Image.Resampling.LANCZOS)
result = Image.alpha_composite(result, layer)
# Save as current
result.save(base_path, 'PNG')
return StatusResponse(
status="success",
message="Layers flattened successfully"
)
+8 -4
View File
@@ -188,9 +188,11 @@ class EditService:
if not result_path.exists():
raise FileNotFoundError(f"Edit {edit_id} result not found")
# Copy result to current
# Copy result to current (preserve alpha channel)
current_path = self.get_current_image_path(project_id)
Image.open(result_path).save(current_path)
img = Image.open(result_path)
# Preserve original mode to maintain transparency
img.save(current_path, format='PNG')
return str(current_path)
@@ -210,7 +212,9 @@ class EditService:
if not original_path.exists():
raise FileNotFoundError(f"Original image for project {project_id} not found")
# Copy original to current
Image.open(original_path).save(current_path)
# Copy original to current (preserve alpha channel)
img = Image.open(original_path)
# Preserve original mode to maintain transparency
img.save(current_path, format='PNG')
return str(current_path)
+18 -7
View File
@@ -55,19 +55,22 @@ def blend_patch(
original_patch: Image.Image,
regenerated_patch: Image.Image,
mask: Image.Image,
feather_px: int = 0
feather_px: int = 0,
preserve_alpha: bool = True
) -> Image.Image:
"""
Blend regenerated patch with original using mask
Blend regenerated patch with original using mask.
Preserves original alpha channel for semi-transparent areas (veils, glass, etc).
Args:
original_patch: Original cropped patch
regenerated_patch: AI-regenerated patch
mask: Binary mask (same size as patches)
feather_px: Feather radius for smooth blending
preserve_alpha: If True, preserves original alpha channel
Returns:
Blended patch
Blended patch with preserved transparency
"""
# Ensure all images are the same size
if regenerated_patch.size != original_patch.size:
@@ -83,12 +86,20 @@ def blend_patch(
# Apply feathering to mask
feathered_mask = create_feathered_mask(mask, feather_px)
# Convert images to RGBA
original_patch = original_patch.convert('RGBA')
regenerated_patch = regenerated_patch.convert('RGBA')
# Convert images to RGBA, storing original alpha
original_rgba = original_patch.convert('RGBA')
original_alpha = original_rgba.split()[3] # Store original alpha channel
regenerated_rgba = regenerated_patch.convert('RGBA')
# Blend using the feathered mask
blended = Image.composite(regenerated_patch, original_patch, feathered_mask)
blended = Image.composite(regenerated_rgba, original_rgba, feathered_mask)
# Restore original alpha channel to preserve transparency
# This keeps semi-transparent areas (veils, glass, smoke) intact
if preserve_alpha:
r, g, b, _ = blended.split()
blended = Image.merge('RGBA', (r, g, b, original_alpha))
return blended
+2
View File
@@ -14,3 +14,5 @@ pydantic-settings==2.1.0
email-validator==2.1.0
opencv-python-headless==4.9.0.80
scikit-image==0.22.0
rembg==2.0.50
onnxruntime==1.16.3
+4 -1
View File
@@ -118,7 +118,10 @@
.right-panel {
display: flex;
flex-direction: column;
gap: 20px;
gap: 12px;
overflow-y: auto;
max-height: calc(100vh - 180px);
padding-right: 8px;
}
.history-wrapper {
+55 -1
View File
@@ -3,7 +3,9 @@ import ImageCanvas from './components/ImageCanvas';
import Controls from './components/Controls';
import History from './components/History';
import EyeCatalog from './components/EyeCatalog';
import { projectsApi, editsApi } from './utils/api';
import AdvancedTools from './components/AdvancedTools';
import Layers from './components/Layers';
import { projectsApi, editsApi, toolsApi } from './utils/api';
import './App.css';
function App() {
@@ -21,6 +23,9 @@ function App() {
const [projectName, setProjectName] = useState('');
const [showProjectInput, setShowProjectInput] = useState(true);
const [currentEditIndex, setCurrentEditIndex] = useState(-1);
const [layers, setLayers] = useState([]);
const [activeLayer, setActiveLayer] = useState('background');
const [generatedMask, setGeneratedMask] = useState(null);
const editsRef = useRef([]);
// Create project and upload image
@@ -273,6 +278,32 @@ function App() {
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [project, edits]);
// Handle layer creation from advanced tools
const handleLayerCreated = (layer) => {
setLayers((prev) => [...prev, { ...layer, visible: true }]);
};
// 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
};
// Handle flatten layers
const handleFlattenLayers = async (layerOrder) => {
if (!project) return;
try {
setIsProcessing(true);
await toolsApi.flattenLayers(project.id, layerOrder);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
setLayers([]);
} catch (err) {
setError(`Failed to flatten layers: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
return (
<div className="app">
<div className="container">
@@ -349,6 +380,29 @@ function App() {
hasSelection={!!selection}
/>
<AdvancedTools
projectId={project?.id}
selection={selection}
onLayerCreated={handleLayerCreated}
onMaskGenerated={handleMaskGenerated}
onImageUpdate={() => {
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
}}
isProcessing={isProcessing}
setIsProcessing={setIsProcessing}
setError={setError}
/>
<Layers
projectId={project?.id}
layers={layers}
setLayers={setLayers}
activeLayer={activeLayer}
setActiveLayer={setActiveLayer}
onFlatten={handleFlattenLayers}
isProcessing={isProcessing}
/>
<EyeCatalog
projectId={project?.id}
selection={selection}
+107
View File
@@ -0,0 +1,107 @@
.advanced-tools {
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444;
padding: 16px;
}
.advanced-tools h3 {
margin: 0 0 16px 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tool-group {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #333;
}
.tool-group:last-of-type {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.tool-group h4 {
font-size: 12px;
color: #888;
margin: 0 0 8px 0;
text-transform: uppercase;
}
.tool-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tool-btn {
flex: 1;
min-width: 100px;
background-color: #3a3a3a;
color: #ffffff;
padding: 10px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid #555;
transition: all 0.2s;
}
.tool-btn:hover:not(:disabled) {
background-color: #4a4a4a;
border-color: #666;
}
.tool-btn.active {
background-color: #0066ff;
border-color: #0066ff;
color: white;
}
.tool-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.color-tolerance {
margin-top: 10px;
padding: 10px;
background-color: #333;
border-radius: 4px;
}
.color-tolerance label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 6px;
}
.color-tolerance input[type="range"] {
width: 100%;
}
.tool-hint {
font-size: 11px;
color: #00ff00;
margin-top: 8px;
padding: 8px;
background-color: rgba(0, 255, 0, 0.1);
border-radius: 4px;
text-align: center;
}
.cancel-mode-btn {
width: 100%;
margin-top: 12px;
background-color: #ff4444;
color: white;
padding: 8px;
font-size: 12px;
}
.cancel-mode-btn:hover {
background-color: #cc0000;
}
+182
View File
@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { toolsApi } from '../utils/api';
import './AdvancedTools.css';
const AdvancedTools = ({
projectId,
selection,
onLayerCreated,
onMaskGenerated,
onImageUpdate,
isProcessing,
setIsProcessing,
setError,
}) => {
const [activeToolMode, setActiveToolMode] = useState(null);
const [colorTolerance, setColorTolerance] = useState(30);
const handleRemoveBackground = async () => {
if (!projectId) return;
try {
setIsProcessing(true);
setError(null);
const result = await toolsApi.removeBackgroundToLayer(projectId);
onLayerCreated(result.layer);
} catch (err) {
setError(`Background removal failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
const handleSmartSelect = () => {
setActiveToolMode(activeToolMode === 'smart-select' ? null : 'smart-select');
};
const handleColorSelect = () => {
setActiveToolMode(activeToolMode === 'color-select' ? null : 'color-select');
};
const handleObjectRemove = () => {
setActiveToolMode(activeToolMode === 'object-remove' ? null : 'object-remove');
};
// Called when user clicks on canvas in smart-select mode
const onCanvasClick = async (x, y) => {
if (!projectId || !activeToolMode) return;
if (activeToolMode === 'smart-select') {
try {
setIsProcessing(true);
const maskBlob = await toolsApi.smartSelect(projectId, x, y);
onMaskGenerated(maskBlob, 'smart-select');
} catch (err) {
setError(`Smart select failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
}
};
// Called when user picks a color for color selection
const onColorPicked = async (r, g, b) => {
if (!projectId) return;
try {
setIsProcessing(true);
const maskBlob = await toolsApi.colorSelect(projectId, r, g, b, colorTolerance);
onMaskGenerated(maskBlob, 'color-select');
} catch (err) {
setError(`Color select failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
const handleExtractObject = async () => {
if (!projectId || !selection) {
setError('Make a selection first');
return;
}
// For this we need the mask from the current selection
// This would be generated from the selection shape
setError('Extract requires a mask - use Smart Select or Color Select first');
};
return (
<div className="advanced-tools">
<h3>Advanced Tools</h3>
<div className="tool-group">
<h4>Background</h4>
<button
className="tool-btn"
onClick={handleRemoveBackground}
disabled={isProcessing || !projectId}
title="Remove background and create new layer"
>
Remove Background
</button>
</div>
<div className="tool-group">
<h4>Selection Tools</h4>
<div className="tool-buttons">
<button
className={`tool-btn ${activeToolMode === 'smart-select' ? 'active' : ''}`}
onClick={handleSmartSelect}
disabled={isProcessing || !projectId}
title="Click on object to select it"
>
Smart Select
</button>
<button
className={`tool-btn ${activeToolMode === 'color-select' ? 'active' : ''}`}
onClick={handleColorSelect}
disabled={isProcessing || !projectId}
title="Select all similar colors"
>
Color Select
</button>
</div>
{activeToolMode === 'color-select' && (
<div className="color-tolerance">
<label>Tolerance: {colorTolerance}</label>
<input
type="range"
min="1"
max="100"
value={colorTolerance}
onChange={(e) => setColorTolerance(parseInt(e.target.value))}
/>
</div>
)}
{activeToolMode && (
<p className="tool-hint">
{activeToolMode === 'smart-select'
? 'Click on an object to select it'
: 'Click on a color to select all similar pixels'}
</p>
)}
</div>
<div className="tool-group">
<h4>Object Tools</h4>
<button
className={`tool-btn ${activeToolMode === 'object-remove' ? 'active' : ''}`}
onClick={handleObjectRemove}
disabled={isProcessing || !projectId}
title="Remove selected object (uses AI)"
>
Object Remove
</button>
<button
className="tool-btn"
onClick={handleExtractObject}
disabled={isProcessing || !projectId || !selection}
title="Extract selected area to new layer"
>
Extract to Layer
</button>
</div>
{activeToolMode && (
<button
className="cancel-mode-btn"
onClick={() => setActiveToolMode(null)}
>
Cancel Tool
</button>
)}
</div>
);
};
// Export the click handler for parent component to use
AdvancedTools.handleCanvasClick = null;
export default AdvancedTools;
+152
View File
@@ -0,0 +1,152 @@
.layers-panel {
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444;
padding: 12px;
}
.layers-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.layers-header h3 {
margin: 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.flatten-btn {
background-color: #555;
color: white;
padding: 4px 10px;
font-size: 11px;
}
.flatten-btn:hover:not(:disabled) {
background-color: #666;
}
.layers-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #333;
border-radius: 4px;
margin-bottom: 10px;
}
.layer-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background-color: #333;
border-bottom: 1px solid #444;
cursor: pointer;
transition: background-color 0.15s;
}
.layer-item:last-child {
border-bottom: none;
}
.layer-item:hover {
background-color: #3a3a3a;
}
.layer-item.active {
background-color: #0066ff33;
border-left: 3px solid #0066ff;
}
.layer-item.dragging {
opacity: 0.5;
background-color: #444;
}
.layer-visibility {
flex-shrink: 0;
}
.layer-visibility input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
.layer-preview {
width: 32px;
height: 32px;
background-color: #222;
border: 1px solid #555;
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.layer-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.layer-preview.background-preview {
background: repeating-conic-gradient(#444 0% 25%, #333 0% 50%) 50% / 8px 8px;
}
.layer-name {
flex: 1;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-lock {
font-size: 10px;
opacity: 0.5;
}
.layer-drag-handle {
cursor: grab;
opacity: 0.5;
font-size: 10px;
}
.layer-drag-handle:active {
cursor: grabbing;
}
.no-layers-hint {
padding: 16px;
text-align: center;
color: #666;
font-size: 11px;
}
.layers-actions {
display: flex;
gap: 6px;
}
.layer-action-btn {
flex: 1;
background-color: #3a3a3a;
color: #ccc;
padding: 6px 8px;
font-size: 10px;
border: 1px solid #555;
}
.layer-action-btn:hover:not(:disabled) {
background-color: #4a4a4a;
color: white;
}
.layer-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+167
View File
@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import { toolsApi } from '../utils/api';
import './Layers.css';
const Layers = ({
projectId,
layers,
setLayers,
activeLayer,
setActiveLayer,
onLayerVisibilityChange,
onFlatten,
isProcessing,
}) => {
const [draggedLayer, setDraggedLayer] = useState(null);
// Load layers on mount and when projectId changes
useEffect(() => {
if (projectId) {
loadLayers();
}
}, [projectId]);
const loadLayers = async () => {
if (!projectId) return;
try {
const result = await toolsApi.listLayers(projectId);
setLayers(result.layers || []);
} catch (err) {
console.error('Failed to load layers:', err);
}
};
const handleDragStart = (e, index) => {
setDraggedLayer(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e, index) => {
e.preventDefault();
if (draggedLayer === null || draggedLayer === index) return;
// Reorder layers
const newLayers = [...layers];
const [removed] = newLayers.splice(draggedLayer, 1);
newLayers.splice(index, 0, removed);
setLayers(newLayers);
setDraggedLayer(index);
};
const handleDragEnd = () => {
setDraggedLayer(null);
};
const toggleVisibility = (layerId) => {
const layer = layers.find((l) => l.id === layerId);
if (layer) {
layer.visible = !layer.visible;
setLayers([...layers]);
onLayerVisibilityChange?.(layerId, layer.visible);
}
};
const handleFlatten = async () => {
if (!projectId || layers.length === 0) return;
const visibleLayers = layers.filter((l) => l.visible !== false);
const layerOrder = visibleLayers.map((l) => l.id);
await onFlatten(layerOrder);
loadLayers();
};
return (
<div className="layers-panel">
<div className="layers-header">
<h3>Layers</h3>
{layers.length > 0 && (
<button
className="flatten-btn"
onClick={handleFlatten}
disabled={isProcessing}
title="Merge all visible layers"
>
Flatten
</button>
)}
</div>
<div className="layers-list">
{/* Background layer (always present) */}
<div
className={`layer-item ${activeLayer === 'background' ? 'active' : ''}`}
onClick={() => setActiveLayer('background')}
>
<span className="layer-visibility">
<input type="checkbox" checked disabled />
</span>
<span className="layer-preview background-preview"></span>
<span className="layer-name">Background</span>
<span className="layer-lock">🔒</span>
</div>
{/* Dynamic layers */}
{layers.map((layer, index) => (
<div
key={layer.id}
className={`layer-item ${activeLayer === layer.id ? 'active' : ''} ${
draggedLayer === index ? 'dragging' : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onClick={() => setActiveLayer(layer.id)}
>
<span className="layer-visibility">
<input
type="checkbox"
checked={layer.visible !== false}
onChange={() => toggleVisibility(layer.id)}
onClick={(e) => e.stopPropagation()}
/>
</span>
<span className="layer-preview">
{layer.thumbnail && (
<img src={layer.thumbnail} alt={layer.name} />
)}
</span>
<span className="layer-name">{layer.name}</span>
<span className="layer-drag-handle"></span>
</div>
))}
{layers.length === 0 && (
<div className="no-layers-hint">
Use "Remove Background" to create layers
</div>
)}
</div>
<div className="layers-actions">
<button
className="layer-action-btn"
disabled={isProcessing || !projectId}
title="Add new empty layer"
>
+ New Layer
</button>
<button
className="layer-action-btn"
disabled={isProcessing || activeLayer === 'background'}
title="Delete selected layer"
>
Delete
</button>
<button
className="layer-action-btn"
disabled={isProcessing || activeLayer === 'background'}
title="Duplicate selected layer"
>
Duplicate
</button>
</div>
</div>
);
};
export default Layers;
+90
View File
@@ -146,4 +146,94 @@ export const patchesApi = {
`${API_BASE_URL}/patches/${patchId}/image${thumbnail ? '?thumbnail=true' : ''}`,
};
export const toolsApi = {
// Remove background from project image
removeBackground: async (projectId) => {
const formData = new FormData();
formData.append('project_id', projectId);
const response = await api.post('/tools/remove-background', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
});
return response.data;
},
// Remove background and save as layer
removeBackgroundToLayer: async (projectId) => {
const formData = new FormData();
formData.append('project_id', projectId);
const response = await api.post('/tools/remove-background-to-layer', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
// Smart select object at point
smartSelect: async (projectId, x, y) => {
const formData = new FormData();
formData.append('project_id', projectId);
formData.append('point_x', x);
formData.append('point_y', y);
const response = await api.post('/tools/smart-select', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
});
return response.data;
},
// Select by color
colorSelect: async (projectId, r, g, b, tolerance = 30) => {
const formData = new FormData();
formData.append('project_id', projectId);
formData.append('color_r', r);
formData.append('color_g', g);
formData.append('color_b', b);
formData.append('tolerance', tolerance);
const response = await api.post('/tools/color-select', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
});
return response.data;
},
// Extract object with mask
extractObject: async (projectId, maskBlob) => {
const formData = new FormData();
formData.append('project_id', projectId);
formData.append('mask', maskBlob, 'mask.png');
const response = await api.post('/tools/extract-object', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
});
return response.data;
},
// List layers
listLayers: async (projectId) => {
const response = await api.get(`/tools/layers/${projectId}`);
return response.data;
},
// Flatten layers
flattenLayers: async (projectId, layerOrder) => {
const formData = new FormData();
formData.append('project_id', projectId);
formData.append('layer_order', JSON.stringify(layerOrder));
const response = await api.post('/tools/flatten-layers', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
// Get layer image URL
getLayerImageUrl: (projectId, layerId) =>
`${API_BASE_URL}/projects/${projectId}/layers/layer_${layerId}.png`,
};
export default api;
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Download sample classical eye images and import them into the patch catalog.
These are public domain images from Wikimedia Commons of classical sculptures.
Run this script to populate the eye catalog with example eyes.
Usage:
cd /home/user/EditmaskwithAI
python scripts/download_sample_eyes.py
"""
import os
import sys
import requests
import sqlite3
from pathlib import Path
from PIL import Image
from io import BytesIO
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent / 'backend'))
# Sample eye images - public domain classical sculpture references
# These URLs point to Wikimedia Commons images of ancient sculptures
SAMPLE_EYES = [
{
'name': 'Greek Serene - Left',
'url': 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Head_Hygieia_BM_550.jpg/220px-Head_Hygieia_BM_550.jpg',
'tags': 'greek,serene,left,marble',
'category': 'eyes',
'description': 'Classical Greek style eye from Hygieia statue'
},
{
'name': 'Roman Portrait - Right',
'url': 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Bust_of_Emperor_Philip_the_Arab_-_Hermitage_Museum.jpg/220px-Bust_of_Emperor_Philip_the_Arab_-_Hermitage_Museum.jpg',
'tags': 'roman,portrait,right,marble',
'category': 'eyes',
'description': 'Roman portrait style eye'
},
{
'name': 'Greek Classical - Pair',
'url': 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Marble_head_of_a_veiled_woman_MET_DT229963.jpg/220px-Marble_head_of_a_veiled_woman_MET_DT229963.jpg',
'tags': 'greek,classical,pair,marble,veiled',
'category': 'eyes',
'description': 'Greek classical style veiled woman'
},
]
def download_image(url: str) -> bytes:
"""Download image from URL and return bytes"""
headers = {
'User-Agent': 'Mozilla/5.0 (compatible; EyeCatalogDownloader/1.0)'
}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
return response.content
def create_patch_directory(patch_id: int, data_dir: Path) -> Path:
"""Create directory for patch files"""
patch_dir = data_dir / 'patches' / str(patch_id)
patch_dir.mkdir(parents=True, exist_ok=True)
return patch_dir
def save_patch_and_thumbnail(image_bytes: bytes, patch_dir: Path) -> tuple:
"""Save patch image and create thumbnail"""
# Open image
img = Image.open(BytesIO(image_bytes)).convert('RGBA')
# Save full size
patch_path = patch_dir / 'patch.png'
img.save(patch_path, 'PNG')
# Create thumbnail (max 200x200)
thumb = img.copy()
thumb.thumbnail((200, 200), Image.Resampling.LANCZOS)
thumb_path = patch_dir / 'thumbnail.png'
thumb.save(thumb_path, 'PNG')
return patch_path, thumb_path, img.size
def import_eye_to_database(db_path: Path, eye_data: dict, patch_path: str, thumb_path: str, width: int, height: int) -> int:
"""Insert patch record into database"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO patches (name, description, source_type, category, tags, file_path, thumbnail_path, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
eye_data['name'],
eye_data.get('description', ''),
'imported',
eye_data['category'],
eye_data['tags'],
str(patch_path),
str(thumb_path),
width,
height
))
patch_id = cursor.lastrowid
conn.commit()
conn.close()
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'
# 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(f"Using database: {db_path}")
print(f"Data directory: {data_dir}")
print()
# Check if patches table exists
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='patches'")
if not cursor.fetchone():
print("Patches table not found. Creating it...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS patches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
description TEXT,
source_type VARCHAR NOT NULL,
category VARCHAR,
tags TEXT,
file_path VARCHAR,
thumbnail_path VARCHAR,
width INTEGER,
height INTEGER,
source_project_id INTEGER,
source_edit_id INTEGER,
user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
successful = 0
failed = 0
for eye_data in SAMPLE_EYES:
print(f"Downloading: {eye_data['name']}...")
try:
# Download image
image_bytes = download_image(eye_data['url'])
# Get next patch ID
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM patches")
next_id = cursor.fetchone()[0]
conn.close()
# Create directory and save files
patch_dir = create_patch_directory(next_id, data_dir)
patch_path, thumb_path, (width, height) = save_patch_and_thumbnail(image_bytes, patch_dir)
# Import to database
patch_id = import_eye_to_database(db_path, eye_data, patch_path, thumb_path, width, height)
print(f" ✓ Imported as patch #{patch_id} ({width}x{height})")
successful += 1
except Exception as e:
print(f" ✗ Failed: {e}")
failed += 1
print()
print(f"Done! Imported {successful} eyes, {failed} failed.")
print()
print("You can now see the eyes in the Eye Catalog panel in the web UI.")
print("To add your own eyes:")
print(" 1. Click '+ Add Eye' in the Eye Catalog")
print(" 2. Upload a PNG image (transparency works best)")
print(" 3. Give it a name and tags")
if __name__ == '__main__':
main()