paintplus: vendor the app source and rename from EditmaskwithAI

Bring the full EditmaskwithAI application into the repo under paintplus/
(429 files) so the service is self-contained — the installer copies the
vendored source to ~/docker/paintplus/src instead of cloning at runtime.

Rename to PaintPlus (service + branding; app logic untouched):
- services/editmaskwithai.sh -> services/paintplus.sh (register_service
  paintplus, install_paintplus, ~/docker/paintplus, Caddy paintplus:8000,
  Authelia option preserved)
- container names -> paintplus across docker-compose*.yml; dev network
  -> paintplus-network
- browser <title> -> "PaintPlus - AI Image Editor"; README heading ->
  PaintPlus with upstream provenance note
- README utilities table: editmaskwithai -> paintplus

Backend/frontend code (help strings referencing the old container name,
the ai_photo_edit.db filename) is intentionally left as-is to avoid
touching application logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb2vJ8W7bHKx1JXVvpCraH
This commit is contained in:
Claude
2026-06-26 05:48:43 +00:00
parent b4e8ba2a79
commit 084922afaa
431 changed files with 87396 additions and 77 deletions
+207
View File
@@ -0,0 +1,207 @@
# =============================================================================
# AI Photo Edit - Environment Configuration
# =============================================================================
#
# SETUP INSTRUCTIONS:
# 1. Copy this file to .env: cp .env.example .env
# 2. Get API key from Replicate (see below)
# 3. Paste your key in the REPLICATE_API_KEY line
# 4. Rebuild: docker-compose up -d --build
#
# =============================================================================
# =============================================================================
# STEP 1: Choose AI Provider
# =============================================================================
# Options: local_gpu, mock, openai, stability, replicate, invokeai, comfyui
#
# local_gpu = FREE, runs on YOUR GPU — best option if you have an NVIDIA card
# (use docker-compose.gpu.yml — models auto-download on first use)
# mock = Free, returns original image unchanged (UI testing only)
# openai = DALL-E 3 / gpt-image-1 (~$0.02-0.04/image)
# stability = Stability AI SDXL (~$0.01/image)
# replicate = Multiple models (~$0.002-0.03/image)
# invokeai = Self-hosted InvokeAI running on another machine
# comfyui = Self-hosted ComfyUI running on another machine
#
# GPU QUICK-START:
# docker compose -f docker-compose.gpu.yml up --build
# (AI_PROVIDER defaults to local_gpu in that compose file)
# =============================================================================
AI_PROVIDER=replicate
# ── Local GPU settings (only relevant when AI_PROVIDER=local_gpu) ────────────
# Auto-download HuggingFace models on first request (true/false)
AUTO_DOWNLOAD_MODELS=true
# Max diffusion pipelines to keep loaded in GPU memory (each is 27 GB)
LOCAL_GPU_MAX_PIPELINES=2
# HuggingFace token — only needed for gated/private models
#HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Override auto-selected model for any operation (leave blank = auto by VRAM tier)
#HF_MODEL_INPAINT=your-org/your-inpaint-model
#HF_MODEL_TXT2IMG=your-org/your-txt2img-model
#HF_MODEL_IMG2IMG=your-org/your-img2img-model
# ─────────────────────────────────────────────────────────────────────────────
# Per-operation provider overrides (optional — blank means use AI_PROVIDER above)
# Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else
#AI_PROVIDER_TXT2IMG=openai
#AI_PROVIDER_INPAINT=invokeai
#AI_PROVIDER_IMG2IMG=invokeai
#AI_PROVIDER_OUTPAINT=invokeai
# =============================================================================
# STEP 2: Get Your API Key
# =============================================================================
#
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ REPLICATE (RECOMMENDED) ║
# ╠═══════════════════════════════════════════════════════════════════════════╣
# ║ ║
# ║ 1. Go to: https://replicate.com ║
# ║ 2. Click "Sign in" (use GitHub, Google, or email) ║
# ║ 3. Go to: https://replicate.com/account/api-tokens ║
# ║ 4. Click "Create token" ║
# ║ 5. Copy the token (starts with "r8_") ║
# ║ 6. Paste it below after REPLICATE_API_KEY= ║
# ║ ║
# ║ FREE TIER: New accounts get some free credits to try models! ║
# ║ PRICING: ~$0.002-0.03 per image depending on model ║
# ║ ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
REPLICATE_API_KEY=r8_PASTE_YOUR_KEY_HERE
# ───────────────────────────────────────────────────────────────────────────
# OPENAI (cloud, dall-e-3 / gpt-image-1)
# Get key at: https://platform.openai.com/api-keys
# AI_PROVIDER=openai
# ───────────────────────────────────────────────────────────────────────────
#OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#OPENAI_MODEL=dall-e-3
# ───────────────────────────────────────────────────────────────────────────
# INVOKEAI (self-hosted, best for Flux/SDXL)
# Run InvokeAI on your local machine or NAS, point URL here.
# AI_PROVIDER=invokeai
# ───────────────────────────────────────────────────────────────────────────
#INVOKEAI_URL=http://192.168.1.x:9090
#INVOKEAI_DEFAULT_MODEL=flux-dev
# ───────────────────────────────────────────────────────────────────────────
# COMFYUI (self-hosted, workflow JSON API)
# AI_PROVIDER=comfyui
# ───────────────────────────────────────────────────────────────────────────
#COMFYUI_URL=http://192.168.1.x:8188
#COMFYUI_DEFAULT_MODEL=v1-5-pruned-emaonly.ckpt
# ───────────────────────────────────────────────────────────────────────────
# STABILITY AI (Alternative)
# Get key at: https://platform.stability.ai/account/keys
# ───────────────────────────────────────────────────────────────────────────
#STABILITY_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# =============================================================================
# STEP 3: Model Selection (OPTIONAL - for advanced users)
# =============================================================================
#
# By default, the system AUTO-SELECTS the best model based on your prompt:
# - Prompt contains "remove/erase/delete" → Uses LaMa (fast removal)
# - Prompt contains "face/hands/person" → Uses Realistic Vision
# - Everything else → Uses SDXL Inpaint
#
# To FORCE a specific model, uncomment ONE line below:
# ───────────────────────────────────────────────────────────────────────────
# REPLICATE_MODEL=sdxl-inpaint # General purpose, good quality (~$0.01)
# REPLICATE_MODEL=lama # Object removal ONLY (~$0.002, fastest)
# REPLICATE_MODEL=realistic-vision # Faces, hands, skin (~$0.02)
# ───────────────────────────────────────────────────────────────────────────
# IMPORTANT: About Flux and other text-to-image models
# ───────────────────────────────────────────────────────────────────────────
#
# Models like "black-forest-labs/flux-kontext-pro" are TEXT-TO-IMAGE models.
# They generate NEW images from text, they DON'T edit existing images.
#
# For EDITING (inpainting), you need models that accept:
# - An existing image
# - A mask showing what to change
# - A prompt describing the change
#
# WORKS for editing: DOESN'T work for editing:
# ✓ sdxl-inpaint ✗ flux-kontext-pro (text-to-image)
# ✓ lama ✗ flux-dev (text-to-image)
# ✓ realistic-vision ✗ ideogram (text-to-image)
#
# ───────────────────────────────────────────────────────────────────────────
# Stability AI model selection (if using AI_PROVIDER=stability)
#STABILITY_MODEL=sdxl # Options: sdxl, sd15, sd21
# =============================================================================
# SECURITY (Change this in production!)
# =============================================================================
SECRET_KEY=change-this-to-a-long-random-string-in-production
# =============================================================================
# ADVANCED SETTINGS (Usually don't need to change)
# =============================================================================
# CORS origins (comma-separated)
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3080,http://localhost
# Database path
DATABASE_URL=sqlite:///./data/ai_photo_edit.db
# Auto-download SAM model on startup (true/false)
# When true (default): Downloads SAM model (~375MB) on first startup for offline Smart Select
# When false: Skips download, Smart Select uses Replicate API (requires REPLICATE_API_KEY)
AUTO_DOWNLOAD_SAM=true
# Auto-download U2Net model on startup (true/false)
# When true (default): Downloads U2Net model (~176MB) on first startup for offline Remove Background
# When false: Skips download, Remove Background falls back to rembg (if installed)
AUTO_DOWNLOAD_U2NET=true
# Background removal model (Remove Background tool) — used when request.model="auto"
# Options: ben2 (default — best for clean cutouts, hair/edges), birefnet-hr
# (best for high-res/print work, slower), u2net (lightweight, always-on fallback)
# ben2 and birefnet-hr download weights from HuggingFace on first use (GPU image only).
BG_REMOVAL_MODEL=ben2
# Allow users to select model per-edit
ALLOW_MODEL_OVERRIDE=true
# =============================================================================
# TROUBLESHOOTING
# =============================================================================
#
# PROBLEM: "405 Method Not Allowed" errors
# FIX: Rebuild container: docker-compose build --no-cache && docker-compose up -d
#
# PROBLEM: "REPLICATE_API_KEY not configured"
# FIX: 1. Make sure .env file exists (not just .env.example)
# 2. Make sure REPLICATE_API_KEY has your actual key
# 3. Restart: docker-compose down && docker-compose up -d
#
# PROBLEM: Edits don't change the image
# FIX: Check AI_PROVIDER isn't set to "mock"
#
# PROBLEM: "rembg not installed"
# FIX: Rebuild: docker-compose build --no-cache backend
#
# PROBLEM: Smart Select uses flood-fill instead of AI
# FIX: Smart Select needs REPLICATE_API_KEY for SAM model
#
# CHECK LOGS: docker-compose logs -f backend
#
# =============================================================================
+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!
+122
View File
@@ -0,0 +1,122 @@
# Caddy 2 Configuration for EditmaskwithAI
# ==========================================
#
# SETUP INSTRUCTIONS:
# 1. Replace 'your-subdomain.yourdomain.com' with your actual domain
# 2. Make sure DNS CNAME record points to your server
# 3. Ensure ports 80 and 443 are open (Caddy handles SSL automatically)
# 4. The frontend runs on port 3080 by default (docker-compose)
#
# Common Issues:
# - "Connection refused": Check if the frontend container is running
# - "Bad gateway": Check if localhost:3080 is accessible
# - "SSL error": Make sure ports 80/443 are open for Let's Encrypt
# ============================================
# OPTION 1: Domain with automatic HTTPS (recommended)
# ============================================
# Replace with your actual domain
your-subdomain.yourdomain.com {
# Reverse proxy to frontend (nginx serves both frontend and proxies API)
reverse_proxy localhost:3080 {
# Health checks
health_uri /health
health_interval 30s
health_timeout 10s
# Headers for proper proxying
header_up Host {upstream_hostport}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Enable compression
encode gzip zstd
# Logging (optional - uncomment for debugging)
# log {
# output file /var/log/caddy/access.log
# format json
# }
}
# ============================================
# OPTION 2: IP address or localhost (no HTTPS)
# ============================================
# Uncomment this block and comment out Option 1 if you don't have a domain
# or want to test locally
# :8080 {
# reverse_proxy localhost:3080 {
# header_up Host {upstream_hostport}
# header_up X-Real-IP {remote_host}
# header_up X-Forwarded-For {remote_host}
# }
# encode gzip zstd
# }
# ============================================
# OPTION 3: Multiple subdomains
# ============================================
# If you want both www and non-www versions
# yourdomain.com, www.yourdomain.com {
# reverse_proxy localhost:3080 {
# header_up Host {upstream_hostport}
# header_up X-Real-IP {remote_host}
# header_up X-Forwarded-For {remote_host}
# header_up X-Forwarded-Proto {scheme}
# }
# encode gzip zstd
# }
# ============================================
# OPTION 4: Behind another reverse proxy (Cloudflare, etc.)
# ============================================
# Use this if Caddy is behind Cloudflare or another proxy
# your-subdomain.yourdomain.com {
# # Trust proxy headers from upstream
# servers {
# trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
# }
#
# reverse_proxy localhost:3080 {
# header_up Host {upstream_hostport}
# header_up X-Real-IP {http.request.header.CF-Connecting-IP}
# header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
# header_up X-Forwarded-Proto {scheme}
# }
# encode gzip zstd
# }
# ============================================
# TROUBLESHOOTING
# ============================================
#
# 1. Check Caddy logs:
# docker logs caddy
# OR: journalctl -u caddy -f
#
# 2. Test backend connectivity:
# curl -I http://localhost:3080
#
# 3. Check DNS resolution:
# dig your-subdomain.yourdomain.com
# nslookup your-subdomain.yourdomain.com
#
# 4. Verify ports are open:
# sudo netstat -tlnp | grep -E ':(80|443|3080)'
#
# 5. Check firewall:
# sudo ufw status
# sudo iptables -L -n
#
# 6. For Let's Encrypt issues:
# - Ensure ports 80 and 443 are accessible from internet
# - Check if domain resolves to your server's IP
# - Try: caddy validate --config /path/to/Caddyfile
#
# 7. Force reload Caddy config:
# caddy reload --config /path/to/Caddyfile
+68
View File
@@ -0,0 +1,68 @@
# ==============================================================================
# AI Photo Edit - Unified Container
# Builds miniPaint frontend and serves it alongside FastAPI backend
# ==============================================================================
# Stage 1: Build miniPaint frontend
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
# Copy package files and install dependencies
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
# Copy frontend source and build
COPY frontend/ ./
RUN npm run build
# Stage 2: Python backend with frontend static files
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for OpenCV, rembg, SAM, and image processing
# Note: onnxruntime 1.17+ fixed executable stack issues, no longer need execstack
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
wget \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Verify rembg loads correctly (model downloads on first use)
# rembg now supports BiRefNet models which are state-of-the-art for background removal
RUN python -c "from rembg import remove; print('rembg ready')" || echo "WARNING: rembg not available - Remove Background will be disabled"
# Copy backend application
COPY backend/ .
# Copy entrypoint script
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy scripts
COPY scripts/ /scripts/
# Copy miniPaint frontend files from stage 1
COPY --from=frontend-build /frontend/index.html /app/static/
COPY --from=frontend-build /frontend/dist /app/static/dist
COPY --from=frontend-build /frontend/images /app/static/images
COPY --from=frontend-build /frontend/src/css /app/static/src/css
# Create data directories
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
# Expose port
EXPOSE 8000
# Use entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
+82
View File
@@ -0,0 +1,82 @@
# =============================================================================
# EditmaskwithAI — GPU Container (NVIDIA CUDA)
#
# Usage:
# docker compose -f docker-compose.gpu.yml up --build
#
# Requirements on host:
# - NVIDIA driver ≥ 525 (for CUDA 12.x)
# - nvidia-container-toolkit installed and configured
# - docker compose v2 (or docker-compose with GPU device support)
#
# AMD ROCm users: replace the pytorch base image with a ROCm variant, e.g.
# rocm/pytorch:latest (and remove the nvidia-smi check below)
# =============================================================================
# ── Stage 1: Build miniPaint frontend ────────────────────────────────────────
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# ── Stage 2: PyTorch CUDA runtime ────────────────────────────────────────────
# pytorch/pytorch already includes torch + torchvision built for CUDA 12.1.
# Using the runtime (not devel) image keeps the layer lean.
FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
WORKDIR /app
# System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
wget \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies — base + GPU extras
# BUILDID forces pip layers to re-run when you need fresh packages without a full --no-cache:
# BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up --build
ARG BUILDID=1
COPY backend/requirements.txt .
COPY backend/requirements.gpu.txt .
RUN echo "BUILDID=$BUILDID" && pip install --no-cache-dir -r requirements.txt
RUN echo "BUILDID=$BUILDID" && pip install --no-cache-dir -r requirements.gpu.txt
# Smoke-test rembg (model downloads on first use)
RUN python -c "from rembg import remove; print('rembg OK')" \
|| echo "WARNING: rembg unavailable — Remove Background disabled"
# Smoke-test ben2 (weights download from HuggingFace on first use)
RUN python -c "import ben2; print('ben2 OK')" \
|| echo "WARNING: ben2 unavailable — Remove Background falls back to U2Net/rembg"
# Copy backend application
COPY backend/ .
# Entrypoint
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Scripts (SAM download, DB init, GPU setup, etc.)
COPY scripts/ /scripts/
RUN chmod +x /scripts/*.py 2>/dev/null || true
# Copy built frontend from Stage 1
COPY --from=frontend-build /frontend/index.html /app/static/
COPY --from=frontend-build /frontend/dist /app/static/dist
COPY --from=frontend-build /frontend/images /app/static/images
COPY --from=frontend-build /frontend/src/css /app/static/src/css
# Persistent data directories
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+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
+292
View File
@@ -0,0 +1,292 @@
# PaintPlus
_Vendored into ubuntu-post-install as the `paintplus` service. Based on EditmaskwithAI (github.com/outis1one/EditmaskwithAI)._
A self-hosted, web-based AI photo editor. Paint over any object, describe what you want, and the AI replaces just that region — every pixel outside your selection stays untouched.
## Quick Start
### GPU machine (recommended — free inference, best quality)
```bash
git clone https://github.com/outis1one/editmaskwithai
cd editmaskwithai
# One-time setup: installs nvidia-container-toolkit, configures Docker,
# sets up a permanent DNS fix, and prefetches all AI models on the host.
chmod +x install-local-gpu.sh
./install-local-gpu.sh
# Start the app (run this each time):
chmod +x bring-up-local-gpu.sh
./bring-up-local-gpu.sh
```
Open **http://localhost:3080**
**Models (~13 GB total, one time) download automatically on the host**, outside Docker — both scripts call `./prefetch-models.sh` for you, since in-container DNS is unreliable on some hosts. They're cached in `./data/hf_cache/` and `./data/models/`, and survive rebuilds.
---
### Cloud API (no GPU required)
```bash
git clone https://github.com/outis1one/editmaskwithai
cd editmaskwithai
cp .env.example .env
# Edit .env: set AI_PROVIDER and your API key (see .env.example for options)
docker compose up -d --build
```
Open **http://localhost:3080**
---
### Updates (any machine)
```bash
git pull
# GPU:
./bring-up-local-gpu.sh
# or cloud (no GPU):
docker compose up -d --build
```
If pip packages seem stale after a pull (e.g., wrong diffusers version), force a pip layer rebuild without re-downloading the entire PyTorch base image:
```bash
BUILDID=$(date +%s) ./bring-up-local-gpu.sh
```
---
## AI Providers
| Provider | Setup | Cost | Quality |
|---|---|---|---|
| `local_gpu` | GPU machine + nvidia-container-toolkit | Free | Best (SDXL/FLUX auto-selected by VRAM) |
| `openai` | `OPENAI_API_KEY=sk-...` | ~$0.020.04/image | DALL-E 3 |
| `replicate` | `REPLICATE_API_KEY=r8_...` | ~$0.0020.03/image | Multiple models |
| `invokeai` | InvokeAI running on another machine | Self-hosted | FLUX/SDXL |
| `comfyui` | ComfyUI running on another machine | Self-hosted | Any model |
You can also mix: set a default provider in `.env` and override per-operation in the **Image → AI Provider Settings** dialog inside the app.
---
## GPU Tier Auto-Selection
The app detects your GPU at startup and picks the best model it can run:
| Effective VRAM | Model selected | Notes |
|---|---|---|
| ≥ 24 GB | FLUX.1-schnell | Best quality, 4-step generation |
| 1224 GB | SDXL | Excellent quality |
| 812 GB | SDXL + xformers | Good quality |
| 68 GB | SDXL + attention slicing | Good quality, slightly slower |
| 46 GB | SDXL + CPU offload | Good quality, slower (GTX 1060 6GB range) |
| 24 GB | SD 1.5 | Fast, lower detail |
| < 2 GB | SD 1.5 + CPU offload | Very slow — consider a cloud provider |
Override the auto-selected model with `HF_MODEL_TXT2IMG`, `HF_MODEL_INPAINT` in `.env`.
---
## What it can do
### Selection
- **Smart Select (SAM brush)** — paint over an object, AI detects its exact boundaries
- **Smart Select (click)** — click any object, SAM selects it
- **Rectangle / Ellipse / Lasso** — classic selection tools
### After selecting
- **AI Edit** — describe what to change ("add a scar", "make it look aged")
- **Make less symmetrical** — AI adds natural organic variation
- **Replace with clipboard** — paste any image into the selection shape
- **Scale by %** — make the selected object bigger/smaller, AI fills the gap
- **Copy / Cut to layer** — non-destructive layer workflow
- **Erase** — remove the selected region with AI fill
### Image tools
- **Text → Image** — generate from a text description (GPU or cloud)
- **Upscale** — Real-ESRGAN AI upscaling (genuinely adds detail, not just resize)
- **Prepare for Print** — one-click: AI upscale to target DPI + fit to frame
- **Fit to Frame** — resize/crop/AI-extend to standard print sizes
- **Expand Canvas (Outpaint)** — AI extends the image in any direction
- **Remove Background** — one-click background removal (BEN2 by default, BiRefNet-HR or U2Net selectable)
### Print presets
Frame sizes: 4×6, 5×7, 8×10, 11×14, 16×20, 18×24, 20×24, 24×36 (portrait + landscape)
DPI options: 72, 150, 200, 300 — 200 DPI is fine for 18×24" and larger (viewed from distance)
---
## Progress bars
All AI operations show a real-time progress overlay. For local GPU inference, the bar advances step-by-step as the model denoises (e.g. "Step 14 / 30"). For cloud providers and upscale operations, it animates to indicate activity.
---
## Logs
```bash
# GPU container:
docker compose -f docker-compose.gpu.yml logs -f
# Standard container:
docker compose logs -f
```
---
## File structure
```
EditmaskwithAI/
├── backend/
│ ├── app/
│ │ ├── routers/ # API endpoints (ai_tools, print_tools, …)
│ │ ├── services/ # gpu_detect, local_diffusion, upscale, …
│ │ └── config.py
│ ├── requirements.txt
│ └── requirements.gpu.txt
├── frontend/
│ └── src/js/
│ ├── tools/ # brush_select (SAM paint), smart_select, …
│ ├── modules/
│ │ ├── generate/ # text_to_image, outpaint
│ │ └── image/ # upscale, frame_fit, print_prepare, …
│ └── libs/
│ └── progress_overlay.js
├── docker-compose.yml # Cloud / no-GPU
├── docker-compose.gpu.yml # NVIDIA GPU (recommended)
├── docker-compose.dev.yml # Dev with hot reload
├── Dockerfile
├── Dockerfile.gpu
├── install-local-gpu.sh # One-time GPU host setup
├── bring-up-local-gpu.sh # Start/stop the GPU container
├── prefetch-models.sh # Download AI models on the host (called automatically; also runnable standalone)
└── .env.example
```
---
## Troubleshooting
**GPU not detected in Docker**
```bash
# Check toolkit is installed and Docker restarted:
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
# If that fails, re-run: sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
```
**Model download stalls or fails**
```bash
# Check logs for HuggingFace errors:
docker compose -f docker-compose.gpu.yml logs -f | grep -E "local_gpu|Error|Failed"
# If a private/gated model: add HF_TOKEN=hf_... to .env
```
**SAM model fails to download (DNS error / firewall blocking port 53)**
If the container can't reach `dl.fbaipublicfiles.com` (you'll see `Errno -3 Name or service not known` in the logs), download SAM directly on the host and let the bind mount make it visible to the container — no rebuild needed:
```bash
./prefetch-models.sh
# or manually:
mkdir -p ./data/models
# sudo needed if ./data/ was created by Docker (root-owned):
sudo curl -L -o ./data/models/sam_vit_b_01ec64.pth \
https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth
```
The file is ~375 MB. Once it exists at `./data/models/sam_vit_b_01ec64.pth`, the container picks it up on the next startup (no rebuild required). Verify with:
```bash
docker compose -f docker-compose.gpu.yml logs | grep -i sam
# Should show: "SAM model loaded on cuda" (or cpu)
```
If Docker created `./data/` as root and you can't write there without `sudo`, you can also use root's curl as above — the container reads the file regardless of owner.
**Remove Background fails ("Install ben2, u2net, or rembg")**
Remove Background tries, in order: the model set by `BG_REMOVAL_MODEL` (default `ben2`), then the other local models, then `rembg` as a last resort. You'll see this error only if all of them fail.
- **ben2 / birefnet-hr** (GPU image only) download their weights from HuggingFace on first use, cached under `./data/hf_cache`. If that download fails (DNS/firewall, see above), run `./prefetch-models.sh` to fetch both directly on the host, or check the logs for the specific error:
```bash
docker compose -f docker-compose.gpu.yml logs -f | grep -iE "ben2|birefnet"
```
- **u2net** auto-downloads (~176MB) from GitHub on first use, same as SAM. If that fails too, download it directly on the host:
```bash
./prefetch-models.sh
# or manually:
mkdir -p ./data/models
sudo curl -L -o ./data/models/u2net.onnx \
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx
```
The file is ~176 MB. Once it exists at `./data/models/u2net.onnx`, the next "Remove Background" click picks it up — no rebuild or restart needed. Verify with:
```bash
docker compose logs -f | grep -i u2net
# Should show: "U2Net model loaded successfully with OpenCV DNN"
```
You can also pick a specific model per-edit from the Remove Background dialog's model dropdown, overriding `BG_REMOVAL_MODEL` for that one call.
**AI models not downloading (container DNS blocked)**
`install-local-gpu.sh` and `bring-up-local-gpu.sh` already run this for you automatically on every start, so you normally don't need to think about it. If a model still didn't download (no network at the time, etc.), re-run it manually — it lands in `./data/`, which is already bind-mounted into the container, so it's picked up with no rebuild:
```bash
./prefetch-models.sh # SAM + U2Net + BEN2 + BiRefNet-HR (~1.5GB)
./prefetch-models.sh --sdxl # also Text→Image / AI Edit models (~13GB)
./bring-up-local-gpu.sh
```
If that also fails to reach the network, the problem is host-level (firewall/DNS), not Docker-specific — see your network/firewall configuration.
Alternatively, if you ran `./install-local-gpu.sh`, container DNS is already permanently fixed via a systemd-managed iptables rule. If you skipped that script, apply the same fix manually (does **not** affect container isolation):
```bash
sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT
./bring-up-local-gpu.sh
```
The container will now resolve hostnames and download models automatically (~13 GB on first run, then cached). Watch progress:
```bash
docker compose -f docker-compose.gpu.yml logs -f | grep -E "local_gpu|Cached|failed"
```
**Alternative: download with a Docker helper container** (no host Python needed):
```bash
# Inpainting model (~6.5 GB) — needed for AI Edit, Make less symmetrical, etc.
docker run --rm \
-v "$(pwd)/data/hf_cache:/root/.cache/huggingface" \
python:3.11-slim \
bash -c "pip install -q huggingface-hub && \
huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 \
--exclude '*.msgpack' 'flax_*' 'tf_*'"
# Text-to-image model (~6.5 GB) — needed for Text → Image
docker run --rm \
-v "$(pwd)/data/hf_cache:/root/.cache/huggingface" \
python:3.11-slim \
bash -c "pip install -q huggingface-hub && \
huggingface-cli download stabilityai/stable-diffusion-xl-base-1.0 \
--exclude '*.msgpack' 'flax_*' 'tf_*'"
```
Then restart: `docker compose -f docker-compose.gpu.yml restart`
**Out of VRAM during generation**
- Reduce `LOCAL_GPU_MAX_PIPELINES=1` in `.env` (default 2)
- Or override to a smaller model: `HF_MODEL_TXT2IMG=runwayml/stable-diffusion-v1-5`
**Settings saved locally only**
- The in-app AI Provider Settings dialog saves to localStorage for the session
- To make settings permanent: edit `.env` and rebuild
**Check API docs**
```
http://localhost:3080/api/docs
```
+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
+37
View File
@@ -0,0 +1,37 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for OpenCV, SAM, and image processing
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
wget \
git \
&& 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 . .
# Copy entrypoint script and make it executable
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create data directories
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
# Expose port
EXPOSE 8000
# Use entrypoint script (auto-populates eyes on first run, then starts server)
ENTRYPOINT ["/entrypoint.sh"]
View File
+79
View File
@@ -0,0 +1,79 @@
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
# Local: blank or "mock" — always available, no config needed
# Remote default (used for any operation without a specific override):
# openai | invokeai | comfyui | replicate | stability
ai_provider: str = "mock"
# Per-operation provider overrides — blank means use ai_provider default.
# Operations: inpaint, txt2img, img2img, outpaint
# Example: AI_PROVIDER_TXT2IMG=openai (use OpenAI for text-to-image only)
ai_provider_inpaint: str = "" # remote inpaint / replace selection
ai_provider_txt2img: str = "" # text-to-image
ai_provider_img2img: str = "" # image-to-image
ai_provider_outpaint: str = "" # expand canvas
# Provider API Keys
openai_api_key: str = ""
openai_model: str = "dall-e-3"
stability_api_key: str = ""
replicate_api_key: str = ""
# InvokeAI (self-hosted)
invokeai_url: str = ""
invokeai_default_model: str = "flux-dev"
# ComfyUI (self-hosted)
comfyui_url: str = ""
comfyui_default_model: str = "v1-5-pruned-emaonly.ckpt"
# 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
# Remove Background — preferred local model when request.model="auto"
# Options: ben2 (default, best for clean cutouts/hair), birefnet-hr (best
# for high-res/print work), u2net (lightweight, smallest download)
bg_removal_model: str = "ben2"
# Local GPU diffusion (AI_PROVIDER=local_gpu)
auto_download_models: bool = True # download HF models on first use
local_gpu_max_pipelines: int = 2 # max diffusion pipelines kept in GPU memory
hf_token: str = "" # HuggingFace token (only needed for gated models)
# Override auto-selected models per operation (leave blank = auto-pick by VRAM tier)
hf_model_inpaint: str = ""
hf_model_txt2img: str = ""
hf_model_img2img: 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()
+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)
+162
View File
@@ -0,0 +1,162 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from contextlib import asynccontextmanager
from pathlib import Path
import asyncio
import os
from app.config import settings
from app.database import init_db
from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools
from app.routers import gpu_status
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup; auto-install Real-ESRGAN NCNN in background."""
init_db()
# Kick off NCNN install in background if no AI upscaler detected
from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed
caps = probe_upscale_capabilities()
if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]:
asyncio.create_task(ensure_ncnn_installed())
# Pre-download SAM model in background so first click is fast
from app.services.sam_service import ensure_sam_installed
asyncio.create_task(ensure_sam_installed())
# If local GPU provider is active, log GPU info at startup
if settings.ai_provider.lower() == "local_gpu" or any(
v.lower() == "local_gpu"
for v in [
settings.ai_provider_inpaint,
settings.ai_provider_txt2img,
settings.ai_provider_img2img,
settings.ai_provider_outpaint,
]
if v
):
from app.services.gpu_detect import get_cached_gpu_info
info = get_cached_gpu_info()
cc_str = f" | CC={info.compute_capability}" if info.compute_capability else ""
print(
f"[gpu] {info.device_name} | {info.vram_total_gb:.1f} GB{cc_str} | "
f"tier={info.tier} | fp16={info.fp16}"
)
for w in info.warnings:
print(f"[gpu] ⚠ {w}")
if settings.auto_download_models:
# Download model weight files to disk cache in background so first
# user request loads from local disk instead of the internet.
from app.services.local_diffusion import prefetch_model_files
asyncio.create_task(prefetch_model_files())
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.include_router(tools.router)
app.include_router(ai_tools.router)
app.include_router(print_tools.router)
app.include_router(gpu_status.router)
@app.get("/api")
def api_root():
"""API info endpoint"""
return {
"name": "AI Photo Edit API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
def health():
"""Health check endpoint"""
return {"status": "healthy"}
# Static files directory
STATIC_DIR = Path("/app/static")
class NoCacheStaticFiles(StaticFiles):
"""webpack outputs a fixed 'bundle.js' filename (no content hash), so
browsers can keep serving a stale cached copy after a rebuild unless
forced to revalidate on every request."""
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers["Cache-Control"] = "no-cache"
return response
# Serve static assets - mount subdirectories if they exist
if STATIC_DIR.exists():
# React-style assets folder
if (STATIC_DIR / "assets").exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# miniPaint dist folder (webpack bundle) - no-cache so code updates are picked up immediately
if (STATIC_DIR / "dist").exists():
app.mount("/dist", NoCacheStaticFiles(directory=STATIC_DIR / "dist"), name="dist")
# miniPaint images folder
if (STATIC_DIR / "images").exists():
app.mount("/images", StaticFiles(directory=STATIC_DIR / "images"), name="images")
# miniPaint CSS folder - no-cache, same reasoning as /dist
if (STATIC_DIR / "src").exists():
app.mount("/src", NoCacheStaticFiles(directory=STATIC_DIR / "src"), name="src")
@app.get("/", response_class=HTMLResponse)
async def serve_spa():
"""Serve miniPaint index.html"""
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path, headers={"Cache-Control": "no-cache"})
return HTMLResponse("<h1>Frontend not built. Run npm build in frontend/</h1>")
@app.get("/{full_path:path}")
async def serve_spa_routes(request: Request, full_path: str):
"""
Catch-all route for serving static files.
Serves static files if they exist, otherwise returns index.html.
"""
# Don't catch API routes
if full_path.startswith(("projects", "edits", "patches", "tools", "generate", "health", "docs", "openapi.json", "api")):
return {"detail": "Not Found"}
# Check if it's a static file
static_file = STATIC_DIR / full_path
if static_file.exists() and static_file.is_file():
return FileResponse(static_file)
# Otherwise serve index.html
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path, headers={"Cache-Control": "no-cache"})
return HTMLResponse("<h1>Frontend not built</h1>", status_code=404)
+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")
+989
View File
@@ -0,0 +1,989 @@
"""
AI tools router — LaMa inpaint, background removal, remote generation, config.
All endpoints are under /api prefix.
"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
import base64
import asyncio
import json
from io import BytesIO
from app.services.local_inpaint import (
lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available,
)
router = APIRouter(prefix="/api", tags=["ai-tools"])
# ─── Request / response models ───────────────────────────────────────────────
class EraseRequest(BaseModel):
image: str # base64
mask: str # base64
class InpaintRemoteRequest(BaseModel):
image: str
mask: str
prompt: str
negative_prompt: Optional[str] = ""
steps: Optional[int] = 30
cfg_scale: Optional[float] = 7.5
model: Optional[str] = None
class Txt2ImgRequest(BaseModel):
prompt: str
width: Optional[int] = 1024
height: Optional[int] = 1024
negative_prompt: Optional[str] = ""
steps: Optional[int] = 30
cfg_scale: Optional[float] = 7.5
model: Optional[str] = None
seed: Optional[int] = 0
class Img2ImgRequest(BaseModel):
image: str
prompt: str
strength: Optional[float] = 0.75
negative_prompt: Optional[str] = ""
steps: Optional[int] = 30
cfg_scale: Optional[float] = 7.5
model: Optional[str] = None
class OutpaintRequest(BaseModel):
image: str
direction: str # left | right | top | bottom
size: Optional[int] = 256
prompt: Optional[str] = ""
class BgRemoveRequest(BaseModel):
image: str
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _decode(b64: str) -> bytes:
return base64.b64decode(b64)
def _encode(data: bytes) -> str:
return base64.b64encode(data).decode()
def _require_remote(operation: str = None):
from app.services.remote_provider import get_remote_provider
from app.config import settings
provider = get_remote_provider(operation)
if provider is None:
if (settings.ai_provider or "").lower() == "local_gpu":
raise HTTPException(
status_code=503,
detail=(
"local_gpu provider failed to load — diffusers may be incompatible with "
"the installed PyTorch version. Check container logs for details. "
"If you see 'torch has no attribute xpu', rebuild the container from the "
"correct branch so the pinned diffusers<0.29.0 is installed."
)
)
op_hint = f"AI_PROVIDER_{operation.upper()} or " if operation else ""
raise HTTPException(
status_code=503,
detail=f"No remote AI provider configured for '{operation or 'default'}'. "
f"Set {op_hint}AI_PROVIDER in .env (openai / invokeai / comfyui)."
)
return provider
# ─── Local inpaint endpoints ─────────────────────────────────────────────────
@router.post("/erase")
async def erase(req: EraseRequest):
"""
Magic eraser: remove object / fill region using LaMa (local, no API key needed).
Falls back to OpenCV if LaMa not installed.
"""
try:
image_bytes = _decode(req.image)
mask_bytes = _decode(req.mask)
if lama_available():
result = await asyncio.get_event_loop().run_in_executor(
None, lama_inpaint, image_bytes, mask_bytes
)
method = "lama"
else:
result = await asyncio.get_event_loop().run_in_executor(
None, opencv_inpaint, image_bytes, mask_bytes
)
method = "opencv"
return {"result": _encode(result), "method": method}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/inpaint/lama")
async def inpaint_lama(req: EraseRequest):
"""LaMa structural inpainting."""
if not lama_available():
raise HTTPException(status_code=503, detail="simple-lama-inpainting not installed.")
try:
result = await asyncio.get_event_loop().run_in_executor(
None, lama_inpaint, _decode(req.image), _decode(req.mask)
)
return {"result": _encode(result)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/inpaint/fast")
async def inpaint_fast(req: EraseRequest):
"""OpenCV fast inpainting (CPU, milliseconds)."""
try:
result = await asyncio.get_event_loop().run_in_executor(
None, opencv_inpaint, _decode(req.image), _decode(req.mask)
)
return {"result": _encode(result)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/background/remove")
async def background_remove(req: BgRemoveRequest):
"""Remove background — rembg if available, else U2Net."""
try:
image_bytes = _decode(req.image)
# Try rembg first
if rembg_available():
from app.services.local_inpaint import remove_background_rembg
result = await asyncio.get_event_loop().run_in_executor(
None, remove_background_rembg, image_bytes
)
return {"result": _encode(result), "method": "rembg"}
# Fall back to U2Net (existing implementation)
from PIL import Image
from io import BytesIO as _BytesIO
img = Image.open(_BytesIO(image_bytes)).convert("RGB")
from app.routers.tools import _remove_background_u2net
result = await _remove_background_u2net(img)
return {"result": _encode(result), "method": "u2net"}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ─── Remote provider endpoints ───────────────────────────────────────────────
@router.post("/inpaint/remote")
async def inpaint_remote(req: InpaintRemoteRequest):
"""Inpaint via configured remote provider (InvokeAI / ComfyUI / OpenAI)."""
provider = _require_remote("inpaint")
try:
params = {
"negative_prompt": req.negative_prompt or "",
"steps": req.steps,
"cfg_scale": req.cfg_scale,
}
if req.model:
params["model"] = req.model
result = await provider.inpaint(_decode(req.image), _decode(req.mask), req.prompt, params)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/generate/progress")
async def generation_progress_stream():
"""
SSE stream of local GPU pipeline inference progress.
Events are JSON arrays of pipeline state objects, emitted every 200 ms.
Each object: {pipeline, state, step, total_steps, progress, message, model_id, …}
Clients open this with EventSource before firing a generation POST,
then close it when the POST resolves.
"""
from app.services.local_diffusion import get_all_model_states
async def event_gen():
try:
while True:
states = get_all_model_states()
yield f"data: {json.dumps(states)}\n\n"
await asyncio.sleep(0.2)
except asyncio.CancelledError:
pass
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.post("/generate/txt2img")
async def txt2img(req: Txt2ImgRequest):
"""Text-to-image via configured remote provider."""
provider = _require_remote("txt2img")
try:
params = {
"negative_prompt": req.negative_prompt or "",
"steps": req.steps,
"cfg_scale": req.cfg_scale,
"seed": req.seed or 0,
}
if req.model:
params["model"] = req.model
result = await provider.txt2img(req.prompt, req.width, req.height, params)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/generate/img2img")
async def img2img(req: Img2ImgRequest):
"""Image-to-image via configured remote provider."""
provider = _require_remote("img2img")
try:
params = {
"negative_prompt": req.negative_prompt or "",
"steps": req.steps,
"cfg_scale": req.cfg_scale,
}
if req.model:
params["model"] = req.model
result = await provider.img2img(_decode(req.image), req.prompt, req.strength, params)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/generate/outpaint")
async def outpaint(req: OutpaintRequest):
"""Expand canvas in given direction via remote provider."""
provider = _require_remote("outpaint")
if req.direction not in ("left", "right", "top", "bottom"):
raise HTTPException(status_code=400, detail="direction must be left/right/top/bottom")
try:
result = await provider.outpaint(_decode(req.image), req.direction, req.size, req.prompt or "")
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ─── Config / capabilities ────────────────────────────────────────────────────
class ConfigUpdateRequest(BaseModel):
ai_provider: Optional[str] = None
# Per-operation overrides (blank = use default)
ai_provider_inpaint: Optional[str] = None
ai_provider_txt2img: Optional[str] = None
ai_provider_img2img: Optional[str] = None
ai_provider_outpaint: Optional[str] = None
# Credentials / URLs
openai_api_key: Optional[str] = None
openai_model: Optional[str] = None
invokeai_url: Optional[str] = None
invokeai_default_model: Optional[str] = None
comfyui_url: Optional[str] = None
comfyui_default_model: Optional[str] = None
replicate_api_key: Optional[str] = None
stability_api_key: Optional[str] = None
@router.post("/config")
async def update_config(req: ConfigUpdateRequest):
"""
Apply runtime provider settings (no restart needed).
Values are applied to the live settings object in-process.
They do NOT persist across restarts — set them in .env for permanence.
"""
from app.config import settings
_str_fields = [
"ai_provider", "ai_provider_inpaint", "ai_provider_txt2img",
"ai_provider_img2img", "ai_provider_outpaint",
"openai_api_key", "openai_model",
"invokeai_url", "invokeai_default_model",
"comfyui_url", "comfyui_default_model",
"replicate_api_key", "stability_api_key",
]
for field in _str_fields:
val = getattr(req, field, None)
if val is not None:
setattr(settings, field, val)
return {
"status": "ok",
"ai_provider": settings.ai_provider,
"overrides": {
"inpaint": settings.ai_provider_inpaint or None,
"txt2img": settings.ai_provider_txt2img or None,
"img2img": settings.ai_provider_img2img or None,
"outpaint": settings.ai_provider_outpaint or None,
}
}
async def _check_provider(operation: str) -> dict:
"""Health-check the provider for a specific operation."""
from app.services.remote_provider import get_remote_provider
try:
p = get_remote_provider(operation)
if p is None:
return {"provider": None, "healthy": False}
healthy = await asyncio.wait_for(p.health(), timeout=5.0)
return {"provider": p.__class__.__name__.replace("Provider", "").lower(), "healthy": healthy}
except Exception:
return {"provider": None, "healthy": False}
@router.get("/config")
async def get_config():
"""
Return capability flags so the frontend can show/hide tools.
Includes per-operation provider assignments and health status.
"""
from app.config import settings
# Run health checks for each operation concurrently
ops = ["inpaint", "txt2img", "img2img", "outpaint"]
results = await asyncio.gather(*[_check_provider(op) for op in ops])
op_status = dict(zip(ops, results))
# Default provider for display (used when no per-op override)
default_name = (settings.ai_provider or "").lower() or None
from app.services.gpu_detect import get_cached_gpu_info
gpu_info = get_cached_gpu_info()
return {
"local": {
"lama": lama_available(),
"rembg": rembg_available(),
"opencv": True,
"gpu_detected": gpu_available(),
"gpu_backend": gpu_info.backend,
"gpu_device": gpu_info.device_name,
"gpu_vram_total": gpu_info.vram_total_gb,
"gpu_vram_free": gpu_info.vram_free_gb,
"gpu_cc": gpu_info.compute_capability,
"gpu_fp16": gpu_info.fp16,
"gpu_bf16": gpu_info.bf16,
"gpu_fp8": gpu_info.fp8,
"gpu_tensor_cores": gpu_info.tensor_cores,
"gpu_tier": gpu_info.tier,
"gpu_eff_vram": gpu_info.effective_vram_gb,
"local_gpu_available": gpu_info.backend in ("cuda", "mps"),
"local_gpu_capabilities": gpu_info.capabilities,
"local_gpu_warnings": gpu_info.warnings,
},
"remote": {
"default_provider": default_name,
# Legacy field kept for backwards compat with badge/capabilities checks
"provider": default_name,
"healthy": any(v["healthy"] for v in op_status.values()),
"operations": op_status,
"overrides": {
"inpaint": settings.ai_provider_inpaint or None,
"txt2img": settings.ai_provider_txt2img or None,
"img2img": settings.ai_provider_img2img or None,
"outpaint": settings.ai_provider_outpaint or None,
},
}
}
# ─── Selection image operations ─────────────────────────────────────────────
class ScaleSelectionRequest(BaseModel):
image: str # base64 full canvas
mask: str # base64 selection mask (white = object)
scale_pct: float = 103.0 # 103 = 3% bigger, 95 = 5% smaller
class AiEditRegionRequest(BaseModel):
image: str
mask: str
instruction: str
negative_prompt: str = ""
steps: int = 30
cfg_scale: float = 7.5
class PasteIntoSelectionRequest(BaseModel):
image: str # base64 target canvas
mask: str # base64 selection mask
paste_image: str # base64 image to paste
@router.post("/image/scale-selection")
async def scale_selection(req: ScaleSelectionRequest):
"""
Scale the object selected by mask by scale_pct%, AI-fill the exposed gap.
Works purely with local tools (LaMa/OpenCV) — no remote provider needed.
"""
try:
import numpy as np
from PIL import Image, ImageFilter
except ImportError:
raise HTTPException(status_code=500, detail="PIL/numpy not available")
img = Image.open(BytesIO(_decode(req.image))).convert("RGB")
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
if img.size != mask.size:
mask = mask.resize(img.size, Image.LANCZOS)
mask_arr = np.array(mask)
ys, xs = np.where(mask_arr > 128)
if len(xs) == 0:
raise HTTPException(status_code=400, detail="Empty mask — nothing to scale")
minx, maxx = int(xs.min()), int(xs.max())
miny, maxy = int(ys.min()), int(ys.max())
cx, cy = (minx + maxx) / 2.0, (miny + maxy) / 2.0
obj_w, obj_h = maxx - minx + 1, maxy - miny + 1
scale = req.scale_pct / 100.0
new_w = max(1, round(obj_w * scale))
new_h = max(1, round(obj_h * scale))
# Extract masked object crop (RGBA with mask as alpha)
img_rgba = img.convert("RGBA")
obj_crop = img_rgba.crop((minx, miny, maxx + 1, maxy + 1))
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
r, g, b, _ = obj_crop.split()
obj_masked = Image.merge("RGBA", (r, g, b, mask_crop))
scaled_obj = obj_masked.resize((new_w, new_h), Image.LANCZOS)
# AI-fill the original mask area (gap) with LaMa/OpenCV
gap_mask = mask.filter(ImageFilter.MaxFilter(9)) # expand ~4px for clean seam
gap_bytes = BytesIO()
img.save(gap_bytes, format="PNG")
gap_mask_bytes = BytesIO()
gap_mask.save(gap_mask_bytes, format="PNG")
try:
if lama_available():
filled_bytes = await asyncio.get_event_loop().run_in_executor(
None, lama_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
)
else:
filled_bytes = await asyncio.get_event_loop().run_in_executor(
None, opencv_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
)
filled = Image.open(BytesIO(filled_bytes)).convert("RGBA")
except Exception as exc:
print(f"[scale-selection] fill fallback: {exc}")
filled = img.convert("RGBA")
# Paste scaled object centered on original centroid
px = round(cx - new_w / 2)
py = round(cy - new_h / 2)
result = filled.copy()
result.paste(scaled_obj, (px, py), scaled_obj.split()[3])
out = BytesIO()
result.convert("RGB").save(out, format="PNG")
return {"result": _encode(out.getvalue())}
@router.post("/image/ai-edit-region")
async def ai_edit_region(req: AiEditRegionRequest):
"""
AI-edit the selected region using the configured inpaint provider.
Works with local_gpu, InvokeAI, ComfyUI, or OpenAI.
"""
provider = _require_remote("inpaint")
try:
result_bytes = await provider.inpaint(
_decode(req.image),
_decode(req.mask),
req.instruction,
{"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale},
)
except Exception as exc:
import traceback; traceback.print_exc()
msg = str(exc)
if "Errno -3" in msg or "Name or service not known" in msg or "ConnectError" in msg:
raise HTTPException(
status_code=503,
detail=(
"AI model files not yet downloaded — container DNS appears to be blocked. "
"Fix: sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT on the host, "
"or pre-download the model: pip install huggingface-hub && "
"huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 "
"--cache-dir ./data/hf_cache"
)
)
raise HTTPException(status_code=500, detail=msg)
return {"result": _encode(result_bytes)}
@router.post("/image/paste-into-selection")
async def paste_into_selection(req: PasteIntoSelectionRequest):
"""
Scale a clipboard image to the selection bounding box, mask it to the
selection shape, and composite it over the original canvas.
"""
try:
import numpy as np
from PIL import Image
except ImportError:
raise HTTPException(status_code=500, detail="PIL/numpy not available")
img = Image.open(BytesIO(_decode(req.image))).convert("RGBA")
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
paste_img = Image.open(BytesIO(_decode(req.paste_image))).convert("RGBA")
if img.size != mask.size:
mask = mask.resize(img.size, Image.LANCZOS)
mask_arr = np.array(mask)
ys, xs = np.where(mask_arr > 128)
if len(xs) == 0:
raise HTTPException(status_code=400, detail="Empty mask")
minx, maxx = int(xs.min()), int(xs.max())
miny, maxy = int(ys.min()), int(ys.max())
target_w = maxx - minx + 1
target_h = maxy - miny + 1
# Scale clipboard image to fit the selection bounding box
paste_scaled = paste_img.resize((target_w, target_h), Image.LANCZOS)
# Clip paste to selection shape using mask
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
r, g, b, a = paste_scaled.split()
mask_np = np.array(mask_crop)
alpha_np = np.array(a)
combined = (alpha_np.astype(np.uint16) * mask_np.astype(np.uint16) // 255).astype(np.uint8)
paste_final = Image.merge("RGBA", (r, g, b, Image.fromarray(combined)))
result = img.copy()
result.paste(paste_final, (minx, miny), paste_final.split()[3])
out = BytesIO()
result.convert("RGB").save(out, format="PNG")
return {"result": _encode(out.getvalue())}
# ─── SAM (Segment Anything) ──────────────────────────────────────────────────
class SegmentPointRequest(BaseModel):
image: str # base64 PNG/JPEG
points: list[list[int]] # [[x, y], ...] original image coords
labels: list[int] # 1=include, 0=exclude — same length as points
@router.post("/segment/point")
async def segment_point(req: SegmentPointRequest):
"""
Run SAM point-prompt segmentation.
Returns a binary mask PNG (white = selected area).
Auto-downloads the SAM ViT-B model (~375 MB) on first call.
"""
if not req.points:
raise HTTPException(status_code=400, detail="At least one point required.")
if len(req.points) != len(req.labels):
raise HTTPException(status_code=400, detail="points and labels must have the same length.")
try:
image_bytes = base64.b64decode(req.image)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
from app.services.sam_service import predict_points, get_install_status
try:
mask_bytes = await predict_points(
image_bytes,
[tuple(p) for p in req.points],
req.labels,
)
return {
"mask": base64.b64encode(mask_bytes).decode(),
"sam_install": get_install_status(),
}
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/segment/install-status")
def segment_install_status():
"""Poll SAM model download progress."""
from app.services.sam_service import get_install_status, sam_model_available
status = get_install_status()
status["model_ready"] = sam_model_available()
return status
@router.post("/segment/install")
async def segment_install():
"""Trigger SAM model download explicitly (also auto-triggered on first /segment/point call)."""
from app.services.sam_service import ensure_sam_installed, get_install_status
asyncio.create_task(ensure_sam_installed())
return get_install_status()
# ─── Enhance ─────────────────────────────────────────────────────────────────
import io as _io
import numpy as _np
import cv2 as _cv2
from PIL import Image as _Image
class EnhanceRequest(BaseModel):
image: str # base64
strength: float = 1.0
def _enhance_image(image_bytes: bytes, strength: float) -> bytes:
"""
Apply a chain of non-AI image enhancements, each blended with `strength` (01).
Steps:
1. Auto white balance (gray-world)
2. CLAHE on L channel of LAB colorspace
3. Auto saturation boost in HSV (×1.15, clamped)
4. Mild unsharp mask (gaussian sigma=1.0, delta weight=0.3)
"""
strength = max(0.0, min(1.0, float(strength)))
# Decode to RGB numpy array
pil = _Image.open(_io.BytesIO(image_bytes)).convert("RGB")
orig = _np.array(pil, dtype=_np.float32) # H×W×3, float [0,255]
img = orig.copy()
# ── Step 1: Auto white balance (gray-world) ──────────────────────────────
mean_r = img[:, :, 0].mean()
mean_g = img[:, :, 1].mean()
mean_b = img[:, :, 2].mean()
overall_mean = (mean_r + mean_g + mean_b) / 3.0
def _scale(channel, channel_mean):
if channel_mean == 0:
return channel
return channel * (overall_mean / channel_mean)
wb = img.copy()
wb[:, :, 0] = _np.clip(_scale(img[:, :, 0], mean_r), 0, 255)
wb[:, :, 1] = _np.clip(_scale(img[:, :, 1], mean_g), 0, 255)
wb[:, :, 2] = _np.clip(_scale(img[:, :, 2], mean_b), 0, 255)
img = (orig + strength * (wb - orig)).clip(0, 255)
# ── Step 2: CLAHE on L channel (LAB) ────────────────────────────────────
img_u8 = img.astype(_np.uint8)
lab = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2LAB)
clahe = _cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
l_orig = lab[:, :, 0].copy()
lab[:, :, 0] = clahe.apply(l_orig)
# Blend L channel back using strength
lab_blended = lab.copy()
lab_blended[:, :, 0] = (l_orig + strength * (lab[:, :, 0].astype(_np.float32) - l_orig.astype(_np.float32))).clip(0, 255).astype(_np.uint8)
img = _cv2.cvtColor(lab_blended, _cv2.COLOR_LAB2RGB).astype(_np.float32)
# ── Step 3: Auto saturation boost (HSV, ×1.15) ──────────────────────────
img_u8 = img.astype(_np.uint8)
hsv = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2HSV).astype(_np.float32)
s_orig = hsv[:, :, 1].copy()
s_boosted = _np.clip(s_orig * 1.15, 0, 255)
hsv[:, :, 1] = s_orig + strength * (s_boosted - s_orig)
hsv = hsv.clip(0, 255).astype(_np.uint8)
img = _cv2.cvtColor(hsv, _cv2.COLOR_HSV2RGB).astype(_np.float32)
# ── Step 4: Mild unsharp mask (sigma=1.0, delta weight=0.3) ─────────────
img_u8 = img.astype(_np.uint8)
blurred = _cv2.GaussianBlur(img_u8, (0, 0), sigmaX=1.0)
sharpness_delta = img_u8.astype(_np.float32) - blurred.astype(_np.float32)
sharpened = img_u8.astype(_np.float32) + 0.3 * sharpness_delta * strength
img = sharpened.clip(0, 255)
# Encode result as PNG
result_pil = _Image.fromarray(img.astype(_np.uint8), mode="RGB")
buf = _io.BytesIO()
result_pil.save(buf, format="PNG")
return buf.getvalue()
@router.post("/enhance")
async def enhance(req: EnhanceRequest):
"""
Non-AI image enhancement: auto white balance, CLAHE, saturation boost,
and unsharp mask. Each step is blended proportionally to `strength` (01).
"""
try:
image_bytes = _decode(req.image)
result = await asyncio.get_event_loop().run_in_executor(
None, _enhance_image, image_bytes, req.strength
)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ─── Subject replace ─────────────────────────────────────────────────────────
class ExtractSubjectRequest(BaseModel):
image: str # base64
class ReplaceSubjectRequest(BaseModel):
background_image: str # base64 — image whose background we keep
subject_image: str # base64 — image whose subject we extract
mask: Optional[str] = None # base64 — white = where the subject should land
match_colors: bool = True # blend subject color stats toward background
def _extract_subject_bytes(image_bytes: bytes) -> bytes:
"""Remove background from image using rembg; return RGBA PNG bytes."""
if rembg_available():
return remove_background_rembg(image_bytes)
raise RuntimeError(
"rembg is not installed. Run: pip install rembg (or add it to requirements.txt)"
)
def _color_transfer_lab(subj_rgba: "Image", bg_rgb: "Image", blend: float = 0.45) -> "Image":
"""
Partial LAB color transfer: nudge subject color statistics 'blend' fraction
toward the background's statistics so it looks like it belongs in the scene.
"""
import cv2
import numpy as np
from PIL import Image
src_arr = np.array(subj_rgba.convert("RGB"), dtype=np.float32)
tgt_arr = np.array(bg_rgb.convert("RGB"), dtype=np.float32)
alpha = np.array(subj_rgba.split()[3])
subject_mask = alpha > 10
if not subject_mask.any():
return subj_rgba
src_lab = cv2.cvtColor(src_arr.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
tgt_lab = cv2.cvtColor(tgt_arr.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
for ch in range(3):
src_ch = src_lab[:, :, ch]
src_pixels = src_ch[subject_mask]
tgt_pixels = tgt_lab[:, :, ch].flatten()
src_mean, src_std = float(src_pixels.mean()), float(src_pixels.std()) + 1e-6
tgt_mean, tgt_std = float(tgt_pixels.mean()), float(tgt_pixels.std()) + 1e-6
adjusted_std = src_std + blend * (tgt_std - src_std)
adjusted = (src_ch - src_mean) * (adjusted_std / src_std) + src_mean + blend * (tgt_mean - src_mean)
src_lab[:, :, ch] = np.clip(adjusted, 0, 255)
result_rgb = cv2.cvtColor(src_lab.astype(np.uint8), cv2.COLOR_LAB2RGB)
r, g, b = result_rgb[:, :, 0], result_rgb[:, :, 1], result_rgb[:, :, 2]
return Image.merge("RGBA", [
Image.fromarray(r), Image.fromarray(g),
Image.fromarray(b), Image.fromarray(alpha),
])
def _do_replace_subject(
bg_bytes: bytes,
subj_bytes: bytes,
mask_bytes: Optional[bytes],
match_colors: bool,
) -> bytes:
"""Core compositing: extract subject → scale → color-match → paste onto background."""
import numpy as np
from PIL import Image
bg_img = Image.open(BytesIO(bg_bytes)).convert("RGBA")
subj_rgba = Image.open(BytesIO(_extract_subject_bytes(subj_bytes))).convert("RGBA")
# Determine target placement bounding box from mask or full canvas
if mask_bytes:
mask_img = Image.open(BytesIO(mask_bytes)).convert("L")
if mask_img.size != bg_img.size:
mask_img = mask_img.resize(bg_img.size, Image.LANCZOS)
mask_arr = np.array(mask_img)
ys, xs = np.where(mask_arr > 128)
else:
mask_img = None
mask_arr = None
ys, xs = np.array([]), np.array([])
if len(xs) > 0:
minx, maxx = int(xs.min()), int(xs.max())
miny, maxy = int(ys.min()), int(ys.max())
else:
minx, miny = 0, 0
maxx, maxy = bg_img.width - 1, bg_img.height - 1
target_w = maxx - minx + 1
target_h = maxy - miny + 1
# Scale subject to fit target area, preserving aspect ratio
sw, sh = subj_rgba.size
scale = min(target_w / sw, target_h / sh)
new_w = max(1, round(sw * scale))
new_h = max(1, round(sh * scale))
subj_scaled = subj_rgba.resize((new_w, new_h), Image.LANCZOS)
# Optional color transfer to blend lighting/tone
if match_colors:
subj_scaled = _color_transfer_lab(subj_scaled, bg_img.convert("RGB"))
# Center in target area
px = minx + (target_w - new_w) // 2
py = miny + (target_h - new_h) // 2
result = bg_img.copy()
if mask_img is not None and len(xs) > 0:
# Build a full-canvas RGBA layer for the subject
subj_canvas = Image.new("RGBA", bg_img.size, (0, 0, 0, 0))
subj_canvas.paste(subj_scaled, (px, py), subj_scaled.split()[3])
# Clip subject's alpha to the selection mask
sc_arr = np.array(subj_canvas)
sc_arr[:, :, 3] = np.minimum(sc_arr[:, :, 3], mask_arr).astype(np.uint8)
subj_canvas = Image.fromarray(sc_arr)
result.paste(subj_canvas, (0, 0), subj_canvas.split()[3])
else:
result.paste(subj_scaled, (px, py), subj_scaled.split()[3])
out = BytesIO()
result.convert("RGB").save(out, format="PNG")
return out.getvalue()
@router.post("/image/extract-subject")
async def extract_subject(req: ExtractSubjectRequest):
"""
Remove background from an image and return the subject with transparency (RGBA PNG).
Uses rembg (AI-powered) when available.
"""
try:
result = await asyncio.get_event_loop().run_in_executor(
None, _extract_subject_bytes, _decode(req.image)
)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/image/replace-subject")
async def replace_subject(req: ReplaceSubjectRequest):
"""
Extract the primary subject from `subject_image` (via rembg background removal),
scale it to fit the `mask` selection on `background_image`, apply optional LAB
color transfer for lighting consistency, and composite the result.
Returns the composited image as base64 PNG.
"""
try:
result = await asyncio.get_event_loop().run_in_executor(
None,
_do_replace_subject,
_decode(req.background_image),
_decode(req.subject_image),
_decode(req.mask) if req.mask else None,
req.match_colors,
)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ─── Extract colors ───────────────────────────────────────────────────────────
class ExtractColorsRequest(BaseModel):
image: str # base64
count: int = 6
def _extract_colors(image_bytes: bytes, count: int) -> list[str]:
"""
Resize image to 150×150, k-means cluster pixels into `count` groups
using pure numpy (no sklearn dependency), return hex strings by frequency.
"""
import numpy as np
from PIL import Image
from io import BytesIO
count = max(1, min(count, 32))
pil = Image.open(BytesIO(image_bytes)).convert("RGB").resize((150, 150))
pixels = np.array(pil, dtype=np.float32).reshape(-1, 3) # (22500, 3)
n = len(pixels)
# Initialise centers with k-means++ seeding
rng = np.random.default_rng(42)
centers = [pixels[rng.integers(n)]]
for _ in range(count - 1):
dists = np.min([np.sum((pixels - c) ** 2, axis=1) for c in centers], axis=0)
probs = dists / dists.sum()
centers.append(pixels[rng.choice(n, p=probs)])
centers = np.array(centers)
labels = np.zeros(n, dtype=np.int32)
for _ in range(20): # max 20 iterations
# Assign each pixel to nearest center
dists = np.sum((pixels[:, None] - centers[None]) ** 2, axis=2) # (n, k)
new_labels = np.argmin(dists, axis=1)
if np.all(new_labels == labels):
break
labels = new_labels
# Recompute centers
for k in range(count):
mask = labels == k
if mask.any():
centers[k] = pixels[mask].mean(axis=0)
counts = np.bincount(labels, minlength=count)
order = np.argsort(-counts)
return [
"#{:02x}{:02x}{:02x}".format(*centers[i].astype(int).clip(0, 255))
for i in order
]
@router.post("/extract-colors")
async def extract_colors(req: ExtractColorsRequest):
"""
Extract dominant colors from an image using k-means clustering.
Returns hex color strings sorted by frequency (most dominant first).
"""
try:
image_bytes = _decode(req.image)
colors = await asyncio.get_event_loop().run_in_executor(
None, _extract_colors, image_bytes, req.count
)
return {"colors": colors}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
+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))
@@ -0,0 +1,96 @@
"""
GPU status and model management endpoints.
All under /api/gpu prefix.
"""
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
import asyncio
router = APIRouter(prefix="/api/gpu", tags=["gpu"])
@router.get("/status")
async def gpu_status():
"""
Full GPU capability report: hardware, feature flags, VRAM budget,
and which model was selected for each operation.
Frontend polls this to show GPU badge and tool availability.
"""
from app.services.gpu_detect import get_cached_gpu_info
from app.services.local_diffusion import get_all_model_states
info = get_cached_gpu_info()
return {
# Hardware
"backend": info.backend,
"device_name": info.device_name,
"vram_total_gb": info.vram_total_gb,
"vram_free_gb": info.vram_free_gb,
"compute_capability": info.compute_capability,
# Feature flags
"fp16": info.fp16,
"bf16": info.bf16,
"fp8": info.fp8,
"int8": info.int8,
"tensor_cores": info.tensor_cores,
"xformers": info.xformers,
# Derived
"effective_vram_gb": info.effective_vram_gb,
"tier": info.tier,
# Selected models per operation
"recommended": {
op: (
{
"model_id": spec.model_id,
"family": spec.family,
"memory_opt": spec.memory_opt,
"native_res": spec.native_res,
"vram_fp16_gb": spec.vram_fp16_gb,
}
if spec else None
)
for op, spec in info.recommended.items()
},
"pipeline_states": get_all_model_states(),
"warnings": info.warnings,
"capabilities": info.capabilities,
}
class PrefetchRequest(BaseModel):
operations: Optional[List[str]] = None
@router.post("/prefetch")
async def prefetch_models(req: PrefetchRequest = PrefetchRequest()):
"""
Eagerly load pipelines into GPU memory for the requested operations.
Returns immediately; poll /api/gpu/prefetch-status for progress.
Default: inpaint, txt2img, img2img.
"""
ops = req.operations or ["inpaint", "txt2img", "img2img"]
valid = {"inpaint", "txt2img", "img2img", "outpaint", "upscale"}
ops = [op for op in ops if op in valid]
from app.services.local_diffusion import get_local_diffusion_provider
provider = get_local_diffusion_provider()
async def _prefetch():
for op in ops:
try:
await provider._get_pipeline(op)
print(f"[gpu] Prefetch complete: {op}")
except Exception as exc:
print(f"[gpu] Prefetch failed for {op}: {exc}")
asyncio.create_task(_prefetch())
return {"status": "prefetch_started", "operations": ops}
@router.get("/prefetch-status")
async def prefetch_status():
"""Poll model download / load progress."""
from app.services.local_diffusion import get_all_model_states
return {"models": get_all_model_states()}
+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
@@ -0,0 +1,487 @@
"""
Print / frame tools — frame fit and upscale.
All endpoints under /api/print prefix.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Literal
import base64
import asyncio
from io import BytesIO
from PIL import Image
import numpy as np
router = APIRouter(prefix="/api/print", tags=["print-tools"])
# ── Frame size catalogue (inches) ──────────────────────────────────────────
FRAME_SIZES = {
"4x6": (4, 6),
"5x7": (5, 7),
"8x10": (8, 10),
"11x14": (11, 14),
"16x20": (16, 20),
"18x24": (18, 24),
"20x24": (20, 24),
"24x36": (24, 36),
# Square
"4x4": (4, 4),
"8x8": (8, 8),
"12x12": (12, 12),
}
def _encode(data: bytes) -> str:
return base64.b64encode(data).decode()
def _decode(b64: str) -> bytes:
return base64.b64decode(b64)
def _to_png(img: Image.Image) -> bytes:
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ── Request models ─────────────────────────────────────────────────────────
class FrameFitRequest(BaseModel):
image: str # base64 PNG/JPEG
frame: str # e.g. "8x10"
orientation: Literal["auto", "portrait", "landscape"] = "auto"
mode: Literal["crop", "extend", "smart"] = "smart"
dpi: int = 300
# For extend mode: prompt passed to outpaint
prompt: Optional[str] = ""
# Smart mode threshold: extend if gap fraction < this, else crop
smart_threshold: float = 0.15
class UpscaleRequest(BaseModel):
image: str # base64
scale: float = 2.0 # 1.5, 2, 3, 4
# auto = pick best available; lanczos = always works; realesrgan_pytorch / realesrgan_ncnn = explicit
method: str = "auto"
class PrepareRequest(BaseModel):
image: str # base64
frame: str # e.g. "8x10"
orientation: Literal["auto", "portrait", "landscape"] = "auto"
target_dpi: int = 300
upscale_method: str = "auto" # auto / realesrgan_pytorch / realesrgan_ncnn / lanczos
mode: Literal["crop", "extend", "smart"] = "smart"
prompt: Optional[str] = ""
# ── Frame sizes endpoint ───────────────────────────────────────────────────
@router.get("/frame-sizes")
def list_frame_sizes():
"""Return the catalogue of supported frame sizes."""
return {
"sizes": list(FRAME_SIZES.keys()),
"catalogue": {k: {"inches": v, "pixels_300dpi": (v[0]*300, v[1]*300)}
for k, v in FRAME_SIZES.items()},
}
# ── Frame fit ──────────────────────────────────────────────────────────────
@router.post("/frame-fit")
async def frame_fit(req: FrameFitRequest):
"""
Fit an image to a print frame size.
Modes:
crop — center-crop to frame aspect ratio, then scale to print resolution.
extend — scale to fill one dimension, outpaint the gap with AI.
smart — extend if gap < smart_threshold of frame dimension, else crop.
Returns the fitted image plus a summary of what was done.
"""
if req.frame not in FRAME_SIZES:
raise HTTPException(status_code=400,
detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}")
if not (72 <= req.dpi <= 600):
raise HTTPException(status_code=400, detail="dpi must be 72600")
try:
image = Image.open(BytesIO(_decode(req.image))).convert("RGB")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
fw, fh = FRAME_SIZES[req.frame] # frame inches (w, h in portrait)
# Resolve orientation
img_w, img_h = image.size
img_landscape = img_w >= img_h
frame_landscape = fw >= fh
if req.orientation == "landscape":
fw, fh = max(fw, fh), min(fw, fh)
elif req.orientation == "portrait":
fw, fh = min(fw, fh), max(fw, fh)
else: # auto — match image orientation
if img_landscape and not frame_landscape:
fw, fh = fh, fw # rotate frame to landscape
elif not img_landscape and frame_landscape:
fw, fh = fh, fw # rotate frame to portrait
target_w = fw * req.dpi
target_h = fh * req.dpi
target_ratio = target_w / target_h
img_ratio = img_w / img_h
# Determine actual mode
mode = req.mode
if mode == "smart":
# Scale image to fill the frame — compute gap fraction
if img_ratio > target_ratio:
# Image wider → fits on height, gap on width
scaled_h = target_h
scaled_w = round(target_h * img_ratio)
gap_frac = (scaled_w - target_w) / target_w # positive = overflow (crop)
else:
scaled_w = target_w
scaled_h = round(target_w / img_ratio)
gap_frac = (scaled_h - target_h) / target_h
# gap_frac > 0 means we'd need to crop; < 0 means we'd need to extend
if gap_frac < 0:
# Need to extend — use extend if gap is small enough
mode = "extend" if abs(gap_frac) <= req.smart_threshold else "crop"
else:
mode = "crop"
if mode == "crop":
result, summary = _crop_fit(image, target_w, target_h)
else: # extend
result, summary = await _extend_fit(image, target_w, target_h, req.prompt or "")
return {
"result": _encode(_to_png(result)),
"mode_used": mode,
"frame": req.frame,
"orientation": "landscape" if fw > fh else "portrait",
"output_pixels": {"width": result.width, "height": result.height},
"output_inches": {"width": fw, "height": fh},
"dpi": req.dpi,
"summary": summary,
}
def _crop_fit(image: Image.Image, target_w: int, target_h: int):
"""Center-crop image to target aspect ratio, then Lanczos scale to target size."""
img_w, img_h = image.size
target_ratio = target_w / target_h
img_ratio = img_w / img_h
if img_ratio > target_ratio:
# Wider than target — crop sides
new_w = round(img_h * target_ratio)
x0 = (img_w - new_w) // 2
cropped = image.crop((x0, 0, x0 + new_w, img_h))
else:
# Taller than target — crop top/bottom
new_h = round(img_w / target_ratio)
y0 = (img_h - new_h) // 2
cropped = image.crop((0, y0, img_w, y0 + new_h))
result = cropped.resize((target_w, target_h), Image.Resampling.LANCZOS)
summary = (
f"Cropped from {img_w}×{img_h} to {cropped.width}×{cropped.height}, "
f"scaled to {target_w}×{target_h}"
)
return result, summary
async def _extend_fit(image: Image.Image, target_w: int, target_h: int, prompt: str):
"""
Scale image to fill one dimension exactly, then outpaint the gap with AI.
Falls back to content-aware mirror fill if no remote provider configured.
"""
from app.services.remote_provider import get_remote_provider
img_w, img_h = image.size
target_ratio = target_w / target_h
img_ratio = img_w / img_h
if img_ratio > target_ratio:
# Image wider — scale to target width, extend height
scale = target_w / img_w
scaled_w = target_w
scaled_h = round(img_h * scale)
gap_dir = "height"
gap_top = (target_h - scaled_h) // 2
gap_bottom = target_h - scaled_h - gap_top
else:
# Image taller — scale to target height, extend width
scale = target_h / img_h
scaled_h = target_h
scaled_w = round(img_w * scale)
gap_dir = "width"
gap_left = (target_w - scaled_w) // 2
gap_right = target_w - scaled_w - gap_left
scaled = image.resize((scaled_w, scaled_h), Image.Resampling.LANCZOS)
# Place scaled image on canvas
canvas = Image.new("RGB", (target_w, target_h), (128, 128, 128))
if gap_dir == "height":
canvas.paste(scaled, (0, gap_top))
# Build mask: top and bottom strips are white (to inpaint)
mask = Image.new("L", (target_w, target_h), 0)
if gap_top > 0:
mask.paste(Image.new("L", (target_w, gap_top), 255), (0, 0))
if gap_bottom > 0:
mask.paste(Image.new("L", (target_w, gap_bottom), 255), (0, target_h - gap_bottom))
else:
canvas.paste(scaled, (gap_left, 0))
mask = Image.new("L", (target_w, target_h), 0)
if gap_left > 0:
mask.paste(Image.new("L", (gap_left, target_h), 255), (0, 0))
if gap_right > 0:
mask.paste(Image.new("L", (gap_right, target_h), 255), (target_w - gap_right, 0))
# Try AI inpaint
provider = get_remote_provider("inpaint")
if provider:
try:
canvas_bytes = _to_png(canvas)
mask_bytes = _to_png(mask)
fill_prompt = prompt or "seamlessly continue the image, natural extension"
result_bytes = await provider.inpaint(canvas_bytes, mask_bytes, fill_prompt, {})
result = Image.open(BytesIO(result_bytes)).convert("RGB")
summary = (
f"Scaled {img_w}×{img_h}{scaled_w}×{scaled_h}, "
f"AI-extended {gap_dir} to {target_w}×{target_h}"
)
return result, summary
except Exception as e:
print(f"AI extend failed, using mirror fill: {e}")
# Fallback: mirror-fill the gap (looks decent for backgrounds/landscapes)
result = _mirror_fill(canvas, mask, scaled, gap_dir,
gap_top if gap_dir == "height" else gap_left,
gap_bottom if gap_dir == "height" else gap_right,
target_w, target_h)
summary = (
f"Scaled {img_w}×{img_h}{scaled_w}×{scaled_h}, "
f"mirror-filled {gap_dir} to {target_w}×{target_h} (no AI provider)"
)
return result, summary
def _mirror_fill(canvas, mask, scaled, gap_dir, gap_a, gap_b, target_w, target_h):
"""Fill gaps by reflecting the nearest edge strip."""
result = canvas.copy()
if gap_dir == "height":
if gap_a > 0:
strip = scaled.crop((0, 0, scaled.width, min(gap_a * 2, scaled.height)))
strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
strip = strip.resize((target_w, gap_a), Image.Resampling.LANCZOS)
result.paste(strip, (0, 0))
if gap_b > 0:
strip = scaled.crop((0, max(0, scaled.height - gap_b * 2), scaled.width, scaled.height))
strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
strip = strip.resize((target_w, gap_b), Image.Resampling.LANCZOS)
result.paste(strip, (0, target_h - gap_b))
else:
if gap_a > 0:
strip = scaled.crop((0, 0, min(gap_a * 2, scaled.width), scaled.height))
strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
strip = strip.resize((gap_a, target_h), Image.Resampling.LANCZOS)
result.paste(strip, (0, 0))
if gap_b > 0:
strip = scaled.crop((max(0, scaled.width - gap_b * 2), 0, scaled.width, scaled.height))
strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
strip = strip.resize((gap_b, target_h), Image.Resampling.LANCZOS)
result.paste(strip, (target_w - gap_b, 0))
return result
# ── Upscale ────────────────────────────────────────────────────────────────
@router.post("/upscale/refresh-caps")
def upscale_refresh_caps():
"""Bust the capability cache (call after installing Real-ESRGAN without restarting)."""
from app.services.upscale import invalidate_caps_cache, probe_upscale_capabilities
invalidate_caps_cache()
return probe_upscale_capabilities()
@router.get("/upscale/available")
async def upscale_available():
"""
Return capability probe: which upscale methods are available,
which device will be used, and which method is recommended.
If no AI upscaler is found, triggers background NCNN auto-install.
Frontend uses this to populate the method selector.
"""
from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed, get_install_status
caps = probe_upscale_capabilities()
# Auto-install NCNN if no AI upscaler is available yet
if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]:
asyncio.create_task(ensure_ncnn_installed())
caps["ncnn_install_status"] = get_install_status()
return caps
@router.get("/upscale/install-status")
def upscale_install_status():
"""Poll for Real-ESRGAN NCNN auto-install progress."""
from app.services.upscale import get_install_status, probe_upscale_capabilities, _find_ncnn_binary
status = get_install_status()
# If install just finished, refresh caps
if status["state"] == "done":
from app.services.upscale import invalidate_caps_cache
invalidate_caps_cache()
caps = probe_upscale_capabilities()
status["ncnn_available"] = caps["realesrgan_ncnn"]
else:
status["ncnn_available"] = False
return status
@router.post("/prepare")
async def prepare_for_print(req: PrepareRequest):
"""
One-shot Prepare for Print: AI upscale to reach target DPI, then fit to frame.
Steps:
1. Resolve target pixel dimensions (frame × target_dpi, orientation-adjusted)
2. Calculate needed upscale factor so the image meets the target resolution
3. Run Real-ESRGAN if scale > 1.05 (else skip — already large enough)
4. Run frame-fit (crop / extend / smart) to exact target dimensions
5. Return the print-ready image and a quality report
"""
if req.frame not in FRAME_SIZES:
raise HTTPException(status_code=400,
detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}")
if not (72 <= req.target_dpi <= 600):
raise HTTPException(status_code=400, detail="target_dpi must be 72600")
try:
image = Image.open(BytesIO(_decode(req.image))).convert("RGB")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
fw, fh = FRAME_SIZES[req.frame]
img_w, img_h = image.size
# Resolve orientation (same logic as frame_fit)
img_landscape = img_w >= img_h
frame_landscape = fw >= fh
if req.orientation == "landscape":
fw, fh = max(fw, fh), min(fw, fh)
elif req.orientation == "portrait":
fw, fh = min(fw, fh), max(fw, fh)
else:
if img_landscape and not frame_landscape:
fw, fh = fh, fw
elif not img_landscape and frame_landscape:
fw, fh = fh, fw
target_w = fw * req.target_dpi
target_h = fh * req.target_dpi
# Scale factor needed so the shorter dimension fills the frame
scale_w = target_w / img_w
scale_h = target_h / img_h
needed_scale = min(scale_w, scale_h) # fill-to-fit (extend) baseline
# For crop mode we need max; use the larger to be safe and let frame-fit crop
needed_scale_crop = max(scale_w, scale_h)
# Use the smaller (extend) scale as the upscale target; frame-fit handles the rest
upscale_factor = max(1.0, needed_scale)
upscale_applied = False
method_used = "none"
upscaled = image
if upscale_factor > 1.05:
# Cap per-pass at 4× (Real-ESRGAN works best at 24×)
remaining = upscale_factor
while remaining > 1.05:
pass_scale = min(remaining, 4.0)
# Round to one decimal to keep scale in 1.18.0 range accepted by upscale service
pass_scale = round(pass_scale, 1)
if pass_scale < 1.1:
break
from app.services.upscale import upscale_image
result_bytes, method_used = await upscale_image(upscaled, pass_scale, req.upscale_method)
upscaled = Image.open(BytesIO(result_bytes)).convert("RGB")
remaining /= pass_scale
upscale_applied = True
# Encode upscaled image and run frame-fit
upscaled_b64 = _encode(_to_png(upscaled))
fit_req = FrameFitRequest(
image=upscaled_b64,
frame=req.frame,
orientation=req.orientation,
mode=req.mode,
dpi=req.target_dpi,
prompt=req.prompt or "",
)
# Re-use the existing frame_fit logic inline
fit_response = await frame_fit(fit_req)
return {
"result": fit_response["result"],
"frame": req.frame,
"orientation": fit_response["orientation"],
"output_pixels": fit_response["output_pixels"],
"output_inches": fit_response["output_inches"],
"dpi": req.target_dpi,
"mode_used": fit_response["mode_used"],
"upscale_applied": upscale_applied,
"upscale_factor": round(upscale_factor, 2),
"upscale_method": method_used,
"summary": fit_response["summary"],
}
@router.post("/upscale")
async def upscale(req: UpscaleRequest):
"""
Upscale image. method values:
auto — pick best available (recommended)
realesrgan_pytorch — Real-ESRGAN via PyTorch (CUDA/MPS/CPU)
realesrgan_ncnn — Real-ESRGAN NCNN Vulkan binary
lanczos — always available, instant
Any AI method falls back to the next best if unavailable.
"""
if not (1.1 <= req.scale <= 8.0):
raise HTTPException(status_code=400, detail="scale must be 1.18.0")
valid_methods = {"auto", "realesrgan_pytorch", "realesrgan_ncnn", "lanczos"}
if req.method not in valid_methods:
raise HTTPException(status_code=400,
detail=f"method must be one of {sorted(valid_methods)}")
try:
image = Image.open(BytesIO(_decode(req.image))).convert("RGB")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
orig_w, orig_h = image.size
try:
from app.services.upscale import upscale_image
result_bytes, method_used = await upscale_image(image, req.scale, req.method)
result = Image.open(BytesIO(result_bytes))
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
return {
"result": _encode(result_bytes),
"method": method_used,
"original": {"width": orig_w, "height": orig_h},
"output": {"width": result.width, "height": result.height},
"scale": req.scale,
}
+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
File diff suppressed because it is too large Load Diff
+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
@@ -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}")
@@ -0,0 +1,220 @@
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 (preserve alpha channel)
current_path = self.get_current_image_path(project_id)
img = Image.open(result_path)
# Preserve original mode to maintain transparency
img.save(current_path, format='PNG')
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 (preserve alpha channel)
img = Image.open(original_path)
# Preserve original mode to maintain transparency
img.save(current_path, format='PNG')
return str(current_path)
@@ -0,0 +1,391 @@
"""
GPU capability detection and per-operation model selection.
Probes the actual GPU — VRAM (total + free), CUDA compute capability, and
feature flags (fp16, bf16, fp8, int8, tensor cores) — then selects the
highest-quality model that fits for each operation.
Model selection ladder (txt2img):
eff_vram ≥ 20 GB → FLUX.1-schnell (no offload)
eff_vram ≥ 10 GB → FLUX.1-schnell (model_cpu_offload, 23× slower but fits)
eff_vram ≥ 7.5 GB → SDXL base
eff_vram ≥ 5.5 GB → SDXL base + attention slicing
eff_vram ≥ 4.0 GB → SDXL + model_cpu_offload (GTX 1060 6 GB, Quadro 6 GB)
eff_vram ≥ 3.5 GB → Stable Diffusion 2.1
eff_vram ≥ 2.5 GB → SD 2.1-base + attention slicing
eff_vram ≥ 1.7 GB → Stable Diffusion 1.5
otherwise → SD 1.5 + sequential CPU offload
Inpaint always uses SDXL/SD-family (no FLUX inpaint pipeline yet).
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass, field
from typing import Optional
# ── Model specification ───────────────────────────────────────────────────────
@dataclass
class ModelSpec:
"""Everything needed to load and run one diffusion pipeline."""
model_id: str
family: str # sd15 | sd2x | sdxl | flux
memory_opt: str # none | attention_slicing | model_cpu_offload | sequential_cpu_offload
native_res: int # 512 | 768 | 1024
vram_fp16_gb: float # approx VRAM needed in fp16, no memory opts
# ── GPU capability record ─────────────────────────────────────────────────────
@dataclass
class GpuCapabilities:
# Hardware
backend: str # cuda | mps | cpu
device_name: str
vram_total_gb: float
vram_free_gb: float
compute_capability: str # "8.6", "7.5", "6.1" …
cc_major: int
cc_minor: int
# Feature flags derived from compute capability
fp16: bool # reliable fp16 (CC ≥ 6.0; CC 5.x works but slower)
bf16: bool # native bf16 (CC ≥ 8.0)
fp8: bool # native fp8 (CC ≥ 8.9, Ada / Hopper)
int8: bool # efficient int8 (CC ≥ 7.0, needed for bitsandbytes)
tensor_cores: bool # tensor cores (CC ≥ 7.0, Volta+)
xformers: bool # xformers installed (reduces attention VRAM ~20-30%)
# Derived budget
effective_vram_gb: float # free VRAM after overhead, halved if fp32-only
# Human-readable tier label
tier: str # flux_full | flux_offload | sdxl | sdxl_low | sdxl_offload | sd2x | sd2x_low | sd15 | minimal
# Best model per operation
recommended: dict[str, Optional[ModelSpec]]
# Metadata
warnings: list[str]
capabilities: list[str]
# ── Detection ─────────────────────────────────────────────────────────────────
def detect_gpu() -> GpuCapabilities:
"""Probe the GPU, return a fully populated GpuCapabilities."""
try:
import torch
if torch.cuda.is_available():
props = torch.cuda.get_device_properties(0)
free_bytes, total_bytes = torch.cuda.mem_get_info(0)
vram_total = total_bytes / (1024 ** 3)
vram_free = free_bytes / (1024 ** 3)
cc = f"{props.major}.{props.minor}"
major, minor = props.major, props.minor
fp16 = major >= 6 # Pascal and newer have good fp16
bf16 = major >= 8 # Ampere A100 / RTX 3000+
fp8 = major > 8 or (major == 8 and minor >= 9) # Ada / Hopper
int8 = major >= 7 # Volta+
tensor_cores = major >= 7
# Pre-Pascal (Maxwell CC 5.x): fp16 works but throughput is lower than fp32
# on some Maxwell cards. Flag it so memory opt logic can account for it.
xf = _xformers_available()
# Subtract driver/CUDA context overhead from free VRAM
overhead_gb = 0.4
eff = max(0.0, vram_free - overhead_gb)
if not fp16:
eff /= 2.0 # fp32 weights are 2× larger
tier = _tier_label(eff)
warnings = _build_warnings(
tier, vram_total, vram_free, cc, major, minor, fp16, bf16, fp8, xf
)
return GpuCapabilities(
backend="cuda",
device_name=props.name,
vram_total_gb=round(vram_total, 1),
vram_free_gb=round(vram_free, 1),
compute_capability=cc,
cc_major=major,
cc_minor=minor,
fp16=fp16,
bf16=bf16,
fp8=fp8,
int8=int8,
tensor_cores=tensor_cores,
xformers=xf,
effective_vram_gb=round(eff, 1),
tier=tier,
recommended=_select_all_models(eff),
warnings=warnings,
capabilities=_caps(tier),
)
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
usable_gb = _apple_usable_gb()
eff = max(0.0, usable_gb - 0.5)
tier = _tier_label(eff)
return GpuCapabilities(
backend="mps",
device_name="Apple Silicon",
vram_total_gb=round(usable_gb, 1),
vram_free_gb=round(usable_gb, 1),
compute_capability="mps",
cc_major=0,
cc_minor=0,
fp16=False, # MPS diffusion more stable in fp32
bf16=False,
fp8=False,
int8=False,
tensor_cores=False,
xformers=False,
effective_vram_gb=round(eff / 2, 1), # fp32 on MPS
tier=tier,
recommended=_select_all_models(eff / 2),
warnings=["Apple MPS: using fp32 (fp16 less stable). Models load slower."],
capabilities=_caps(tier),
)
except ImportError:
pass
# CPU fallback
return GpuCapabilities(
backend="cpu",
device_name="CPU (no GPU)",
vram_total_gb=0.0,
vram_free_gb=0.0,
compute_capability="",
cc_major=0, cc_minor=0,
fp16=False, bf16=False, fp8=False, int8=False,
tensor_cores=False, xformers=False,
effective_vram_gb=0.0,
tier="minimal",
recommended=_select_all_models(0.0),
warnings=[
"No GPU found. Running on CPU — expect 530 minutes per image. "
"Consider setting AI_PROVIDER to a remote/cloud provider instead."
],
capabilities=["txt2img", "inpaint", "img2img", "outpaint"],
)
# ── Model selection ───────────────────────────────────────────────────────────
def _select_all_models(eff_vram: float) -> dict[str, Optional[ModelSpec]]:
return {
"txt2img": _select_txt2img(eff_vram),
"img2img": _select_img2img(eff_vram),
"inpaint": _select_inpaint(eff_vram),
"outpaint": _select_inpaint(eff_vram), # shares inpaint pipeline
"upscale": _select_upscale(eff_vram),
}
def _select_txt2img(eff: float) -> ModelSpec:
# FLUX.1-schnell (Apache 2.0, 4-step distilled)
if eff >= 20.0:
return ModelSpec("black-forest-labs/FLUX.1-schnell", "flux", "none", 1024, 20.0)
if eff >= 10.0:
return ModelSpec("black-forest-labs/FLUX.1-schnell", "flux", "model_cpu_offload", 1024, 20.0)
# SDXL base
if eff >= 7.5:
return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "none", 1024, 6.5)
if eff >= 5.5:
return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "attention_slicing", 1024, 6.5)
if eff >= 4.0:
return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "model_cpu_offload", 1024, 6.5)
# SD 2.x
if eff >= 3.5:
return ModelSpec("stabilityai/stable-diffusion-2-1", "sd2x", "none", 768, 3.5)
if eff >= 2.5:
return ModelSpec("stabilityai/stable-diffusion-2-1-base", "sd2x", "attention_slicing", 512, 3.2)
# SD 1.5
if eff >= 1.7:
return ModelSpec("stable-diffusion-v1-5/stable-diffusion-v1-5", "sd15", "attention_slicing", 512, 1.7)
return ModelSpec("stable-diffusion-v1-5/stable-diffusion-v1-5", "sd15", "sequential_cpu_offload", 512, 1.7)
def _select_img2img(eff: float) -> ModelSpec:
# img2img uses the same model family as txt2img
s = _select_txt2img(eff)
# FLUX img2img uses a different pipeline class but same model weights
return s
def _select_inpaint(eff: float) -> ModelSpec:
# No FLUX inpaint pipeline available yet — SDXL is the ceiling
if eff >= 7.5:
return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "none", 1024, 6.5)
if eff >= 5.5:
return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "attention_slicing", 1024, 6.5)
if eff >= 4.0:
return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "model_cpu_offload", 1024, 6.5)
if eff >= 3.5:
return ModelSpec("stabilityai/stable-diffusion-2-inpainting", "sd2x", "none", 512, 3.5)
if eff >= 2.5:
return ModelSpec("stabilityai/stable-diffusion-2-inpainting", "sd2x", "attention_slicing", 512, 3.5)
if eff >= 1.7:
return ModelSpec("runwayml/stable-diffusion-inpainting", "sd15", "attention_slicing", 512, 1.7)
return ModelSpec("runwayml/stable-diffusion-inpainting", "sd15", "sequential_cpu_offload", 512, 1.7)
def _select_upscale(eff: float) -> Optional[ModelSpec]:
# SD x4 upscaler — needs ~2 GB fp16 PLUS headroom for the loaded inpaint/txt2img model.
# Only enable if eff_vram suggests room for it as a secondary pipeline.
if eff >= 6.0:
return ModelSpec("stabilityai/stable-diffusion-x4-upscaler", "sd2x", "attention_slicing", 512, 2.0)
return None # fall through to Real-ESRGAN
# ── Tier label (display only) ─────────────────────────────────────────────────
def _tier_label(eff_vram: float) -> str:
if eff_vram >= 20: return "flux_full"
if eff_vram >= 10: return "flux_offload"
if eff_vram >= 7.5: return "sdxl"
if eff_vram >= 5.5: return "sdxl_low"
if eff_vram >= 4.0: return "sdxl_offload"
if eff_vram >= 3.5: return "sd2x"
if eff_vram >= 2.5: return "sd2x_low"
if eff_vram >= 1.7: return "sd15"
return "minimal"
def _caps(tier: str) -> list[str]:
base = ["txt2img", "inpaint", "img2img", "outpaint"]
if tier in ("flux_full", "flux_offload", "sdxl", "sdxl_low", "sdxl_offload"):
return base + ["upscale_diffusion"]
return base
# ── Warnings ──────────────────────────────────────────────────────────────────
def _build_warnings(
tier: str, vram_total: float, vram_free: float,
cc: str, major: int, minor: int,
fp16: bool, bf16: bool, fp8: bool, xf: bool,
) -> list[str]:
w = []
if major < 5:
w.append(
f"GPU compute capability {cc} is not supported by PyTorch 2.x. "
"Upgrade to a Kepler/Maxwell-era or newer GPU (CC ≥ 5.0)."
)
elif major < 6:
w.append(
f"GPU is Maxwell-era (CC {cc}). fp32 mode — models need 2× VRAM. "
"A Pascal GTX 1000-series or newer card enables fp16."
)
elif not bf16 and tier in ("flux_full", "flux_offload"):
w.append(
f"GPU CC {cc}: FLUX runs in fp16 (bf16 needs CC ≥ 8.0). "
"Results are still good but Ampere/Ada GPUs are faster here."
)
if fp8 and tier in ("flux_full", "flux_offload"):
w.append(
"FP8 native support detected (Ada Lovelace / Hopper). "
"Set HF_MODEL_TXT2IMG=flux-community/flux.1-schnell-fp8 for ~40% VRAM reduction."
)
if tier == "minimal":
w.append(
f"Very low effective VRAM ({vram_free:.1f} GB free). "
"Sequential CPU offload will be used — expect 1030 min per image."
)
elif tier == "sdxl_offload":
w.append(
f"Limited VRAM ({vram_free:.1f} GB free). "
"Using SDXL with model_cpu_offload — better quality than SD 2.x, ~30% slower. "
"Install xformers or upgrade to ≥5.5 GB effective VRAM for full-speed SDXL."
)
elif tier in ("sd15", "sd2x_low"):
w.append(
f"Limited VRAM ({vram_free:.1f} GB free). "
"Using SD 1.5/2.x. Upgrade to ≥5.5 GB free for SDXL quality."
)
if xf:
w.append(
"xformers detected — attention VRAM reduced ~20-30%. "
"You may be able to run a higher-tier model than listed."
)
else:
if tier in ("sdxl_low", "sdxl_offload", "sd2x"):
w.append(
"xformers not installed. Install it (pip install xformers) to reduce "
"VRAM usage ~20-30% and potentially unlock the next model tier."
)
return w
# ── Helpers ───────────────────────────────────────────────────────────────────
def _xformers_available() -> bool:
try:
import xformers # noqa: F401
return True
except ImportError:
return False
def _apple_usable_gb() -> float:
"""Estimate GPU-usable unified memory (≈ half of total RAM)."""
try:
r = subprocess.run(
["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5
)
if r.returncode == 0:
return int(r.stdout.strip()) / (1024 ** 3) / 2
except Exception:
pass
return 8.0
def infer_spec_from_model_id(model_id: str) -> ModelSpec:
"""
When the user supplies HF_MODEL_* overrides, infer the pipeline family
from naming conventions so the correct diffusers class is chosen.
"""
mid = model_id.lower()
if "flux" in mid:
return ModelSpec(model_id, "flux", "model_cpu_offload", 1024, 20.0)
if "xl" in mid or "sdxl" in mid:
return ModelSpec(model_id, "sdxl", "attention_slicing", 1024, 6.5)
if any(x in mid for x in ["sd-2", "sd2", "stable-diffusion-2", "-2-", "-2inpaint"]):
res = 512 if "base" in mid else 768
return ModelSpec(model_id, "sd2x", "attention_slicing", res, 3.5)
return ModelSpec(model_id, "sd15", "attention_slicing", 512, 1.7)
# ── Singleton ─────────────────────────────────────────────────────────────────
_cached: Optional[GpuCapabilities] = None
def get_cached_gpu_info() -> GpuCapabilities:
global _cached
if _cached is None:
_cached = detect_gpu()
return _cached
# Alias kept for any callers still using the old name
def get_model_ids(tier: str) -> dict:
"""Compatibility shim — returns model_id strings keyed by operation."""
info = get_cached_gpu_info()
return {
op: (spec.model_id if spec else None)
for op, spec in info.recommended.items()
}
@@ -0,0 +1,584 @@
"""
Local GPU diffusion provider — HuggingFace Diffusers backend.
Implements RemoteAIProvider so all existing routes work unchanged.
Pipelines are lazy-loaded, cached in an LRU store, and memory-optimised
per the ModelSpec chosen by gpu_detect.
Supported model families:
flux → FluxPipeline / FluxImg2ImgPipeline (FLUX.1-schnell)
sdxl → StableDiffusionXL*Pipeline (SDXL base + SDXL Inpaint)
sd2x → StableDiffusion2*Pipeline (SD 2.x)
sd15 → StableDiffusionPipeline (SD 1.5)
Requires: diffusers>=0.28.0,<0.29.0, transformers, accelerate, safetensors
(all in requirements.gpu.txt — pinned <0.29.0 for PyTorch 2.1.x compatibility)
"""
from __future__ import annotations
import asyncio
import threading
from collections import OrderedDict
from io import BytesIO
from typing import Optional
from PIL import Image
from app.services.gpu_detect import (
GpuCapabilities,
ModelSpec,
get_cached_gpu_info,
infer_spec_from_model_id,
)
from app.services.remote_provider import RemoteAIProvider
# ── Model state tracking ──────────────────────────────────────────────────────
_states: dict[str, dict] = {}
_states_lock = threading.Lock()
def _set_state(key: str, **kw):
with _states_lock:
_states.setdefault(key, {}).update(kw)
def get_all_model_states() -> list[dict]:
with _states_lock:
return list(_states.values())
def _make_step_cb(pipe_type: str, total_steps: int):
"""
Returns a diffusers callback_on_step_end that writes per-step progress
into _states so the SSE /api/generate/progress endpoint can stream it.
Called from a thread executor — _set_state is thread-safe.
"""
def cb(pipe, step_index: int, timestep, callback_kwargs: dict) -> dict:
done = step_index + 1
_set_state(pipe_type,
state="running",
step=done,
total_steps=total_steps,
progress=round(done / total_steps * 85, 1),
message=f"Step {done} / {total_steps}")
return callback_kwargs
return cb
# ── LRU pipeline cache ────────────────────────────────────────────────────────
class _PipelineCache:
def __init__(self, maxsize: int = 2):
self._cache: OrderedDict[str, object] = OrderedDict()
self._maxsize = maxsize
self._lock = asyncio.Lock()
async def get(self, key: str):
async with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
return self._cache[key]
return None
async def put(self, key: str, pipe: object):
async with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
else:
if len(self._cache) >= self._maxsize:
evicted_key, evicted = self._cache.popitem(last=False)
_evict(evicted, evicted_key)
self._cache[key] = pipe
def _evict(pipe, key: str):
try:
import torch
pipe.to("cpu")
torch.cuda.empty_cache()
print(f"[local_gpu] Evicted '{key}' from GPU cache")
except Exception:
pass
# ── Pipeline loading helpers ──────────────────────────────────────────────────
def _apply_hf_token():
try:
from app.config import settings
if settings.hf_token:
import huggingface_hub
huggingface_hub.login(token=settings.hf_token, add_to_git_credential=False)
except Exception:
pass
def _get_spec(pipe_type: str, info: GpuCapabilities) -> ModelSpec:
"""Return the ModelSpec for a pipeline type, respecting user overrides."""
# Map outpaint to inpaint (same pipeline)
op_key = "inpaint" if pipe_type == "outpaint" else pipe_type
# img2img uses same family/model as txt2img for FLUX/SDXL
if pipe_type == "img2img" and op_key not in info.recommended:
op_key = "txt2img"
# User config override
try:
from app.config import settings
override_map = {
"inpaint": settings.hf_model_inpaint,
"outpaint": settings.hf_model_inpaint,
"txt2img": settings.hf_model_txt2img,
"img2img": settings.hf_model_img2img,
}
override_id = override_map.get(pipe_type, "") or ""
if override_id:
return infer_spec_from_model_id(override_id)
except Exception:
pass
spec = info.recommended.get(op_key)
if spec is None:
raise RuntimeError(
f"No model available for '{pipe_type}' at effective VRAM "
f"{info.effective_vram_gb:.1f} GB. GPU may not have enough memory."
)
return spec
def _load_sd_pipeline(pipe_type: str, spec: ModelSpec, info: GpuCapabilities) -> object:
"""Load a Stable Diffusion (1.5 / 2.x / XL) pipeline."""
import torch
from diffusers import (
StableDiffusionPipeline,
StableDiffusionImg2ImgPipeline,
StableDiffusionInpaintPipeline,
StableDiffusionUpscalePipeline,
StableDiffusionXLPipeline,
StableDiffusionXLImg2ImgPipeline,
StableDiffusionXLInpaintPipeline,
)
dtype = torch.float16 if info.fp16 else torch.float32
is_xl = spec.family == "sdxl"
kwargs: dict = {"torch_dtype": dtype}
if not is_xl:
kwargs["safety_checker"] = None
kwargs["requires_safety_checker"] = False
op_key = "inpaint" if pipe_type in ("inpaint", "outpaint") else pipe_type
if op_key == "inpaint":
cls = StableDiffusionXLInpaintPipeline if is_xl else StableDiffusionInpaintPipeline
elif op_key == "txt2img":
cls = StableDiffusionXLPipeline if is_xl else StableDiffusionPipeline
elif op_key == "img2img":
cls = StableDiffusionXLImg2ImgPipeline if is_xl else StableDiffusionImg2ImgPipeline
elif op_key == "upscale":
cls = StableDiffusionUpscalePipeline
else:
raise ValueError(f"Unknown SD operation: {op_key}")
pipe = cls.from_pretrained(spec.model_id, **kwargs)
return _apply_mem_opts(pipe, spec, info)
def _load_flux_pipeline(pipe_type: str, spec: ModelSpec, info: GpuCapabilities) -> object:
"""Load a FLUX pipeline (txt2img or img2img)."""
import torch
from diffusers import FluxPipeline, FluxImg2ImgPipeline
# FLUX works best in bf16 on Ampere+; fp16 on older Turing/Pascal
dtype = torch.bfloat16 if info.bf16 else torch.float16
op_key = "img2img" if pipe_type == "img2img" else "txt2img"
cls = FluxImg2ImgPipeline if op_key == "img2img" else FluxPipeline
pipe = cls.from_pretrained(spec.model_id, torch_dtype=dtype)
return _apply_mem_opts(pipe, spec, info)
def _apply_mem_opts(pipe, spec: ModelSpec, info: GpuCapabilities) -> object:
"""Apply memory optimisations then move pipeline to device."""
device = info.backend
opt = spec.memory_opt
# VAE slicing is always beneficial (reduces VRAM for decoding large images)
try:
pipe.enable_vae_slicing()
except Exception:
pass
# xformers memory-efficient attention
if info.xformers and spec.family != "flux":
try:
pipe.enable_xformers_memory_efficient_attention()
except Exception:
pass
if opt == "sequential_cpu_offload":
# Each layer moved to GPU only during its forward pass — very VRAM-efficient
# enable_sequential_cpu_offload() also calls .to(device) internally
try:
pipe.enable_sequential_cpu_offload()
except Exception:
pipe.to("cpu")
elif opt == "model_cpu_offload":
# Entire sub-models (text encoder, unet/transformer, VAE) moved between CPU/GPU
# Faster than sequential but needs ~3-4 GB free to hold the active module
try:
pipe.enable_model_cpu_offload()
except Exception:
pipe.to(device)
elif opt == "attention_slicing":
try:
pipe.enable_attention_slicing(1)
except Exception:
pass
pipe.to(device)
else: # "none"
pipe.to(device)
return pipe
# ── Provider ─────────────────────────────────────────────────────────────────
class LocalDiffusionProvider(RemoteAIProvider):
def __init__(self, max_cached_pipelines: int = 2):
self._cache = _PipelineCache(maxsize=max_cached_pipelines)
self._load_locks: dict[str, asyncio.Lock] = {}
self._meta_lock = asyncio.Lock()
@property
def _info(self) -> GpuCapabilities:
return get_cached_gpu_info()
async def _lock_for(self, key: str) -> asyncio.Lock:
async with self._meta_lock:
if key not in self._load_locks:
self._load_locks[key] = asyncio.Lock()
return self._load_locks[key]
def _load_pipeline_sync(self, pipe_type: str) -> object:
info = self._info
spec = _get_spec(pipe_type, info)
_apply_hf_token()
_set_state(pipe_type, pipeline=pipe_type, model_id=spec.model_id,
family=spec.family, memory_opt=spec.memory_opt,
state="downloading", progress=0.0,
message=f"Downloading {spec.model_id}", error="")
try:
if spec.family == "flux":
pipe = _load_flux_pipeline(pipe_type, spec, info)
else:
pipe = _load_sd_pipeline(pipe_type, spec, info)
_set_state(pipe_type, state="ready", progress=100.0, message="Ready")
return pipe
except Exception as exc:
_set_state(pipe_type, state="failed", error=str(exc), message="Load failed")
raise
async def _get_pipeline(self, pipe_type: str) -> object:
cached = await self._cache.get(pipe_type)
if cached is not None:
return cached
lock = await self._lock_for(pipe_type)
async with lock:
cached = await self._cache.get(pipe_type)
if cached is not None:
return cached
loop = asyncio.get_event_loop()
pipe = await loop.run_in_executor(None, self._load_pipeline_sync, pipe_type)
await self._cache.put(pipe_type, pipe)
return pipe
# ── RemoteAIProvider ──────────────────────────────────────────────────────
async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
pipe = await self._get_pipeline("inpaint")
spec = _get_spec("inpaint", self._info)
img = Image.open(BytesIO(image_bytes)).convert("RGB")
mask = Image.open(BytesIO(mask_bytes)).convert("L")
orig = img.size
img_r, mask_r = _resize_pair(img, mask, spec.native_res)
steps = int(params.get("steps", 30))
cfg = float(params.get("cfg_scale", 7.5))
neg = params.get("negative_prompt", "") or None
step_cb = _make_step_cb("inpaint", steps)
_set_state("inpaint", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
def _run():
try:
return pipe(
prompt=prompt,
negative_prompt=neg,
image=img_r,
mask_image=mask_r,
num_inference_steps=steps,
guidance_scale=cfg,
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0].resize(orig, Image.LANCZOS)
except TypeError:
return pipe(
prompt=prompt,
negative_prompt=neg,
image=img_r,
mask_image=mask_r,
num_inference_steps=steps,
guidance_scale=cfg,
).images[0].resize(orig, Image.LANCZOS)
result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
_set_state("inpaint", state="ready", step=None, total_steps=None, progress=100, message="Ready")
return result
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
pipe = await self._get_pipeline("txt2img")
spec = _get_spec("txt2img", self._info)
max_dim = spec.native_res
w = min(width, max_dim) // 8 * 8
h = min(height, max_dim) // 8 * 8
seed = int(params.get("seed", 0))
is_flux = spec.family == "flux"
steps = 4 if is_flux else int(params.get("steps", 30))
step_cb = _make_step_cb("txt2img", steps)
_set_state("txt2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
def _run():
import torch
device = self._info.backend
gen = torch.Generator(device=device).manual_seed(seed) if seed else None
try:
if is_flux:
return pipe(
prompt=prompt,
width=w, height=h,
num_inference_steps=steps,
guidance_scale=0.0,
max_sequence_length=256,
generator=gen,
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0]
else:
return pipe(
prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None,
width=w, height=h,
num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
generator=gen,
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0]
except TypeError:
# Older diffusers without callback_on_step_end
if is_flux:
return pipe(
prompt=prompt, width=w, height=h,
num_inference_steps=steps, guidance_scale=0.0,
max_sequence_length=256, generator=gen,
).images[0]
else:
return pipe(
prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None,
width=w, height=h,
num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
generator=gen,
).images[0]
result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
_set_state("txt2img", state="ready", step=None, total_steps=None, progress=100, message="Ready")
return result
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
pipe = await self._get_pipeline("img2img")
spec = _get_spec("img2img", self._info)
img = Image.open(BytesIO(image_bytes)).convert("RGB")
orig = img.size
img_r = _resize_square(img, spec.native_res)
is_flux = spec.family == "flux"
steps = 4 if is_flux else int(params.get("steps", 30))
step_cb = _make_step_cb("img2img", steps)
_set_state("img2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
def _run():
try:
if is_flux:
result = pipe(
prompt=prompt, image=img_r, strength=strength,
num_inference_steps=steps, guidance_scale=0.0,
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0]
else:
result = pipe(
prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None,
image=img_r, strength=strength,
num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0]
except TypeError:
if is_flux:
result = pipe(
prompt=prompt, image=img_r, strength=strength,
num_inference_steps=steps, guidance_scale=0.0,
).images[0]
else:
result = pipe(
prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None,
image=img_r, strength=strength,
num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
).images[0]
return result.resize(orig, Image.LANCZOS)
result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
_set_state("img2img", state="ready", step=None, total_steps=None, progress=100, message="Ready")
return result
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
from PIL import ImageDraw
img = Image.open(BytesIO(image_bytes)).convert("RGB")
w, h = img.size
positions = {
"right": ((w + size, h), (0, 0), (w, 0, w + size, h)),
"left": ((w + size, h), (size, 0), (0, 0, size, h)),
"bottom": ((w, h + size), (0, 0), (0, h, w, h + size)),
"top": ((w, h + size), (0, size), (0, 0, w, size)),
}
new_size, paste_at, mask_box = positions[direction]
expanded = Image.new("RGB", new_size, (127, 127, 127))
expanded.paste(img, paste_at)
mask = Image.new("L", new_size, 0)
ImageDraw.Draw(mask).rectangle(mask_box, fill=255)
fill_prompt = prompt or "seamless natural continuation of the scene"
return await self.inpaint(_to_png(expanded), _to_png(mask), fill_prompt, {})
async def health(self) -> bool:
return True
def capabilities(self) -> list[str]:
return self._info.capabilities
# ── Image utilities ───────────────────────────────────────────────────────────
def _resize_pair(img: Image.Image, mask: Image.Image, target: int):
w, h = img.size
scale = target / max(w, h)
nw = max(8, int(w * scale) // 8 * 8)
nh = max(8, int(h * scale) // 8 * 8)
return img.resize((nw, nh), Image.LANCZOS), mask.resize((nw, nh), Image.NEAREST)
def _resize_square(img: Image.Image, target: int) -> Image.Image:
w, h = img.size
scale = target / max(w, h)
nw = max(8, int(w * scale) // 8 * 8)
nh = max(8, int(h * scale) // 8 * 8)
return img.resize((nw, nh), Image.LANCZOS)
def _to_png(img: Image.Image) -> bytes:
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ── Singleton ─────────────────────────────────────────────────────────────────
_provider: Optional[LocalDiffusionProvider] = None
def get_local_diffusion_provider(max_pipelines: int = 2) -> LocalDiffusionProvider:
global _provider
if _provider is None:
_provider = LocalDiffusionProvider(max_cached_pipelines=max_pipelines)
return _provider
async def prefetch_model_files() -> None:
"""
Download model weight files to HuggingFace disk cache without loading into GPU.
Called at container startup so the first request loads from disk (fast).
"""
from app.services.gpu_detect import get_cached_gpu_info
try:
from huggingface_hub import snapshot_download
except ImportError:
print("[local_gpu] huggingface_hub not installed — skipping model prefetch")
return
info = get_cached_gpu_info()
_apply_hf_token()
loop = asyncio.get_event_loop()
seen: set[str] = set()
for op, spec in info.recommended.items():
if spec is None or spec.model_id in seen:
continue
seen.add(spec.model_id)
# Apply user override if set
try:
from app.config import settings
override_map = {
"inpaint": settings.hf_model_inpaint,
"txt2img": settings.hf_model_txt2img,
"img2img": settings.hf_model_img2img,
}
override = override_map.get(op, "") or ""
if override and override not in seen:
seen.add(override)
spec = infer_spec_from_model_id(override)
except Exception:
pass
_set_state(op, pipeline=op, model_id=spec.model_id, family=spec.family,
memory_opt=spec.memory_opt, state="downloading", progress=0.0,
message=f"Downloading {spec.model_id}", error="")
print(f"[local_gpu] Prefetching: {spec.model_id}")
def _dl(model_id=spec.model_id):
snapshot_download(
repo_id=model_id,
ignore_patterns=["*.msgpack", "flax_*", "tf_*", "rust_model*"],
)
try:
await loop.run_in_executor(None, _dl)
_set_state(op, state="cached", progress=100.0,
message="Files cached — loads into GPU on first request")
print(f"[local_gpu] ✓ Cached: {spec.model_id}")
except Exception as exc:
_set_state(op, state="download_failed", error=str(exc),
message="Download failed — will retry on first request")
print(f"[local_gpu] Prefetch failed for {spec.model_id}: {exc}")
@@ -0,0 +1,82 @@
"""
Local inpainting operations — LaMa, OpenCV, and background removal.
All operations use GPU automatically if PyTorch detects one, CPU otherwise.
"""
from io import BytesIO
from PIL import Image
import numpy as np
import cv2
# Lazy-loaded LaMa model (downloaded on first use, ~100MB)
_lama = None
def get_lama():
global _lama
if _lama is None:
from simple_lama_inpainting import SimpleLama
_lama = SimpleLama()
return _lama
def lama_available() -> bool:
try:
import simple_lama_inpainting # noqa: F401
return True
except ImportError:
return False
def lama_inpaint(image_bytes: bytes, mask_bytes: bytes) -> bytes:
"""LaMa structural inpainting — best for object removal and large fills."""
lama = get_lama()
image = Image.open(BytesIO(image_bytes)).convert("RGB")
mask = Image.open(BytesIO(mask_bytes)).convert("L")
if mask.size != image.size:
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
result = lama(image, mask)
buf = BytesIO()
result.save(buf, format="PNG")
return buf.getvalue()
def opencv_inpaint(image_bytes: bytes, mask_bytes: bytes, method: str = "telea") -> bytes:
"""OpenCV fast structural inpainting — CPU only, milliseconds."""
image = Image.open(BytesIO(image_bytes)).convert("RGB")
mask = Image.open(BytesIO(mask_bytes)).convert("L")
if mask.size != image.size:
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
img_np = np.array(image)
mask_np = np.array(mask)
_, mask_bin = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY)
flags = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
result = cv2.inpaint(img_np, mask_bin, inpaintRadius=3, flags=flags)
buf = BytesIO()
Image.fromarray(result).save(buf, format="PNG")
return buf.getvalue()
def remove_background_rembg(image_bytes: bytes) -> bytes:
"""Background removal using rembg."""
from rembg import remove
return remove(image_bytes)
def rembg_available() -> bool:
try:
import rembg # noqa: F401
return True
except ImportError:
return False
def gpu_available() -> bool:
try:
import torch
return torch.cuda.is_available()
except ImportError:
return False
@@ -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
@@ -0,0 +1,474 @@
"""
Remote AI provider abstraction.
One interface, three drivers: OpenAI, InvokeAI, ComfyUI.
Configure one provider via AI_PROVIDER in .env.
"""
from abc import ABC, abstractmethod
from typing import Optional
import httpx
import base64
import asyncio
from io import BytesIO
class RemoteAIProvider(ABC):
@abstractmethod
async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: ...
@abstractmethod
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: ...
@abstractmethod
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: ...
@abstractmethod
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: ...
@abstractmethod
async def health(self) -> bool: ...
@abstractmethod
def capabilities(self) -> list[str]: ...
class OpenAIRemoteProvider(RemoteAIProvider):
"""OpenAI image API — gpt-image-1 / dall-e-3."""
def __init__(self, api_key: str, model: str = "dall-e-3"):
self.api_key = api_key
self.model = model
self.base_url = "https://api.openai.com/v1"
def _headers(self):
return {"Authorization": f"Bearer {self.api_key}"}
async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
async with httpx.AsyncClient(timeout=120.0) as client:
files = {
"image": ("image.png", image_bytes, "image/png"),
"mask": ("mask.png", mask_bytes, "image/png"),
}
data = {"prompt": prompt, "n": "1", "size": "1024x1024"}
r = await client.post(f"{self.base_url}/images/edits", files=files, data=data, headers=self._headers())
r.raise_for_status()
url = r.json()["data"][0]["url"]
img_r = await client.get(url)
img_r.raise_for_status()
return img_r.content
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
size = f"{width}x{height}" if f"{width}x{height}" in {"256x256", "512x512", "1024x1024"} else "1024x1024"
async with httpx.AsyncClient(timeout=120.0) as client:
data = {"model": self.model, "prompt": prompt, "n": 1, "size": size}
r = await client.post(f"{self.base_url}/images/generations", json=data, headers=self._headers())
r.raise_for_status()
url = r.json()["data"][0]["url"]
img_r = await client.get(url)
img_r.raise_for_status()
return img_r.content
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
# OpenAI doesn't have img2img natively — use edits with blank mask
from PIL import Image
import numpy as np
img = Image.open(BytesIO(image_bytes)).convert("RGBA")
mask = Image.new("RGBA", img.size, (0, 0, 0, 0))
mask_buf = BytesIO()
mask.save(mask_buf, format="PNG")
return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, params)
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
from PIL import Image
img = Image.open(BytesIO(image_bytes)).convert("RGBA")
w, h = img.size
directions = {"left": (size, 0), "right": (size, 0), "top": (0, size), "bottom": (0, size)}
dw, dh = directions.get(direction, (size, 0))
new_w, new_h = w + dw, h + dh
canvas = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0))
offsets = {
"left": (size, 0), "right": (0, 0), "top": (0, size), "bottom": (0, 0)
}
ox, oy = offsets.get(direction, (0, 0))
canvas.paste(img, (ox, oy))
# mask: transparent = inpaint
mask = Image.new("L", (new_w, new_h), 0)
# fill the expanded region with white in mask
import numpy as np
mask_arr = np.zeros((new_h, new_w), dtype=np.uint8)
if direction == "left":
mask_arr[:, :size] = 255
elif direction == "right":
mask_arr[:, w:] = 255
elif direction == "top":
mask_arr[:size, :] = 255
else:
mask_arr[h:, :] = 255
mask = Image.fromarray(mask_arr, "L")
canvas_rgb = canvas.convert("RGB")
img_buf = BytesIO()
canvas_rgb.save(img_buf, format="PNG")
mask_buf = BytesIO()
mask.save(mask_buf, format="PNG")
return await self.inpaint(img_buf.getvalue(), mask_buf.getvalue(), prompt, {})
async def health(self) -> bool:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url}/models", headers=self._headers())
return r.status_code == 200
except Exception:
return False
def capabilities(self) -> list[str]:
return ["inpaint", "txt2img", "img2img", "outpaint"]
class InvokeAIProvider(RemoteAIProvider):
"""InvokeAI REST API driver — supports Flux, SDXL, SD1.5 and more."""
def __init__(self, base_url: str, default_model: str = "flux-dev"):
self.base_url = base_url.rstrip("/")
self.default_model = default_model
async def _b64(self, data: bytes) -> str:
return base64.b64encode(data).decode()
async def _upload_image(self, client: httpx.AsyncClient, image_bytes: bytes, category: str = "general") -> str:
"""Upload image to InvokeAI and return image_name."""
files = {"file": ("image.png", image_bytes, "image/png")}
data = {"image_category": category, "is_intermediate": "false"}
r = await client.post(f"{self.base_url}/api/v1/images/upload", files=files, data=data)
r.raise_for_status()
return r.json()["image_name"]
async def _run_graph(self, client: httpx.AsyncClient, graph: dict) -> bytes:
"""Post a graph, poll for completion, return result image bytes."""
r = await client.post(f"{self.base_url}/api/v1/queue/default/enqueue_batch",
json={"prepend": False, "batch": {"graph": graph, "runs": 1}})
r.raise_for_status()
batch_id = r.json()["batch"]["batch_id"]
# Poll queue status
for _ in range(180):
await asyncio.sleep(2)
sr = await client.get(f"{self.base_url}/api/v1/queue/default/status")
sr.raise_for_status()
status = sr.json()
if status.get("queue", {}).get("completed", 0) > 0:
break
if status.get("queue", {}).get("failed", 0) > 0:
raise RuntimeError("InvokeAI graph failed")
# Fetch latest result image
lr = await client.get(f"{self.base_url}/api/v1/images/?categories=general&limit=1&is_intermediate=false")
lr.raise_for_status()
items = lr.json().get("items", [])
if not items:
raise RuntimeError("No output image from InvokeAI")
img_name = items[0]["image_name"]
img_r = await client.get(f"{self.base_url}/api/v1/images/i/{img_name}/full")
img_r.raise_for_status()
return img_r.content
async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
async with httpx.AsyncClient(timeout=300.0) as client:
img_name = await self._upload_image(client, image_bytes)
mask_name = await self._upload_image(client, mask_bytes, "mask")
model = params.get("model", self.default_model)
graph = {
"id": "inpaint_graph",
"nodes": {
"img_node": {"id": "img_node", "type": "image", "image": {"image_name": img_name}},
"mask_node": {"id": "mask_node", "type": "image", "image": {"image_name": mask_name}},
"model_node": {"id": "model_node", "type": "main_model_loader", "model": {"model_name": model, "base": "any"}},
"clip_skip": {"id": "clip_skip", "type": "clip_skip", "skipped_layers": 0},
"positive": {"id": "positive", "type": "compel", "prompt": prompt},
"negative": {"id": "negative", "type": "compel", "prompt": params.get("negative_prompt", "")},
"denoise": {
"id": "denoise", "type": "denoise_latents",
"steps": params.get("steps", 30),
"cfg_scale": params.get("cfg_scale", 7.5),
"denoising_start": 0.0, "denoising_end": 1.0,
"scheduler": "euler", "is_intermediate": False
},
"vae_loader": {"id": "vae_loader", "type": "vae_loader", "vae_model": {"model_name": model, "base": "any"}},
"img_to_latents": {"id": "img_to_latents", "type": "i2l"},
"latents_to_img": {"id": "latents_to_img", "type": "l2i"},
},
"edges": [
{"source": {"node_id": "model_node", "field": "unet"}, "destination": {"node_id": "denoise", "field": "unet"}},
{"source": {"node_id": "model_node", "field": "clip"}, "destination": {"node_id": "clip_skip", "field": "clip"}},
{"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "positive", "field": "clip"}},
{"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "negative", "field": "clip"}},
{"source": {"node_id": "positive", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "positive_conditioning"}},
{"source": {"node_id": "negative", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "negative_conditioning"}},
{"source": {"node_id": "img_node", "field": "image"}, "destination": {"node_id": "img_to_latents", "field": "image"}},
{"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "img_to_latents", "field": "vae"}},
{"source": {"node_id": "img_to_latents", "field": "latents"}, "destination": {"node_id": "denoise", "field": "latents"}},
{"source": {"node_id": "mask_node", "field": "image"}, "destination": {"node_id": "denoise", "field": "mask"}},
{"source": {"node_id": "denoise", "field": "latents"}, "destination": {"node_id": "latents_to_img", "field": "latents"}},
{"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "latents_to_img", "field": "vae"}},
]
}
return await self._run_graph(client, graph)
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
async with httpx.AsyncClient(timeout=300.0) as client:
model = params.get("model", self.default_model)
graph = {
"id": "txt2img_graph",
"nodes": {
"model_node": {"id": "model_node", "type": "main_model_loader", "model": {"model_name": model, "base": "any"}},
"clip_skip": {"id": "clip_skip", "type": "clip_skip", "skipped_layers": 0},
"positive": {"id": "positive", "type": "compel", "prompt": prompt},
"negative": {"id": "negative", "type": "compel", "prompt": params.get("negative_prompt", "")},
"noise": {"id": "noise", "type": "noise", "width": width, "height": height, "seed": params.get("seed", 0)},
"denoise": {
"id": "denoise", "type": "denoise_latents",
"steps": params.get("steps", 30),
"cfg_scale": params.get("cfg_scale", 7.5),
"denoising_start": 0.0, "denoising_end": 1.0,
"scheduler": "euler",
},
"vae_loader": {"id": "vae_loader", "type": "vae_loader", "vae_model": {"model_name": model, "base": "any"}},
"latents_to_img": {"id": "latents_to_img", "type": "l2i"},
},
"edges": [
{"source": {"node_id": "model_node", "field": "unet"}, "destination": {"node_id": "denoise", "field": "unet"}},
{"source": {"node_id": "model_node", "field": "clip"}, "destination": {"node_id": "clip_skip", "field": "clip"}},
{"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "positive", "field": "clip"}},
{"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "negative", "field": "clip"}},
{"source": {"node_id": "positive", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "positive_conditioning"}},
{"source": {"node_id": "negative", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "negative_conditioning"}},
{"source": {"node_id": "noise", "field": "noise"}, "destination": {"node_id": "denoise", "field": "noise"}},
{"source": {"node_id": "denoise", "field": "latents"}, "destination": {"node_id": "latents_to_img", "field": "latents"}},
{"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "latents_to_img", "field": "vae"}},
]
}
return await self._run_graph(client, graph)
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
# Reuse inpaint with a full-white mask at the given strength
from PIL import Image
img = Image.open(BytesIO(image_bytes))
mask = Image.new("L", img.size, 255)
mask_buf = BytesIO()
mask.save(mask_buf, format="PNG")
p = dict(params)
p.setdefault("denoising_start", 1.0 - strength)
return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, p)
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
# Delegate to inpaint with expanded canvas
provider = OpenAIRemoteProvider.__new__(OpenAIRemoteProvider)
return await provider.outpaint(image_bytes, direction, size, prompt)
async def health(self) -> bool:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url}/api/v1/app/version")
return r.status_code == 200
except Exception:
return False
def capabilities(self) -> list[str]:
return ["inpaint", "txt2img", "img2img", "outpaint"]
class ComfyUIProvider(RemoteAIProvider):
"""ComfyUI workflow JSON API driver."""
def __init__(self, base_url: str, default_model: str = "v1-5-pruned-emaonly.ckpt"):
self.base_url = base_url.rstrip("/")
self.default_model = default_model
async def _upload_image(self, client: httpx.AsyncClient, image_bytes: bytes, name: str = "image.png") -> str:
files = {"image": (name, image_bytes, "image/png")}
data = {"overwrite": "true"}
r = await client.post(f"{self.base_url}/upload/image", files=files, data=data)
r.raise_for_status()
j = r.json()
return j.get("name", name)
async def _queue_prompt(self, client: httpx.AsyncClient, workflow: dict) -> str:
r = await client.post(f"{self.base_url}/prompt", json={"prompt": workflow})
r.raise_for_status()
return r.json()["prompt_id"]
async def _wait_for_result(self, client: httpx.AsyncClient, prompt_id: str) -> bytes:
for _ in range(180):
await asyncio.sleep(2)
r = await client.get(f"{self.base_url}/history/{prompt_id}")
r.raise_for_status()
history = r.json()
if prompt_id in history:
outputs = history[prompt_id].get("outputs", {})
for node_output in outputs.values():
for img_info in node_output.get("images", []):
img_r = await client.get(
f"{self.base_url}/view",
params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""),
"type": img_info.get("type", "output")}
)
img_r.raise_for_status()
return img_r.content
raise RuntimeError("ComfyUI timed out waiting for result")
async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes:
model = params.get("model", self.default_model)
async with httpx.AsyncClient(timeout=300.0) as client:
img_name = await self._upload_image(client, image_bytes, "input.png")
mask_name = await self._upload_image(client, mask_bytes, "mask.png")
workflow = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": params.get("negative_prompt", ""), "clip": ["1", 1]}},
"4": {"class_type": "LoadImage", "inputs": {"image": img_name}},
"5": {"class_type": "LoadImage", "inputs": {"image": mask_name}},
"6": {"class_type": "VAEEncode", "inputs": {"pixels": ["4", 0], "vae": ["1", 2]}},
"7": {"class_type": "KSampler", "inputs": {
"model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0],
"latent_image": ["6", 0], "mask": ["5", 0],
"seed": params.get("seed", 42), "steps": params.get("steps", 20),
"cfg": params.get("cfg_scale", 7.0), "sampler_name": "euler",
"scheduler": "normal", "denoise": params.get("denoise", 1.0)
}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "api_out"}},
}
pid = await self._queue_prompt(client, workflow)
return await self._wait_for_result(client, pid)
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
model = params.get("model", self.default_model)
async with httpx.AsyncClient(timeout=300.0) as client:
workflow = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": params.get("negative_prompt", ""), "clip": ["1", 1]}},
"4": {"class_type": "EmptyLatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}},
"5": {"class_type": "KSampler", "inputs": {
"model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0],
"latent_image": ["4", 0],
"seed": params.get("seed", 42), "steps": params.get("steps", 20),
"cfg": params.get("cfg_scale", 7.0), "sampler_name": "euler",
"scheduler": "normal", "denoise": 1.0
}},
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}},
"7": {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "api_out"}},
}
pid = await self._queue_prompt(client, workflow)
return await self._wait_for_result(client, pid)
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
from PIL import Image
img = Image.open(BytesIO(image_bytes))
mask = Image.new("L", img.size, 255)
mask_buf = BytesIO()
mask.save(mask_buf, format="PNG")
p = dict(params)
p["denoise"] = strength
return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, p)
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
# Build expanded canvas then inpaint with blank mask
from PIL import Image
import numpy as np
img = Image.open(BytesIO(image_bytes)).convert("RGB")
w, h = img.size
dw = size if direction in ("left", "right") else 0
dh = size if direction in ("top", "bottom") else 0
canvas = Image.new("RGB", (w + dw, h + dh), (128, 128, 128))
ox = size if direction == "left" else 0
oy = size if direction == "top" else 0
canvas.paste(img, (ox, oy))
mask_arr = np.zeros((h + dh, w + dw), dtype=np.uint8)
if direction == "left":
mask_arr[:, :size] = 255
elif direction == "right":
mask_arr[:, w:] = 255
elif direction == "top":
mask_arr[:size, :] = 255
else:
mask_arr[h:, :] = 255
img_buf = BytesIO()
canvas.save(img_buf, format="PNG")
mask_buf = BytesIO()
Image.fromarray(mask_arr, "L").save(mask_buf, format="PNG")
return await self.inpaint(img_buf.getvalue(), mask_buf.getvalue(), prompt, {})
async def health(self) -> bool:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url}/system_stats")
return r.status_code == 200
except Exception:
return False
def capabilities(self) -> list[str]:
return ["inpaint", "txt2img", "img2img", "outpaint"]
def _build_provider(name: str) -> Optional[RemoteAIProvider]:
"""Instantiate a named provider from current settings."""
from app.config import settings
name = (name or "").lower().strip()
if name == "openai":
if not settings.openai_api_key:
return None
return OpenAIRemoteProvider(settings.openai_api_key, settings.openai_model)
if name == "invokeai":
if not settings.invokeai_url:
return None
return InvokeAIProvider(settings.invokeai_url, settings.invokeai_default_model)
if name == "comfyui":
if not settings.comfyui_url:
return None
return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model)
if name == "local_gpu":
try:
from app.services.local_diffusion import get_local_diffusion_provider
return get_local_diffusion_provider(max_pipelines=settings.local_gpu_max_pipelines)
except (ImportError, AttributeError) as exc:
print(f"[local_gpu] Cannot load diffusion provider: {exc}")
return None
return None
# Map operation names to the settings field that holds the override
_OP_FIELD = {
"inpaint": "ai_provider_inpaint",
"txt2img": "ai_provider_txt2img",
"img2img": "ai_provider_img2img",
"outpaint": "ai_provider_outpaint",
}
def get_remote_provider(operation: Optional[str] = None) -> Optional[RemoteAIProvider]:
"""
Return the provider for a given operation.
Resolution order:
1. Per-operation override (AI_PROVIDER_INPAINT, AI_PROVIDER_TXT2IMG, etc.)
2. Global default (AI_PROVIDER)
3. None (local-only mode)
Example .env for mixed setup:
AI_PROVIDER=invokeai # default for inpaint/img2img/outpaint
AI_PROVIDER_TXT2IMG=openai # use OpenAI only for text-to-image
"""
from app.config import settings
if operation and operation in _OP_FIELD:
override = getattr(settings, _OP_FIELD[operation], "")
if override:
provider = _build_provider(override)
if provider is not None:
return provider
# override configured but not usable (missing key/url) — fall through to default
return _build_provider(settings.ai_provider)
@@ -0,0 +1,184 @@
"""
SAM (Segment Anything Model) service.
Auto-downloads the ViT-B checkpoint (~375 MB) on first use.
Caches the loaded model in memory; re-uses predictor across calls.
Prediction API:
predict_points(image_bytes, points, labels) -> mask_bytes (PNG, white=selected)
points: list of (x, y) in original image pixels
labels: list of 1 (include) or 0 (exclude), same length as points
"""
import asyncio
import io
import os
import urllib.request
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
import numpy as np
from PIL import Image
# ── Model download ────────────────────────────────────────────────────────────
SAM_DIR = Path("/app/data/models/sam")
SAM_FILENAME = "sam_vit_b_01ec64.pth"
SAM_URL = f"https://dl.fbaipublicfiles.com/segment_anything/{SAM_FILENAME}"
SAM_PATH = SAM_DIR / SAM_FILENAME
class SamInstallState(str, Enum):
idle = "idle"
downloading = "downloading"
done = "done"
failed = "failed"
@dataclass
class SamInstallStatus:
state: SamInstallState = SamInstallState.idle
progress: int = 0
message: str = ""
error: str = ""
_install_status = SamInstallStatus()
_install_lock = asyncio.Lock()
def get_install_status() -> dict:
s = _install_status
return {"state": s.state.value, "progress": s.progress,
"message": s.message, "error": s.error}
def sam_model_available() -> bool:
return SAM_PATH.exists() and SAM_PATH.stat().st_size > 100_000_000
async def ensure_sam_installed() -> bool:
"""Download SAM ViT-B checkpoint if not present. Returns True on success."""
global _install_status
if sam_model_available():
_install_status = SamInstallStatus(state=SamInstallState.done, progress=100,
message="SAM model ready.")
return True
async with _install_lock:
if sam_model_available():
_install_status = SamInstallStatus(state=SamInstallState.done, progress=100,
message="SAM model ready.")
return True
if _install_status.state == SamInstallState.downloading:
return False
try:
SAM_DIR.mkdir(parents=True, exist_ok=True)
_install_status = SamInstallStatus(
state=SamInstallState.downloading, progress=0,
message="Downloading SAM ViT-B model (~375 MB)…",
)
def _download():
def _progress(count, block, total):
if total > 0:
_install_status.progress = min(99, int(count * block * 99 / total))
tmp = SAM_PATH.with_suffix(".tmp")
urllib.request.urlretrieve(SAM_URL, tmp, _progress)
tmp.rename(SAM_PATH)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _download)
_install_status = SamInstallStatus(state=SamInstallState.done, progress=100,
message="SAM model ready.")
return True
except Exception as exc:
_install_status = SamInstallStatus(
state=SamInstallState.failed, error=str(exc),
message="SAM download failed.",
)
print(f"[sam] Download failed: {exc}")
return False
# ── Model cache ───────────────────────────────────────────────────────────────
_predictor = None
_predictor_lock = asyncio.Lock()
def _load_predictor():
"""Load SAM model and return a SamPredictor. Called in thread pool."""
global _predictor
if _predictor is not None:
return _predictor
import torch
from segment_anything import sam_model_registry, SamPredictor
if torch.cuda.is_available():
device = "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
print(f"[sam] Loading SAM ViT-B on {device}")
sam = sam_model_registry["vit_b"](checkpoint=str(SAM_PATH))
sam.to(device)
_predictor = SamPredictor(sam)
print("[sam] Model loaded.")
return _predictor
# ── Prediction ────────────────────────────────────────────────────────────────
def _predict_sync(image_bytes: bytes,
points: list[tuple[int, int]],
labels: list[int]) -> bytes:
"""
Run SAM prediction synchronously (call via run_in_executor).
Returns PNG bytes: white = selected, black = background.
"""
predictor = _load_predictor()
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
img_array = np.array(image)
predictor.set_image(img_array)
pt_array = np.array(points, dtype=np.float32) # [[x, y], ...]
lbl_array = np.array(labels, dtype=np.int32) # [1=fg, 0=bg, ...]
masks, scores, _ = predictor.predict(
point_coords=pt_array,
point_labels=lbl_array,
multimask_output=True,
)
# Pick the highest-confidence mask
best = masks[int(np.argmax(scores))] # bool array H×W
mask_img = Image.fromarray((best * 255).astype(np.uint8), mode="L")
buf = io.BytesIO()
mask_img.save(buf, format="PNG")
return buf.getvalue()
async def predict_points(image_bytes: bytes,
points: list[tuple[int, int]],
labels: list[int]) -> bytes:
"""Async wrapper for SAM point prediction."""
if not sam_model_available():
ok = await ensure_sam_installed()
if not ok:
raise RuntimeError("SAM model not available.")
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _predict_sync, image_bytes, points, labels)
+494
View File
@@ -0,0 +1,494 @@
"""
Upscale service — auto-detects best available method and runs it.
Auto-installs Real-ESRGAN NCNN Vulkan binary when Vulkan GPU is available.
Skips NCNN on headless/CPU-only machines and uses PyTorch CPU or Lanczos instead.
Priority (auto mode):
1. Real-ESRGAN PyTorch + CUDA GPU — fastest, best quality
2. Real-ESRGAN PyTorch + Apple MPS — fast on Apple Silicon
3. Real-ESRGAN NCNN Vulkan binary — fast on any Vulkan GPU
4. Real-ESRGAN PyTorch CPU — AI quality, slow (~1-3 min)
5. Lanczos — always available, instant
Capability probe is run once at first call and cached.
NCNN binary is auto-downloaded only when Vulkan is detected.
Set REALESRGAN_NCNN=force env var to override the Vulkan check.
"""
import asyncio
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Optional
from PIL import Image
# ── NCNN auto-install ─────────────────────────────────────────────────────────
NCNN_DEST_DIR = Path("/app/data/models/realesrgan")
NCNN_VERSION = "v0.2.5.0"
NCNN_BASE_URL = f"https://github.com/xinntao/Real-ESRGAN/releases/download/{NCNN_VERSION}"
_PLATFORM_ZIP = {
"linux": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-ubuntu.zip",
"darwin": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-macos.zip",
"win32": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip",
"windows": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip",
}
class InstallState(str, Enum):
idle = "idle"
skipped = "skipped" # headless / no Vulkan
downloading = "downloading"
extracting = "extracting"
verifying = "verifying"
done = "done"
failed = "failed"
@dataclass
class InstallStatus:
state: InstallState = InstallState.idle
progress: int = 0 # 0-100
message: str = ""
error: str = ""
_install_status = InstallStatus()
_install_lock = asyncio.Lock()
def get_install_status() -> dict:
s = _install_status
return {
"state": s.state.value,
"progress": s.progress,
"message": s.message,
"error": s.error,
}
def _ncnn_binary_name() -> str:
return "realesrgan-ncnn-vulkan.exe" if "win" in sys.platform.lower() else "realesrgan-ncnn-vulkan"
def _vulkan_available() -> bool:
"""
Check whether a Vulkan-capable GPU is accessible.
Returns True if confident a GPU with Vulkan exists; False on headless/CPU-only.
Set REALESRGAN_NCNN=force to bypass this check.
"""
if os.environ.get("REALESRGAN_NCNN", "").lower() == "force":
return True
plat = sys.platform.lower()
if plat == "linux":
# DRI render nodes exist when a GPU is present and drivers loaded
dri = Path("/dev/dri")
if dri.exists() and list(dri.glob("renderD*")):
return True
# Fallback: vulkaninfo (not always installed)
if shutil.which("vulkaninfo"):
r = subprocess.run(["vulkaninfo", "--summary"],
capture_output=True, timeout=5)
if r.returncode == 0 and b"GPU" in r.stdout:
return True
return False
if plat == "darwin":
# macOS with Metal/MPS — Vulkan via MoltenVK always present on Apple Silicon/modern Intel
return True
if "win" in plat:
# Windows always has a display adapter; assume Vulkan available
return True
return False
def _test_ncnn_binary(binary_path: Path) -> bool:
"""Run binary with --help to confirm it actually works (Vulkan loads ok)."""
try:
r = subprocess.run(
[str(binary_path), "--help"],
capture_output=True, timeout=15,
)
# NCNN binary exits 255 for --help but prints usage; that's fine.
# A Vulkan init failure produces "no vulkan device" on stderr.
stderr = r.stderr.decode(errors="replace").lower()
if "no vulkan" in stderr or "failed to create" in stderr:
return False
return True
except Exception:
return False
async def ensure_ncnn_installed() -> Optional[Path]:
"""
Check for Vulkan, then download+install the NCNN binary if needed.
Skips silently on headless/CPU-only machines.
Returns binary Path on success, None otherwise.
"""
global _install_status
binary_path = NCNN_DEST_DIR / _ncnn_binary_name()
# Already installed — quick verify it still works
if binary_path.exists() and os.access(binary_path, os.X_OK):
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(None, _test_ncnn_binary, binary_path)
if ok:
_install_status = InstallStatus(state=InstallState.done, progress=100,
message="Already installed.")
return binary_path
else:
# Binary exists but Vulkan broken — treat as headless
_install_status = InstallStatus(
state=InstallState.skipped,
message="Vulkan unavailable — skipping NCNN (using PyTorch CPU or Lanczos).",
)
return None
async with _install_lock:
# Re-check after lock
if binary_path.exists() and os.access(binary_path, os.X_OK):
_install_status = InstallStatus(state=InstallState.done, progress=100,
message="Already installed.")
return binary_path
if _install_status.state in (InstallState.downloading, InstallState.extracting,
InstallState.verifying):
return None # already running
# Check Vulkan before downloading anything
loop = asyncio.get_event_loop()
has_vulkan = await loop.run_in_executor(None, _vulkan_available)
if not has_vulkan:
_install_status = InstallStatus(
state=InstallState.skipped,
message="No Vulkan GPU detected — skipping NCNN install. "
"AI upscaling via PyTorch CPU or set REALESRGAN_NCNN=force to override.",
)
print("[upscale] Headless/no-Vulkan detected — skipping NCNN download.")
return None
plat = sys.platform.lower()
zip_name = _PLATFORM_ZIP.get(plat)
if not zip_name:
_install_status = InstallStatus(
state=InstallState.failed,
error=f"Unsupported platform: {plat}",
)
return None
url = f"{NCNN_BASE_URL}/{zip_name}"
try:
NCNN_DEST_DIR.mkdir(parents=True, exist_ok=True)
zip_path = NCNN_DEST_DIR / zip_name
# Download
_install_status = InstallStatus(
state=InstallState.downloading, progress=0,
message=f"Downloading Real-ESRGAN NCNN {NCNN_VERSION}",
)
def _do_download():
def _progress(count, block, total):
if total > 0:
_install_status.progress = min(85, int(count * block * 85 / total))
urllib.request.urlretrieve(url, zip_path, _progress)
await loop.run_in_executor(None, _do_download)
# Extract
_install_status.state = InstallState.extracting
_install_status.progress = 88
_install_status.message = "Extracting…"
def _do_extract():
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(NCNN_DEST_DIR)
found = list(NCNN_DEST_DIR.rglob(_ncnn_binary_name()))
if not found:
raise FileNotFoundError(f"Binary not found after extract: {_ncnn_binary_name()}")
extracted = found[0]
if extracted != binary_path:
extracted.rename(binary_path)
if "win" not in sys.platform.lower():
binary_path.chmod(
binary_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH
)
zip_path.unlink(missing_ok=True)
await loop.run_in_executor(None, _do_extract)
# Verify binary actually works
_install_status.state = InstallState.verifying
_install_status.progress = 95
_install_status.message = "Verifying Vulkan…"
ok = await loop.run_in_executor(None, _test_ncnn_binary, binary_path)
if not ok:
binary_path.unlink(missing_ok=True)
_install_status = InstallStatus(
state=InstallState.skipped,
message="Binary installed but Vulkan unavailable at runtime — "
"falling back to PyTorch CPU / Lanczos.",
)
print("[upscale] NCNN binary installed but Vulkan check failed — skipping.")
return None
_install_status = InstallStatus(
state=InstallState.done, progress=100,
message=f"Real-ESRGAN NCNN installed: {binary_path}",
)
invalidate_caps_cache()
return binary_path
except Exception as exc:
_install_status = InstallStatus(
state=InstallState.failed,
error=str(exc),
message="Installation failed.",
)
print(f"[upscale] NCNN auto-install failed: {exc}")
return None
# ── Capability detection ──────────────────────────────────────────────────────
_caps: Optional[dict] = None
def probe_upscale_capabilities() -> dict:
"""Detect available upscaling methods. Cached after first call."""
global _caps
if _caps is not None:
return _caps
caps = {
"lanczos": True,
"realesrgan_pytorch": False,
"realesrgan_pytorch_device": None,
"realesrgan_ncnn": False,
"realesrgan_ncnn_path": None,
"recommended": "lanczos",
"recommended_label": "Lanczos (no AI upscaler found)",
"methods": ["lanczos"],
"ncnn_install_status": get_install_status(),
}
# ── PyTorch path ──────────────────────────────────────────────────────────
pytorch_device = None
try:
import torch
if torch.cuda.is_available():
pytorch_device = "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
pytorch_device = "mps"
else:
pytorch_device = "cpu"
except ImportError:
pass
if pytorch_device:
try:
import realesrgan # noqa: F401
from basicsr.archs.rrdbnet_arch import RRDBNet # noqa: F401
caps["realesrgan_pytorch"] = True
caps["realesrgan_pytorch_device"] = pytorch_device
caps["methods"].append("realesrgan_pytorch")
except ImportError:
pass
# ── NCNN Vulkan binary ────────────────────────────────────────────────────
ncnn_path = _find_ncnn_binary()
if ncnn_path:
caps["realesrgan_ncnn"] = True
caps["realesrgan_ncnn_path"] = str(ncnn_path)
caps["methods"].append("realesrgan_ncnn")
# ── Pick recommended ──────────────────────────────────────────────────────
if caps["realesrgan_pytorch"] and pytorch_device in ("cuda", "mps"):
device_label = "CUDA GPU" if pytorch_device == "cuda" else "Apple Silicon"
caps["recommended"] = "realesrgan_pytorch"
caps["recommended_label"] = f"Real-ESRGAN ({device_label})"
elif caps["realesrgan_ncnn"]:
caps["recommended"] = "realesrgan_ncnn"
caps["recommended_label"] = "Real-ESRGAN NCNN (Vulkan)"
elif caps["realesrgan_pytorch"] and pytorch_device == "cpu":
caps["recommended"] = "realesrgan_pytorch"
caps["recommended_label"] = "Real-ESRGAN (CPU — may be slow)"
else:
install_state = _install_status.state
if install_state in (InstallState.downloading, InstallState.extracting, InstallState.verifying):
caps["recommended_label"] = "Lanczos (AI upscaler installing…)"
elif install_state == InstallState.skipped:
caps["recommended_label"] = "Lanczos (headless — no Vulkan GPU)"
else:
caps["recommended_label"] = "Lanczos (no AI upscaler found)"
_caps = caps
return caps
def _find_ncnn_binary() -> Optional[Path]:
found = shutil.which("realesrgan-ncnn-vulkan")
if found:
return Path(found)
candidates = [
NCNN_DEST_DIR / _ncnn_binary_name(),
Path("/usr/local/bin/realesrgan-ncnn-vulkan"),
Path.home() / ".local/bin/realesrgan-ncnn-vulkan",
Path(r"C:/realesrgan-ncnn-vulkan/realesrgan-ncnn-vulkan.exe"),
Path("/opt/homebrew/bin/realesrgan-ncnn-vulkan"),
]
for p in candidates:
if p.exists() and os.access(p, os.X_OK):
return p
return None
def invalidate_caps_cache():
global _caps
_caps = None
# ── Upscale implementations ───────────────────────────────────────────────────
def _to_png_bytes(img: Image.Image) -> bytes:
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def upscale_lanczos(image: Image.Image, scale: float) -> tuple[bytes, str]:
new_w = round(image.width * scale)
new_h = round(image.height * scale)
result = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
return _to_png_bytes(result), "lanczos"
def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes, str]:
import torch
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
caps = probe_upscale_capabilities()
device = caps.get("realesrgan_pytorch_device", "cpu")
model_scale = 2 if scale <= 2.5 else 4
model = RRDBNet(
num_in_ch=3, num_out_ch=3, num_feat=64,
num_block=23, num_grow_ch=32, scale=model_scale
)
model_dir = Path("/app/data/models/realesrgan")
model_dir.mkdir(parents=True, exist_ok=True)
model_path = model_dir / f"RealESRGAN_x{model_scale}plus.pth"
if not model_path.exists():
model_path = None
upsampler = RealESRGANer(
scale=model_scale,
model_path=str(model_path) if model_path else None,
model=model,
tile=512,
tile_pad=10,
pre_pad=0,
half=(device == "cuda"),
device=torch.device(device),
)
import numpy as np
img_bgr = np.array(image)[:, :, ::-1].copy()
enhanced, _ = upsampler.enhance(img_bgr, outscale=scale)
result = Image.fromarray(enhanced[:, :, ::-1])
return _to_png_bytes(result), f"realesrgan_pytorch_{device}"
def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, str]:
caps = probe_upscale_capabilities()
binary = caps.get("realesrgan_ncnn_path")
if not binary:
raise RuntimeError("realesrgan-ncnn-vulkan binary not found")
model_scale = 4 if scale > 2.5 else 2
target_w = round(image.width * scale)
target_h = round(image.height * scale)
with tempfile.TemporaryDirectory() as tmpdir:
in_path = Path(tmpdir) / "input.png"
out_path = Path(tmpdir) / "output.png"
image.save(in_path, format="PNG")
cmd = [
binary,
"-i", str(in_path), "-o", str(out_path),
"-s", str(model_scale), "-n", f"realesrgan-x{model_scale}plus", "-f", "png",
]
r = subprocess.run(cmd, capture_output=True, timeout=300)
if r.returncode != 0:
raise RuntimeError(f"realesrgan-ncnn-vulkan failed: {r.stderr.decode()}")
result = Image.open(out_path).convert("RGB")
if result.width != target_w or result.height != target_h:
result = result.resize((target_w, target_h), Image.Resampling.LANCZOS)
return _to_png_bytes(result), "realesrgan_ncnn"
# ── Public entry point ────────────────────────────────────────────────────────
def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]:
"""Upscale synchronously. Returns (png_bytes, method_label)."""
caps = probe_upscale_capabilities()
if method == "auto":
method = caps["recommended"]
if method == "realesrgan_pytorch":
if caps["realesrgan_pytorch"]:
try:
return upscale_realesrgan_pytorch(image, scale)
except Exception as e:
print(f"Real-ESRGAN PyTorch failed, falling back: {e}")
if caps["realesrgan_ncnn"]:
try:
return upscale_realesrgan_ncnn(image, scale)
except Exception as e:
print(f"Real-ESRGAN NCNN fallback failed: {e}")
return upscale_lanczos(image, scale)
if method == "realesrgan_ncnn":
if caps["realesrgan_ncnn"]:
try:
return upscale_realesrgan_ncnn(image, scale)
except Exception as e:
print(f"Real-ESRGAN NCNN failed, falling back: {e}")
if caps["realesrgan_pytorch"]:
try:
return upscale_realesrgan_pytorch(image, scale)
except Exception as e:
print(f"Real-ESRGAN PyTorch fallback failed: {e}")
return upscale_lanczos(image, scale)
return upscale_lanczos(image, scale)
async def upscale_image(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]:
"""Async wrapper — runs upscale in thread pool."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, upscale_sync, image, scale, method)
@@ -0,0 +1,228 @@
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,
preserve_alpha: bool = True
) -> Image.Image:
"""
Blend regenerated patch with original using mask.
Preserves original alpha channel for semi-transparent areas (veils, glass, etc).
Args:
original_patch: Original cropped patch
regenerated_patch: AI-regenerated patch
mask: Binary mask (same size as patches)
feather_px: Feather radius for smooth blending
preserve_alpha: If True, preserves original alpha channel
Returns:
Blended patch with preserved transparency
"""
# 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, storing original alpha
original_rgba = original_patch.convert('RGBA')
original_alpha = original_rgba.split()[3] # Store original alpha channel
regenerated_rgba = regenerated_patch.convert('RGBA')
# Blend using the feathered mask
blended = Image.composite(regenerated_rgba, original_rgba, feathered_mask)
# Restore original alpha channel to preserve transparency
# This keeps semi-transparent areas (veils, glass, smoke) intact
if preserve_alpha:
r, g, b, _ = blended.split()
blended = Image.merge('RGBA', (r, g, b, original_alpha))
return blended
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)
}
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# =============================================================================
# AI Photo Edit - Container Startup Script
# =============================================================================
# This script runs when the container starts. It:
# 1. Initializes the database
# 2. Downloads SAM model automatically (can be disabled with AUTO_DOWNLOAD_SAM=false)
# 3. Downloads U2Net model automatically (can be disabled with AUTO_DOWNLOAD_U2NET=false)
# 4. Downloads sample eye images if the catalog is empty
# 5. Starts the FastAPI server
# =============================================================================
set -e
echo "=========================================="
echo "AI Photo Edit - Starting Up"
echo "=========================================="
# Ensure data directories exist
mkdir -p /app/data/projects
mkdir -p /app/data/patches
mkdir -p /app/data/models
mkdir -p /app/data/patch_library
# Initialize database FIRST (before eye import)
echo ""
echo "Initializing database..."
echo "------------------------------------------"
cd /app && python /scripts/init_database.py || echo "Warning: Database init failed (non-fatal)"
# Check and download SAM model automatically
echo ""
echo "Checking SAM model (Smart Select)..."
echo "------------------------------------------"
if [ -f "/app/data/models/sam_model.pth" ] || \
[ -f "/app/data/models/sam_vit_b_01ec64.pth" ] || \
[ -f "/app/data/models/sam_vit_l_0b3195.pth" ] || \
[ -f "/app/data/models/sam_vit_h_4b8939.pth" ]; then
echo "✓ SAM model found - Smart Select will use local AI (free, offline)"
else
# Auto-download SAM unless explicitly disabled
AUTO_DOWNLOAD_SAM="${AUTO_DOWNLOAD_SAM:-true}"
if [ "$AUTO_DOWNLOAD_SAM" = "true" ]; then
echo "SAM model not found. Downloading automatically..."
echo "(This is a one-time ~375MB download that persists across rebuilds)"
echo ""
python /scripts/download_sam_model.py vit_b || {
echo ""
echo "⚠ SAM download failed (non-fatal)"
echo " Smart Select will fall back to Replicate API (requires REPLICATE_API_KEY)"
echo " To retry later: docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py"
}
else
echo ""
echo "⚠ SAM model not found (AUTO_DOWNLOAD_SAM=false)"
echo ""
echo " Smart Select will use Replicate API (requires REPLICATE_API_KEY)"
echo ""
echo " To enable FREE offline Smart Select, run:"
echo " docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py"
echo ""
fi
fi
echo ""
echo "Checking U2Net model (Remove Background)..."
echo "------------------------------------------"
if [ -f "/app/data/models/u2net.onnx" ] || [ -f "/app/data/models/u2netp.onnx" ]; then
echo "✓ U2Net model found - Remove Background will use local AI (free, offline)"
else
# Auto-download U2Net unless explicitly disabled
AUTO_DOWNLOAD_U2NET="${AUTO_DOWNLOAD_U2NET:-true}"
if [ "$AUTO_DOWNLOAD_U2NET" = "true" ]; then
echo "U2Net model not found. Downloading automatically..."
echo "(This is a one-time ~176MB download that persists across rebuilds)"
echo ""
python /scripts/download_u2net_model.py u2net || {
echo ""
echo "⚠ U2Net download failed (non-fatal)"
echo " Remove Background will fall back to rembg (if installed)"
echo " To retry later: docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py u2net"
}
else
echo ""
echo "⚠ U2Net model not found (AUTO_DOWNLOAD_U2NET=false)"
echo ""
echo " Remove Background will fall back to rembg (if installed)"
echo ""
echo " To enable FREE offline Remove Background, run:"
echo " docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py u2net"
echo ""
fi
fi
echo ""
echo "Checking GPU capabilities..."
echo "------------------------------------------"
python /scripts/gpu_setup.py || echo "Warning: GPU detection failed (non-fatal)"
echo ""
echo "=========================================="
echo "Starting FastAPI server..."
echo "=========================================="
# Start the server
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
+44
View File
@@ -0,0 +1,44 @@
# =============================================================================
# GPU / Local Diffusion dependencies
# Install alongside requirements.txt when running with AI_PROVIDER=local_gpu
#
# Usage:
# pip install -r requirements.txt -r requirements.gpu.txt
#
# These are pre-installed in Dockerfile.gpu; optional in the standard image.
# =============================================================================
# HuggingFace Diffusers ecosystem
# Pinned <0.29.0: diffusers 0.29.0 added torch.xpu (Intel GPU) which fails on
# PyTorch 2.1.x with "AttributeError: module 'torch' has no attribute 'xpu'".
# Upgrade the base image in Dockerfile.gpu to pytorch 2.4+ before lifting this pin.
# (FLUX support requires diffusers>=0.29 + PyTorch>=2.4; SDXL/SD works fine here.)
diffusers>=0.28.0,<0.29.0
transformers>=4.36.0,<4.40.0
accelerate>=0.27.0
huggingface-hub>=0.23.0
safetensors>=0.4.0
# Required by SDXL pipelines
invisible-watermark>=0.2.0
omegaconf>=2.3.0
# Required by FLUX (T5 text encoder tokenizer)
sentencepiece>=0.2.0
# xformers — reduces attention VRAM ~20-30%, often unlocks the next model tier
# Must match your PyTorch+CUDA version; leave out if unsure.
# Install post-container-start if needed:
# pip install xformers --index-url https://download.pytorch.org/whl/cu121
# xformers
# Background removal — BEN2 (default, clean cutouts/hair) + BiRefNet-HR
# (high-res/print alternate). Both MIT-licensed. Verified against upstream
# source: neither requires torch>=2.5 despite the BiRefNet repo's own
# requirements.txt floor — that pin is for its training/eval scripts, not
# the inference path used here. Weights download from HuggingFace on first
# use (cached via the hf_cache bind mount, same as the diffusion models).
ben2 @ git+https://github.com/PramaLLC/BEN2.git
timm>=1.0.10
einops>=0.6.0
kornia>=0.7.0
+25
View File
@@ -0,0 +1,25 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
Pillow>=10.0.0,<11.0.0
numpy<2.0.0
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
email-validator==2.1.0
opencv-python-headless>=4.10.0
# SAM (Segment Anything) for smart object selection - runs locally, no API needed
torch==2.1.2
torchvision==0.16.2
segment-anything @ git+https://github.com/facebookresearch/segment-anything.git
# Local AI inpainting — LaMa model (auto GPU/CPU, no API key needed)
simple-lama-inpainting
# Background removal — rembg enabled now that opencv 4.10+ supports numpy 2.x
rembg[gpu]
+225
View File
@@ -0,0 +1,225 @@
# Eye Catalog Import Scripts
Tools for populating your carved eye catalog with public domain examples.
---
## Quick Start
### 1. Download Classical Eyes
Follow the guide: `/docs/PUBLIC_DOMAIN_EYE_SOURCES.md`
**Best sources:**
- Metropolitan Museum (CC0)
- Smithsonian Open Access
- Getty Museum Open Content
**Download 10-20 high-res images** of carved eyes from classical sculptures.
---
### 2. Crop the Eyes
Use any image editor (Photoshop, GIMP, Preview, etc.):
1. Open statue photo
2. Zoom in on eye
3. Crop just the eye (include eyelids, socket, tear duct)
4. Save as PNG with descriptive name:
- `greek_serene_left.png`
- `roman_fierce_right.png`
- `egyptian_wise_left.png`
---
### 3. Import to Catalog
**Easy way (one at a time):**
```bash
cd backend/scripts
# Import a Greek serene eye
python import_eyes.py greek_serene_left.png \
--emotion serene \
--side left \
--style greek
# Import a Roman fierce eye
python import_eyes.py roman_fierce_right.png \
--emotion fierce \
--side right \
--style roman
```
**Batch import:**
```bash
# Import all Greek eyes at once
python import_eyes.py greek_*.png \
--emotion serene \
--side both \
--style greek
# Import all Roman eyes
python import_eyes.py roman_*.png \
--emotion fierce \
--side both \
--style roman
```
---
## Available Options
### Emotions
- `serene` - Peaceful, calm (most classical Greek)
- `fierce` - Intense, powerful (Hellenistic, Alexander)
- `wise` - Aged, experienced (Roman senators)
- `peaceful` - Gentle, kind (Archaic Greek)
- `joyful` - Happy, smiling (rare in classical)
- `sorrowful` - Sad, mourning (some Hellenistic)
- `neutral` - Default, no strong emotion
### Styles
- `greek` - Classical Greek (450-400 BCE)
- `roman` - Roman Republican/Imperial
- `egyptian` - Ancient Egyptian carved eyes
- `renaissance` - Renaissance sculpture
- `baroque` - Baroque period
- `modern` - Contemporary carving
- `custom` - Your own style
### Sides
- `left` - Left eye
- `right` - Right eye
- `both` - Can be used for either (symmetric)
---
## Example Workflow
### Build a Complete Catalog
```bash
# 1. Download eyes from Met Museum
# (See PUBLIC_DOMAIN_EYE_SOURCES.md)
# 2. Crop and save them:
# greek_serene_left.png
# greek_serene_right.png
# roman_fierce_left.png
# roman_fierce_right.png
# etc.
# 3. Import them all:
python import_eyes.py greek_serene_left.png --emotion serene --side left --style greek
python import_eyes.py greek_serene_right.png --emotion serene --side right --style greek
python import_eyes.py roman_fierce_left.png --emotion fierce --side left --style roman
python import_eyes.py roman_fierce_right.png --emotion fierce --side right --style roman
# Or batch:
python import_eyes.py greek_*.png --emotion serene --side both --style greek
python import_eyes.py roman_*.png --emotion fierce --side both --style roman
```
---
## Recommended Starting Collection
### 10 Essential Eyes
1. **Greek Serene Left** (peaceful carvings)
2. **Greek Serene Right**
3. **Greek Archaic Left** (stylized, simple)
4. **Greek Archaic Right**
5. **Roman Fierce Left** (powerful portraits)
6. **Roman Fierce Right**
7. **Roman Wise Left** (aged, realistic)
8. **Roman Wise Right**
9. **Egyptian Stylized Left** (distinctive style)
10. **Egyptian Stylized Right**
This gives you 5 styles/emotions to start!
---
## Check Your Catalog
### Via API
```bash
# List all eyes in catalog
curl http://localhost:8101/patches/?category=carved_eye
# Filter by emotion
curl http://localhost:8101/patches/?category=carved_eye&tags=serene
# Filter by style
curl http://localhost:8101/patches/?tags=greek
```
### Via Web Interface
Go to: `http://your-server:3080`
Navigate to patch library to browse your eyes visually.
---
## Advanced: Seed Script
For batch importing from the `seed_data/eyes/` directory:
```bash
# 1. Place all cropped eyes in:
mkdir -p seed_data/eyes/
# Copy your eye images there
# 2. Edit seed_eye_catalog.py to add metadata
# 3. Run:
python seed_eye_catalog.py
```
This auto-imports all eyes in `seed_data/eyes/` with pre-configured metadata.
---
## Tips
1. **High resolution:** Use images 1000px+ for best results
2. **Clean crops:** Include some surrounding area, not just the eyeball
3. **Consistent naming:** Use descriptive filenames
4. **Test first:** Import 2-3 eyes to test the workflow
5. **Build gradually:** Start with 10 eyes, expand as needed
---
## Troubleshooting
**"File not found":**
- Make sure you're in `backend/scripts/` directory
- Use full path or relative path to image
**"Database connection error":**
- Make sure backend is running: `docker compose up backend`
- Check database exists: `ls -la ../../data/`
**"Import failed":**
- Check image format (PNG, JPG supported)
- Verify file isn't corrupted
- Check file permissions
---
## Next Steps
After importing eyes:
1. **Test them:** Apply to a colored photo via API
2. **Refine:** Add more variations as needed
3. **Build library:** Aim for 20-30 eyes covering all emotions
4. **Share:** Your best eyes can be exported and shared
**Your catalog of master sculptor's eyes is ready to use!**
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Quick eye import script
Usage:
python import_eyes.py greek_serene_left.png --emotion serene --side left --style greek
python import_eyes.py *.png --emotion fierce --style roman
This will add eyes to the patch library with proper metadata.
"""
import sys
import argparse
from pathlib import Path
from PIL import Image
# Add parent directory to path to import app modules
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.database import SessionLocal
from app.models.patch import Patch
from app.services.patch_library import PatchLibraryService
def import_eye(
image_path: Path,
emotion: str,
side: str,
style: str,
description: str = None
):
"""
Import a single eye image into the catalog
Args:
image_path: Path to eye image file
emotion: serene, fierce, wise, peaceful, joyful, sorrowful
side: left, right, both
style: greek, roman, egyptian, renaissance, custom
description: Optional custom description
"""
db = SessionLocal()
patch_service = PatchLibraryService()
# Generate name from filename if not provided
name = image_path.stem.replace('_', ' ').title()
# Auto-generate description if not provided
if not description:
description = f"{style.title()} carved eye, {emotion} expression, {side} side. Suitable for CNC wood carving."
# Generate tags
tags = f"{style}, {emotion}, {side}, carved, cnc-ready, wood-carving"
print(f"\n📸 Importing: {name}")
print(f" File: {image_path.name}")
print(f" Style: {style}")
print(f" Emotion: {emotion}")
print(f" Side: {side}")
try:
# Create patch record
patch = Patch(
name=name,
description=description,
source_type="imported",
category="carved_eye",
tags=tags,
width=0,
height=0,
user_id=None,
file_path=""
)
db.add(patch)
db.commit()
db.refresh(patch)
# Save image file
file_path = patch_service.save_patch_from_file(
patch.id,
str(image_path),
create_thumb=True
)
# Get and update dimensions
width, height = patch_service.get_patch_size(patch.id)
patch.width = width
patch.height = height
patch.file_path = file_path
patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id))
db.commit()
print(f"✅ Successfully imported!")
print(f" ID: {patch.id}")
print(f" Size: {width}x{height}px")
return patch.id
except Exception as e:
print(f"❌ Error importing: {e}")
db.rollback()
return None
finally:
db.close()
def main():
parser = argparse.ArgumentParser(
description="Import carved eye images into the patch library",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Import a single eye
python import_eyes.py greek_serene_left.png --emotion serene --side left --style greek
# Import multiple eyes with same metadata
python import_eyes.py roman_*.png --emotion fierce --side both --style roman
# With custom description
python import_eyes.py statue_eye.png --emotion wise --side right --style roman --description "Emperor Augustus portrait eye"
Emotions: serene, fierce, wise, peaceful, joyful, sorrowful, neutral
Sides: left, right, both
Styles: greek, roman, egyptian, renaissance, baroque, modern, custom
"""
)
parser.add_argument(
'images',
nargs='+',
help='Image file(s) to import (supports wildcards)'
)
parser.add_argument(
'--emotion',
required=True,
choices=['serene', 'fierce', 'wise', 'peaceful', 'joyful', 'sorrowful', 'neutral'],
help='Emotional expression of the eye'
)
parser.add_argument(
'--side',
required=True,
choices=['left', 'right', 'both'],
help='Which eye (left or right)'
)
parser.add_argument(
'--style',
required=True,
choices=['greek', 'roman', 'egyptian', 'renaissance', 'baroque', 'modern', 'custom'],
help='Carving style/period'
)
parser.add_argument(
'--description',
help='Custom description (optional)'
)
args = parser.parse_args()
# Resolve wildcards and get all image files
image_files = []
for pattern in args.images:
path = Path(pattern)
if '*' in pattern:
# Wildcard - expand it
parent = path.parent if path.parent.exists() else Path('.')
image_files.extend(parent.glob(path.name))
else:
# Single file
if path.exists():
image_files.append(path)
else:
print(f"⚠️ File not found: {pattern}")
if not image_files:
print("❌ No image files found!")
return
print(f"\n🎨 Importing {len(image_files)} eye image(s) into catalog...")
print(f" Style: {args.style}")
print(f" Emotion: {args.emotion}")
print(f" Side: {args.side}")
print("="*60)
imported_count = 0
for image_path in image_files:
patch_id = import_eye(
image_path,
args.emotion,
args.side,
args.style,
args.description
)
if patch_id:
imported_count += 1
print("="*60)
print(f"\n✅ Import complete! {imported_count}/{len(image_files)} eyes added to catalog")
print(f"\n💡 Access your eye catalog at: http://your-server:3080")
print(f" Or via API: GET /patches/?category=carved_eye")
if __name__ == "__main__":
main()
@@ -0,0 +1,176 @@
"""
Seed the patch library with classical carved eyes from public domain sources
This script helps pre-populate the eye catalog with examples from:
- Greek statues (Metropolitan Museum, Louvre)
- Roman sculptures (Smithsonian, British Museum)
- Renaissance carvings
- Ancient Egyptian carved eyes
All images should be public domain (CC0, Public Domain Mark)
"""
import asyncio
import httpx
from pathlib import Path
from PIL import Image
from io import BytesIO
# Public domain eye examples to seed the catalog
# These are examples - you would add actual URLs from museum APIs
CLASSICAL_EYES = [
{
"name": "Greek Statue - Serene Left Eye",
"description": "Classical Greek marble carving, convex eyeball, defined upper lid, deep socket. Perfect for serene expressions.",
"category": "carved_eye",
"tags": "greek, serene, left, marble, classical, convex, deep-socket",
"style": "greek_classical",
"emotion": "serene",
"side": "left",
"source_url": "https://images.metmuseum.org/...", # Example
"source": "Metropolitan Museum of Art - Public Domain"
},
{
"name": "Roman Sculpture - Fierce Right Eye",
"description": "Roman marble, prominent brow ridge, intense gaze, sharp eyelid definition.",
"category": "carved_eye",
"tags": "roman, fierce, right, marble, intense, sharp-detail",
"style": "roman_classical",
"emotion": "fierce",
"side": "right",
"source_url": "https://...",
"source": "Smithsonian - CC0"
},
{
"name": "Greek Kouros - Peaceful Left Eye",
"description": "Archaic Greek style, almond-shaped, subtle carving, peaceful expression.",
"category": "carved_eye",
"tags": "greek, peaceful, left, archaic, almond-shaped, subtle",
"style": "greek_archaic",
"emotion": "peaceful",
"side": "left",
"source_url": "https://...",
"source": "Getty Museum - Public Domain"
},
{
"name": "Roman Portrait - Wise Right Eye",
"description": "Late Roman period, detailed eyelids, slight downward gaze, wisdom and age.",
"category": "carved_eye",
"tags": "roman, wise, right, portrait, detailed, aged",
"style": "roman_portrait",
"emotion": "wise",
"side": "right",
"source_url": "https://...",
"source": "British Museum - CC0"
},
]
async def download_image(url: str) -> bytes:
"""Download image from URL"""
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.content
async def crop_eye_from_statue(image_bytes: bytes, crop_box: tuple) -> bytes:
"""
Crop just the eye from a full statue photo
Args:
image_bytes: Full statue image
crop_box: (left, top, right, bottom) coordinates
Returns:
Cropped eye image bytes
"""
img = Image.open(BytesIO(image_bytes))
eye = img.crop(crop_box)
# Save as PNG
buffer = BytesIO()
eye.save(buffer, format='PNG')
return buffer.getvalue()
async def seed_eye_catalog():
"""
Seed the patch library with classical carved eyes
NOTE: This is a template. You need to:
1. Get actual public domain image URLs
2. Manually crop the eyes (or provide crop coordinates)
3. Run this to populate the catalog
"""
from app.database import SessionLocal
from app.models.patch import Patch
from app.services.patch_library import PatchLibraryService
db = SessionLocal()
patch_service = PatchLibraryService()
print("Seeding eye catalog with classical carved eyes...")
for eye_data in CLASSICAL_EYES:
print(f"\nAdding: {eye_data['name']}")
# Create patch record
patch = Patch(
name=eye_data['name'],
description=eye_data['description'],
source_type="imported",
category=eye_data['category'],
tags=eye_data['tags'],
width=0, # Will be set after image save
height=0,
user_id=None,
file_path=""
)
db.add(patch)
db.commit()
db.refresh(patch)
# Download and save image
# NOTE: You need to manually download/crop these first
# This is just the structure
try:
# image_bytes = await download_image(eye_data['source_url'])
# cropped_eye = await crop_eye_from_statue(image_bytes, crop_box)
# For now, you would manually place images in:
# ./seed_data/eyes/greek_serene_left.png
# ./seed_data/eyes/roman_fierce_right.png
# etc.
seed_image_path = Path(__file__).parent / "seed_data" / "eyes" / f"{eye_data['style']}_{eye_data['emotion']}_{eye_data['side']}.png"
if seed_image_path.exists():
file_path = patch_service.save_patch_from_file(
patch.id,
str(seed_image_path),
create_thumb=True
)
# Get dimensions
width, height = patch_service.get_patch_size(patch.id)
patch.width = width
patch.height = height
patch.file_path = file_path
patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id))
db.commit()
print(f"✅ Added {eye_data['name']}")
else:
print(f"⚠️ Image not found: {seed_image_path}")
print(f" Please download and crop eye from: {eye_data['source']}")
except Exception as e:
print(f"❌ Error adding {eye_data['name']}: {e}")
db.rollback()
db.close()
print("\n✅ Eye catalog seeding complete!")
if __name__ == "__main__":
asyncio.run(seed_eye_catalog())
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# bring-up-local-gpu.sh — start the GPU container.
#
# Run this each time you want to start the app.
# Run ./install-local-gpu.sh once first on a new machine.
#
# Before starting, this fetches any missing models on the host (outside
# Docker) via ./prefetch-models.sh — in-container DNS/network is unreliable
# on some hosts, so this is the default now, not a manual troubleshooting
# step. It never blocks startup: if it fails (no network, no python3, etc.)
# the container still starts and falls back to its own in-container download.
#
# Usage:
# ./bring-up-local-gpu.sh # start (detached, rebuild if needed)
# ./bring-up-local-gpu.sh --no-build # start without rebuilding
# ./bring-up-local-gpu.sh down # stop and remove container
# ./bring-up-local-gpu.sh logs -f # tail logs
#
# Force pip layer rebuild (e.g. after requirements change):
# BUILDID=$(date +%s) ./bring-up-local-gpu.sh
set -euo pipefail
cd "$(dirname "$0")"
# Pre-create ./data as the current (non-root) user. Otherwise, on a fresh
# checkout, Docker's daemon (root) auto-creates these bind-mount sources on
# the first 'up' — leaving them root-owned and blocking this same user from
# later writing to them without sudo (e.g. ./prefetch-models.sh). No-op if
# they already exist, regardless of current ownership.
mkdir -p data/models data/hf_cache data/projects data/patches
if [ $# -eq 0 ]; then
# Best-effort: fetch any missing models on the host first (see header).
./prefetch-models.sh --sdxl \
|| echo "⚠ Model prefetch had failures (see above) — continuing anyway, the container will retry in-container."
exec docker compose -f docker-compose.gpu.yml up -d --build
else
exec docker compose -f docker-compose.gpu.yml "$@"
fi
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Convert PPM files to PNG using PIL."""
from pathlib import Path
from PIL import Image
ppm_dir = Path(__file__).parent
for ppm_file in ppm_dir.glob("*.ppm"):
png_file = ppm_file.with_suffix('.png')
img = Image.open(ppm_file)
img.save(png_file, 'PNG')
print(f"Converted: {ppm_file.name} -> {png_file.name}")
@@ -0,0 +1,164 @@
[
{
"filename": "classic_realistic_blue.png",
"ppm_filename": "classic_realistic_blue.ppm",
"name": "Classic Realistic Eye - Blue",
"description": "A realistic style eye with blue iris color",
"tags": "eye,classic,realistic,blue,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_realistic_green.png",
"ppm_filename": "classic_realistic_green.ppm",
"name": "Classic Realistic Eye - Green",
"description": "A realistic style eye with green iris color",
"tags": "eye,classic,realistic,green,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_realistic_brown.png",
"ppm_filename": "classic_realistic_brown.ppm",
"name": "Classic Realistic Eye - Brown",
"description": "A realistic style eye with brown iris color",
"tags": "eye,classic,realistic,brown,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_realistic_hazel.png",
"ppm_filename": "classic_realistic_hazel.ppm",
"name": "Classic Realistic Eye - Hazel",
"description": "A realistic style eye with hazel iris color",
"tags": "eye,classic,realistic,hazel,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_realistic_grey.png",
"ppm_filename": "classic_realistic_grey.ppm",
"name": "Classic Realistic Eye - Grey",
"description": "A realistic style eye with grey iris color",
"tags": "eye,classic,realistic,grey,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_realistic_amber.png",
"ppm_filename": "classic_realistic_amber.ppm",
"name": "Classic Realistic Eye - Amber",
"description": "A realistic style eye with amber iris color",
"tags": "eye,classic,realistic,amber,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_anime_blue.png",
"ppm_filename": "classic_anime_blue.ppm",
"name": "Classic Anime Eye - Blue",
"description": "A anime style eye with blue iris color",
"tags": "eye,classic,anime,blue,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_anime_green.png",
"ppm_filename": "classic_anime_green.ppm",
"name": "Classic Anime Eye - Green",
"description": "A anime style eye with green iris color",
"tags": "eye,classic,anime,green,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_anime_brown.png",
"ppm_filename": "classic_anime_brown.ppm",
"name": "Classic Anime Eye - Brown",
"description": "A anime style eye with brown iris color",
"tags": "eye,classic,anime,brown,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_anime_hazel.png",
"ppm_filename": "classic_anime_hazel.ppm",
"name": "Classic Anime Eye - Hazel",
"description": "A anime style eye with hazel iris color",
"tags": "eye,classic,anime,hazel,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_anime_grey.png",
"ppm_filename": "classic_anime_grey.ppm",
"name": "Classic Anime Eye - Grey",
"description": "A anime style eye with grey iris color",
"tags": "eye,classic,anime,grey,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_anime_amber.png",
"ppm_filename": "classic_anime_amber.ppm",
"name": "Classic Anime Eye - Amber",
"description": "A anime style eye with amber iris color",
"tags": "eye,classic,anime,amber,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_cartoon_blue.png",
"ppm_filename": "classic_cartoon_blue.ppm",
"name": "Classic Cartoon Eye - Blue",
"description": "A cartoon style eye with blue iris color",
"tags": "eye,classic,cartoon,blue,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_cartoon_green.png",
"ppm_filename": "classic_cartoon_green.ppm",
"name": "Classic Cartoon Eye - Green",
"description": "A cartoon style eye with green iris color",
"tags": "eye,classic,cartoon,green,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_cartoon_brown.png",
"ppm_filename": "classic_cartoon_brown.ppm",
"name": "Classic Cartoon Eye - Brown",
"description": "A cartoon style eye with brown iris color",
"tags": "eye,classic,cartoon,brown,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_cartoon_hazel.png",
"ppm_filename": "classic_cartoon_hazel.ppm",
"name": "Classic Cartoon Eye - Hazel",
"description": "A cartoon style eye with hazel iris color",
"tags": "eye,classic,cartoon,hazel,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_cartoon_grey.png",
"ppm_filename": "classic_cartoon_grey.ppm",
"name": "Classic Cartoon Eye - Grey",
"description": "A cartoon style eye with grey iris color",
"tags": "eye,classic,cartoon,grey,medium",
"width": 200,
"height": 200
},
{
"filename": "classic_cartoon_amber.png",
"ppm_filename": "classic_cartoon_amber.ppm",
"name": "Classic Cartoon Eye - Amber",
"description": "A cartoon style eye with amber iris color",
"tags": "eye,classic,cartoon,amber,medium",
"width": 200,
"height": 200
}
]
View File
+46
View File
@@ -0,0 +1,46 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: paintplus-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:
- paintplus-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: paintplus-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:
- paintplus-network
networks:
paintplus-network:
driver: bridge
+148
View File
@@ -0,0 +1,148 @@
# =============================================================================
# EditmaskwithAI — GPU Docker Compose (NVIDIA CUDA)
#
# ── PREREQUISITES ─────────────────────────────────────────────────────────────
#
# 1. NVIDIA driver ≥ 525 installed on the host
# Check: nvidia-smi
#
# 2. nvidia-container-toolkit installed and configured:
# (Ubuntu/Debian)
# curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
# | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-ctk.gpg
# curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
# | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-ctk.gpg] https://#g' \
# | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
# sudo nvidia-ctk runtime configure --runtime=docker
# sudo systemctl restart docker
#
# (RHEL/Fedora/Rocky)
# sudo dnf install -y nvidia-container-toolkit
# sudo nvidia-ctk runtime configure --runtime=docker
# sudo systemctl restart docker
#
# 3. Verify GPU access in Docker:
# docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
#
# ── QUICK START ───────────────────────────────────────────────────────────────
#
# docker compose -f docker-compose.gpu.yml up --build
# Then open: http://localhost:3080
#
# ── OLDER DOCKER SETUPS (docker-compose v1 / nvidia-docker2) ─────────────────
#
# If you installed nvidia-docker2 (older approach) instead of nvidia-container-toolkit,
# replace the 'deploy:' block below with:
#
# runtime: nvidia
# environment:
# - NVIDIA_VISIBLE_DEVICES=all
# - NVIDIA_DRIVER_CAPABILITIES=compute,utility
#
# ── GPU TIER AUTO-SELECTION ───────────────────────────────────────────────────
#
# ≥16 GB VRAM → SDXL (best quality)
# 816 GB → SDXL
# 48 GB → Stable Diffusion 2.x
# 24 GB → Stable Diffusion 1.5 (older GPUs: GTX 970/1060/RX 580)
# <2 GB → SD 1.5 + CPU offload (very slow — consider a remote provider)
#
# ── AMD ROCm ──────────────────────────────────────────────────────────────────
#
# Swap the base image in Dockerfile.gpu:
# FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
# → FROM rocm/pytorch:rocm6.0_ubuntu22.04_py3.9_pytorch_2.1.0
# Remove the 'driver: nvidia' line and add: device_ids: ['0']
#
# =============================================================================
services:
app:
build:
context: .
dockerfile: Dockerfile.gpu
args:
# Increment BUILDID to force pip layers to re-run without full --no-cache:
# BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up --build
BUILDID: ${BUILDID:-1}
container_name: paintplus
ports:
- "${PORT:-3080}:8000"
volumes:
# Persistent project data
- ./data:/app/data
# HuggingFace model cache — bind mount so models can be pre-downloaded on the host.
# If container DNS is blocked, run ./prefetch-models.sh on the host first —
# it downloads BEN2/BiRefNet-HR (and optionally SDXL with --sdxl) straight
# into this directory, and the container picks them up on next start.
# To free disk space: rm -rf ./data/hf_cache
- ./data/hf_cache:/root/.cache/huggingface
# Scripts (for exec access)
- ./scripts:/scripts
environment:
# ── Local GPU (default for this compose) ────────────────────────────────
- AI_PROVIDER=${AI_PROVIDER:-local_gpu}
- AUTO_DOWNLOAD_MODELS=${AUTO_DOWNLOAD_MODELS:-true}
# ── Per-operation overrides (optional) ──────────────────────────────────
# Leave blank to use AI_PROVIDER for all operations.
# Example: use InvokeAI for inpaint, local GPU for everything else:
# AI_PROVIDER_INPAINT=invokeai
- AI_PROVIDER_INPAINT=${AI_PROVIDER_INPAINT:-}
- AI_PROVIDER_TXT2IMG=${AI_PROVIDER_TXT2IMG:-}
- AI_PROVIDER_IMG2IMG=${AI_PROVIDER_IMG2IMG:-}
- AI_PROVIDER_OUTPAINT=${AI_PROVIDER_OUTPAINT:-}
# ── Remote/cloud providers (all optional) ────────────────────────────────
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_MODEL=${OPENAI_MODEL:-dall-e-3}
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
- STABILITY_API_KEY=${STABILITY_API_KEY:-}
# ── InvokeAI / ComfyUI (running on another machine or container) ────────
- INVOKEAI_URL=${INVOKEAI_URL:-}
- INVOKEAI_DEFAULT_MODEL=${INVOKEAI_DEFAULT_MODEL:-flux-dev}
- COMFYUI_URL=${COMFYUI_URL:-}
- COMFYUI_DEFAULT_MODEL=${COMFYUI_DEFAULT_MODEL:-v1-5-pruned-emaonly.ckpt}
# ── HuggingFace model overrides (optional) ───────────────────────────────
# Override the auto-selected model for any operation:
# HF_MODEL_INPAINT=your-org/your-model
- HF_MODEL_INPAINT=${HF_MODEL_INPAINT:-}
- HF_MODEL_TXT2IMG=${HF_MODEL_TXT2IMG:-}
- HF_MODEL_IMG2IMG=${HF_MODEL_IMG2IMG:-}
- HF_TOKEN=${HF_TOKEN:-}
# ── App settings ─────────────────────────────────────────────────────────
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db
- SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production}
- CORS_ORIGINS=*
- AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true}
- AUTO_DOWNLOAD_U2NET=${AUTO_DOWNLOAD_U2NET:-true}
- BG_REMOVAL_MODEL=${BG_REMOVAL_MODEL:-ben2}
# ── NVIDIA GPU passthrough ────────────────────────────────────────────────
# Requires nvidia-container-toolkit; see prerequisites at top of this file.
# For older nvidia-docker2 setups, replace this block with:
# runtime: nvidia
# environment:
# - NVIDIA_VISIBLE_DEVICES=all
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# DNS: try host resolver first (works on most networks including corporate/VPN),
# fall back to Cloudflare then Google public resolvers.
# If all three fail (Errno -3), your firewall is blocking port 53 UDP from Docker.
# Fix on the host: sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT
dns:
- 1.1.1.1
- 8.8.8.8
- 8.8.4.4
restart: unless-stopped
+27
View File
@@ -0,0 +1,27 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: paintplus
ports:
- "3080:8000"
volumes:
- ./data:/app/data
- ./scripts:/scripts
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:-}
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
- CORS_ORIGINS=*
# DNS servers for reliable external API access (Replicate, etc.)
dns:
- 8.8.8.8
- 8.8.4.4
restart: unless-stopped
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!
+300
View File
@@ -0,0 +1,300 @@
# Public Domain Carved Eye Sources
Where to find high-quality images of carved eyes from classical sculptures (all public domain).
---
## Best Museums with Public Domain Images
### 1. Metropolitan Museum of Art (CC0 Public Domain)
**Website:** https://www.metmuseum.org/art/collection
**Search tips:**
- Search: "greek statue marble head"
- Search: "roman portrait bust"
- Filter: "Public Domain" only
- Download: Click "Download" for high-resolution
**Great examples:**
- Greek Kouros heads (Archaic period)
- Roman portrait busts
- Hellenistic marble sculptures
**Direct collections:**
- Greek & Roman Art: https://www.metmuseum.org/art/collection/search#!?department=13
- Filter by "Images" → "Public Domain"
---
### 2. Smithsonian Open Access (CC0)
**Website:** https://www.si.edu/openaccess
**Features:**
- 3 million+ images
- All CC0 (no copyright restrictions)
- High-resolution downloads
**Search:**
- "roman marble head"
- "greek sculpture eyes"
- "classical portrait bust"
**API available:** https://api.si.edu/openaccess/api/v1.0/
---
### 3. Getty Museum (Open Content)
**Website:** https://www.getty.edu/art/collection/
**Search tips:**
- Filter: "Open Content Program"
- Greek and Roman antiquities
- High-resolution IIIF images
**Great for:**
- Archaic Greek sculptures
- Classical period heads
- Detailed close-ups
---
### 4. Rijksmuseum (Public Domain)
**Website:** https://www.rijksmuseum.nl/en/rijksstudio
**Features:**
- Rijksstudio (free download tool)
- High-resolution images
- Classical sculpture collection
---
### 5. British Museum (CC BY-NC-SA 4.0)
**Website:** https://www.britishmuseum.org/collection
**Note:** Some restrictions, but many images free for non-commercial use
**Great for:**
- Egyptian carved eyes
- Greek marble heads
- Roman portraits
---
### 6. Louvre Collections
**Website:** https://collections.louvre.fr/en/
**Search:** "sculpture greek head" or "sculpture roman portrait"
**Note:** Check individual image licenses
---
## How to Find the Perfect Eyes
### Search Strategy
1. **Search for heads/busts, not full statues:**
- "greek marble head"
- "roman portrait bust"
- "classical sculpture face"
2. **Specific periods:**
- "archaic greek kouros" (serene, stylized)
- "classical greek sculpture" (idealized, peaceful)
- "hellenistic sculpture" (emotional, dramatic)
- "roman portrait" (realistic, wise)
3. **Look for close-ups:**
- Museums often provide detail shots
- Check "zoom" or "IIIF viewer" options
---
## Recommended Starting Collection
### Serene/Peaceful Eyes
**Greek Classical Period (450-400 BCE):**
- Doryphoros (Spear Bearer) type
- Athena heads
- Apollo statues
- Smooth, idealized features
- Almond-shaped eyes
- Minimal lid detail
**Best sources:** Met Museum, Getty
---
### Fierce/Intense Eyes
**Hellenistic Period (323-31 BCE):**
- Alexander the Great portraits
- Dying Gaul
- Laocoon group
- Dramatic expressions
- Deep-set eyes
- Strong brow ridges
**Best sources:** Smithsonian, British Museum
---
### Wise/Aged Eyes
**Roman Republican Period:**
- Senator portraits
- Veristic portraits
- Realistic aging details
- Detailed wrinkles
- Saggy eyelids
- Life-like features
**Best sources:** Met Museum, Getty
---
### Stylized/Archaic Eyes
**Greek Archaic Period (700-480 BCE):**
- Kouros statues
- Kore statues
- Almond-shaped
- Simplified forms
- "Archaic smile"
- Clean, simple carving
**Best sources:** Getty, Met Museum
---
## How to Download and Crop
### Step 1: Find the Statue
Example: Met Museum
1. Go to https://www.metmuseum.org/art/collection
2. Search: "roman portrait marble"
3. Filter: Public Domain only
4. Click on a good example
### Step 2: Download High-Res
1. Click "Download" button
2. Choose largest size (usually 4000px+)
3. Save to your computer
### Step 3: Crop the Eyes
Use any image editor (Photoshop, GIMP, etc.):
1. Open the full statue image
2. Zoom in on one eye
3. Crop just the eye area:
- Include: eyeball, eyelids, tear duct, socket
- Leave some surrounding area for context
- Square or slightly rectangular crop
4. Save as PNG:
- `greek_serene_left.png`
- `roman_fierce_right.png`
- etc.
5. Repeat for other eye (if different)
### Step 4: Organize
Place cropped eyes in:
```
./backend/scripts/seed_data/eyes/
├── greek_serene_left.png
├── greek_serene_right.png
├── roman_fierce_left.png
├── roman_fierce_right.png
├── greek_peaceful_left.png
└── ...
```
### Step 5: Run Seed Script
```bash
cd backend
python scripts/seed_eye_catalog.py
```
---
## Recommended Starting Collection (10 Eyes)
To start, get these 10 eyes:
### Greek Classical (Serene)
1. Left eye - Greek marble head
2. Right eye - Greek marble head
### Greek Archaic (Stylized/Peaceful)
3. Left eye - Kouros statue
4. Right eye - Kouros statue
### Hellenistic (Fierce/Dramatic)
5. Left eye - Alexander portrait
6. Right eye - Alexander portrait
### Roman Republican (Wise/Aged)
7. Left eye - Roman senator bust
8. Right eye - Roman senator bust
### Roman Imperial (Powerful)
9. Left eye - Emperor portrait
10. Right eye - Emperor portrait
This gives you 5 emotional ranges × 2 eyes = 10 eyes to start!
---
## Quick Links
- **Met Museum Collection:** https://www.metmuseum.org/art/collection/search#!?department=13&showOnly=openAccess
- **Smithsonian Open Access:** https://www.si.edu/openaccess
- **Getty Open Content:** https://www.getty.edu/about/whatwedo/opencontent.html
- **Rijksmuseum API:** https://data.rijksmuseum.nl/object-metadata/api/
---
## Legal Notes
- **CC0/Public Domain:** Use freely for any purpose
- **CC BY:** Must credit the source
- **CC BY-NC:** Non-commercial use only
- **Always check** individual image licenses
For commercial carving business, stick to **CC0** or **Public Domain** images.
---
## Tips for Best Results
1. **High resolution:** Download largest size available (2000px+ minimum)
2. **Good lighting:** Look for evenly lit photographs
3. **Straight-on angle:** Avoid extreme angles
4. **Clear detail:** Can you see the eyelid lines clearly?
5. **Minimal damage:** Choose well-preserved sculptures
---
## Next Steps
1. Browse the museums above
2. Download 10-20 good eye examples
3. Crop them in an image editor
4. Place in `backend/scripts/seed_data/eyes/`
5. Run the seed script
6. Your catalog is ready!
**You'll have a library of proven carved eyes from master sculptors spanning 2000+ years!**
+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
+8
View File
@@ -0,0 +1,8 @@
{
"presets": ["@babel/preset-env"],
"plugins": [
["@babel/plugin-transform-runtime", {
"regenerator": true
}]
]
}
+17
View File
@@ -0,0 +1,17 @@
# OS generated files #
.DS_Store
.DS_Store?
._*
.Trashes
ehthumbs.db
Thumbs.db
nbproject/
.idea/
.git/
.vscode
/.project
*.log
/node_modules/
*.js.ignore
+31
View File
@@ -0,0 +1,31 @@
FROM node:20-alpine as build
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Build webpack bundle
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy all static files needed by miniPaint
COPY --from=build /app/index.html /usr/share/nginx/html/
COPY --from=build /app/dist /usr/share/nginx/html/dist
COPY --from=build /app/images /usr/share/nginx/html/images
COPY --from=build /app/src/css /usr/share/nginx/html/src/css
# 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"]
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) ViliusL
https://github.com/viliusle
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.
+60
View File
@@ -0,0 +1,60 @@
# miniPaint
Online image editor lets you create and edit images using HTML5 technologies. No need to buy, download, install, or have outdated flash. No ads. Key features: layers, filters, open source Photoshop alternative.
miniPaint operates directly in the browser. You can create images by pasting from the clipboard (ctrl+v) or uploading from the computer (_using menu or drag & drop_). Nothing will be sent to any server. Everything stays in your browser.
## URL:
**https://viliusle.github.io/miniPaint/**
## Preview:
![miniPaint](https://raw.githubusercontent.com/viliusle/miniPaint/master/images/preview.gif)
(generated using miniPaint)
**Change log:** [/miniPaint/releases](https://github.com/viliusle/miniPaint/releases)
## Browser Support
- Chrome
- Firefox
- Opera
- Edge
- Safari
- Yandex
## Features
**Files**: open images, directories, URLs, data URLs, drag and drop, save (PNG, JPG, BMP, WEBP, animated GIF, TIFF, JSON (layers data), print.
**Edit**: undo, cut, copy, paste, selection, paste from the clipboard.
**Image**: information, EXIF, trim, zoom, resize (Hermite resample, default resize), rotate, flip, color corrections (brightness, contrast, hue, saturation, luminance), automatic color adjustment, grid, histogram, negative.
**Layers**: multi-layer system, differences, merging, flattening, transparency support.
**Effects**: black and white, blur (box, gaussian, stack, zoom), bulge/pinch, denoise, desaturation, dither, dot screen, edge, emboss, enrich, gamma, grains, grayscale, heatmap, jpg compression, mosaic, oil, sepia, sharpen, solarize, tilt shift, vignette, vibrance, vintage, blueprint, night vision, pencil, also instagram filters: 1977, aden, clarendon, gingham, inkwell, lo-fi, toaster, valencia, x-pro ii.
**Tools**: pencil, brush, magic wand, eraser, fill, color picker, letter, crop, blur, sharpener, desaturation, clone, borders, sprites, keypoints, color zoom, change color, restore transparency, content fill.
**Help**: keyboard shortcuts, translation.
## Embed
To embed this app on another page, use the following HTML code:
<iframe style="box-sizing:border-box; width:100%; height:100vh;" id="miniPaint" src="https://viliusle.github.io/miniPaint/" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
## Build instructions
See [Wiki > Build instructions](https://github.com/viliusle/miniPaint/wiki/Build-instructions)
## Wiki
See [Wiki](https://github.com/viliusle/miniPaint/wiki)
## Contributors
<a align="center" href="https://github.com/viliusle/miniPaint/graphs/contributors">
<img src="https://contrib.rocks/image?repo=viliusle/miniPaint" />
</a>
## License
MIT License
## Support
Please use the GitHub issues for support, feature requests and bug reports, or contact us by sending an email to www.viliusl@gmail.com.
+15
View File
@@ -0,0 +1,15 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
| < latest | :x: |
## Reporting a Vulnerability
Please send details to www.viliusl@gmail.com
@@ -0,0 +1,76 @@
<html>
<body style="margin:0;">
<iframe id="myFrame" style="width:100%;height:100vh;border:0;" src="../" allow="camera"></iframe>
<script>
window.addEventListener('load', function (e) {
draw_various();
}, false);
async function draw_various() {
var Layers = document.getElementById('myFrame').contentWindow.Layers;
//add layer
var canvas = document.createElement('canvas');
canvas.width = 50;
canvas.height = 50;
var ctx = canvas.getContext("2d");
cross_X(ctx, 25, 25);
var params = {
type: 'image',
data: canvas.toDataURL("image/png"),
x: 50,
y: 50,
width: canvas.width,
height: canvas.height,
};
await Layers.insert(params);
//add new layer
var canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 400;
var ctx = canvas.getContext("2d");
cross_X(ctx, 200, 200, null, '#00cc00');
var params = {
type: 'image',
data: canvas.toDataURL("image/png"),
width: canvas.width,
height: canvas.height,
};
await Layers.insert(params);
//update last layer
var canvas = Layers.convert_layer_to_canvas(null, true); //no trim
var ctx = canvas.getContext("2d");
cross_X(ctx, 300, 300, null, '#00cc00');
Layers.update_layer_image(canvas);
//also update layer
var link = Layers.get_layer();
link.x = parseInt(canvas.dataset.x);
link.y = parseInt(canvas.dataset.y);
link.width = canvas.width;
link.height = canvas.height;
}
function cross_X(ctx, x, y, size = 20, col = '#ff0000') {
if(size == null)
size = 20;
x = parseFloat(x);
y = parseFloat(y);
size = parseInt(size);
var lw = parseInt(5);
ctx.beginPath();
ctx.moveTo(x - size, y - size);
ctx.lineTo(x + size, y + size);
ctx.lineWidth = lw;
ctx.strokeStyle = col;
ctx.stroke();
ctx.moveTo(x + size, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineWidth = lw;
ctx.strokeStyle = col;
ctx.stroke();
}
</script>
@@ -0,0 +1,128 @@
<html>
<body style="margin:0;">
<iframe id="myFrame" style="width:100%;height:70vh;border:0;" src="../" allow="camera"></iframe>
<div style="height:20vh;margin:10px;">
Click on image to edit.
<br /><br />
<button onclick="open_image()">Open image</button>
<button onclick="update_image()">Update image</button>
<button onclick="save_image()">Save image</button>
OR
<button onclick="open_json()">Open JSON</button>
<button onclick="save_json()">Save JSON</button>
<br /><br />
<img style="max-height:100%" id="testImage" alt="" src="../images/logo-colors.png" onclick="open_image(this)" />
<textarea rows="8" style="width:500px;" id="testJson"></textarea>
</div>
<script>
/**
* will open image on minipaint
*
* @param {string|image} image image or image id
*/
function open_image(image){
if(image == undefined)
image = document.getElementById('testImage');
if(typeof image == 'string'){
image = document.getElementById(image);
}
var Layers = document.getElementById('myFrame').contentWindow.Layers;
var name = image.src.replace(/^.*[\\\/]/, '');
var new_layer = {
name: name,
type: 'image',
data: image,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
width_original: image.naturalWidth || image.width,
height_original: image.naturalHeight || image.height,
};
Layers.insert(new_layer);
}
function open_json(){
var miniPaint = document.getElementById('myFrame').contentWindow;
var miniPaint_FileOpen = miniPaint.FileOpen;
window.fetch("../images/test-collection.json").then(function(response) {
return response.json();
}).then(function(json) {
miniPaint_FileOpen.load_json(json, false);
}).catch(function(ex) {
alert('Sorry, image could not be loaded.');
});
}
function save_image(){
var Layers = document.getElementById('myFrame').contentWindow.Layers;
var tempCanvas = document.createElement("canvas");
var tempCtx = tempCanvas.getContext("2d");
var dim = Layers.get_dimensions();
tempCanvas.width = dim.width;
tempCanvas.height = dim.height;
Layers.convert_layers_to_canvas(tempCtx);
if(is_edge_or_ie() == false){
//update image using blob (faster)
tempCanvas.toBlob(function (blob) {
alert('Data length: ' + blob.size);
console.log(blob);
}, 'image/png');
}
else{
//slow way for IE, Edge
var data = tempCanvas.toDataURL();
alert('Data length: ' + data.length);
console.log(data);
}
}
function save_json(){
var miniPaint = document.getElementById('myFrame').contentWindow;
var miniPaint_FileSave = miniPaint.FileSave;
var data_json = miniPaint_FileSave.export_as_json();
document.getElementById('testJson').value = data_json;
}
function update_image(){
var target = document.getElementById('testImage');
var Layers = document.getElementById('myFrame').contentWindow.Layers;
var tempCanvas = document.createElement("canvas");
var tempCtx = tempCanvas.getContext("2d");
var dim = Layers.get_dimensions();
tempCanvas.width = dim.width;
tempCanvas.height = dim.height;
Layers.convert_layers_to_canvas(tempCtx);
target.width = dim.width;
target.height = dim.height;
target.src = tempCanvas.toDataURL();
}
/**
* will auto load image on page load. Uncomment line below to make it work
*/
window.onload = function () {
//open_image(document.getElementById('testImage')); //uncomment me
};
//if IE 11 or Edge
function is_edge_or_ie() {
//ie11
if( !(window.ActiveXObject) && "ActiveXObject" in window )
return true;
//edge
if( navigator.userAgent.indexOf('Edge/') != -1 )
return true;
return false;
}
</script>
+71
View File
@@ -0,0 +1,71 @@
<html>
<body style="margin:0;">
<iframe id="myFrame" style="width:100%;height:100vh;border:0;" src="../" allow="camera"></iframe>
<script>
window.addEventListener('load', function (e) {
main();
}, false);
async function main() {
var Layers = document.getElementById('myFrame').contentWindow.Layers;
var config = document.getElementById('myFrame').contentWindow.AppConfig;
var canvas_width = 800;
var canvas_height = 600;
//set size
Layers.Base_gui.set_size(canvas_width, canvas_height);
//add rectangle
this.layer = {
name: 'layer1',
type: 'rectangle',
params: {
fill: true,
square: false,
border_size: 2,
border: false,
border_color: "#1b1bd8",
fill_color: "#1b1bd8"
},
color: '#ff0000',
render_function: ['rectangle', 'render'],
x: canvas_width/2 - 25,
y: canvas_height/2 - 25,
width: 50,
height: 50,
};
await Layers.insert(this.layer);
//zoom to 500%
setTimeout(function () {
Layers.Base_gui.GUI_preview.zoom_to_position(0, 0);
Layers.Base_gui.GUI_preview.zoom(500);
}, 100);
//do this after system changed zoom position
setTimeout(function () {
//move visible area to begin of rectangle
Layers.Base_gui.GUI_preview.zoom_to_position(500, 500); //change zoom position
}, 500);
//action after 1 s - for preview purpose
setTimeout(function () {
//move visible area so rect is in center
var visible_area = Layers.Base_gui.get_visible_area_size();
//center of rect
var cx = canvas_width/2;
var cy = canvas_height/2;
//calc needed coords
var x = cx - visible_area.width / 2;
var y = cy - visible_area.height / 2;
Layers.Base_gui.GUI_preview.zoom_to_position(x, y); //change zoom position
}, 1000);
}
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 298.73 298.73" style="enable-background:new 0 0 298.73 298.73;" xml:space="preserve">
<g>
<path style="fill:#010002;" d="M264.959,9.35H33.787C15.153,9.35,0,24.498,0,43.154v212.461c0,18.634,15.153,33.766,33.787,33.766
h231.171c18.634,0,33.771-15.132,33.771-33.766V43.154C298.73,24.498,283.593,9.35,264.959,9.35z M193.174,59.623
c18.02,0,32.634,14.615,32.634,32.634s-14.615,32.634-32.634,32.634c-18.025,0-32.634-14.615-32.634-32.634
S175.149,59.623,193.174,59.623z M254.363,258.149H149.362H49.039c-9.013,0-13.027-6.521-8.964-14.566l56.006-110.93
c4.058-8.044,11.792-8.762,17.269-1.605l56.316,73.596c5.477,7.158,15.05,7.767,21.386,1.354l13.777-13.951
c6.331-6.413,15.659-5.619,20.826,1.762l35.675,50.959C266.487,252.16,263.376,258.149,254.363,258.149z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<!-- Brush handle -->
<path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/>
<!-- AI sparkle star -->
<path d="M20 13l.94 2.06L23 16l-2.06.94L20 19l-.94-2.06L17 16l2.06-.94z" fill="currentColor" stroke="none"/>
<!-- Small dot -->
<circle cx="20" cy="16" r="0" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Paint brush -->
<path d="M18.37 2.63L14 7l-1.59-1.59a2 2 0 00-2.82 0L8 7l9 9 1.59-1.59a2 2 0 000-2.82L17 10l4.37-4.37a2.12 2.12 0 10-3-3z"/>
<path d="M9 8c-2 3-4 3.5-7 4l8 10c2-1 6-5 6-7"/>
<path d="M14.5 17.5L4.5 15"/>
<!-- AI sparkle -->
<circle cx="19" cy="19" r="1" fill="currentColor"/>
<path d="M19 16v1"/>
<path d="M19 21v1"/>
<path d="M16 19h1"/>
<path d="M21 19h1"/>
</svg>

After

Width:  |  Height:  |  Size: 567 B

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30.051 30.051" style="enable-background:new 0 0 30.051 30.051;" xml:space="preserve">
<g>
<path d="M19.982,14.438l-6.24-4.536c-0.229-0.166-0.533-0.191-0.784-0.062c-0.253,0.128-0.411,0.388-0.411,0.669v9.069
c0,0.284,0.158,0.543,0.411,0.671c0.107,0.054,0.224,0.081,0.342,0.081c0.154,0,0.31-0.049,0.442-0.146l6.24-4.532
c0.197-0.145,0.312-0.369,0.312-0.607C20.295,14.803,20.177,14.58,19.982,14.438z"/>
<path d="M15.026,0.002C6.726,0.002,0,6.728,0,15.028c0,8.297,6.726,15.021,15.026,15.021c8.298,0,15.025-6.725,15.025-15.021
C30.052,6.728,23.324,0.002,15.026,0.002z M15.026,27.542c-6.912,0-12.516-5.601-12.516-12.514c0-6.91,5.604-12.518,12.516-12.518
c6.911,0,12.514,5.607,12.514,12.518C27.541,21.941,21.937,27.542,15.026,27.542z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,5 @@
<svg width="124" height="150" viewBox="0 0 124 150" xmlns="http://www.w3.org/2000/svg">
<rect x="55" y="14" width="14" height="63"/>
<rect x="116" width="14" height="61" transform="rotate(90 116 0)"/>
<path d="M62 150L8.30643 75L115.694 75L62 150Z"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path d="M264.574,4.675C262.697,1.761,259.467,0,256,0c-3.467,0-6.697,1.761-8.574,4.675
c-6.532,10.14-159.966,249.362-159.966,338.784C87.459,436.393,163.066,512,256,512s168.541-75.607,168.541-168.541
C424.541,254.037,271.106,14.815,264.574,4.675z M256,491.602c-81.686,0-148.142-66.456-148.142-148.143
c0-34.037,26.926-101.269,77.865-194.427C213.83,97.626,242.219,51.324,256,29.29c13.77,22.016,42.123,68.259,70.223,119.64
c50.976,93.212,77.92,160.478,77.92,194.529C404.142,425.146,337.686,491.602,256,491.602z"/>
</g>
</g>
<g>
<g>
<path d="M375.907,332.939c-5.633,0-10.199,4.566-10.199,10.199c0,43.197-25.482,82.521-64.919,100.181
c-5.141,2.301-7.442,8.335-5.14,13.476c1.695,3.788,5.416,6.034,9.314,6.034c1.393,0,2.809-0.287,4.163-0.893
c46.764-20.941,76.981-67.572,76.981-118.797C386.106,337.505,381.54,332.939,375.907,332.939z"/>
</g>
</g>
<g>
<g>
<path d="M281.818,460.702c-0.729-5.586-5.85-9.519-11.435-8.791c-4.736,0.619-9.574,0.933-14.383,0.933
c-5.633,0-10.199,4.566-10.199,10.199c0,5.633,4.566,10.199,10.199,10.199c5.69,0,11.419-0.372,17.028-1.106
C278.613,471.407,282.548,466.287,281.818,460.702z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/>
</svg>

After

Width:  |  Height:  |  Size: 491 B

@@ -0,0 +1 @@
<svg height="443pt" viewBox="0 0 443.06138 443" width="443pt" xmlns="http://www.w3.org/2000/svg"><path d="m431.328125 25.894531-14.136719-14.136719c-8.070312-8.078124-19.210937-12.320312-30.613281-11.6601558-11.402344.6601558-21.976563 6.1640628-29.058594 15.1249998l-117.023437 164.839844c-4.089844 5.761719-10.511719 9.425781-17.554688 10.019531-7.039062.59375-13.984375-1.945312-18.980468-6.941406l-34.234376-34.207031c-9.480468-9.109375-24.46875-9.109375-33.949218 0l-11.296875 11.304687 158.398437 158.398438 11.304688-11.304688c4.527344-4.488281 7.070312-10.597656 7.070312-16.96875 0-6.375-2.542968-12.484375-7.070312-16.972656l-34.222656-34.234375c-4.996094-4.996094-7.535157-11.941406-6.941407-18.980469.59375-7.042969 4.257813-13.464843 10.019531-17.554687l165-117.183594c8.890626-7.109375 14.332032-17.671875 14.960938-29.035156.628906-11.367188-3.617188-22.464844-11.671875-30.507813zm-24 43.808594c-9.375 9.371094-24.570313 9.371094-33.945313 0-9.371093-9.375-9.371093-24.574219 0-33.945313 9.496094-9.0625 24.441407-9.0625 33.9375 0 9.375 9.371094 9.378907 24.570313.007813 33.945313zm0 0"/><path d="m390.351562 44.734375c-3.234374 0-6.152343 1.949219-7.390624 4.9375-1.234376 2.988281-.550782 6.429687 1.734374 8.71875 3.160157 3.03125 8.148438 3.03125 11.304688 0 2.289062-2.289063 2.972656-5.730469 1.734375-8.71875s-4.15625-4.9375-7.390625-4.9375zm0 0"/><path d="m135.792969 420.429688 22.65625 22.65625 113.085937-113.167969-158.398437-158.402344-113.136719 113.121094 22.65625 22.65625 84.847656-84.855469c2.007813-2.078125 4.984375-2.914062 7.78125-2.183594 2.796875.734375 4.980469 2.917969 5.710938 5.714844.734375 2.796875-.101563 5.773438-2.179688 7.78125l-84.847656 84.855469 22.632812 22.625 50.902344-50.90625c3.140625-3.03125 8.128906-2.988281 11.214844.097656s3.128906 8.078125.097656 11.214844l-50.90625 50.921875 22.617188 22.621094 62.214844-62.222657c3.125-3.125 8.191406-3.128906 11.316406-.003906 3.128906 3.125 3.128906 8.191406.003906 11.316406l-62.222656 62.222657 22.625 22.625 73.535156-73.535157c3.140625-3.03125 8.128906-2.988281 11.214844.097657 3.085937 3.085937 3.128906 8.074218.097656 11.214843zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- Brush body -->
<path d="M18 4L21 7L11 17H8V14L18 4Z"/>
<!-- Brush handle -->
<path d="M15.5 6.5L17.5 8.5"/>
<!-- AI sparkles/selection indicators -->
<circle cx="5" cy="5" r="1" fill="currentColor"/>
<circle cx="3" cy="9" r="0.75" fill="currentColor"/>
<circle cx="7" cy="3" r="0.75" fill="currentColor"/>
<!-- Selection dashes around brush tip -->
<path d="M6 15L4 17" stroke-dasharray="2,1"/>
<path d="M6 19L8 21" stroke-dasharray="2,1"/>
<path d="M10 19L12 21" stroke-dasharray="2,1"/>
</svg>

After

Width:  |  Height:  |  Size: 686 B

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 402.56 402.56" style="enable-background:new 0 0 402.56 402.56;" xml:space="preserve">
<g>
<g>
<polygon points="38.613,234.88 38.4,274.56 96.64,274.56 116.693,265.173 9.6,372.48 39.68,402.56 147.2,295.253 137.813,316.587
137.813,373.76 177.493,374.187 177.493,234.88 "/>
</g>
</g>
<g>
<g>
<polygon points="306.987,128.213 285.653,137.6 392.96,30.08 362.88,0 255.573,107.307 264.96,87.04 264.96,28.8 225.28,29.013
225.28,167.893 364.587,167.893 364.16,128.213 "/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 778 B

+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 540.721 540.721" style="enable-background:new 0 0 540.721 540.721;" xml:space="preserve">
<g>
<g>
<path d="M521.858,465.271H18.862c-7.545,0-12.575,5.03-12.575,12.575v50.3c0,7.545,5.03,12.575,12.575,12.575h502.996
c7.545,0,12.575-5.03,12.575-12.575v-50.3C534.433,470.301,529.403,465.271,521.858,465.271z"/>
<path d="M227.606,98.084c5.03-5.03,20.12-12.575,30.18-12.575c7.545,0,12.575-5.03,12.575-12.575c0-7.545-5.03-10.06-12.575-10.06
l0,0c-17.605,0-37.725,7.545-47.785,17.605c-12.575,10.06-22.635,27.665-22.635,47.785c0,7.545,5.03,10.06,12.575,10.06l0,0
c7.545,0,12.575-2.515,12.575-10.06C212.516,118.204,220.061,103.114,227.606,98.084z"/>
<path d="M18.862,440.121h502.996c5.03,0,7.545-2.515,10.06-5.03c2.515-2.515,2.515-7.545,0-12.575l-47.785-100.599
c0-5.03-5.03-7.545-10.06-7.545H333.235v-77.964c25.15-20.12,60.359-60.36,60.359-110.659C393.594,55.33,338.265,0,270.36,0
S147.126,57.845,147.126,125.749c-2.515,47.785,35.21,88.024,60.36,110.659v77.964H66.647c-5.03,0-10.06,2.515-10.06,7.545
L8.802,422.517c-2.515,5.03-2.515,7.545,0,12.575C11.317,440.121,13.832,440.121,18.862,440.121z M169.761,125.749
c0-55.33,45.27-100.599,98.084-100.599c52.815,0,98.084,45.27,98.084,100.599c0,37.725-27.665,75.449-55.33,93.054
c-2.515,2.515-5.03,5.03-5.03,10.06v123.234c0,2.515-12.575,10.06-22.635,10.06h-37.725c-7.545,0-15.09-7.545-15.09-10.06V228.863
c0-5.03-2.515-7.545-5.03-10.06C207.486,206.228,169.761,168.504,169.761,125.749z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More