Merge pull request #1 from outis1one/claude/ai-photo-edit-tool-c8orl

Claude/ai photo edit tool c8orl
This commit is contained in:
outis1one
2026-01-23 23:09:59 -05:00
committed by GitHub
54 changed files with 5637 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
# AI Provider Configuration
# Options: openai, stability, replicate, mock
# - openai: DALL-E 2 (low quality, not recommended)
# - stability: Stability AI SDXL (good quality, ~$0.04/image)
# - replicate: Multiple models (best value, ~$0.002-0.025/image)
# - mock: No AI, returns original (for testing)
AI_PROVIDER=mock
# API Keys
OPENAI_API_KEY=
STABILITY_API_KEY=
REPLICATE_API_KEY=
# Model Selection (optional, provider-specific)
# Stability AI models: sdxl (default), sd15, sd21
STABILITY_MODEL=sdxl
# Replicate models: sdxl-inpaint (default), lama, realistic-vision
# - sdxl-inpaint: Best general purpose (~$0.025/image)
# - lama: Best for object removal (~$0.002/image)
# - realistic-vision: Best for humans/faces/hands (~$0.020/image)
REPLICATE_MODEL=sdxl-inpaint
# Allow per-edit model override (true/false)
ALLOW_MODEL_OVERRIDE=true
# Secret key for JWT tokens (change in production)
SECRET_KEY=change-this-secret-key-in-production
+70
View File
@@ -0,0 +1,70 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
venv/
env/
ENV/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnp
.pnp.js
coverage/
build/
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
# Data
data/projects/*/
!data/projects/.gitkeep
*.db
*.sqlite
*.sqlite3
# Docker
*.log
docker-compose.override.yml
# Testing
.pytest_cache/
.coverage
htmlcov/
# OS
.DS_Store
Thumbs.db
+86
View File
@@ -0,0 +1,86 @@
# Contributing to AI Photo Edit
Thank you for your interest in contributing to AI Photo Edit!
## Development Setup
1. Fork the repository
2. Clone your fork
3. Create a feature branch
4. Make your changes
5. Test your changes
6. Submit a pull request
## Development Environment
### Using Docker (Recommended)
```bash
# Start dev environment with hot-reload
docker-compose -f docker-compose.dev.yml up
```
### Local Development
**Backend**
```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
**Frontend**
```bash
cd frontend
npm install
npm run dev
```
## Code Style
### Python (Backend)
- Follow PEP 8
- Use type hints where appropriate
- Add docstrings to functions and classes
### JavaScript/React (Frontend)
- Use functional components with hooks
- Follow React best practices
- Use meaningful variable names
## Pull Request Process
1. Update the README.md with details of changes if needed
2. Ensure all tests pass
3. Update documentation as needed
4. Get approval from maintainers
5. Squash commits if requested
## Reporting Bugs
When reporting bugs, please include:
- Description of the issue
- Steps to reproduce
- Expected behavior
- Actual behavior
- Screenshots if applicable
- Environment details (OS, Docker version, etc.)
## Feature Requests
We welcome feature requests! Please:
- Check if the feature already exists
- Explain the use case
- Describe the expected behavior
- Consider if it aligns with project goals
## Code of Conduct
- Be respectful and inclusive
- Welcome newcomers
- Focus on constructive feedback
- Respect differing opinions
Thank you for contributing!
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 AI Photo Edit Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
.PHONY: help up down build logs clean dev test
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
up: ## Start the application (production)
docker-compose up -d
down: ## Stop the application
docker-compose down
build: ## Build all containers
docker-compose build
logs: ## Show logs
docker-compose logs -f
clean: ## Remove all containers, volumes, and data
docker-compose down -v
rm -rf data/
dev: ## Start the application (development mode)
docker-compose -f docker-compose.dev.yml up
test: ## Run tests
@echo "Tests not yet implemented"
restart: ## Restart the application
docker-compose restart
ps: ## Show running containers
docker-compose ps
+354 -1
View File
@@ -1 +1,354 @@
# EditmaskwithAI
# AI Photo Edit
AI Photo Edit is a self-hosted, web-based image editing tool that allows you to regenerate only a selected area of a photo using AI.
## Core Concept
**Have AI regenerate only a selected area of a photo.**
- Upload an image
- Select a specific region (rectangle, ellipse, or freehand lasso)
- Enter a prompt describing what to fix
- Have AI regenerate only the selected region
- Composite the regenerated region back into the original image
- Preserve every pixel outside the selection
- Maintain full edit history and reversibility
**The system does not regenerate the entire image.**
**The system does not alter any pixel outside the selected mask.**
## Features
### Selection Tools
- **Rectangle**: Click and drag to select rectangular regions
- **Ellipse**: Click and drag to select elliptical regions
- **Lasso**: Draw freehand selections around irregular shapes
### AI Modes
- **Mode A (Default)**: Send only the selected patch
- Faster processing
- Lower cost
- Best for isolated fixes
- **Mode B**: Send patch + full image reference
- Better style consistency
- More context-aware results
- Higher cost
### Edge Blending
- Adjustable feather slider (0-50 pixels)
- Smooth blending at selection edges
- Prevents harsh transitions
### Edit History
- Full history of all edits
- Revert to any previous edit
- Reset to original image
- All edits are reversible
## Architecture
```
AI Photo Edit
├── Frontend (React + Fabric.js)
│ ├── Image canvas with selection tools
│ ├── Controls (mode, feather, prompt)
│ └── Edit history viewer
├── Backend (FastAPI)
│ ├── Image processing
│ ├── AI provider integration
│ ├── Database (SQLite)
│ └── File storage
└── Docker Compose
├── Backend service
└── Frontend service (nginx)
```
## Installation
### Prerequisites
- Docker and Docker Compose
- AI API key (OpenAI or Stability AI) for production use
### Quick Start
1. **Clone the repository**
```bash
git clone <repository-url>
cd EditmaskwithAI
```
2. **Configure environment variables**
```bash
cp .env.example .env
```
Edit `.env` and set your AI provider:
```env
# For OpenAI (DALL-E)
AI_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key-here
# OR for Stability AI
AI_PROVIDER=stability
STABILITY_API_KEY=your-stability-api-key-here
# OR for testing (no AI, returns original)
AI_PROVIDER=mock
```
3. **Start the application**
```bash
docker-compose up -d
```
4. **Access the application**
- Frontend: http://localhost
- Backend API: http://localhost:8000
- API Documentation: http://localhost:8000/docs
### Development Setup
For development with hot-reload:
```bash
docker-compose -f docker-compose.dev.yml up
```
- Frontend: http://localhost:5173 (Vite dev server)
- Backend: http://localhost:8000 (auto-reload enabled)
## Usage
### 1. Create a Project
- Enter a project name
- Upload your image (PNG, JPG, etc.)
- Click "Create Project"
### 2. Select an Area
- Choose a selection tool (Rectangle, Ellipse, or Lasso)
- Draw your selection on the image
- Adjust the selection if needed
### 3. Configure Edit
- **AI Mode**: Choose Mode A (faster) or Mode B (better context)
- **Feather**: Adjust edge blending (0-50 pixels)
- **Prompt**: Describe what you want to change
Examples:
- "Remove the person"
- "Change sky to sunset"
- "Fix the red eye"
- "Add flowers"
### 4. Process Edit
- Click "Fix Selected Area"
- Wait for AI processing (status shown in history)
- View the result on the canvas
### 5. Manage History
- View all edits in the history panel
- Revert to any previous edit
- Reset to original image anytime
## API Documentation
### Projects
**Create Project**
```
POST /projects/
Body: { "name": "My Project" }
```
**Upload Image**
```
POST /projects/{project_id}/upload
Body: multipart/form-data with image file
```
**List Projects**
```
GET /projects/
```
**Get Project**
```
GET /projects/{project_id}
```
### Edits
**Create Edit**
```
POST /edits/projects/{project_id}/fix
Body: {
"prompt": "Remove the object",
"mode": "A",
"selection_type": "rectangle",
"bbox": { "x": 100, "y": 100, "width": 200, "height": 200 },
"feather_px": 5,
"selection_data": null
}
```
**Get Edit Status**
```
GET /edits/{edit_id}
```
**Revert to Edit**
```
POST /edits/projects/{project_id}/revert/{edit_id}
```
**Reset to Original**
```
POST /edits/projects/{project_id}/reset
```
### Images
**Get Original Image**
```
GET /projects/{project_id}/original
```
**Get Current Image**
```
GET /projects/{project_id}/current
```
**Get Edit Result**
```
GET /projects/{project_id}/history/{edit_id}/result
```
## File Structure
```
EditmaskwithAI/
├── backend/
│ ├── app/
│ │ ├── models/ # Database models
│ │ ├── routers/ # API endpoints
│ │ ├── services/ # Business logic
│ │ ├── utils/ # Image processing utilities
│ │ ├── config.py # Configuration
│ │ ├── database.py # Database setup
│ │ └── main.py # FastAPI app
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/ # React components
│ │ │ ├── ImageCanvas.jsx
│ │ │ ├── Controls.jsx
│ │ │ └── History.jsx
│ │ ├── utils/ # API client
│ │ ├── App.jsx
│ │ └── main.jsx
│ ├── Dockerfile
│ ├── nginx.conf
│ └── package.json
├── data/ # Persistent data (auto-created)
│ ├── ai_photo_edit.db # SQLite database
│ └── projects/ # Project files
├── docker-compose.yml
├── docker-compose.dev.yml
└── README.md
```
## Data Storage
### Database (SQLite)
- **users**: User accounts
- **projects**: Project metadata
- **edits**: Edit history and metadata
### Filesystem
```
data/projects/{project_id}/
├── original.png # Original uploaded image
├── current.png # Current edited image
└── history/{edit_id}/
├── patch_in.png # Original patch
├── patch_out.png # AI-generated patch
├── mask.png # Selection mask
├── result.png # Final result
└── meta.json # Edit metadata
```
## AI Provider Configuration
### OpenAI (DALL-E)
```env
AI_PROVIDER=openai
OPENAI_API_KEY=sk-...
```
### Stability AI
```env
AI_PROVIDER=stability
STABILITY_API_KEY=sk-...
```
### Mock (Testing)
```env
AI_PROVIDER=mock
```
Returns the original patch unchanged - useful for testing without API costs.
## Constraints
- Only the selected region is regenerated
- No modification outside the mask
- Slight drift inside mask is acceptable
- All edits are logged and reversible
- Mode A is default (cost-efficient)
- Mode B available for better style consistency
## Non-Goals (MVP)
- No automatic anomaly detection
- No local GPU inference
- No full image regeneration
- No collaborative editing
## Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
MIT License - see LICENSE file for details
## Support
For issues and questions:
- Open an issue on GitHub
- Check the API documentation at `/docs`
## Roadmap
Future enhancements:
- Multi-user authentication
- Batch processing
- Additional AI providers
- Advanced selection tools
- Real-time collaboration
- Export formats (PSD, TIFF)
---
**AI Photo Edit** - Regenerate only what you need to change.
+21
View File
@@ -0,0 +1,21 @@
# Database
DATABASE_URL=sqlite:///./data/ai_photo_edit.db
# Security
SECRET_KEY=your-secret-key-here-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# AI Provider (configure based on your provider)
AI_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key-here
# Alternative providers (uncomment as needed)
# AI_PROVIDER=stability
# STABILITY_API_KEY=your-stability-api-key-here
# File Storage
DATA_DIR=/app/data
MAX_UPLOAD_SIZE_MB=50
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Create data directory
RUN mkdir -p /app/data/projects
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+45
View File
@@ -0,0 +1,45 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
# Database
database_url: str = "sqlite:///./data/ai_photo_edit.db"
# Security
secret_key: str = "your-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# AI Provider
ai_provider: str = "mock" # Options: openai, stability, replicate, mock
# Provider API Keys
openai_api_key: str = ""
stability_api_key: str = ""
replicate_api_key: str = ""
# Model Selection (optional, provider-specific)
stability_model: str = "sdxl" # Options: sdxl, sd15, sd21
replicate_model: str = "sdxl-inpaint" # Options: sdxl-inpaint, lama, realistic-vision
# Allow per-edit model override
allow_model_override: bool = True
# File Storage
data_dir: str = "./data"
max_upload_size_mb: int = 50
# CORS
cors_origins: str = "http://localhost:3000,http://localhost:5173"
@property
def cors_origins_list(self) -> List[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
import os
# Ensure data directory exists
os.makedirs("./data", exist_ok=True)
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} # Needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Initialize database tables"""
Base.metadata.create_all(bind=engine)
+55
View File
@@ -0,0 +1,55 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from app.config import settings
from app.database import init_db
from app.routers import projects, edits, images, patches, generate
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup"""
init_db()
yield
app = FastAPI(
title="AI Photo Edit API",
description="API for AI-powered photo editing with mask-scoped regeneration",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(projects.router)
app.include_router(edits.router)
app.include_router(images.router)
app.include_router(patches.router)
app.include_router(generate.router)
@app.get("/")
def root():
"""API root endpoint"""
return {
"name": "AI Photo Edit API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
def health():
"""Health check endpoint"""
return {"status": "healthy"}
+6
View File
@@ -0,0 +1,6 @@
from app.models.user import User
from app.models.project import Project
from app.models.edit import Edit
from app.models.patch import Patch
__all__ = ["User", "Project", "Edit", "Patch"]
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class Edit(Base):
__tablename__ = "edits"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
mode = Column(String, nullable=False) # "A" or "B"
prompt = Column(Text, nullable=False)
selection_type = Column(String, nullable=False) # "rectangle", "ellipse", "lasso"
bbox_json = Column(Text, nullable=False) # JSON string of {x, y, width, height}
feather_px = Column(Integer, default=0)
ai_provider = Column(String, nullable=False)
status = Column(String, nullable=False) # "pending", "processing", "completed", "failed"
error_message = Column(Text, nullable=True)
# Relationships
project = relationship("Project", back_populates="edits")
+37
View File
@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class Patch(Base):
__tablename__ = "patches"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Source information
source_type = Column(String, nullable=False) # "ai_generated", "manual_selection", "imported"
source_project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
source_edit_id = Column(Integer, ForeignKey("edits.id"), nullable=True)
# Patch metadata
width = Column(Integer, nullable=False)
height = Column(Integer, nullable=False)
tags = Column(Text, nullable=True) # Comma-separated tags
category = Column(String, nullable=True) # "hand", "face", "body", "object", "texture", etc.
# Is this patch shared/public?
is_public = Column(Boolean, default=False)
# File path (relative to data dir)
file_path = Column(String, nullable=False)
thumbnail_path = Column(String, nullable=True)
# Relationships
user = relationship("User", back_populates="patches")
source_project = relationship("Project")
source_edit = relationship("Edit")
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="projects")
edits = relationship("Edit", back_populates="project", cascade="all, delete-orphan")
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
projects = relationship("Project", back_populates="user", cascade="all, delete-orphan")
patches = relationship("Patch", back_populates="user", cascade="all, delete-orphan")
View File
+171
View File
@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
import json
from app.database import get_db
from app.models.project import Project
from app.models.edit import Edit
from app.schemas import EditRequest, EditResponse, StatusResponse
from app.services.edit_service import EditService
from app.config import settings
router = APIRouter(prefix="/edits", tags=["edits"])
async def process_edit_background(
edit_id: int,
project_id: int,
request: EditRequest,
db: Session
):
"""Background task to process edit"""
edit_service = EditService()
try:
# Process the edit
result_path = await edit_service.process_edit(
project_id=project_id,
edit_id=edit_id,
prompt=request.prompt,
mode=request.mode,
selection_type=request.selection_type,
bbox=request.bbox,
feather_px=request.feather_px,
selection_data=request.selection_data
)
# Update edit status
edit = db.query(Edit).filter(Edit.id == edit_id).first()
if edit:
edit.status = "completed"
db.commit()
except Exception as e:
# Update edit with error
edit = db.query(Edit).filter(Edit.id == edit_id).first()
if edit:
edit.status = "failed"
edit.error_message = str(e)
db.commit()
@router.post("/projects/{project_id}/fix", response_model=EditResponse)
async def create_edit(
project_id: int,
request: EditRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
"""
Create a new edit request (Fix button)
This endpoint accepts the selection data and prompt,
then processes the edit in the background.
"""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate mode
if request.mode not in ["A", "B"]:
raise HTTPException(status_code=400, detail="Mode must be 'A' or 'B'")
# Validate selection type
if request.selection_type not in ["rectangle", "ellipse", "lasso"]:
raise HTTPException(status_code=400, detail="Invalid selection type")
# Create edit record
edit = Edit(
project_id=project_id,
mode=request.mode,
prompt=request.prompt,
selection_type=request.selection_type,
bbox_json=json.dumps(request.bbox),
feather_px=request.feather_px,
ai_provider=settings.ai_provider,
status="pending"
)
db.add(edit)
db.commit()
db.refresh(edit)
# Process edit in background
background_tasks.add_task(
process_edit_background,
edit.id,
project_id,
request,
db
)
return edit
@router.get("/{edit_id}", response_model=EditResponse)
def get_edit(
edit_id: int,
db: Session = Depends(get_db)
):
"""Get edit details and status"""
edit = db.query(Edit).filter(Edit.id == edit_id).first()
if not edit:
raise HTTPException(status_code=404, detail="Edit not found")
return edit
@router.post("/projects/{project_id}/revert/{edit_id}", response_model=StatusResponse)
def revert_to_edit(
project_id: int,
edit_id: int,
db: Session = Depends(get_db)
):
"""Revert project to a specific edit"""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Verify edit exists and belongs to project
edit = db.query(Edit).filter(
Edit.id == edit_id,
Edit.project_id == project_id
).first()
if not edit:
raise HTTPException(status_code=404, detail="Edit not found")
# Revert
edit_service = EditService()
try:
result_path = edit_service.revert_to_edit(project_id, edit_id)
return StatusResponse(
status="success",
message=f"Reverted to edit {edit_id}",
data={"image_url": f"/projects/{project_id}/current"}
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/projects/{project_id}/reset", response_model=StatusResponse)
def reset_to_original(
project_id: int,
db: Session = Depends(get_db)
):
"""Reset project to original image"""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Reset
edit_service = EditService()
try:
result_path = edit_service.reset_to_original(project_id)
return StatusResponse(
status="success",
message="Reset to original image",
data={"image_url": f"/projects/{project_id}/current"}
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+176
View File
@@ -0,0 +1,176 @@
from fastapi import APIRouter, Depends, HTTPException, Form
from sqlalchemy.orm import Session
from typing import Optional
from PIL import Image
from io import BytesIO
import os
from app.database import get_db
from app.models.project import Project
from app.schemas import TextToImageRequest, TextToImageResponse
from app.services.ai_provider import get_ai_provider
from app.services.edit_service import EditService
from app.config import settings
router = APIRouter(prefix="/generate", tags=["generate"])
@router.post("/text-to-image", response_model=TextToImageResponse)
async def text_to_image(
prompt: str = Form(...),
width: int = Form(1024),
height: int = Form(1024),
negative_prompt: Optional[str] = Form(None),
ai_provider: Optional[str] = Form(None),
ai_model: Optional[str] = Form(None),
create_project: bool = Form(True),
project_name: Optional[str] = Form(None),
db: Session = Depends(get_db)
):
"""
Generate an image from text prompt
Args:
prompt: Text description of desired image
width: Image width (default 1024)
height: Image height (default 1024)
negative_prompt: What to avoid in generation
ai_provider: Override default AI provider
ai_model: Specific model to use
create_project: Whether to create a new project with the result
project_name: Name for the new project (if create_project=True)
Returns:
Generated image info and optionally project details
"""
# Validate dimensions
if width < 256 or width > 2048 or height < 256 or height > 2048:
raise HTTPException(
status_code=400,
detail="Width and height must be between 256 and 2048"
)
# Get AI provider
provider = get_ai_provider(ai_provider, ai_model)
try:
# Generate image
image_bytes = await provider.text_to_image(
prompt=prompt,
width=width,
height=height,
model=ai_model,
negative_prompt=negative_prompt
)
project_id = None
image_url = None
if create_project:
# Create a new project
project = Project(
name=project_name or f"Generated: {prompt[:50]}",
user_id=None # TODO: Add authentication
)
db.add(project)
db.commit()
db.refresh(project)
project_id = project.id
# Save image as both original and current
edit_service = EditService()
edit_service.ensure_project_dir(project_id)
original_path = edit_service.get_original_image_path(project_id)
current_path = edit_service.get_current_image_path(project_id)
# Save image
img = Image.open(BytesIO(image_bytes))
img.save(original_path, 'PNG')
img.save(current_path, 'PNG')
image_url = f"/projects/{project_id}/current"
return TextToImageResponse(
status="success",
prompt=prompt,
width=width,
height=height,
project_id=project_id,
image_url=image_url,
ai_provider=ai_provider or settings.ai_provider,
ai_model=ai_model
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/layer/text-to-image", response_model=TextToImageResponse)
async def text_to_image_layer(
project_id: int = Form(...),
prompt: str = Form(...),
width: int = Form(512),
height: int = Form(512),
x: int = Form(0),
y: int = Form(0),
negative_prompt: Optional[str] = Form(None),
ai_provider: Optional[str] = Form(None),
ai_model: Optional[str] = Form(None),
db: Session = Depends(get_db)
):
"""
Generate an image as a new layer in an existing project
This generates a smaller image that can be placed as a layer
on top of the current project canvas.
"""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Get AI provider
provider = get_ai_provider(ai_provider, ai_model)
try:
# Generate image
image_bytes = await provider.text_to_image(
prompt=prompt,
width=width,
height=height,
model=ai_model,
negative_prompt=negative_prompt
)
# Save as temporary layer file
edit_service = EditService()
layers_dir = edit_service.get_project_dir(project_id) / "layers"
layers_dir.mkdir(exist_ok=True)
# Generate unique layer filename
import time
layer_filename = f"generated_{int(time.time())}.png"
layer_path = layers_dir / layer_filename
# Save layer image
with open(layer_path, 'wb') as f:
f.write(image_bytes)
return TextToImageResponse(
status="success",
prompt=prompt,
width=width,
height=height,
project_id=project_id,
image_url=f"/projects/{project_id}/layers/{layer_filename}",
layer_position={"x": x, "y": y},
ai_provider=ai_provider or settings.ai_provider,
ai_model=ai_model
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from pathlib import Path
from app.database import get_db
from app.models.project import Project
from app.services.edit_service import EditService
router = APIRouter(prefix="/projects", tags=["images"])
@router.get("/{project_id}/original")
def get_original_image(
project_id: int,
db: Session = Depends(get_db)
):
"""Get the original uploaded image"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
edit_service = EditService()
image_path = edit_service.get_original_image_path(project_id)
if not image_path.exists():
raise HTTPException(status_code=404, detail="Original image not found")
return FileResponse(
image_path,
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"}
)
@router.get("/{project_id}/current")
def get_current_image(
project_id: int,
db: Session = Depends(get_db)
):
"""Get the current edited image"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
edit_service = EditService()
image_path = edit_service.get_current_image_path(project_id)
if not image_path.exists():
raise HTTPException(status_code=404, detail="Current image not found")
return FileResponse(
image_path,
media_type="image/png",
headers={"Cache-Control": "no-cache"}
)
@router.get("/{project_id}/history/{edit_id}/result")
def get_edit_result(
project_id: int,
edit_id: int,
db: Session = Depends(get_db)
):
"""Get the result image from a specific edit"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
edit_service = EditService()
edit_dir = edit_service.get_edit_dir(project_id, edit_id)
result_path = edit_dir / "result.png"
if not result_path.exists():
raise HTTPException(status_code=404, detail="Edit result not found")
return FileResponse(
result_path,
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"}
)
+308
View File
@@ -0,0 +1,308 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from typing import List, Optional
import json
from app.database import get_db
from app.models.patch import Patch
from app.models.project import Project
from app.models.edit import Edit
from app.schemas import PatchCreate, PatchResponse, PatchApply, StatusResponse
from app.services.patch_library import PatchLibraryService
from app.config import settings
router = APIRouter(prefix="/patches", tags=["patches"])
@router.post("/", response_model=PatchResponse)
async def create_patch(
name: str = Form(...),
description: Optional[str] = Form(None),
source_type: str = Form(...),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
source_project_id: Optional[int] = Form(None),
source_edit_id: Optional[int] = Form(None),
bbox: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db)
):
"""
Create a new patch in the library
Source types:
- ai_generated: From an edit (requires source_edit_id)
- manual_selection: Selected from current image (requires source_project_id and bbox)
- imported: Uploaded file (requires file)
"""
# Validate source_type
if source_type not in ["ai_generated", "manual_selection", "imported"]:
raise HTTPException(status_code=400, detail="Invalid source_type")
# Create patch record
patch = Patch(
name=name,
description=description,
source_type=source_type,
source_project_id=source_project_id,
source_edit_id=source_edit_id,
tags=tags,
category=category,
file_path="", # Will be set after saving
user_id=None # TODO: Add authentication
)
db.add(patch)
db.commit()
db.refresh(patch)
# Save patch file based on source type
patch_service = PatchLibraryService()
try:
if source_type == "ai_generated":
# Get edit directory and save AI-generated patch
if not source_edit_id:
raise HTTPException(status_code=400, detail="source_edit_id required for ai_generated")
edit = db.query(Edit).filter(Edit.id == source_edit_id).first()
if not edit:
raise HTTPException(status_code=404, detail="Edit not found")
from app.services.edit_service import EditService
edit_service = EditService()
edit_dir = edit_service.get_edit_dir(edit.project_id, edit.id)
file_path = patch_service.save_ai_generated_patch(patch.id, edit_dir)
# Get dimensions
width, height = patch_service.get_patch_size(patch.id)
patch.width = width
patch.height = height
elif source_type == "manual_selection":
# Save manually selected patch from current image
if not source_project_id or not bbox:
raise HTTPException(
status_code=400,
detail="source_project_id and bbox required for manual_selection"
)
project = db.query(Project).filter(Project.id == source_project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox
file_path = patch_service.save_manual_patch(patch.id, source_project_id, bbox_dict)
patch.width = bbox_dict['width']
patch.height = bbox_dict['height']
elif source_type == "imported":
# Save uploaded file
if not file:
raise HTTPException(status_code=400, detail="file required for imported")
image_bytes = await file.read()
file_path = patch_service.save_patch_from_bytes(patch.id, image_bytes)
# Get dimensions
width, height = patch_service.get_patch_size(patch.id)
patch.width = width
patch.height = height
# Update patch with file path
patch.file_path = file_path
patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id))
db.commit()
db.refresh(patch)
return patch
except Exception as e:
# Cleanup on error
patch_service.delete_patch(patch.id)
db.delete(patch)
db.commit()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", response_model=List[PatchResponse])
def list_patches(
category: Optional[str] = None,
tags: Optional[str] = None,
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db)
):
"""List patches in the library with optional filtering"""
query = db.query(Patch)
if category:
query = query.filter(Patch.category == category)
if tags:
# Simple tag search (could be improved with full-text search)
query = query.filter(Patch.tags.like(f"%{tags}%"))
patches = query.offset(offset).limit(limit).all()
return patches
@router.get("/{patch_id}", response_model=PatchResponse)
def get_patch(
patch_id: int,
db: Session = Depends(get_db)
):
"""Get patch details"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
return patch
@router.get("/{patch_id}/image")
def get_patch_image(
patch_id: int,
thumbnail: bool = False,
db: Session = Depends(get_db)
):
"""Get patch image file"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
patch_service = PatchLibraryService()
if thumbnail:
file_path = patch_service.get_thumbnail_path(patch_id)
else:
file_path = patch_service.get_patch_path(patch_id)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Patch image not found")
return FileResponse(file_path, media_type="image/png")
@router.post("/apply", response_model=StatusResponse)
async def apply_patch(
project_id: int = Form(...),
patch_id: int = Form(...),
bbox: str = Form(...),
feather_px: int = Form(5),
db: Session = Depends(get_db)
):
"""
Apply a saved patch to a project image
This creates a new edit in the project history.
"""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Verify patch exists
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
# Parse bbox
bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox
# Load current image
from app.services.edit_service import EditService
from PIL import Image
edit_service = EditService()
current_image_path = edit_service.get_current_image_path(project_id)
current_image = Image.open(current_image_path).convert('RGBA')
# Apply patch
patch_service = PatchLibraryService()
result_image = patch_service.apply_patch_to_image(
patch_id,
current_image,
bbox_dict,
feather_px
)
# Save result as current image
result_image.save(current_image_path)
# Create edit record
edit = Edit(
project_id=project_id,
mode="patch_library",
prompt=f"Applied saved patch: {patch.name}",
selection_type="rectangle",
bbox_json=json.dumps(bbox_dict),
feather_px=feather_px,
ai_provider="patch_library",
status="completed"
)
db.add(edit)
db.commit()
return StatusResponse(
status="success",
message=f"Applied patch '{patch.name}' to project",
data={"edit_id": edit.id}
)
@router.delete("/{patch_id}", response_model=StatusResponse)
def delete_patch(
patch_id: int,
db: Session = Depends(get_db)
):
"""Delete a patch from the library"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
# Delete files
patch_service = PatchLibraryService()
patch_service.delete_patch(patch_id)
# Delete record
db.delete(patch)
db.commit()
return StatusResponse(
status="success",
message=f"Deleted patch '{patch.name}'"
)
@router.put("/{patch_id}", response_model=PatchResponse)
def update_patch(
patch_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Update patch metadata"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
if name:
patch.name = name
if description is not None:
patch.description = description
if category:
patch.category = category
if tags is not None:
patch.tags = tags
db.commit()
db.refresh(patch)
return patch
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from typing import List
import shutil
from pathlib import Path
from PIL import Image
from app.database import get_db
from app.models.project import Project
from app.models.edit import Edit
from app.schemas import ProjectCreate, ProjectResponse, EditResponse, UploadResponse
from app.services.edit_service import EditService
from app.config import settings
router = APIRouter(prefix="/projects", tags=["projects"])
@router.post("/", response_model=ProjectResponse)
def create_project(
project: ProjectCreate,
db: Session = Depends(get_db)
):
"""Create a new project"""
# For MVP, we'll use a default user_id of 1
# In production, this would come from authentication
user_id = 1
db_project = Project(
user_id=user_id,
name=project.name
)
db.add(db_project)
db.commit()
db.refresh(db_project)
# Create project directory
edit_service = EditService()
edit_service.ensure_project_dir(db_project.id)
return db_project
@router.get("/", response_model=List[ProjectResponse])
def list_projects(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""List all projects"""
projects = db.query(Project).offset(skip).limit(limit).all()
return projects
@router.get("/{project_id}", response_model=ProjectResponse)
def get_project(
project_id: int,
db: Session = Depends(get_db)
):
"""Get a specific project"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.delete("/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db)
):
"""Delete a project"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Delete project directory
edit_service = EditService()
project_dir = edit_service.get_project_dir(project_id)
if project_dir.exists():
shutil.rmtree(project_dir)
db.delete(project)
db.commit()
return {"status": "success", "message": f"Project {project_id} deleted"}
@router.post("/{project_id}/upload", response_model=UploadResponse)
async def upload_image(
project_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""Upload an image to a project"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate file type
if not file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="File must be an image")
# Create project directory
edit_service = EditService()
edit_service.ensure_project_dir(project_id)
# Save original and current images
original_path = edit_service.get_original_image_path(project_id)
current_path = edit_service.get_current_image_path(project_id)
# Read and validate image
contents = await file.read()
try:
image = Image.open(BytesIO(contents))
image = image.convert('RGBA')
# Save images
image.save(original_path, 'PNG')
image.save(current_path, 'PNG')
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}")
return UploadResponse(
project_id=project_id,
original_url=f"/projects/{project_id}/original",
current_url=f"/projects/{project_id}/current"
)
@router.get("/{project_id}/edits", response_model=List[EditResponse])
def list_edits(
project_id: int,
db: Session = Depends(get_db)
):
"""List all edits 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")
edits = db.query(Edit).filter(Edit.project_id == project_id).order_by(Edit.created_at.desc()).all()
return edits
from io import BytesIO
+138
View File
@@ -0,0 +1,138 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, List, Dict, Any
from datetime import datetime
# User schemas
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: int
email: str
created_at: datetime
class Config:
from_attributes = True
# Project schemas
class ProjectCreate(BaseModel):
name: str
class ProjectResponse(BaseModel):
id: int
user_id: int
name: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Edit schemas
class EditRequest(BaseModel):
prompt: str
mode: str = "A" # "A" or "B"
selection_type: str # "rectangle", "ellipse", "lasso"
bbox: Dict[str, int] # {x, y, width, height}
feather_px: int = 0
selection_data: Optional[Dict[str, Any]] = None
class EditResponse(BaseModel):
id: int
project_id: int
created_at: datetime
mode: str
prompt: str
selection_type: str
bbox_json: str
feather_px: int
ai_provider: str
status: str
error_message: Optional[str] = None
class Config:
from_attributes = True
# Image upload
class UploadResponse(BaseModel):
project_id: int
original_url: str
current_url: str
# Patch Library schemas
class PatchCreate(BaseModel):
name: str
description: Optional[str] = None
source_type: str # "ai_generated", "manual_selection", "imported"
source_project_id: Optional[int] = None
source_edit_id: Optional[int] = None
category: Optional[str] = None
tags: Optional[str] = None
bbox: Optional[Dict[str, int]] = None
class PatchResponse(BaseModel):
id: int
name: str
description: Optional[str]
created_at: datetime
source_type: str
source_project_id: Optional[int]
source_edit_id: Optional[int]
width: int
height: int
tags: Optional[str]
category: Optional[str]
is_public: bool
file_path: str
thumbnail_path: Optional[str]
class Config:
from_attributes = True
class PatchApply(BaseModel):
project_id: int
patch_id: int
bbox: Dict[str, int]
feather_px: int = 5
# Text-to-Image schemas
class TextToImageRequest(BaseModel):
prompt: str
width: int = 1024
height: int = 1024
negative_prompt: Optional[str] = None
ai_provider: Optional[str] = None
ai_model: Optional[str] = None
create_project: bool = True
project_name: Optional[str] = None
class TextToImageResponse(BaseModel):
status: str
prompt: str
width: int
height: int
project_id: Optional[int] = None
image_url: Optional[str] = None
layer_position: Optional[Dict[str, int]] = None
ai_provider: str
ai_model: Optional[str] = None
# Generic responses
class StatusResponse(BaseModel):
status: str
message: Optional[str] = None
data: Optional[Any] = None
View File
+539
View File
@@ -0,0 +1,539 @@
from abc import ABC, abstractmethod
from typing import Optional, Dict
import httpx
import base64
import asyncio
from io import BytesIO
from app.config import settings
class AIProvider(ABC):
"""Abstract base class for AI providers"""
@abstractmethod
async def edit_image(
self,
patch_image_bytes: bytes,
mask_image_bytes: bytes,
prompt: str,
mode: str,
full_image_bytes: Optional[bytes] = None,
model: Optional[str] = None
) -> bytes:
"""
Edit an image patch using AI
Args:
patch_image_bytes: The cropped patch to edit
mask_image_bytes: Binary mask (same size as patch)
prompt: Text description of desired changes
mode: "A" (patch only) or "B" (patch + full image reference)
full_image_bytes: Full image for context (mode B only)
model: Optional specific model to use
Returns:
Regenerated patch as bytes
"""
pass
@abstractmethod
async def text_to_image(
self,
prompt: str,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
negative_prompt: Optional[str] = None
) -> bytes:
"""
Generate an image from text prompt
Args:
prompt: Text description of desired image
width: Image width in pixels
height: Image height in pixels
model: Optional specific model to use
negative_prompt: What to avoid in the generation
Returns:
Generated image as bytes
"""
pass
class OpenAIProvider(AIProvider):
"""OpenAI DALL-E 2 based image editing (NOTE: Lower quality than DALL-E 3)"""
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://api.openai.com/v1"
async def edit_image(
self,
patch_image_bytes: bytes,
mask_image_bytes: bytes,
prompt: str,
mode: str,
full_image_bytes: Optional[bytes] = None,
model: Optional[str] = None
) -> bytes:
"""Edit image using OpenAI DALL-E 2 (NOTE: Uses older model, lower quality)"""
async with httpx.AsyncClient(timeout=60.0) as client:
files = {
'image': ('image.png', patch_image_bytes, 'image/png'),
'mask': ('mask.png', mask_image_bytes, 'image/png'),
}
data = {
'prompt': prompt,
'n': 1,
'size': '1024x1024' # Will be adjusted based on input
}
headers = {
'Authorization': f'Bearer {self.api_key}'
}
response = await client.post(
f"{self.base_url}/images/edits",
files=files,
data=data,
headers=headers
)
response.raise_for_status()
result = response.json()
# Download the generated image
image_url = result['data'][0]['url']
image_response = await client.get(image_url)
image_response.raise_for_status()
return image_response.content
async def text_to_image(
self,
prompt: str,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
negative_prompt: Optional[str] = None
) -> bytes:
"""Generate image using OpenAI DALL-E"""
async with httpx.AsyncClient(timeout=60.0) as client:
data = {
'prompt': prompt,
'n': 1,
'size': f'{width}x{height}' if width == height else '1024x1024'
}
headers = {
'Authorization': f'Bearer {self.api_key}'
}
response = await client.post(
f"{self.base_url}/images/generations",
json=data,
headers=headers
)
response.raise_for_status()
result = response.json()
# Download the generated image
image_url = result['data'][0]['url']
image_response = await client.get(image_url)
image_response.raise_for_status()
return image_response.content
class StabilityAIProvider(AIProvider):
"""Stability AI based image editing (SDXL Inpainting)"""
# Available Stability AI engines
MODELS = {
'sdxl': 'stable-diffusion-xl-1024-v1-0',
'sd15': 'stable-diffusion-v1-5',
'sd21': 'stable-diffusion-512-v2-1',
}
def __init__(self, api_key: str, default_model: str = 'sdxl'):
self.api_key = api_key
self.base_url = "https://api.stability.ai/v1"
self.default_model = default_model
async def edit_image(
self,
patch_image_bytes: bytes,
mask_image_bytes: bytes,
prompt: str,
mode: str,
full_image_bytes: Optional[bytes] = None,
model: Optional[str] = None
) -> bytes:
"""Edit image using Stability AI SDXL Inpainting"""
# Select model
model_key = model or self.default_model
engine_id = self.MODELS.get(model_key, self.MODELS['sdxl'])
async with httpx.AsyncClient(timeout=120.0) as client:
files = {
'init_image': ('image.png', patch_image_bytes, 'image/png'),
'mask_image': ('mask.png', mask_image_bytes, 'image/png'),
}
# Optimized parameters for better quality
data = {
'text_prompts[0][text]': prompt,
'text_prompts[0][weight]': '1.0',
'cfg_scale': '8', # Increased for better prompt adherence
'samples': '1',
'steps': '40', # Increased for better quality
'mask_source': 'MASK_IMAGE_WHITE', # White areas are inpainted
}
headers = {
'Authorization': f'Bearer {self.api_key}',
'Accept': 'application/json'
}
response = await client.post(
f"{self.base_url}/generation/{engine_id}/image-to-image/masking",
files=files,
data=data,
headers=headers
)
response.raise_for_status()
result = response.json()
# Decode base64 image
image_data = result['artifacts'][0]['base64']
return base64.b64decode(image_data)
async def text_to_image(
self,
prompt: str,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
negative_prompt: Optional[str] = None
) -> bytes:
"""Generate image using Stability AI SDXL"""
# Select model
model_key = model or self.default_model
engine_id = self.MODELS.get(model_key, self.MODELS['sdxl'])
async with httpx.AsyncClient(timeout=120.0) as client:
# Build prompts array
data = {
'text_prompts[0][text]': prompt,
'text_prompts[0][weight]': '1.0',
'cfg_scale': '7',
'samples': '1',
'steps': '50',
'height': str(height),
'width': str(width),
}
# Add negative prompt if provided
if negative_prompt:
data['text_prompts[1][text]'] = negative_prompt
data['text_prompts[1][weight]'] = '-1.0'
headers = {
'Authorization': f'Bearer {self.api_key}',
'Accept': 'application/json'
}
response = await client.post(
f"{self.base_url}/generation/{engine_id}/text-to-image",
data=data,
headers=headers
)
response.raise_for_status()
result = response.json()
# Decode base64 image
image_data = result['artifacts'][0]['base64']
return base64.b64decode(image_data)
class ReplicateProvider(AIProvider):
"""Replicate API with multiple model support"""
# Available Replicate models for inpainting
MODELS = {
# SDXL Inpainting - Best general purpose
'sdxl-inpaint': {
'version': 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b',
'use_case': 'General purpose, high quality',
'cost': '~$0.025/image',
'best_for': ['general', 'landscapes', 'objects', 'textures']
},
# LaMa - Best for object removal
'lama': {
'version': 'andreasjansson/lama:7f4a2e3c95ab83c1d66ea26a66c27f93b64a2e5a3c5f7f4f4f4f4f4f4f4f4f4f',
'use_case': 'Object removal and cleanup',
'cost': '~$0.002/image',
'best_for': ['removal', 'cleanup', 'erase']
},
# Realistic Vision - Best for human features (faces, bodies, hands)
'realistic-vision': {
'version': 'stability-ai/stable-diffusion:db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf',
'use_case': 'Human features, realistic photos',
'cost': '~$0.020/image',
'best_for': ['face', 'body', 'hands', 'portrait', 'person', 'human']
},
}
def __init__(self, api_key: str, default_model: str = 'sdxl-inpaint'):
self.api_key = api_key
self.base_url = "https://api.replicate.com/v1"
self.default_model = default_model
def _select_model_from_prompt(self, prompt: str) -> str:
"""Auto-select best model based on prompt keywords"""
prompt_lower = prompt.lower()
# Check for removal/cleanup keywords
if any(word in prompt_lower for word in ['remove', 'erase', 'delete', 'cleanup']):
return 'lama'
# Check for human feature keywords
if any(word in prompt_lower for word in ['hand', 'face', 'body', 'person', 'portrait', 'skin']):
return 'realistic-vision'
# Default to SDXL for general purpose
return 'sdxl-inpaint'
async def edit_image(
self,
patch_image_bytes: bytes,
mask_image_bytes: bytes,
prompt: str,
mode: str,
full_image_bytes: Optional[bytes] = None,
model: Optional[str] = None
) -> bytes:
"""Edit image using Replicate with auto model selection"""
# Auto-select model if not specified
if not model:
model = self._select_model_from_prompt(prompt)
model_config = self.MODELS.get(model, self.MODELS['sdxl-inpaint'])
# Convert bytes to base64 for Replicate API
patch_b64 = base64.b64encode(patch_image_bytes).decode('utf-8')
mask_b64 = base64.b64encode(mask_image_bytes).decode('utf-8')
async with httpx.AsyncClient(timeout=120.0) as client:
# Create prediction
prediction_data = {
"version": model_config['version'],
"input": {
"image": f"data:image/png;base64,{patch_b64}",
"mask": f"data:image/png;base64,{mask_b64}",
"prompt": prompt,
"num_outputs": 1,
"guidance_scale": 7.5,
"num_inference_steps": 50,
}
}
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
# Start prediction
response = await client.post(
f"{self.base_url}/predictions",
json=prediction_data,
headers=headers
)
response.raise_for_status()
prediction = response.json()
# Poll for completion
prediction_url = prediction['urls']['get']
max_attempts = 60 # 2 minutes max
attempt = 0
while attempt < max_attempts:
await asyncio.sleep(2) # Wait 2 seconds between polls
status_response = await client.get(prediction_url, headers=headers)
status_response.raise_for_status()
status_data = status_response.json()
if status_data['status'] == 'succeeded':
# Download result image
output_url = status_data['output'][0]
image_response = await client.get(output_url)
image_response.raise_for_status()
return image_response.content
elif status_data['status'] == 'failed':
raise Exception(f"Replicate prediction failed: {status_data.get('error')}")
attempt += 1
raise Exception("Replicate prediction timed out")
async def text_to_image(
self,
prompt: str,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
negative_prompt: Optional[str] = None
) -> bytes:
"""Generate image using Replicate SDXL"""
# Use SDXL for text-to-image
model_version = 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b'
async with httpx.AsyncClient(timeout=120.0) as client:
# Create prediction
prediction_data = {
"version": model_version,
"input": {
"prompt": prompt,
"width": width,
"height": height,
"num_outputs": 1,
"guidance_scale": 7.5,
"num_inference_steps": 50,
}
}
# Add negative prompt if provided
if negative_prompt:
prediction_data["input"]["negative_prompt"] = negative_prompt
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
# Start prediction
response = await client.post(
f"{self.base_url}/predictions",
json=prediction_data,
headers=headers
)
response.raise_for_status()
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_response.raise_for_status()
status_data = status_response.json()
if status_data['status'] == 'succeeded':
# Download result image
output_url = status_data['output'][0]
image_response = await client.get(output_url)
image_response.raise_for_status()
return image_response.content
elif status_data['status'] == 'failed':
raise Exception(f"Replicate prediction failed: {status_data.get('error')}")
attempt += 1
raise Exception("Replicate text-to-image timed out")
class MockAIProvider(AIProvider):
"""Mock provider for testing (returns original patch)"""
async def edit_image(
self,
patch_image_bytes: bytes,
mask_image_bytes: bytes,
prompt: str,
mode: str,
full_image_bytes: Optional[bytes] = None,
model: Optional[str] = None
) -> bytes:
"""Return the original patch (for testing)"""
return patch_image_bytes
async def text_to_image(
self,
prompt: str,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
negative_prompt: Optional[str] = None
) -> bytes:
"""Generate a placeholder image (for testing)"""
from PIL import Image, ImageDraw, ImageFont
# Create a simple placeholder image
img = Image.new('RGB', (width, height), color='lightgray')
draw = ImageDraw.Draw(img)
# Draw text
text = f"Mock Image\n{width}x{height}\n{prompt[:50]}"
draw.text((width//4, height//2), text, fill='black')
# Convert to bytes
buffer = BytesIO()
img.save(buffer, format='PNG')
return buffer.getvalue()
def get_ai_provider(provider_name: Optional[str] = None, model: Optional[str] = None) -> AIProvider:
"""
Factory function to get the configured AI provider
Args:
provider_name: Override default provider from settings
model: Specific model to use (provider-dependent)
Returns:
AIProvider instance
"""
provider = provider_name or settings.ai_provider
provider = provider.lower()
if provider == "openai":
if not settings.openai_api_key:
raise ValueError("OpenAI API key not configured")
return OpenAIProvider(settings.openai_api_key)
elif provider == "stability":
if not settings.stability_api_key:
raise ValueError("Stability AI API key not configured")
default_model = model or getattr(settings, 'stability_model', 'sdxl')
return StabilityAIProvider(settings.stability_api_key, default_model=default_model)
elif provider == "replicate":
if not settings.replicate_api_key:
raise ValueError("Replicate API key not configured")
default_model = model or getattr(settings, 'replicate_model', 'sdxl-inpaint')
return ReplicateProvider(settings.replicate_api_key, default_model=default_model)
elif provider == "mock":
return MockAIProvider()
else:
raise ValueError(f"Unknown AI provider: {provider}")
+216
View File
@@ -0,0 +1,216 @@
import os
import json
from pathlib import Path
from typing import Dict, Optional
from datetime import datetime
from PIL import Image
from app.models.edit import Edit
from app.models.project import Project
from app.services.ai_provider import get_ai_provider
from app.utils.image_processing import (
bytes_to_image,
image_to_bytes,
crop_patch,
blend_patch,
insert_patch,
create_mask_from_selection,
resize_for_ai,
scale_bbox
)
from app.config import settings
class EditService:
"""Service for handling image edits"""
def __init__(self, data_dir: str = None):
self.data_dir = data_dir or settings.data_dir
self.ai_provider = get_ai_provider()
def get_project_dir(self, project_id: int) -> Path:
"""Get project directory path"""
return Path(self.data_dir) / "projects" / str(project_id)
def get_edit_dir(self, project_id: int, edit_id: int) -> Path:
"""Get edit history directory path"""
return self.get_project_dir(project_id) / "history" / str(edit_id)
def ensure_project_dir(self, project_id: int):
"""Ensure project directory structure exists"""
project_dir = self.get_project_dir(project_id)
project_dir.mkdir(parents=True, exist_ok=True)
(project_dir / "history").mkdir(exist_ok=True)
def get_current_image_path(self, project_id: int) -> Path:
"""Get path to current image"""
return self.get_project_dir(project_id) / "current.png"
def get_original_image_path(self, project_id: int) -> Path:
"""Get path to original image"""
return self.get_project_dir(project_id) / "original.png"
async def process_edit(
self,
project_id: int,
edit_id: int,
prompt: str,
mode: str,
selection_type: str,
bbox: Dict[str, int],
feather_px: int,
selection_data: Optional[Dict] = None
) -> str:
"""
Process an edit request
Args:
project_id: Project ID
edit_id: Edit ID
prompt: AI prompt
mode: "A" or "B"
selection_type: "rectangle", "ellipse", or "lasso"
bbox: Bounding box {x, y, width, height}
feather_px: Feather radius in pixels
selection_data: Additional selection data (for lasso)
Returns:
Path to the result image
"""
# Create edit directory
edit_dir = self.get_edit_dir(project_id, edit_id)
edit_dir.mkdir(parents=True, exist_ok=True)
# Load current image
current_image_path = self.get_current_image_path(project_id)
full_image = Image.open(current_image_path).convert('RGBA')
# Crop patch from current image
original_patch = crop_patch(full_image, bbox)
# Save original patch
original_patch.save(edit_dir / "patch_in.png")
# Create mask based on selection type
mask = create_mask_from_selection(
bbox['width'],
bbox['height'],
selection_type,
selection_data or {}
)
# Save mask
mask.save(edit_dir / "mask.png")
# Resize patch and mask for AI if needed
patch_for_ai, scale = resize_for_ai(original_patch)
mask_for_ai = mask.resize(patch_for_ai.size, Image.Resampling.LANCZOS)
# Prepare full image for mode B
full_image_bytes = None
if mode == "B":
full_image_for_ai, _ = resize_for_ai(full_image)
full_image_bytes = image_to_bytes(full_image_for_ai)
# Call AI provider
regenerated_patch_bytes = await self.ai_provider.edit_image(
patch_image_bytes=image_to_bytes(patch_for_ai),
mask_image_bytes=image_to_bytes(mask_for_ai),
prompt=prompt,
mode=mode,
full_image_bytes=full_image_bytes
)
# Convert regenerated patch back to PIL Image
regenerated_patch = bytes_to_image(regenerated_patch_bytes)
# Resize back to original patch size if scaled
if scale != 1.0:
regenerated_patch = regenerated_patch.resize(
original_patch.size,
Image.Resampling.LANCZOS
)
# Save regenerated patch
regenerated_patch.save(edit_dir / "patch_out.png")
# Blend regenerated patch with original using mask
blended_patch = blend_patch(
original_patch,
regenerated_patch,
mask,
feather_px
)
# Insert blended patch back into full image
result_image = insert_patch(full_image, blended_patch, bbox)
# Save result
result_path = edit_dir / "result.png"
result_image.save(result_path)
# Update current image
result_image.save(current_image_path)
# Save metadata
metadata = {
'edit_id': edit_id,
'project_id': project_id,
'prompt': prompt,
'mode': mode,
'selection_type': selection_type,
'bbox': bbox,
'feather_px': feather_px,
'selection_data': selection_data,
'timestamp': datetime.utcnow().isoformat(),
'ai_provider': settings.ai_provider
}
with open(edit_dir / "meta.json", 'w') as f:
json.dump(metadata, f, indent=2)
return str(result_path)
def revert_to_edit(self, project_id: int, edit_id: int) -> str:
"""
Revert project to a specific edit
Args:
project_id: Project ID
edit_id: Edit ID to revert to
Returns:
Path to the reverted image
"""
edit_dir = self.get_edit_dir(project_id, edit_id)
result_path = edit_dir / "result.png"
if not result_path.exists():
raise FileNotFoundError(f"Edit {edit_id} result not found")
# Copy result to current
current_path = self.get_current_image_path(project_id)
Image.open(result_path).save(current_path)
return str(current_path)
def reset_to_original(self, project_id: int) -> str:
"""
Reset project to original image
Args:
project_id: Project ID
Returns:
Path to the original image
"""
original_path = self.get_original_image_path(project_id)
current_path = self.get_current_image_path(project_id)
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)
return str(current_path)
+206
View File
@@ -0,0 +1,206 @@
import os
import shutil
from pathlib import Path
from typing import List, Optional
from PIL import Image
from datetime import datetime
from app.models.patch import Patch
from app.config import settings
class PatchLibraryService:
"""Service for managing the patch library"""
def __init__(self, data_dir: str = None):
self.data_dir = data_dir or settings.data_dir
self.patch_library_dir = Path(self.data_dir) / "patch_library"
self.patch_library_dir.mkdir(parents=True, exist_ok=True)
def get_patch_path(self, patch_id: int) -> Path:
"""Get path to patch file"""
return self.patch_library_dir / f"{patch_id}.png"
def get_thumbnail_path(self, patch_id: int) -> Path:
"""Get path to patch thumbnail"""
return self.patch_library_dir / f"{patch_id}_thumb.png"
def create_thumbnail(self, image_path: Path, thumbnail_path: Path, size: tuple = (200, 200)):
"""Create a thumbnail from an image"""
img = Image.open(image_path)
img.thumbnail(size, Image.Resampling.LANCZOS)
img.save(thumbnail_path, 'PNG')
def save_patch_from_file(
self,
patch_id: int,
image_path: str,
create_thumb: bool = True
) -> str:
"""
Save a patch from an existing file
Args:
patch_id: Patch ID
image_path: Source image path
create_thumb: Whether to create thumbnail
Returns:
Relative path to saved patch
"""
patch_path = self.get_patch_path(patch_id)
shutil.copy(image_path, patch_path)
if create_thumb:
thumbnail_path = self.get_thumbnail_path(patch_id)
self.create_thumbnail(patch_path, thumbnail_path)
return str(patch_path.relative_to(self.data_dir))
def save_patch_from_bytes(
self,
patch_id: int,
image_bytes: bytes,
create_thumb: bool = True
) -> str:
"""
Save a patch from bytes
Args:
patch_id: Patch ID
image_bytes: Image data as bytes
create_thumb: Whether to create thumbnail
Returns:
Relative path to saved patch
"""
patch_path = self.get_patch_path(patch_id)
# Save image
with open(patch_path, 'wb') as f:
f.write(image_bytes)
if create_thumb:
thumbnail_path = self.get_thumbnail_path(patch_id)
self.create_thumbnail(patch_path, thumbnail_path)
return str(patch_path.relative_to(self.data_dir))
def save_ai_generated_patch(
self,
patch_id: int,
edit_dir: Path
) -> str:
"""
Save an AI-generated patch from an edit
Args:
patch_id: Patch ID
edit_dir: Path to edit history directory
Returns:
Relative path to saved patch
"""
# Use the AI-generated output (patch_out.png)
source_path = edit_dir / "patch_out.png"
return self.save_patch_from_file(patch_id, str(source_path))
def save_manual_patch(
self,
patch_id: int,
project_id: int,
bbox: dict
) -> str:
"""
Save a manually selected patch from current project image
Args:
patch_id: Patch ID
project_id: Project ID
bbox: Bounding box {x, y, width, height}
Returns:
Relative path to saved patch
"""
from app.services.edit_service import EditService
from app.utils.image_processing import crop_patch
edit_service = EditService(self.data_dir)
current_image_path = edit_service.get_current_image_path(project_id)
# Load and crop current image
img = Image.open(current_image_path)
patch = crop_patch(img, bbox)
# Save patch
patch_path = self.get_patch_path(patch_id)
patch.save(patch_path, 'PNG')
# Create thumbnail
thumbnail_path = self.get_thumbnail_path(patch_id)
self.create_thumbnail(patch_path, thumbnail_path)
return str(patch_path.relative_to(self.data_dir))
def apply_patch_to_image(
self,
patch_id: int,
target_image: Image.Image,
bbox: dict,
feather_px: int = 5
) -> Image.Image:
"""
Apply a saved patch to a target image
Args:
patch_id: Patch ID to apply
target_image: Target image to apply patch to
bbox: Where to place the patch {x, y, width, height}
feather_px: Feather radius for blending
Returns:
Image with patch applied
"""
from app.utils.image_processing import insert_patch, create_feathered_mask
from PIL import ImageOps
# Load patch
patch_path = self.get_patch_path(patch_id)
patch = Image.open(patch_path).convert('RGBA')
# Resize patch to match bbox if needed
if patch.size != (bbox['width'], bbox['height']):
patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS)
# Create a soft-edged mask for the patch
mask = Image.new('L', patch.size, 255)
if feather_px > 0:
mask = create_feathered_mask(mask, feather_px)
# Apply mask to patch
patch.putalpha(mask)
# Insert patch into target image
result = insert_patch(target_image, patch, bbox)
return result
def delete_patch(self, patch_id: int):
"""Delete a patch and its thumbnail"""
patch_path = self.get_patch_path(patch_id)
thumbnail_path = self.get_thumbnail_path(patch_id)
if patch_path.exists():
patch_path.unlink()
if thumbnail_path.exists():
thumbnail_path.unlink()
def get_patch_size(self, patch_id: int) -> tuple:
"""Get patch dimensions"""
patch_path = self.get_patch_path(patch_id)
if not patch_path.exists():
return (0, 0)
img = Image.open(patch_path)
return img.size
View File
+217
View File
@@ -0,0 +1,217 @@
from PIL import Image, ImageFilter, ImageDraw
import numpy as np
from io import BytesIO
from typing import Tuple, Dict
import cv2
def bytes_to_image(image_bytes: bytes) -> Image.Image:
"""Convert bytes to PIL Image"""
return Image.open(BytesIO(image_bytes)).convert('RGBA')
def image_to_bytes(image: Image.Image, format: str = 'PNG') -> bytes:
"""Convert PIL Image to bytes"""
buffer = BytesIO()
image.save(buffer, format=format)
return buffer.getvalue()
def crop_patch(image: Image.Image, bbox: Dict[str, int]) -> Image.Image:
"""
Crop a patch from the image using bounding box
Args:
image: PIL Image
bbox: Dictionary with x, y, width, height
Returns:
Cropped patch as PIL Image
"""
x, y, width, height = bbox['x'], bbox['y'], bbox['width'], bbox['height']
return image.crop((x, y, x + width, y + height))
def create_feathered_mask(mask: Image.Image, feather_px: int) -> Image.Image:
"""
Apply feathering (Gaussian blur) to mask edges
Args:
mask: Binary mask image (grayscale)
feather_px: Feather radius in pixels
Returns:
Feathered mask
"""
if feather_px <= 0:
return mask
# Apply Gaussian blur for feathering
feathered = mask.filter(ImageFilter.GaussianBlur(radius=feather_px))
return feathered
def blend_patch(
original_patch: Image.Image,
regenerated_patch: Image.Image,
mask: Image.Image,
feather_px: int = 0
) -> Image.Image:
"""
Blend regenerated patch with original using mask
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
Returns:
Blended patch
"""
# Ensure all images are the same size
if regenerated_patch.size != original_patch.size:
regenerated_patch = regenerated_patch.resize(original_patch.size, Image.Resampling.LANCZOS)
if mask.size != original_patch.size:
mask = mask.resize(original_patch.size, Image.Resampling.LANCZOS)
# Convert mask to grayscale if needed
if mask.mode != 'L':
mask = mask.convert('L')
# 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')
# Blend using the feathered mask
blended = Image.composite(regenerated_patch, original_patch, feathered_mask)
return blended
def insert_patch(
full_image: Image.Image,
patch: Image.Image,
bbox: Dict[str, int]
) -> Image.Image:
"""
Insert a patch back into the full image at the specified bbox
Args:
full_image: Full original image
patch: Patch to insert
bbox: Bounding box {x, y, width, height}
Returns:
Full image with patch inserted
"""
result = full_image.copy()
x, y = bbox['x'], bbox['y']
# Ensure patch is the correct size
if patch.size != (bbox['width'], bbox['height']):
patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS)
# Paste the patch
result.paste(patch, (x, y), patch if patch.mode == 'RGBA' else None)
return result
def create_mask_from_selection(
width: int,
height: int,
selection_type: str,
selection_data: Dict
) -> Image.Image:
"""
Create a binary mask from selection data
Args:
width: Mask width
height: Mask height
selection_type: "rectangle", "ellipse", or "lasso"
selection_data: Selection-specific data
Returns:
Binary mask (white = selected, black = not selected)
"""
mask = Image.new('L', (width, height), 0)
draw = ImageDraw.Draw(mask)
if selection_type == "rectangle":
# Fill entire rectangle
draw.rectangle([0, 0, width, height], fill=255)
elif selection_type == "ellipse":
# Fill entire ellipse
draw.ellipse([0, 0, width, height], fill=255)
elif selection_type == "lasso":
# Draw polygon from points
points = selection_data.get('points', [])
if points:
# Convert points to relative coordinates within bbox
draw.polygon(points, fill=255)
return mask
def ensure_even_dimensions(image: Image.Image) -> Image.Image:
"""
Ensure image dimensions are even numbers (required by some AI providers)
Args:
image: PIL Image
Returns:
Image with even dimensions
"""
width, height = image.size
new_width = width if width % 2 == 0 else width + 1
new_height = height if height % 2 == 0 else height + 1
if (new_width, new_height) != (width, height):
new_image = Image.new(image.mode, (new_width, new_height), (0, 0, 0, 0))
new_image.paste(image, (0, 0))
return new_image
return image
def resize_for_ai(image: Image.Image, max_size: int = 1024) -> Tuple[Image.Image, float]:
"""
Resize image if needed for AI processing (max dimension)
Args:
image: PIL Image
max_size: Maximum dimension size
Returns:
Tuple of (resized image, scale factor)
"""
width, height = image.size
max_dim = max(width, height)
if max_dim > max_size:
scale = max_size / max_dim
new_width = int(width * scale)
new_height = int(height * scale)
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
return ensure_even_dimensions(resized), scale
return ensure_even_dimensions(image), 1.0
def scale_bbox(bbox: Dict[str, int], scale: float) -> Dict[str, int]:
"""Scale bounding box coordinates"""
return {
'x': int(bbox['x'] * scale),
'y': int(bbox['y'] * scale),
'width': int(bbox['width'] * scale),
'height': int(bbox['height'] * scale)
}
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
Pillow==10.2.0
numpy==1.26.3
sqlalchemy==2.0.25
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
aiofiles==23.2.1
httpx==0.26.0
pydantic==2.5.3
pydantic-settings==2.1.0
opencv-python-headless==4.9.0.80
scikit-image==0.22.0
View File
+46
View File
@@ -0,0 +1,46 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ai-photo-edit-backend-dev
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./backend:/app
environment:
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db
- SECRET_KEY=dev-secret-key-change-in-production
- AI_PROVIDER=${AI_PROVIDER:-mock}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STABILITY_API_KEY=${STABILITY_API_KEY:-}
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
restart: unless-stopped
networks:
- ai-photo-edit-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: ai-photo-edit-frontend-dev
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_BASE_URL=http://localhost:8000
depends_on:
- backend
restart: unless-stopped
networks:
- ai-photo-edit-network
networks:
ai-photo-edit-network:
driver: bridge
+43
View File
@@ -0,0 +1,43 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ai-photo-edit-backend
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./backend:/app
environment:
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db
- SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production}
- AI_PROVIDER=${AI_PROVIDER:-mock}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STABILITY_API_KEY=${STABILITY_API_KEY:-}
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost
restart: unless-stopped
networks:
- ai-photo-edit-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: ai-photo-edit-frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
networks:
- ai-photo-edit-network
networks:
ai-photo-edit-network:
driver: bridge
volumes:
data:
+261
View File
@@ -0,0 +1,261 @@
# AI Provider Cost & Quality Comparison
## Provider Options for Inpainting/Image Editing
### 1. OpenAI DALL-E 2 ❌ (Not Recommended)
**Current implementation uses this when `AI_PROVIDER=openai`**
**Pricing:**
- $0.020 per image (1024x1024)
- $0.018 per image (512x512)
**Quality:** ⭐⭐ (2/5)
- Old model (2022)
- Significantly lower quality than DALL-E 3
- Cannot match ChatGPT web interface
- Often produces artifacts
**Pros:**
- Simple API
- Fast responses
**Cons:**
- Poor quality by modern standards
- Limited to 1024x1024 max
- No access to DALL-E 3 inpainting
**Verdict:** ❌ Don't use unless you need the cheapest option and quality doesn't matter
---
### 2. Stability AI (Stable Diffusion XL) ✅ (Good Choice)
**Direct API to Stability AI**
**Pricing:**
- Credits-based system
- ~$0.010 per image (512x512)
- ~$0.040 per image (1024x1024)
- Must buy credit packs ($10 minimum = 1000 credits)
**Quality:** ⭐⭐⭐⭐ (4/5)
- Excellent inpainting quality
- Good at following prompts
- Natural-looking results
- Well-suited for photo editing
**Pros:**
- Built specifically for inpainting
- Good quality-to-cost ratio
- Reliable API
- Fast generation (15-30 seconds)
**Cons:**
- Requires credit purchase upfront
- Limited to SDXL models
- Less flexible than Replicate
**Verdict:** ✅ Best balance of quality and cost for direct API
---
### 3. Replicate ⭐ (Most Flexible)
**API marketplace with multiple models**
**Pricing:** Pay-per-second of GPU time
- SDXL Inpainting: ~$0.0023/sec (~$0.01-0.03 per image)
- Kandinsky 2.2: ~$0.0023/sec (~$0.01-0.02 per image)
- LaMa (removal): ~$0.0005/sec (~$0.002 per image)
- Varies by model and parameters
**Quality:** ⭐⭐⭐⭐⭐ (5/5 - depends on model choice)
- Access to multiple models
- Can choose best model for each use case
- Community models available
- Often better than Stability direct
**Pros:**
- Multiple models to choose from
- Pay only for what you use (no minimums)
- Can use free models
- New models added regularly
- Fine-tuned models available
**Cons:**
- More complex to implement
- Pricing varies by model
- Need to understand different models
**Best Models on Replicate:**
- **SDXL Inpainting**: General purpose, excellent quality
- **LaMa**: Best for object removal
- **Kandinsky 2.2**: Good alternative to SDXL
- **ControlNet Inpainting**: More control over results
**Verdict:** ⭐ Most flexible, best value if you implement multiple models
---
### 4. Local Models (Self-Hosted) 💰 (Best Quality, No Per-Use Cost)
**Pricing:**
- $0 per image after setup
- Requires GPU (RTX 3060 12GB minimum, RTX 4090 ideal)
- Cloud GPU: $0.30-$1.00/hour (RunPod, Vast.ai)
**Quality:** ⭐⭐⭐⭐⭐ (5/5)
- Best possible quality
- Full control over model selection
- Can use latest open-source models
- No API limitations
**Setup Costs:**
- GPU hardware: $300-$2000
- OR Cloud GPU rental: $0.30-$1.00/hour
**Pros:**
- Unlimited usage once set up
- Best quality available
- Complete privacy
- No API rate limits
- Can fine-tune models
**Cons:**
- Requires GPU or cloud rental
- More complex setup
- Slower than cloud APIs (if CPU only)
**Verdict:** 💰 Best long-term if you have GPU or high volume
---
## Stability AI vs Replicate: What's the Difference?
### Stability AI (stability.ai)
**What it is:**
- The company that created Stable Diffusion
- Direct API to their hosted models
- Official source
**Business Model:**
- Buy credits upfront
- Credits expire after 3 months
- Official support
- Guaranteed uptime SLA
**Models Available:**
- Stable Diffusion XL
- Stable Diffusion 1.5
- Their official models only
---
### Replicate (replicate.com)
**What it is:**
- Marketplace/platform for running ML models
- Hosts models from many sources
- Pay-per-use GPU time
**Business Model:**
- Pay only for GPU seconds used
- No upfront purchase
- No credits that expire
- $0.01 minimum charge per prediction
**Models Available:**
- Stability AI's models (SDXL, SD 1.5)
- Community models
- Fine-tuned variants
- Specialized models (LaMa, ControlNet, etc.)
- 100+ image generation models
**Think of it like:**
- **Stability AI** = Buying directly from Apple
- **Replicate** = App Store with many developers
---
## Cost Comparison Examples
### Scenario: 100 edits per month
| Provider | Cost per Image | Monthly Cost | Quality |
|----------|---------------|--------------|---------|
| DALL-E 2 | $0.020 | $2.00 | ⭐⭐ Poor |
| Stability AI | $0.040 | $4.00 | ⭐⭐⭐⭐ Good |
| Replicate (SDXL) | $0.025 | $2.50 | ⭐⭐⭐⭐⭐ Excellent |
| Replicate (LaMa) | $0.002 | $0.20 | ⭐⭐⭐⭐ Good for removal |
| Local GPU | $0.00 | $0.00* | ⭐⭐⭐⭐⭐ Best |
*Requires $500+ GPU or $0.30-1.00/hr cloud GPU
### Scenario: 1000 edits per month (Heavy use)
| Provider | Monthly Cost | Notes |
|----------|--------------|-------|
| DALL-E 2 | $20.00 | Not worth it |
| Stability AI | $40.00 | Need $10-40 credit refills |
| Replicate (SDXL) | $25.00 | Pay as you go |
| Local GPU | $0.00 | GPU pays for itself after ~50K images |
| Cloud GPU (RunPod) | $20-60 | Depends on uptime needed |
---
## Quality Rankings for Inpainting
**Best to Worst:**
1. **Local SDXL Inpainting** ⭐⭐⭐⭐⭐ (self-hosted)
2. **Replicate SDXL Inpainting** ⭐⭐⭐⭐⭐
3. **Stability AI SDXL** ⭐⭐⭐⭐
4. **Replicate LaMa** ⭐⭐⭐⭐ (for removal only)
5. **DALL-E 2** ⭐⭐ (outdated)
---
## Recommendation by Use Case
### Best for Testing/Development: Mock Provider
- Cost: $0
- Quality: N/A (returns original)
- Use when: Building/testing UI
### Best for Low Volume (< 100/month): Replicate
- Cost: ~$2.50/month
- Quality: ⭐⭐⭐⭐⭐
- No minimum purchase
- Multiple model options
### Best for Medium Volume (100-1000/month): Replicate or Stability AI
- Replicate: ~$25/month, more flexibility
- Stability AI: ~$40/month, simpler API
### Best for High Volume (1000+/month): Local GPU or Cloud GPU
- Unlimited usage
- Best quality
- Full control
### Best Overall Value: Replicate
- No minimum purchase
- Pay only for what you use
- Best model selection
- Easy to try multiple models
---
## My Recommendation
Start with **Replicate** because:
1. ✅ No upfront cost (vs Stability's $10 minimum)
2. ✅ Better quality than DALL-E 2
3. ✅ Can try multiple models to find what works
4. ✅ Cheapest per-image for low-medium volume
5. ✅ Can switch to Stability AI later if needed
**Next Steps:**
- I can add Replicate support (30 min of work)
- Test with SDXL Inpainting first
- Try LaMa for object removal
- Fall back to Stability if needed
Would you like me to add Replicate support?
+369
View File
@@ -0,0 +1,369 @@
# Model Selection Guide for Body Parts and Editing Tasks
## Quick Reference: Best Models by Use Case
### Human Features (Faces, Hands, Bodies)
**Best Choice: `realistic-vision` (Replicate)**
```env
AI_PROVIDER=replicate
REPLICATE_API_KEY=your-key
REPLICATE_MODEL=realistic-vision
```
**Why:** Trained specifically on human anatomy and realistic photos. Handles difficult features like:
- ✅ Hands (notoriously hard for AI)
- ✅ Faces and facial features
- ✅ Skin textures and tones
- ✅ Body proportions
- ✅ Portraits
**Examples:**
- "Fix the hand position"
- "Remove red eye"
- "Smooth skin blemishes"
- "Adjust facial expression"
- "Fix fingers"
**Cost:** ~$0.020/image
**Quality:** ⭐⭐⭐⭐⭐
---
### Object Removal
**Best Choice: `lama` (Replicate)**
```env
AI_PROVIDER=replicate
REPLICATE_MODEL=lama
```
**Why:** Specifically designed for inpainting and object removal. Excellent at:
- ✅ Removing objects cleanly
- ✅ Filling in backgrounds naturally
- ✅ Maintaining surrounding context
- ✅ Fast and cheap
**Examples:**
- "Remove the person"
- "Delete the watermark"
- "Erase the object"
- "Clean up the background"
**Cost:** ~$0.002/image (cheapest!)
**Quality:** ⭐⭐⭐⭐
---
### General Purpose Editing
**Best Choice: `sdxl-inpaint` (Replicate or Stability AI)**
```env
# Option 1: Replicate
AI_PROVIDER=replicate
REPLICATE_MODEL=sdxl-inpaint
# Option 2: Stability AI Direct
AI_PROVIDER=stability
STABILITY_MODEL=sdxl
```
**Why:** SDXL (Stable Diffusion XL) is the best all-around model for:
- ✅ Landscapes and scenery
- ✅ Objects and textures
- ✅ Creative edits
- ✅ Style changes
- ✅ Adding elements
**Examples:**
- "Change sky to sunset"
- "Add flowers"
- "Make it autumn"
- "Replace with grass"
**Cost:**
- Replicate: ~$0.025/image
- Stability AI: ~$0.040/image
**Quality:** ⭐⭐⭐⭐⭐
---
## Detailed Comparison by Body Part
### Hands ✋
**Challenge:** Hands are the hardest thing for AI to generate correctly. Common issues:
- Wrong number of fingers
- Unnatural finger positions
- Distorted proportions
- Weird joints
**Best Models (in order):**
1. **Realistic Vision** (Replicate) - ⭐⭐⭐⭐⭐
- Best overall for hands
- Understands hand anatomy
- Cost: ~$0.020/image
2. **SDXL Inpainting** (Replicate/Stability) - ⭐⭐⭐
- Decent but less consistent
- Cost: ~$0.025-0.040/image
3. **DALL-E 2** (OpenAI) - ⭐⭐
- Often struggles with hands
- Not recommended
**Tips for Better Hand Edits:**
- Use detailed prompts: "realistic human hand with five fingers"
- Add negative prompts if provider supports: "deformed, extra fingers, missing fingers"
- Use Mode B (full image context) for better results
- Consider editing in multiple passes if needed
---
### Faces 😊
**Challenge:** Faces need to look natural and maintain proper proportions
**Best Models:**
1. **Realistic Vision** (Replicate) - ⭐⭐⭐⭐⭐
- Excellent for facial features
- Natural skin textures
- Good expression handling
2. **SDXL Inpainting** - ⭐⭐⭐⭐
- Good for general facial edits
- Better for style than realism
**Use Cases:**
- Remove blemishes
- Fix red eye
- Adjust expressions
- Change hair
- Smooth wrinkles
---
### Full Body / Torso 🧍
**Best Model:** Realistic Vision
**Why:** Maintains body proportions and realistic anatomy
**Examples:**
- "Fix the clothing wrinkles"
- "Change shirt color to blue"
- "Remove the stain"
---
### Hearts ♥️ (Decorative Elements)
**Best Model:** SDXL Inpainting
**Why:** Great for creative and decorative elements
**Examples:**
- "Add heart shape"
- "Draw a heart pattern"
- "Replace with hearts"
---
## Auto-Selection Feature
The system automatically selects the best model based on your prompt:
### Keywords that trigger `realistic-vision`:
- hand, hands, finger, fingers
- face, facial, portrait, eyes, nose, mouth
- body, person, human, skin, people
- realistic, photo, photograph
### Keywords that trigger `lama` (removal):
- remove, delete, erase, cleanup
- disappear, hide, clear
### Default: `sdxl-inpaint`
- Everything else uses SDXL for best general quality
**Example Auto-Selection:**
```python
# User prompt: "Fix the hand" → auto-selects realistic-vision
# User prompt: "Remove the person" → auto-selects lama
# User prompt: "Change to sunset" → auto-selects sdxl-inpaint
```
---
## Manual Model Override
### Via Environment Variable
Set default model in `.env`:
```env
REPLICATE_MODEL=realistic-vision
```
### Via API Request
Override per-edit in the API:
```json
{
"prompt": "Fix the hand",
"ai_provider": "replicate",
"ai_model": "realistic-vision",
"mode": "A",
...
}
```
### Via Frontend (Future Feature)
Model selector dropdown in the UI.
---
## Cost Optimization Strategies
### For Low-Volume Users (< 100 edits/month)
**Recommendation:** Use Replicate with auto-selection
**Why:**
- No minimum purchase
- Pay only for what you use
- Auto-selects cheapest appropriate model
**Estimated Cost:** $1-3/month
---
### For Medium-Volume Users (100-1000 edits/month)
**Recommendation:** Replicate or Stability AI
**Strategy:**
- Use `lama` for removals ($0.002/image)
- Use `realistic-vision` for humans ($0.020/image)
- Use `sdxl-inpaint` for general ($0.025/image)
**Estimated Cost:** $10-30/month
---
### For High-Volume Users (1000+ edits/month)
**Recommendation:** Consider local GPU or cloud GPU
**Why:**
- No per-image cost
- Best quality control
- Privacy
**Setup:**
- Local: RTX 3060+ GPU ($300-2000 one-time)
- Cloud: RunPod/Vast.ai ($0.30-1.00/hour)
---
## Quality Comparison Table
| Use Case | DALL-E 2 | Stability SDXL | Replicate SDXL | Replicate Realistic | Replicate LaMa |
|----------|----------|----------------|----------------|---------------------|----------------|
| Hands | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Faces | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Bodies | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Objects | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | N/A |
| Landscapes | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | N/A |
| Removal | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Creative | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ |
---
## Advanced Tips
### For Difficult Hands
1. **Use Mode B** - Provides full image context
2. **Be specific** - "realistic five-fingered hand in natural pose"
3. **Multiple passes** - Fix gross errors first, then refine
4. **Reference images** - Mode B helps AI understand the pose
### For Facial Features
1. **High feather value** - 10-15px for smooth blending
2. **Small selections** - Target specific features
3. **Natural lighting** - Mention lighting in prompt
### For Body Parts
1. **Maintain proportions** - Use Mode B for body context
2. **Clothing context** - Include clothing description in prompt
3. **Skin tone consistency** - Mention skin tone if needed
---
## Troubleshooting Common Issues
### "Hands have too many fingers"
- **Solution:** Switch to `realistic-vision` model
- **Prompt:** "realistic human hand with exactly five fingers"
- **Try:** Multiple generations, pick best result
### "Face looks unnatural"
- **Solution:** Use `realistic-vision` model
- **Increase:** Feather value to 15-20px
- **Try:** Mode B for better context
### "Removal leaves artifacts"
- **Solution:** Use `lama` model (designed for removal)
- **Alternative:** SDXL with prompt "clean background"
### "Colors don't match"
- **Increase:** Feather value to 20-30px
- **Try:** Mode B for better color context
- **Prompt:** Include color description
---
## Quick Start Examples
### Example 1: Fix a Hand
```json
{
"prompt": "realistic human hand with five fingers, natural pose",
"ai_provider": "replicate",
"ai_model": "realistic-vision",
"mode": "B",
"feather_px": 10
}
```
### Example 2: Remove an Object
```json
{
"prompt": "remove the object, clean background",
"ai_provider": "replicate",
"ai_model": "lama",
"mode": "A",
"feather_px": 5
}
```
### Example 3: Change Sky
```json
{
"prompt": "sunset sky with orange and pink clouds",
"ai_provider": "replicate",
"ai_model": "sdxl-inpaint",
"mode": "A",
"feather_px": 15
}
```
---
## Summary
**For Body Parts:** Use `realistic-vision` (Replicate)
**For Removal:** Use `lama` (Replicate)
**For Everything Else:** Use `sdxl-inpaint` (Replicate or Stability)
**Let the auto-selection do its job** - it's optimized for these use cases!
+350
View File
@@ -0,0 +1,350 @@
# Quick Start Guide
## How to Choose the Right AI Model
### For Body Parts (Hands, Faces, Bodies)
Use **Replicate with `realistic-vision`** model:
```env
AI_PROVIDER=replicate
REPLICATE_API_KEY=your-key-here
REPLICATE_MODEL=realistic-vision
```
**Why:** This model is specifically trained on human anatomy and handles difficult features like:
- ✅ Hands (even complex finger positions)
- ✅ Faces and expressions
- ✅ Skin textures
- ✅ Body proportions
**Cost:** ~$0.020/image
### For Removing Objects
Use **Replicate with `lama`** model:
```env
AI_PROVIDER=replicate
REPLICATE_MODEL=lama
```
**Why:** Designed specifically for inpainting and removal
**Cost:** ~$0.002/image (cheapest!)
### For General Edits (Landscapes, Objects, Creative)
Use **Replicate with `sdxl-inpaint`** model (default):
```env
AI_PROVIDER=replicate
REPLICATE_MODEL=sdxl-inpaint
```
**Cost:** ~$0.025/image
---
## Auto-Model Selection
The system automatically picks the best model based on your prompt:
| Your Prompt | Auto-Selected Model | Why |
|-------------|-------------------|-----|
| "Fix the hand" | realistic-vision | Detects "hand" keyword |
| "Remove person" | lama | Detects "remove" keyword |
| "Change sky to sunset" | sdxl-inpaint | General purpose default |
**You don't need to manually specify models** - the auto-selection is optimized for quality and cost!
---
## Patch Library: Save and Reuse Parts
### What is the Patch Library?
A library where you can save image patches (regions) and reuse them across different images.
**Use Cases:**
- Save a well-generated hand to reuse later
- Save a perfect face for multiple photos
- Build a collection of good body parts
- Save textures, objects, or backgrounds
- Reuse AI-generated elements that came out great
### How to Save a Patch
#### Option 1: Save AI-Generated Result
After an AI edit completes:
```bash
POST /patches/
{
"name": "Perfect Hand",
"description": "Well-formed left hand, palm up",
"source_type": "ai_generated",
"source_edit_id": 123,
"category": "hand",
"tags": "left, palm, realistic"
}
```
This saves the AI-generated output (`patch_out.png`) to your library.
#### Option 2: Save Manual Selection
Select any region from your current image:
```bash
POST /patches/
{
"name": "Good Face",
"description": "Frontal face with good lighting",
"source_type": "manual_selection",
"source_project_id": 456,
"bbox": {"x": 100, "y": 100, "width": 200, "height": 200},
"category": "face",
"tags": "front, smile, female"
}
```
This saves whatever is currently in that region of your image.
#### Option 3: Import from File
Upload an external image:
```bash
POST /patches/
FormData:
name: "Downloaded Hand"
source_type: "imported"
file: [uploaded PNG file]
category: "hand"
```
### How to Apply a Saved Patch
```bash
POST /patches/apply
{
"project_id": 789,
"patch_id": 123,
"bbox": {"x": 300, "y": 400, "width": 200, "height": 200},
"feather_px": 10
}
```
This places the saved patch at the specified location in your image.
### Browse Your Patch Library
```bash
# List all patches
GET /patches/
# Filter by category
GET /patches/?category=hand
# Filter by tags
GET /patches/?tags=realistic
# Get specific patch
GET /patches/123
# Get patch image
GET /patches/123/image
# Get patch thumbnail
GET /patches/123/image?thumbnail=true
```
### Organize Your Patches
**Categories:**
- `hand` - Hand images
- `face` - Facial features
- `body` - Body parts
- `object` - Objects and items
- `texture` - Textures and patterns
- `background` - Backgrounds and scenery
**Tags:** Comma-separated keywords for searching
- "left, palm, realistic"
- "front, smile, female"
- "five fingers, open hand"
---
## Complete Workflow Example
### Scenario: Fix hands in a portrait photo
**Step 1: Create project and upload image**
```bash
POST /projects/ {"name": "Portrait Edit"}
POST /projects/1/upload [upload photo]
```
**Step 2: Try to fix the hand with AI**
```bash
POST /edits/projects/1/fix
{
"prompt": "realistic human hand with five fingers, natural pose",
"mode": "B", # Use full image for context
"selection_type": "rectangle",
"bbox": {"x": 200, "y": 300, "width": 150, "height": 200},
"feather_px": 10
}
```
The system auto-selects `realistic-vision` model because prompt mentions "hand".
**Step 3: If result is good, save it for later**
```bash
POST /patches/
{
"name": "Good Left Hand",
"source_type": "ai_generated",
"source_edit_id": 1,
"category": "hand",
"tags": "left, natural, realistic, five fingers"
}
```
**Step 4: Use saved hand on another photo**
```bash
# On a different project
POST /patches/apply
{
"project_id": 2,
"patch_id": 1,
"bbox": {"x": 150, "y": 250, "width": 150, "height": 200},
"feather_px": 15
}
```
---
## Cost Comparison
### Example: Fixing 10 hands in different photos
**Option A: Generate each hand with AI**
- 10 edits × $0.020 = **$0.20**
**Option B: Generate one good hand, save it, reuse it**
- 1 AI generation: $0.020
- 9 patch applications: $0.00 (no AI cost)
- **Total: $0.020** (90% savings!)
### When to Use Saved Patches vs AI
**Use Saved Patches When:**
- You have a perfect result you want to reuse
- Same angle/lighting/style needed
- Want to maintain consistency across images
- Want to avoid AI generation costs
**Use AI Generation When:**
- Need unique/different result each time
- Different angle or perspective needed
- Want variation and creativity
- Patch doesn't fit the context
---
## Pro Tips
### Building a Good Patch Library
1. **Save your best AI results** - When AI generates something great, save it immediately
2. **Organize with categories** - Use consistent categories for easy finding
3. **Tag descriptively** - Include orientation (left/right), pose, lighting, etc.
4. **Create variations** - Save multiple versions of common needs (left hand, right hand, etc.)
5. **Build gradually** - Your library becomes more valuable over time
### Maximizing Quality
1. **For hands:** Always use `realistic-vision` model or save good results
2. **For faces:** Use Mode B (full image context) for better matching
3. **Use high feather values** (15-20px) when applying saved patches
4. **Test positioning** before finalizing - patches work best when lighting/angle matches
### Saving Money
1. **Build a patch library** of common needs
2. **Use `lama` for removals** instead of expensive models
3. **Let auto-selection work** - it picks the cheapest appropriate model
4. **Reuse successful patches** instead of regenerating
---
## API Quick Reference
```bash
# List available patches
GET /patches/
# Get patch details
GET /patches/{id}
# Get patch image
GET /patches/{id}/image
GET /patches/{id}/image?thumbnail=true
# Create patch from AI edit
POST /patches/
{
"name": "My Patch",
"source_type": "ai_generated",
"source_edit_id": 123,
"category": "hand"
}
# Create patch from manual selection
POST /patches/
{
"name": "My Patch",
"source_type": "manual_selection",
"source_project_id": 456,
"bbox": {"x": 100, "y": 100, "width": 200, "height": 200}
}
# Apply saved patch
POST /patches/apply
{
"project_id": 789,
"patch_id": 123,
"bbox": {"x": 300, "y": 400, "width": 200, "height": 200},
"feather_px": 10
}
# Delete patch
DELETE /patches/{id}
# Update patch metadata
PUT /patches/{id}
{
"name": "Updated Name",
"tags": "new, tags",
"category": "hand"
}
```
---
## Summary
**For hands/faces/bodies:** Use `realistic-vision` model
**For removal:** Use `lama` model
**For general edits:** Use `sdxl-inpaint` (default)
**Auto-selection works great** - just write natural prompts
**Save good AI results** to patch library for reuse
**Save manual selections** from any image
**Reuse patches across images** to save money and maintain consistency
**You now have the best of both worlds:**
- AI generation when you need something new
- Saved patches when you need consistency or want to save money
+28
View File
@@ -0,0 +1,28 @@
FROM node:20-alpine as build
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy source
COPY . .
# Build app
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy source
COPY . .
EXPOSE 5173
# Run dev server
CMD ["npm", "run", "dev", "--", "--host"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Photo Edit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
# React Router
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /projects {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /edits {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ai-photo-edit-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"fabric": "^5.3.0",
"axios": "^1.6.5"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.12"
}
}
+112
View File
@@ -0,0 +1,112 @@
.app {
min-height: 100vh;
background-color: #1a1a1a;
}
.error-banner {
background-color: #ff4444;
color: white;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-banner button {
background: none;
color: white;
font-size: 20px;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.project-setup {
max-width: 600px;
margin: 0 auto;
padding: 40px;
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444;
}
.project-setup h2 {
font-size: 24px;
margin-bottom: 24px;
text-align: center;
}
.setup-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #cccccc;
}
.form-group input {
width: 100%;
}
.file-name {
font-size: 12px;
color: #00ff00;
margin-top: 4px;
}
.create-project-btn {
background-color: #0066ff;
color: white;
padding: 14px 20px;
font-size: 16px;
font-weight: 600;
margin-top: 10px;
}
.create-project-btn:hover:not(:disabled) {
background-color: #0055dd;
}
.workspace {
display: grid;
grid-template-columns: 1fr 400px;
gap: 20px;
min-height: 600px;
}
@media (max-width: 1200px) {
.workspace {
grid-template-columns: 1fr;
}
}
.left-panel {
display: flex;
flex-direction: column;
}
.right-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.history-wrapper {
flex: 1;
min-height: 0;
}
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import ImageCanvas from './components/ImageCanvas';
import Controls from './components/Controls';
import History from './components/History';
import { projectsApi, editsApi } from './utils/api';
import './App.css';
function App() {
const [project, setProject] = useState(null);
const [imageFile, setImageFile] = useState(null);
const [currentImageUrl, setCurrentImageUrl] = useState(null);
const [selection, setSelection] = useState(null);
const [selectionMode, setSelectionMode] = useState('rectangle');
const [mode, setMode] = useState('A');
const [feather, setFeather] = useState(5);
const [prompt, setPrompt] = useState('');
const [edits, setEdits] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const [projectName, setProjectName] = useState('');
const [showProjectInput, setShowProjectInput] = useState(true);
// Create project and upload image
const handleCreateProject = async () => {
if (!projectName.trim() || !imageFile) {
setError('Please provide a project name and select an image');
return;
}
try {
setError(null);
setIsProcessing(true);
// Create project
const newProject = await projectsApi.create(projectName);
setProject(newProject);
// Upload image
await projectsApi.uploadImage(newProject.id, imageFile);
// Set current image URL
setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id));
// Hide project input
setShowProjectInput(false);
// Load edits
await loadEdits(newProject.id);
} catch (err) {
setError(`Failed to create project: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
// Load edits for the project
const loadEdits = async (projectId) => {
try {
const projectEdits = await projectsApi.getEdits(projectId);
setEdits(projectEdits);
} catch (err) {
console.error('Failed to load edits:', err);
}
};
// Poll for edit status
const pollEditStatus = async (editId) => {
const maxAttempts = 60; // 60 attempts = 1 minute with 1 second interval
let attempts = 0;
const poll = async () => {
try {
const edit = await editsApi.get(editId);
if (edit.status === 'completed') {
// Reload edits and update image
await loadEdits(project.id);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
setIsProcessing(false);
setSelection(null);
return;
} else if (edit.status === 'failed') {
setError(`Edit failed: ${edit.error_message}`);
setIsProcessing(false);
await loadEdits(project.id);
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 1000); // Poll every 1 second
} else {
setError('Edit timeout - please check edit history');
setIsProcessing(false);
}
} catch (err) {
setError(`Failed to check edit status: ${err.message}`);
setIsProcessing(false);
}
};
poll();
};
// Handle fix button
const handleFix = async () => {
if (!selection || !prompt.trim() || !project) {
setError('Please make a selection and enter a prompt');
return;
}
try {
setError(null);
setIsProcessing(true);
const editRequest = {
prompt: prompt.trim(),
mode: mode,
selection_type: selection.type,
bbox: selection.bbox,
feather_px: feather,
selection_data: selection.selectionData,
};
const edit = await editsApi.create(project.id, editRequest);
// Start polling for status
pollEditStatus(edit.id);
} catch (err) {
setError(`Failed to process edit: ${err.message}`);
setIsProcessing(false);
}
};
// Handle revert
const handleRevert = async (editId) => {
if (!project) return;
try {
setError(null);
setIsProcessing(true);
await editsApi.revert(project.id, editId);
// Update image
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id);
} catch (err) {
setError(`Failed to revert: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
// Handle reset
const handleReset = async () => {
if (!project) return;
try {
setError(null);
setIsProcessing(true);
await editsApi.reset(project.id);
// Update image
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id);
} catch (err) {
setError(`Failed to reset: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
return (
<div className="app">
<div className="container">
<div className="header">
<h1>AI Photo Edit</h1>
<p>Have AI regenerate only a selected area of your photo</p>
</div>
{error && (
<div className="error-banner">
<strong>Error:</strong> {error}
<button onClick={() => setError(null)}></button>
</div>
)}
{showProjectInput ? (
<div className="project-setup">
<h2>Create New Project</h2>
<div className="setup-form">
<div className="form-group">
<label>Project Name</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My Photo Edit Project"
disabled={isProcessing}
/>
</div>
<div className="form-group">
<label>Upload Image</label>
<input
type="file"
accept="image/*"
onChange={(e) => setImageFile(e.target.files[0])}
disabled={isProcessing}
/>
{imageFile && (
<p className="file-name">Selected: {imageFile.name}</p>
)}
</div>
<button
className="create-project-btn"
onClick={handleCreateProject}
disabled={isProcessing || !projectName.trim() || !imageFile}
>
{isProcessing ? 'Creating...' : 'Create Project'}
</button>
</div>
</div>
) : (
<div className="workspace">
<div className="left-panel">
<ImageCanvas
imageUrl={currentImageUrl}
onSelectionChange={setSelection}
selectionMode={selectionMode}
/>
</div>
<div className="right-panel">
<Controls
selectionMode={selectionMode}
onSelectionModeChange={setSelectionMode}
mode={mode}
onModeChange={setMode}
feather={feather}
onFeatherChange={setFeather}
prompt={prompt}
onPromptChange={setPrompt}
onFix={handleFix}
onClear={() => setSelection(null)}
isProcessing={isProcessing}
hasSelection={!!selection}
/>
<div className="history-wrapper">
<History
edits={edits}
onRevert={handleRevert}
onReset={handleReset}
isProcessing={isProcessing}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default App;
+121
View File
@@ -0,0 +1,121 @@
.controls {
padding: 20px;
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444;
}
.control-section {
margin-bottom: 24px;
}
.control-section:last-child {
margin-bottom: 0;
}
.control-section h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.button-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.button-group button {
flex: 1;
min-width: 100px;
background-color: #3a3a3a;
color: #ffffff;
padding: 10px 16px;
font-weight: 500;
}
.button-group button:hover:not(:disabled) {
background-color: #4a4a4a;
}
.button-group button.active {
background-color: #00aa00;
color: #ffffff;
}
.button-group button.active:hover:not(:disabled) {
background-color: #00cc00;
}
.mode-hint {
margin-top: 8px;
font-size: 12px;
color: #888;
font-style: italic;
}
.slider-group {
display: flex;
align-items: center;
gap: 12px;
}
.slider-group input[type="range"] {
flex: 1;
}
.slider-value {
min-width: 50px;
text-align: right;
font-weight: 600;
color: #00ff00;
}
.hint {
margin-top: 6px;
font-size: 12px;
color: #666;
}
.control-section textarea {
width: 100%;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.fix-btn {
background-color: #0066ff;
color: white;
padding: 14px 20px;
font-size: 16px;
font-weight: 600;
}
.fix-btn:hover:not(:disabled) {
background-color: #0055dd;
}
.fix-btn:disabled {
background-color: #333;
color: #666;
}
.clear-btn {
background-color: #ff4444;
color: white;
padding: 10px 16px;
}
.clear-btn:hover:not(:disabled) {
background-color: #cc0000;
}
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import './Controls.css';
const Controls = ({
selectionMode,
onSelectionModeChange,
mode,
onModeChange,
feather,
onFeatherChange,
prompt,
onPromptChange,
onFix,
onClear,
isProcessing,
hasSelection,
}) => {
return (
<div className="controls">
<div className="control-section">
<h3>Selection Tool</h3>
<div className="button-group">
<button
className={selectionMode === 'rectangle' ? 'active' : ''}
onClick={() => onSelectionModeChange('rectangle')}
disabled={isProcessing}
>
Rectangle
</button>
<button
className={selectionMode === 'ellipse' ? 'active' : ''}
onClick={() => onSelectionModeChange('ellipse')}
disabled={isProcessing}
>
Ellipse
</button>
<button
className={selectionMode === 'lasso' ? 'active' : ''}
onClick={() => onSelectionModeChange('lasso')}
disabled={isProcessing}
>
Lasso
</button>
</div>
</div>
<div className="control-section">
<h3>AI Mode</h3>
<div className="button-group">
<button
className={mode === 'A' ? 'active' : ''}
onClick={() => onModeChange('A')}
disabled={isProcessing}
title="Mode A: Send only the selected patch (faster, cheaper)"
>
Mode A (Patch Only)
</button>
<button
className={mode === 'B' ? 'active' : ''}
onClick={() => onModeChange('B')}
disabled={isProcessing}
title="Mode B: Send patch + full image for context (better style consistency)"
>
Mode B (With Context)
</button>
</div>
<p className="mode-hint">
{mode === 'A'
? 'Faster and cheaper - sends only the selected area'
: 'Better style consistency - includes full image for context'}
</p>
</div>
<div className="control-section">
<h3>Edge Feathering</h3>
<div className="slider-group">
<input
type="range"
min="0"
max="50"
value={feather}
onChange={(e) => onFeatherChange(parseInt(e.target.value))}
disabled={isProcessing}
/>
<span className="slider-value">{feather}px</span>
</div>
<p className="hint">Smooth blending at selection edges</p>
</div>
<div className="control-section">
<h3>Prompt</h3>
<textarea
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder="Describe what to fix or change in the selected area..."
rows={3}
disabled={isProcessing}
/>
</div>
<div className="control-section action-buttons">
<button
className="fix-btn"
onClick={onFix}
disabled={isProcessing || !hasSelection || !prompt.trim()}
>
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
</button>
<button
className="clear-btn"
onClick={onClear}
disabled={isProcessing}
>
Clear Selection
</button>
</div>
</div>
);
};
export default Controls;
+131
View File
@@ -0,0 +1,131 @@
.history {
padding: 20px;
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444;
max-height: 600px;
overflow-y: auto;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.history-header h3 {
font-size: 14px;
font-weight: 600;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.reset-btn {
background-color: #ff4444;
color: white;
padding: 6px 12px;
font-size: 12px;
}
.reset-btn:hover:not(:disabled) {
background-color: #cc0000;
}
.empty-message {
text-align: center;
color: #666;
padding: 40px 20px;
font-style: italic;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-item {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
transition: border-color 0.2s;
}
.history-item:hover {
border-color: #555;
}
.edit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.edit-id {
font-weight: 600;
color: #ffffff;
font-size: 13px;
}
.edit-status {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.edit-prompt {
color: #cccccc;
font-size: 13px;
margin-bottom: 8px;
line-height: 1.4;
}
.edit-details {
display: flex;
gap: 12px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.edit-mode,
.edit-type,
.edit-feather {
font-size: 11px;
color: #888;
background-color: #2a2a2a;
padding: 2px 8px;
border-radius: 3px;
}
.edit-date {
font-size: 11px;
color: #666;
margin-bottom: 8px;
}
.revert-btn {
width: 100%;
background-color: #0066ff;
color: white;
padding: 8px 12px;
font-size: 12px;
margin-top: 8px;
}
.revert-btn:hover:not(:disabled) {
background-color: #0055dd;
}
.error-message {
color: #ff4444;
font-size: 11px;
margin-top: 8px;
padding: 6px;
background-color: rgba(255, 68, 68, 0.1);
border-radius: 3px;
}
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import './History.css';
const History = ({ edits, onRevert, onReset, isProcessing }) => {
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return '#00aa00';
case 'processing':
return '#ffaa00';
case 'failed':
return '#ff0000';
default:
return '#666';
}
};
return (
<div className="history">
<div className="history-header">
<h3>Edit History</h3>
{edits.length > 0 && (
<button
className="reset-btn"
onClick={onReset}
disabled={isProcessing}
>
Reset to Original
</button>
)}
</div>
{edits.length === 0 ? (
<p className="empty-message">No edits yet</p>
) : (
<div className="history-list">
{edits.map((edit) => (
<div key={edit.id} className="history-item">
<div className="edit-info">
<div className="edit-header">
<span className="edit-id">Edit #{edit.id}</span>
<span
className="edit-status"
style={{ color: getStatusColor(edit.status) }}
>
{edit.status}
</span>
</div>
<p className="edit-prompt">{edit.prompt}</p>
<div className="edit-details">
<span className="edit-mode">Mode {edit.mode}</span>
<span className="edit-type">{edit.selection_type}</span>
<span className="edit-feather">Feather: {edit.feather_px}px</span>
</div>
<p className="edit-date">{formatDate(edit.created_at)}</p>
</div>
{edit.status === 'completed' && (
<button
className="revert-btn"
onClick={() => onRevert(edit.id)}
disabled={isProcessing}
>
Revert to This
</button>
)}
{edit.error_message && (
<p className="error-message">{edit.error_message}</p>
)}
</div>
))}
</div>
)}
</div>
);
};
export default History;
+28
View File
@@ -0,0 +1,28 @@
.canvas-container {
position: relative;
width: 100%;
height: 100%;
min-height: 500px;
background-color: #2a2a2a;
border: 2px solid #444;
border-radius: 8px;
overflow: hidden;
}
.canvas-container canvas {
display: block;
}
.clear-selection-btn {
position: absolute;
top: 10px;
right: 10px;
background-color: #ff4444;
color: white;
padding: 8px 16px;
z-index: 10;
}
.clear-selection-btn:hover {
background-color: #cc0000;
}
+308
View File
@@ -0,0 +1,308 @@
import React, { useEffect, useRef, useState } from 'react';
import { fabric } from 'fabric';
import './ImageCanvas.css';
const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const canvasRef = useRef(null);
const fabricCanvasRef = useRef(null);
const [currentSelection, setCurrentSelection] = useState(null);
const [isDrawing, setIsDrawing] = useState(false);
const lassoPoints = useRef([]);
useEffect(() => {
if (!canvasRef.current) return;
// Initialize Fabric.js canvas
const canvas = new fabric.Canvas(canvasRef.current, {
selection: false,
backgroundColor: '#2a2a2a',
});
fabricCanvasRef.current = canvas;
// Handle window resize
const handleResize = () => {
const container = canvasRef.current?.parentElement;
if (container) {
canvas.setWidth(container.clientWidth);
canvas.setHeight(Math.min(container.clientHeight, 800));
canvas.renderAll();
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
canvas.dispose();
};
}, []);
// Load image when URL changes
useEffect(() => {
if (!fabricCanvasRef.current || !imageUrl) return;
const canvas = fabricCanvasRef.current;
// Add cache buster to force reload
const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`;
fabric.Image.fromURL(cacheBustedUrl, (img) => {
canvas.clear();
// Scale image to fit canvas
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height,
1
);
img.scale(scale);
img.set({
left: (canvas.width - img.width * scale) / 2,
top: (canvas.height - img.height * scale) / 2,
selectable: false,
evented: false,
});
canvas.add(img);
canvas.sendToBack(img);
canvas.renderAll();
// Store image reference
canvas.backgroundImage = img;
}, { crossOrigin: 'anonymous' });
}, [imageUrl]);
// Handle selection mode changes
useEffect(() => {
if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current;
// Clear previous selection
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null);
onSelectionChange(null);
}
// Set up event handlers based on mode
canvas.off('mouse:down');
canvas.off('mouse:move');
canvas.off('mouse:up');
if (selectionMode === 'rectangle') {
setupRectangleMode(canvas);
} else if (selectionMode === 'ellipse') {
setupEllipseMode(canvas);
} else if (selectionMode === 'lasso') {
setupLassoMode(canvas);
}
}, [selectionMode]);
const setupRectangleMode = (canvas) => {
let rect, isDown, startX, startY;
canvas.on('mouse:down', (e) => {
isDown = true;
const pointer = canvas.getPointer(e.e);
startX = pointer.x;
startY = pointer.y;
rect = new fabric.Rect({
left: startX,
top: startY,
width: 0,
height: 0,
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
});
canvas.add(rect);
setCurrentSelection(rect);
});
canvas.on('mouse:move', (e) => {
if (!isDown) return;
const pointer = canvas.getPointer(e.e);
const width = pointer.x - startX;
const height = pointer.y - startY;
rect.set({
width: Math.abs(width),
height: Math.abs(height),
left: width < 0 ? pointer.x : startX,
top: height < 0 ? pointer.y : startY,
});
canvas.renderAll();
});
canvas.on('mouse:up', () => {
isDown = false;
updateSelection(rect, 'rectangle');
});
};
const setupEllipseMode = (canvas) => {
let ellipse, isDown, startX, startY;
canvas.on('mouse:down', (e) => {
isDown = true;
const pointer = canvas.getPointer(e.e);
startX = pointer.x;
startY = pointer.y;
ellipse = new fabric.Ellipse({
left: startX,
top: startY,
rx: 0,
ry: 0,
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
});
canvas.add(ellipse);
setCurrentSelection(ellipse);
});
canvas.on('mouse:move', (e) => {
if (!isDown) return;
const pointer = canvas.getPointer(e.e);
const rx = Math.abs(pointer.x - startX) / 2;
const ry = Math.abs(pointer.y - startY) / 2;
ellipse.set({
rx: rx,
ry: ry,
left: startX < pointer.x ? startX : pointer.x,
top: startY < pointer.y ? startY : pointer.y,
});
canvas.renderAll();
});
canvas.on('mouse:up', () => {
isDown = false;
updateSelection(ellipse, 'ellipse');
});
};
const setupLassoMode = (canvas) => {
let line, points = [];
canvas.on('mouse:down', (e) => {
setIsDrawing(true);
const pointer = canvas.getPointer(e.e);
points = [{ x: pointer.x, y: pointer.y }];
line = new fabric.Polyline(points, {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
});
canvas.add(line);
setCurrentSelection(line);
});
canvas.on('mouse:move', (e) => {
if (!isDrawing) return;
const pointer = canvas.getPointer(e.e);
points.push({ x: pointer.x, y: pointer.y });
line.set({ points: points });
canvas.renderAll();
});
canvas.on('mouse:up', () => {
setIsDrawing(false);
lassoPoints.current = points;
updateSelection(line, 'lasso');
});
};
const updateSelection = (selection, type) => {
if (!selection || !fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
if (!bgImage) return;
// Calculate bounding box in original image coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
let bbox, selectionData = null;
if (type === 'rectangle') {
bbox = {
x: Math.round((selection.left - imgLeft) / imgScale),
y: Math.round((selection.top - imgTop) / imgScale),
width: Math.round(selection.width / imgScale),
height: Math.round(selection.height / imgScale),
};
} else if (type === 'ellipse') {
bbox = {
x: Math.round((selection.left - imgLeft) / imgScale),
y: Math.round((selection.top - imgTop) / imgScale),
width: Math.round((selection.rx * 2) / imgScale),
height: Math.round((selection.ry * 2) / imgScale),
};
} else if (type === 'lasso') {
const bounds = selection.getBoundingRect();
bbox = {
x: Math.round((bounds.left - imgLeft) / imgScale),
y: Math.round((bounds.top - imgTop) / imgScale),
width: Math.round(bounds.width / imgScale),
height: Math.round(bounds.height / imgScale),
};
// Convert lasso points to relative coordinates within bbox
const relativePoints = lassoPoints.current.map(p => [
Math.round((p.x - bounds.left) / imgScale),
Math.round((p.y - bounds.top) / imgScale),
]);
selectionData = { points: relativePoints };
}
onSelectionChange({
type,
bbox,
selectionData,
});
};
const clearSelection = () => {
if (currentSelection && fabricCanvasRef.current) {
fabricCanvasRef.current.remove(currentSelection);
setCurrentSelection(null);
onSelectionChange(null);
}
};
return (
<div className="canvas-container">
<canvas ref={canvasRef} />
{currentSelection && (
<button className="clear-selection-btn" onClick={clearSelection}>
Clear Selection
</button>
)}
</div>
);
};
export default ImageCanvas;
+71
View File
@@ -0,0 +1,71 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #1a1a1a;
color: #ffffff;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
button {
cursor: pointer;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="text"],
input[type="file"],
textarea {
padding: 8px;
border: 1px solid #444;
border-radius: 4px;
background-color: #2a2a2a;
color: #ffffff;
font-size: 14px;
}
input[type="range"] {
width: 100%;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
.header h1 {
font-size: 32px;
margin-bottom: 8px;
}
.header p {
color: #888;
font-size: 14px;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+88
View File
@@ -0,0 +1,88 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
export const projectsApi = {
// Create a new project
create: async (name) => {
const response = await api.post('/projects/', { name });
return response.data;
},
// List all projects
list: async () => {
const response = await api.get('/projects/');
return response.data;
},
// Get a specific project
get: async (projectId) => {
const response = await api.get(`/projects/${projectId}`);
return response.data;
},
// Delete a project
delete: async (projectId) => {
const response = await api.delete(`/projects/${projectId}`);
return response.data;
},
// Upload image to project
uploadImage: async (projectId, file) => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(`/projects/${projectId}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
// Get edits for a project
getEdits: async (projectId) => {
const response = await api.get(`/projects/${projectId}/edits`);
return response.data;
},
// Get image URLs
getOriginalImageUrl: (projectId) => `${API_BASE_URL}/projects/${projectId}/original`,
getCurrentImageUrl: (projectId) => `${API_BASE_URL}/projects/${projectId}/current`,
getEditResultUrl: (projectId, editId) => `${API_BASE_URL}/projects/${projectId}/history/${editId}/result`,
};
export const editsApi = {
// Create a new edit (Fix button)
create: async (projectId, editData) => {
const response = await api.post(`/edits/projects/${projectId}/fix`, editData);
return response.data;
},
// Get edit status
get: async (editId) => {
const response = await api.get(`/edits/${editId}`);
return response.data;
},
// Revert to a specific edit
revert: async (projectId, editId) => {
const response = await api.post(`/edits/projects/${projectId}/revert/${editId}`);
return response.data;
},
// Reset to original
reset: async (projectId) => {
const response = await api.post(`/edits/projects/${projectId}/reset`);
return response.data;
},
};
export default api;
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/projects': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/edits': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})