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:
+11
-3
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Executable
+193
@@ -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()
|
||||
Reference in New Issue
Block a user