From 7a86d53c8807b11a732e44ddaad1a26146f447a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 15:08:50 +0000 Subject: [PATCH 1/3] Add script to download sample eye images for catalog Creates scripts/download_sample_eyes.py that: - Downloads public domain classical sculpture images from Wikimedia - Creates patch records in database - Generates thumbnails - Populates the Eye Catalog with starter content --- scripts/download_sample_eyes.py | 193 ++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100755 scripts/download_sample_eyes.py diff --git a/scripts/download_sample_eyes.py b/scripts/download_sample_eyes.py new file mode 100755 index 0000000..6dacbf5 --- /dev/null +++ b/scripts/download_sample_eyes.py @@ -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() From 909bb41f8a2ce5c8e9e92bd56c3b941da07c8c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 15:20:11 +0000 Subject: [PATCH 2/3] Add advanced editing features: layers, background removal, smart selection Backend: - Add /tools router with background removal, smart select, color select - Add rembg dependency for AI background removal - Add layer management API (list, flatten) - Fix transparency preservation in blend_patch (veil collapse fix) - Preserve alpha channel when reverting/resetting images Frontend: - Add AdvancedTools panel with background removal, smart select, color select - Add Layers panel with drag-to-reorder, visibility toggle, flatten - Add toolsApi for new backend endpoints - Make right panel scrollable for additional controls This adds "Photoshop light" capabilities: - Remove background and create layer - Smart object selection (click to select) - Color selection with tolerance - Layer system with compositing --- backend/app/main.py | 3 +- backend/app/routers/tools.py | 379 ++++++++++++++++++++++ backend/app/services/edit_service.py | 12 +- backend/app/utils/image_processing.py | 25 +- backend/requirements.txt | 2 + frontend/src/App.css | 5 +- frontend/src/App.jsx | 56 +++- frontend/src/components/AdvancedTools.css | 107 ++++++ frontend/src/components/AdvancedTools.jsx | 182 +++++++++++ frontend/src/components/Layers.css | 152 +++++++++ frontend/src/components/Layers.jsx | 167 ++++++++++ frontend/src/utils/api.js | 90 +++++ 12 files changed, 1166 insertions(+), 14 deletions(-) create mode 100644 backend/app/routers/tools.py create mode 100644 frontend/src/components/AdvancedTools.css create mode 100644 frontend/src/components/AdvancedTools.jsx create mode 100644 frontend/src/components/Layers.css create mode 100644 frontend/src/components/Layers.jsx diff --git a/backend/app/main.py b/backend/app/main.py index f408e8f..f6a95fd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/routers/tools.py b/backend/app/routers/tools.py new file mode 100644 index 0000000..f13edda --- /dev/null +++ b/backend/app/routers/tools.py @@ -0,0 +1,379 @@ +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""" + # This would require SAM to be installed and model downloaded + # For now, raise to fall back to flood fill + raise NotImplementedError("SAM integration pending") + + +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" + ) diff --git a/backend/app/services/edit_service.py b/backend/app/services/edit_service.py index 93965fb..4007b9d 100644 --- a/backend/app/services/edit_service.py +++ b/backend/app/services/edit_service.py @@ -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) diff --git a/backend/app/utils/image_processing.py b/backend/app/utils/image_processing.py index 4f6ae25..978d2e6 100644 --- a/backend/app/utils/image_processing.py +++ b/backend/app/utils/image_processing.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 1114661..d1be434 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/App.css b/frontend/src/App.css index bf7c1fc..86634e7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 19b23ee..a61287e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
@@ -349,6 +380,29 @@ function App() { hasSelection={!!selection} /> + { + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); + }} + isProcessing={isProcessing} + setIsProcessing={setIsProcessing} + setError={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 ( +
+

Advanced Tools

+ +
+

Background

+ +
+ +
+

Selection Tools

+
+ + +
+ + {activeToolMode === 'color-select' && ( +
+ + setColorTolerance(parseInt(e.target.value))} + /> +
+ )} + + {activeToolMode && ( +

+ {activeToolMode === 'smart-select' + ? 'Click on an object to select it' + : 'Click on a color to select all similar pixels'} +

+ )} +
+ +
+

Object Tools

+ + +
+ + {activeToolMode && ( + + )} +
+ ); +}; + +// Export the click handler for parent component to use +AdvancedTools.handleCanvasClick = null; + +export default AdvancedTools; diff --git a/frontend/src/components/Layers.css b/frontend/src/components/Layers.css new file mode 100644 index 0000000..b804f36 --- /dev/null +++ b/frontend/src/components/Layers.css @@ -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; +} diff --git a/frontend/src/components/Layers.jsx b/frontend/src/components/Layers.jsx new file mode 100644 index 0000000..22b50e2 --- /dev/null +++ b/frontend/src/components/Layers.jsx @@ -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 ( +
+
+

Layers

+ {layers.length > 0 && ( + + )} +
+ +
+ {/* Background layer (always present) */} +
setActiveLayer('background')} + > + + + + + Background + 🔒 +
+ + {/* Dynamic layers */} + {layers.map((layer, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + onClick={() => setActiveLayer(layer.id)} + > + + toggleVisibility(layer.id)} + onClick={(e) => e.stopPropagation()} + /> + + + {layer.thumbnail && ( + {layer.name} + )} + + {layer.name} + ⋮⋮ +
+ ))} + + {layers.length === 0 && ( +
+ Use "Remove Background" to create layers +
+ )} +
+ +
+ + + +
+
+ ); +}; + +export default Layers; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 2c75f89..cea392d 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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; From 4c2574ace4a9a6b2c0f8af0440401b609c3e7bb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 16:24:08 +0000 Subject: [PATCH 3/3] Add SAM (Segment Anything) via Replicate API and update Docker - Implement SAM object selection via Replicate API - Click on any object to select it with AI precision - Falls back to flood-fill if Replicate API unavailable - Update Dockerfile for rembg dependencies - Add required system libraries (libsm6, libxext6, etc) - Pre-download rembg model during build - Create data directories for models and patches --- backend/Dockerfile | 14 ++++-- backend/app/routers/tools.py | 83 ++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index cd7fe09..6f85f98 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/app/routers/tools.py b/backend/app/routers/tools.py index f13edda..6145390 100644 --- a/backend/app/routers/tools.py +++ b/backend/app/routers/tools.py @@ -173,10 +173,85 @@ async def smart_select( async def _sam_select(img_array: np.ndarray, x: int, y: int) -> np.ndarray: - """Use Segment Anything Model for selection""" - # This would require SAM to be installed and model downloaded - # For now, raise to fall back to flood fill - raise NotImplementedError("SAM integration pending") + """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: