diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..edba349 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# AI Provider Configuration +# Options: openai, stability, mock +AI_PROVIDER=mock + +# OpenAI API Key (for OpenAI provider) +OPENAI_API_KEY= + +# Stability AI API Key (for Stability AI provider) +STABILITY_API_KEY= + +# Secret key for JWT tokens (change in production) +SECRET_KEY=change-this-secret-key-in-production diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e7cac6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1d715ef --- /dev/null +++ b/CONTRIBUTING.md @@ -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! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cbc2ce1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6467ed0 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index ab4e135..156ff58 100644 --- a/README.md +++ b/README.md @@ -1 +1,354 @@ -# EditmaskwithAI \ No newline at end of file +# 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 +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. \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2b27e7c --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c2eb018 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..529af56 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,35 @@ +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 = "openai" + openai_api_key: str = "" + stability_api_key: str = "" + + # 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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..1256bfb --- /dev/null +++ b/backend/app/database.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..186a9b0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,53 @@ +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 + + +@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.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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..107cec8 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.user import User +from app.models.project import Project +from app.models.edit import Edit + +__all__ = ["User", "Project", "Edit"] diff --git a/backend/app/models/edit.py b/backend/app/models/edit.py new file mode 100644 index 0000000..670850a --- /dev/null +++ b/backend/app/models/edit.py @@ -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") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..a76d79e --- /dev/null +++ b/backend/app/models/project.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8a17220 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,16 @@ +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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/edits.py b/backend/app/routers/edits.py new file mode 100644 index 0000000..d8f28a9 --- /dev/null +++ b/backend/app/routers/edits.py @@ -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)) diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py new file mode 100644 index 0000000..514f798 --- /dev/null +++ b/backend/app/routers/images.py @@ -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"} + ) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..3630007 --- /dev/null +++ b/backend/app/routers/projects.py @@ -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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..2a7b56a --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,75 @@ +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 + + +# Generic responses +class StatusResponse(BaseModel): + status: str + message: Optional[str] = None + data: Optional[Any] = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py new file mode 100644 index 0000000..d8a42ac --- /dev/null +++ b/backend/app/services/ai_provider.py @@ -0,0 +1,173 @@ +from abc import ABC, abstractmethod +from typing import Optional +import httpx +import base64 +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 + ) -> 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) + + Returns: + Regenerated patch as bytes + """ + pass + + +class OpenAIProvider(AIProvider): + """OpenAI DALL-E based image editing""" + + 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 + ) -> bytes: + """Edit image using OpenAI DALL-E""" + + 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 + + +class StabilityAIProvider(AIProvider): + """Stability AI based image editing""" + + def __init__(self, api_key: str): + self.api_key = api_key + self.base_url = "https://api.stability.ai/v1" + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None + ) -> bytes: + """Edit image using Stability AI""" + + async with httpx.AsyncClient(timeout=60.0) as client: + files = { + 'init_image': ('image.png', patch_image_bytes, 'image/png'), + 'mask_image': ('mask.png', mask_image_bytes, 'image/png'), + } + + data = { + 'text_prompts[0][text]': prompt, + 'text_prompts[0][weight]': '1.0', + 'cfg_scale': '7', + 'samples': '1', + 'steps': '30', + } + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'application/json' + } + + response = await client.post( + f"{self.base_url}/generation/stable-diffusion-xl-1024-v1-0/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) + + +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 + ) -> bytes: + """Return the original patch (for testing)""" + return patch_image_bytes + + +def get_ai_provider() -> AIProvider: + """Factory function to get the configured AI provider""" + + provider_name = settings.ai_provider.lower() + + if provider_name == "openai": + if not settings.openai_api_key: + raise ValueError("OpenAI API key not configured") + return OpenAIProvider(settings.openai_api_key) + + elif provider_name == "stability": + if not settings.stability_api_key: + raise ValueError("Stability AI API key not configured") + return StabilityAIProvider(settings.stability_api_key) + + elif provider_name == "mock": + return MockAIProvider() + + else: + raise ValueError(f"Unknown AI provider: {provider_name}") diff --git a/backend/app/services/edit_service.py b/backend/app/services/edit_service.py new file mode 100644 index 0000000..93965fb --- /dev/null +++ b/backend/app/services/edit_service.py @@ -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) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/image_processing.py b/backend/app/utils/image_processing.py new file mode 100644 index 0000000..4f6ae25 --- /dev/null +++ b/backend/app/utils/image_processing.py @@ -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) + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..87cf9dc --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/data/projects/.gitkeep b/data/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4ee01f1 --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..803fc1a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2f552b7 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..7b5b1c2 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d729151 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + AI Photo Edit + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9196674 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ea5b4a1 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..92c87d5 --- /dev/null +++ b/frontend/src/App.css @@ -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; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a2c1882 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+
+
+

AI Photo Edit

+

Have AI regenerate only a selected area of your photo

+
+ + {error && ( +
+ Error: {error} + +
+ )} + + {showProjectInput ? ( +
+

Create New Project

+
+
+ + setProjectName(e.target.value)} + placeholder="My Photo Edit Project" + disabled={isProcessing} + /> +
+
+ + setImageFile(e.target.files[0])} + disabled={isProcessing} + /> + {imageFile && ( +

Selected: {imageFile.name}

+ )} +
+ +
+
+ ) : ( +
+
+ +
+ +
+ setSelection(null)} + isProcessing={isProcessing} + hasSelection={!!selection} + /> + +
+ +
+
+
+ )} +
+
+ ); +} + +export default App; diff --git a/frontend/src/components/Controls.css b/frontend/src/components/Controls.css new file mode 100644 index 0000000..13c6634 --- /dev/null +++ b/frontend/src/components/Controls.css @@ -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; +} diff --git a/frontend/src/components/Controls.jsx b/frontend/src/components/Controls.jsx new file mode 100644 index 0000000..8d078cb --- /dev/null +++ b/frontend/src/components/Controls.jsx @@ -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 ( +
+
+

Selection Tool

+
+ + + +
+
+ +
+

AI Mode

+
+ + +
+

+ {mode === 'A' + ? 'Faster and cheaper - sends only the selected area' + : 'Better style consistency - includes full image for context'} +

+
+ +
+

Edge Feathering

+
+ onFeatherChange(parseInt(e.target.value))} + disabled={isProcessing} + /> + {feather}px +
+

Smooth blending at selection edges

+
+ +
+

Prompt

+