Spaces:
Sleeping
Sleeping
s0sp commited on
Commit ·
a9e6328
0
Parent(s):
cpu intensive without tweak best result
Browse files- .gitignore +14 -0
- Dockerfile +23 -0
- README.md +40 -0
- app.py +14 -0
- app/__init__.py +0 -0
- app/config.py +126 -0
- app/main.py +201 -0
- app/models/background.py +54 -0
- app/models/depth_estimator.py +87 -0
- app/models/face_parser.py +142 -0
- app/models/generator.py +169 -0
- app/models/line_art.py +209 -0
- app/models/registry.py +79 -0
- app/models/sam_segmenter.py +319 -0
- app/models/super_resolution.py +68 -0
- app/pipeline.py +393 -0
- app/processing/difficulty.py +89 -0
- app/processing/quantizer.py +189 -0
- app/processing/utils.py +377 -0
- app/processing/vectorizer.py +194 -0
- download_model.py +29 -0
- download_models.py +127 -0
- inspect_sam.py +9 -0
- models/.gitkeep +0 -0
- outputs/colo.code-workspace +8 -0
- packages.txt +2 -0
- render_build.py +18 -0
- requirements.txt +14 -0
- sam_info.txt +0 -0
- test_model.py +18 -0
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
venv/
|
| 3 |
+
.env
|
| 4 |
+
outputs/*.svg
|
| 5 |
+
models/*.onnx
|
| 6 |
+
models/*.onnx_data
|
| 7 |
+
*.log
|
| 8 |
+
.DS_Store
|
| 9 |
+
*.png
|
| 10 |
+
*.webp
|
| 11 |
+
*.jpg
|
| 12 |
+
*.jpeg
|
| 13 |
+
result_*.json
|
| 14 |
+
outputs/
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System deps for OpenCV headless
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
libgl1-mesa-glx \
|
| 8 |
+
libglib2.0-0 \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY download_model.py .
|
| 15 |
+
RUN python download_model.py
|
| 16 |
+
|
| 17 |
+
COPY app/ app/
|
| 18 |
+
|
| 19 |
+
RUN mkdir -p outputs
|
| 20 |
+
|
| 21 |
+
EXPOSE 8000
|
| 22 |
+
|
| 23 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Color By Number Pro API
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Color-by-Number Pro API
|
| 12 |
+
|
| 13 |
+
Advanced image processing backend for converting photos, illustrations, and anime into high-quality color-by-number templates.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
- **SAM2 Segmentation**: Pixel-perfect region detection.
|
| 17 |
+
- **Depth Estimation**: Automatic difficulty layers based on image depth.
|
| 18 |
+
- **Adjacency Matrix**: Pre-calculated region neighbors for fast frontend logic.
|
| 19 |
+
- **Pole of Inaccessibility**: Perfect number placement inside complex borders.
|
| 20 |
+
- **SVG Output**: Clean, vectorized outputs with optional auto-coloring.
|
| 21 |
+
|
| 22 |
+
## API Documentation
|
| 23 |
+
Once running, visit the interactive docs at:
|
| 24 |
+
`https://your-space-name.hf.space/docs`
|
| 25 |
+
|
| 26 |
+
### Main Endpoints
|
| 27 |
+
- `POST /api/process`: Primary endpoint for converting images.
|
| 28 |
+
- `GET /health`: Check service status.
|
| 29 |
+
|
| 30 |
+
## Deployment on Hugging Face
|
| 31 |
+
1. Create a new **Space**.
|
| 32 |
+
2. Select **Gradio** as the SDK (even though we use FastAPI).
|
| 33 |
+
3. Upload all files.
|
| 34 |
+
4. Set `GENERATION_API_KEY` in the Space settings if using AI prompt generation.
|
| 35 |
+
|
| 36 |
+
## Local Development
|
| 37 |
+
```bash
|
| 38 |
+
pip install -r requirements.txt
|
| 39 |
+
python app.py
|
| 40 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from app.main import app
|
| 3 |
+
from download_models import download_all
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
# Ensure models are downloaded on first start
|
| 8 |
+
print("Pre-loading models...")
|
| 9 |
+
download_all(required_only=True)
|
| 10 |
+
|
| 11 |
+
# Hugging Face usually provides a PORT env var
|
| 12 |
+
port = int(os.environ.get("PORT", 7860))
|
| 13 |
+
# Note: Hugging Face default port is 7860
|
| 14 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration for all models and pipeline settings."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Quality(str, Enum):
|
| 9 |
+
FAST = "fast"
|
| 10 |
+
BALANCED = "balanced"
|
| 11 |
+
HIGH = "high"
|
| 12 |
+
ULTRA = "ultra"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class LineArtStyle(str, Enum):
|
| 16 |
+
INFORMATIVE = "informative"
|
| 17 |
+
ANYLINE = "anyline"
|
| 18 |
+
MANGA = "manga"
|
| 19 |
+
FUSED = "fused" # Merge multiple models
|
| 20 |
+
AUTO = "auto" # Pick based on content
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ContentType(str, Enum):
|
| 24 |
+
PHOTO = "photo"
|
| 25 |
+
ILLUSTRATION = "illustration"
|
| 26 |
+
ANIME = "anime"
|
| 27 |
+
PORTRAIT = "portrait"
|
| 28 |
+
LANDSCAPE = "landscape"
|
| 29 |
+
AUTO = "auto"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Difficulty(str, Enum):
|
| 33 |
+
EASY = "easy" # 6-8 colors, large regions
|
| 34 |
+
MEDIUM = "medium" # 10-14 colors
|
| 35 |
+
HARD = "hard" # 16-22 colors, small regions
|
| 36 |
+
EXPERT = "expert" # 24-30 colors, tiny regions
|
| 37 |
+
AUTO = "auto"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class ModelPaths:
|
| 42 |
+
informative_drawings: str = "models/informative_drawings.onnx"
|
| 43 |
+
anyline: str = "models/anyline.onnx"
|
| 44 |
+
manga_line: str = "models/manga_line.onnx"
|
| 45 |
+
sam2_encoder: str = "models/sam2_encoder.onnx"
|
| 46 |
+
sam2_decoder: str = "models/sam2_decoder.onnx"
|
| 47 |
+
depth_anything: str = "models/depth_anything_v2.onnx"
|
| 48 |
+
real_esrgan: str = "models/real_esrgan_x4.onnx"
|
| 49 |
+
rmbg: str = "models/rmbg2.onnx"
|
| 50 |
+
face_parsing: str = "models/face_parsing.onnx"
|
| 51 |
+
|
| 52 |
+
def available(self, name: str) -> bool:
|
| 53 |
+
path = getattr(self, name, None)
|
| 54 |
+
return path is not None and Path(path).exists()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class PipelineConfig:
|
| 59 |
+
# Content
|
| 60 |
+
content_type: ContentType = ContentType.AUTO
|
| 61 |
+
difficulty: Difficulty = Difficulty.AUTO
|
| 62 |
+
quality: Quality = Quality.BALANCED
|
| 63 |
+
|
| 64 |
+
# Colors
|
| 65 |
+
k_colors: int | None = None # None = auto
|
| 66 |
+
use_lab: bool = True
|
| 67 |
+
spatial_weight: float = 0.0
|
| 68 |
+
|
| 69 |
+
# Line art
|
| 70 |
+
line_style: LineArtStyle = LineArtStyle.FUSED
|
| 71 |
+
line_threshold: int = 128
|
| 72 |
+
|
| 73 |
+
# Regions
|
| 74 |
+
min_region_area: int = 200
|
| 75 |
+
use_sam: bool = True # Use SAM2 for segmentation
|
| 76 |
+
|
| 77 |
+
# Enhancement
|
| 78 |
+
upscale_if_small: bool = True
|
| 79 |
+
remove_background: bool = False
|
| 80 |
+
detect_faces: bool = True
|
| 81 |
+
|
| 82 |
+
# Depth
|
| 83 |
+
use_depth: bool = True
|
| 84 |
+
depth_layers: int = 3 # Number of difficulty layers
|
| 85 |
+
|
| 86 |
+
# Closing
|
| 87 |
+
density: str = "auto"
|
| 88 |
+
close_kernel: int = 3
|
| 89 |
+
close_iterations: int = 2
|
| 90 |
+
bridge_gaps: bool = True
|
| 91 |
+
|
| 92 |
+
# System
|
| 93 |
+
max_dimension: int = 1024
|
| 94 |
+
save_debug: bool = False
|
| 95 |
+
debug_dir: str = "outputs/debug"
|
| 96 |
+
|
| 97 |
+
def apply_difficulty(self):
|
| 98 |
+
"""Set parameters based on difficulty level."""
|
| 99 |
+
presets = {
|
| 100 |
+
Difficulty.EASY: {"k": 8, "min_area": 500, "spatial": 0.4},
|
| 101 |
+
Difficulty.MEDIUM: {"k": 14, "min_area": 300, "spatial": 0.2},
|
| 102 |
+
Difficulty.HARD: {"k": 22, "min_area": 150, "spatial": 0.1},
|
| 103 |
+
Difficulty.EXPERT: {"k": 30, "min_area": 20, "spatial": 0.05},
|
| 104 |
+
}
|
| 105 |
+
if self.difficulty in presets:
|
| 106 |
+
p = presets[self.difficulty]
|
| 107 |
+
if self.k_colors is None:
|
| 108 |
+
self.k_colors = p["k"]
|
| 109 |
+
self.min_region_area = p["min_area"]
|
| 110 |
+
self.spatial_weight = p["spatial"]
|
| 111 |
+
|
| 112 |
+
def apply_quality(self):
|
| 113 |
+
"""Set processing params based on quality level."""
|
| 114 |
+
if self.quality == Quality.FAST:
|
| 115 |
+
self.use_sam = False
|
| 116 |
+
self.use_depth = False
|
| 117 |
+
self.bridge_gaps = False
|
| 118 |
+
self.line_style = LineArtStyle.INFORMATIVE
|
| 119 |
+
self.upscale_if_small = False
|
| 120 |
+
elif self.quality == Quality.ULTRA:
|
| 121 |
+
self.use_sam = True
|
| 122 |
+
self.use_depth = True
|
| 123 |
+
self.bridge_gaps = True
|
| 124 |
+
self.line_style = LineArtStyle.FUSED
|
| 125 |
+
self.density = "dense"
|
| 126 |
+
self.close_iterations = 2
|
app/main.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI app with all model endpoints.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 13 |
+
from fastapi.responses import Response, JSONResponse
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
|
| 17 |
+
from app.config import PipelineConfig, ModelPaths, Quality, ContentType, Difficulty, LineArtStyle
|
| 18 |
+
from app.pipeline import ColorByNumberPipeline
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
pipeline: ColorByNumberPipeline | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@asynccontextmanager
|
| 25 |
+
async def lifespan(app: FastAPI):
|
| 26 |
+
global pipeline
|
| 27 |
+
paths = ModelPaths()
|
| 28 |
+
|
| 29 |
+
# Create directories if they don't exist
|
| 30 |
+
for d in ["outputs", "outputs/debug", "models"]:
|
| 31 |
+
Path(d).mkdir(exist_ok=True)
|
| 32 |
+
|
| 33 |
+
# Note: We don't crash if models are missing, the pipeline gracefully degrades
|
| 34 |
+
# but we print their status.
|
| 35 |
+
pipeline = ColorByNumberPipeline(
|
| 36 |
+
model_paths=paths,
|
| 37 |
+
generation_api_key=os.getenv("GENERATION_API_KEY", ""),
|
| 38 |
+
generation_provider=os.getenv("GENERATION_PROVIDER", "replicate"),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
print("✓ Pro Pipeline loaded")
|
| 42 |
+
print(f" Model Status: {pipeline.registry.status()}")
|
| 43 |
+
yield
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
app = FastAPI(title="Color-by-Number Pro API", version="3.0.0", lifespan=lifespan)
|
| 47 |
+
|
| 48 |
+
app.add_middleware(
|
| 49 |
+
CORSMiddleware,
|
| 50 |
+
allow_origins=["*"],
|
| 51 |
+
allow_credentials=True,
|
| 52 |
+
allow_methods=["*"],
|
| 53 |
+
allow_headers=["*"],
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Mount outputs for static access
|
| 57 |
+
app.mount("/static", StaticFiles(directory="outputs"), name="static")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.get("/health")
|
| 61 |
+
async def health():
|
| 62 |
+
return {"status": "ok", "model_loaded": pipeline is not None}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@app.get("/models/status")
|
| 66 |
+
async def model_status():
|
| 67 |
+
if pipeline is None:
|
| 68 |
+
raise HTTPException(503, "Not initialized")
|
| 69 |
+
return pipeline.registry.status()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.post("/generate")
|
| 73 |
+
async def generate(
|
| 74 |
+
image: UploadFile = File(...),
|
| 75 |
+
difficulty: str = Form(default="auto"),
|
| 76 |
+
quality: str = Form(default="balanced"),
|
| 77 |
+
content_type: str = Form(default="auto"),
|
| 78 |
+
line_style: str = Form(default="auto"),
|
| 79 |
+
k_colors: int = Form(default=0, ge=0, le=30),
|
| 80 |
+
remove_background: bool = Form(default=False),
|
| 81 |
+
use_sam: bool = Form(default=True),
|
| 82 |
+
use_depth: bool = Form(default=True),
|
| 83 |
+
save_debug: bool = Form(default=False),
|
| 84 |
+
max_dimension: int = Form(default=1024, ge=256, le=2048),
|
| 85 |
+
response_format: str = Form(default="json"),
|
| 86 |
+
):
|
| 87 |
+
if pipeline is None:
|
| 88 |
+
raise HTTPException(503, "Not initialized")
|
| 89 |
+
|
| 90 |
+
ct = image.content_type or ""
|
| 91 |
+
if not ct.startswith("image/"):
|
| 92 |
+
raise HTTPException(400, f"Expected image, got {ct}")
|
| 93 |
+
|
| 94 |
+
raw = await image.read()
|
| 95 |
+
arr = np.frombuffer(raw, dtype=np.uint8)
|
| 96 |
+
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
| 97 |
+
if img is None:
|
| 98 |
+
raise HTTPException(400, "Could not decode image")
|
| 99 |
+
|
| 100 |
+
job_id = uuid.uuid4().hex[:12]
|
| 101 |
+
|
| 102 |
+
config = PipelineConfig(
|
| 103 |
+
content_type=ContentType(content_type),
|
| 104 |
+
difficulty=Difficulty(difficulty),
|
| 105 |
+
quality=Quality(quality),
|
| 106 |
+
line_style=LineArtStyle(line_style),
|
| 107 |
+
k_colors=k_colors if k_colors > 0 else None,
|
| 108 |
+
remove_background=remove_background,
|
| 109 |
+
use_sam=use_sam,
|
| 110 |
+
use_depth=use_depth,
|
| 111 |
+
save_debug=save_debug,
|
| 112 |
+
debug_dir=f"outputs/debug/{job_id}",
|
| 113 |
+
max_dimension=max_dimension,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
result = pipeline.process(
|
| 117 |
+
image=img,
|
| 118 |
+
config=config,
|
| 119 |
+
output_path=f"outputs/{job_id}.svg",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
response = {
|
| 123 |
+
"job_id": job_id,
|
| 124 |
+
"svg_url": f"/static/{job_id}.svg",
|
| 125 |
+
"outline_url": f"/static/{job_id}_outline.svg",
|
| 126 |
+
"palette": result["palette"],
|
| 127 |
+
"num_regions": result["num_regions"],
|
| 128 |
+
"content_type": result["content_type"],
|
| 129 |
+
"models_used": result["models_used"],
|
| 130 |
+
"k_colors_used": result["k_colors_used"],
|
| 131 |
+
"density_used": result["density_used"],
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if "suggested_order" in result:
|
| 135 |
+
response["suggested_order"] = result["suggested_order"]
|
| 136 |
+
if "difficulty_layers" in result:
|
| 137 |
+
response["difficulty_layers"] = result["difficulty_layers"]
|
| 138 |
+
if "regions" in result:
|
| 139 |
+
response["regions"] = result["regions"]
|
| 140 |
+
if save_debug:
|
| 141 |
+
response["debug_dir"] = f"/static/debug/{job_id}/"
|
| 142 |
+
|
| 143 |
+
if response_format == "svg":
|
| 144 |
+
return Response(content=result["svg_string"], media_type="image/svg+xml")
|
| 145 |
+
elif response_format == "outline":
|
| 146 |
+
return Response(content=result["svg_outline_string"], media_type="image/svg+xml")
|
| 147 |
+
|
| 148 |
+
response["svg_string"] = result["svg_string"]
|
| 149 |
+
return JSONResponse(response)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.post("/generate-from-prompt")
|
| 153 |
+
async def generate_from_prompt(
|
| 154 |
+
prompt: str = Form(..., description="What to generate"),
|
| 155 |
+
style: str = Form(default="coloring_book"),
|
| 156 |
+
difficulty: str = Form(default="medium"),
|
| 157 |
+
quality: str = Form(default="balanced"),
|
| 158 |
+
response_format: str = Form(default="json"),
|
| 159 |
+
):
|
| 160 |
+
if pipeline is None:
|
| 161 |
+
raise HTTPException(503, "Not initialized")
|
| 162 |
+
|
| 163 |
+
if not pipeline.generator.available:
|
| 164 |
+
raise HTTPException(
|
| 165 |
+
501,
|
| 166 |
+
"Generation API not configured. Set GENERATION_API_KEY env var.",
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
job_id = uuid.uuid4().hex[:12]
|
| 170 |
+
|
| 171 |
+
config = PipelineConfig(
|
| 172 |
+
difficulty=Difficulty(difficulty),
|
| 173 |
+
quality=Quality(quality),
|
| 174 |
+
content_type=ContentType.ILLUSTRATION,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
result = await pipeline.generate_from_prompt(
|
| 178 |
+
prompt=prompt,
|
| 179 |
+
style=style,
|
| 180 |
+
config=config,
|
| 181 |
+
output_path=f"outputs/{job_id}.svg",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
if result is None:
|
| 185 |
+
raise HTTPException(500, "Image generation failed")
|
| 186 |
+
|
| 187 |
+
response = {
|
| 188 |
+
"job_id": job_id,
|
| 189 |
+
"prompt": prompt,
|
| 190 |
+
"style": style,
|
| 191 |
+
"svg_url": f"/static/{job_id}.svg",
|
| 192 |
+
"palette": result["palette"],
|
| 193 |
+
"num_regions": result["num_regions"],
|
| 194 |
+
"models_used": result["models_used"],
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
if response_format == "svg":
|
| 198 |
+
return Response(content=result["svg_string"], media_type="image/svg+xml")
|
| 199 |
+
|
| 200 |
+
response["svg_string"] = result["svg_string"]
|
| 201 |
+
return JSONResponse(response)
|
app/models/background.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RMBG-2.0 background removal.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from app.models.registry import ModelRegistry
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BackgroundRemover:
|
| 12 |
+
"""RMBG-2.0 for subject isolation."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, registry: ModelRegistry):
|
| 15 |
+
self.registry = registry
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def available(self) -> bool:
|
| 19 |
+
return self.registry.available("rmbg")
|
| 20 |
+
|
| 21 |
+
def remove(self, image: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 22 |
+
session = self.registry.get("rmbg")
|
| 23 |
+
if session is None:
|
| 24 |
+
mask = np.full(image.shape[:2], 255, dtype=np.uint8)
|
| 25 |
+
return image, mask
|
| 26 |
+
|
| 27 |
+
h, w = image.shape[:2]
|
| 28 |
+
input_info = session.get_inputs()[0]
|
| 29 |
+
if isinstance(input_info.shape[2], int):
|
| 30 |
+
th, tw = input_info.shape[2], input_info.shape[3]
|
| 31 |
+
else:
|
| 32 |
+
th, tw = 1024, 1024
|
| 33 |
+
|
| 34 |
+
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 35 |
+
resized = cv2.resize(rgb, (tw, th))
|
| 36 |
+
blob = resized.astype(np.float32) / 255.0
|
| 37 |
+
mean = np.array([0.485, 0.456, 0.406])
|
| 38 |
+
std = np.array([0.229, 0.224, 0.225])
|
| 39 |
+
blob = (blob - mean) / std
|
| 40 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 41 |
+
blob = np.expand_dims(blob, axis=0).astype(np.float32)
|
| 42 |
+
|
| 43 |
+
output = session.run(None, {input_info.name: blob})[0]
|
| 44 |
+
alpha = np.squeeze(output)
|
| 45 |
+
alpha = np.clip(alpha, 0, 1)
|
| 46 |
+
alpha = cv2.resize(alpha, (w, h))
|
| 47 |
+
mask = (alpha > 0.5).astype(np.uint8) * 255
|
| 48 |
+
result = image.copy()
|
| 49 |
+
result[mask == 0] = [255, 255, 255]
|
| 50 |
+
return result, mask
|
| 51 |
+
|
| 52 |
+
def get_foreground_mask(self, image: np.ndarray) -> np.ndarray:
|
| 53 |
+
_, mask = self.remove(image)
|
| 54 |
+
return mask
|
app/models/depth_estimator.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Depth Anything V2 integration for automatic difficulty layering.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
import onnxruntime as ort
|
| 8 |
+
|
| 9 |
+
from app.models.registry import ModelRegistry
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DepthEstimator:
|
| 13 |
+
"""Monocular depth estimation using Depth Anything V2."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, registry: ModelRegistry):
|
| 16 |
+
self.registry = registry
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def available(self) -> bool:
|
| 20 |
+
return self.registry.available("depth_anything")
|
| 21 |
+
|
| 22 |
+
def estimate(self, image: np.ndarray) -> np.ndarray:
|
| 23 |
+
session = self.registry.get("depth_anything")
|
| 24 |
+
if session is None:
|
| 25 |
+
raise RuntimeError("Depth model not available")
|
| 26 |
+
|
| 27 |
+
h, w = image.shape[:2]
|
| 28 |
+
input_info = session.get_inputs()[0]
|
| 29 |
+
if isinstance(input_info.shape[2], int):
|
| 30 |
+
target_h, target_w = input_info.shape[2], input_info.shape[3]
|
| 31 |
+
else:
|
| 32 |
+
target_h, target_w = 518, 518
|
| 33 |
+
|
| 34 |
+
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 35 |
+
resized = cv2.resize(rgb, (target_w, target_h))
|
| 36 |
+
blob = resized.astype(np.float32) / 255.0
|
| 37 |
+
mean = np.array([0.485, 0.456, 0.406])
|
| 38 |
+
std = np.array([0.229, 0.224, 0.225])
|
| 39 |
+
blob = (blob - mean) / std
|
| 40 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 41 |
+
blob = np.expand_dims(blob, axis=0).astype(np.float32)
|
| 42 |
+
|
| 43 |
+
depth = session.run(None, {input_info.name: blob})[0]
|
| 44 |
+
depth = np.squeeze(depth)
|
| 45 |
+
depth = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
|
| 46 |
+
depth = cv2.resize(depth, (w, h))
|
| 47 |
+
|
| 48 |
+
return depth
|
| 49 |
+
|
| 50 |
+
def assign_difficulty_layers(
|
| 51 |
+
self,
|
| 52 |
+
depth_map: np.ndarray,
|
| 53 |
+
label_map: np.ndarray,
|
| 54 |
+
n_layers: int = 3,
|
| 55 |
+
) -> dict[int, int]:
|
| 56 |
+
unique_labels = np.unique(label_map)
|
| 57 |
+
unique_labels = unique_labels[unique_labels >= 0]
|
| 58 |
+
region_depths = {}
|
| 59 |
+
for lbl in unique_labels:
|
| 60 |
+
mask = label_map == lbl
|
| 61 |
+
avg_depth = depth_map[mask].mean()
|
| 62 |
+
region_depths[int(lbl)] = avg_depth
|
| 63 |
+
depths = np.array(list(region_depths.values()))
|
| 64 |
+
if len(depths) == 0:
|
| 65 |
+
return {int(lbl): 0 for lbl in unique_labels}
|
| 66 |
+
|
| 67 |
+
thresholds = np.quantile(depths, np.linspace(0, 1, n_layers + 1)[1:-1])
|
| 68 |
+
layer_assignments = {}
|
| 69 |
+
for lbl, depth in region_depths.items():
|
| 70 |
+
layer = int(np.searchsorted(thresholds, depth))
|
| 71 |
+
layer_assignments[lbl] = min(layer, n_layers - 1)
|
| 72 |
+
return layer_assignments
|
| 73 |
+
|
| 74 |
+
def create_ordering(
|
| 75 |
+
self,
|
| 76 |
+
depth_map: np.ndarray,
|
| 77 |
+
label_map: np.ndarray,
|
| 78 |
+
) -> list[int]:
|
| 79 |
+
unique_labels = np.unique(label_map)
|
| 80 |
+
unique_labels = unique_labels[unique_labels >= 0]
|
| 81 |
+
depths = []
|
| 82 |
+
for lbl in unique_labels:
|
| 83 |
+
mask = label_map == lbl
|
| 84 |
+
avg_depth = depth_map[mask].mean()
|
| 85 |
+
depths.append((int(lbl), avg_depth))
|
| 86 |
+
depths.sort(key=lambda x: x[1])
|
| 87 |
+
return [lbl for lbl, _ in depths]
|
app/models/face_parser.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Face parsing for portrait-specific region handling.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from app.models.registry import ModelRegistry
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
FACE_REGION_GROUPS = {
|
| 12 |
+
"skin": [1, 14],
|
| 13 |
+
"left_eye": [4],
|
| 14 |
+
"right_eye": [5],
|
| 15 |
+
"left_eyebrow": [2],
|
| 16 |
+
"right_eyebrow": [3],
|
| 17 |
+
"nose": [10],
|
| 18 |
+
"upper_lip": [12],
|
| 19 |
+
"lower_lip": [13],
|
| 20 |
+
"mouth_interior": [11],
|
| 21 |
+
"hair": [17],
|
| 22 |
+
"left_ear": [7],
|
| 23 |
+
"right_ear": [8],
|
| 24 |
+
"glasses": [6],
|
| 25 |
+
"hat": [18],
|
| 26 |
+
"clothing": [16],
|
| 27 |
+
"accessories": [9, 15],
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FaceParser:
|
| 32 |
+
"""BiSeNet face parsing for portrait-optimized coloring pages."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, registry: ModelRegistry):
|
| 35 |
+
self.registry = registry
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def available(self) -> bool:
|
| 39 |
+
return self.registry.available("face_parsing")
|
| 40 |
+
|
| 41 |
+
def detect_faces(self, image: np.ndarray) -> list[tuple[int, int, int, int]]:
|
| 42 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 43 |
+
cascade = cv2.CascadeClassifier(
|
| 44 |
+
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
| 45 |
+
)
|
| 46 |
+
faces = cascade.detectMultiScale(gray, 1.1, 5, minSize=(80, 80))
|
| 47 |
+
return [tuple(f) for f in faces]
|
| 48 |
+
|
| 49 |
+
def parse(self, image: np.ndarray) -> np.ndarray | None:
|
| 50 |
+
session = self.registry.get("face_parsing")
|
| 51 |
+
if session is None:
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
h, w = image.shape[:2]
|
| 55 |
+
input_info = session.get_inputs()[0]
|
| 56 |
+
if isinstance(input_info.shape[2], int):
|
| 57 |
+
th, tw = input_info.shape[2], input_info.shape[3]
|
| 58 |
+
else:
|
| 59 |
+
th, tw = 512, 512
|
| 60 |
+
|
| 61 |
+
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 62 |
+
resized = cv2.resize(rgb, (tw, th))
|
| 63 |
+
blob = resized.astype(np.float32) / 255.0
|
| 64 |
+
mean = np.array([0.485, 0.456, 0.406])
|
| 65 |
+
std = np.array([0.229, 0.224, 0.225])
|
| 66 |
+
blob = (blob - mean) / std
|
| 67 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 68 |
+
blob = np.expand_dims(blob, axis=0).astype(np.float32)
|
| 69 |
+
|
| 70 |
+
output = session.run(None, {input_info.name: blob})[0]
|
| 71 |
+
parsed = np.squeeze(output)
|
| 72 |
+
|
| 73 |
+
if parsed.ndim == 3:
|
| 74 |
+
parsed = np.argmax(parsed, axis=0)
|
| 75 |
+
|
| 76 |
+
parsed = cv2.resize(
|
| 77 |
+
parsed.astype(np.float32), (w, h),
|
| 78 |
+
interpolation=cv2.INTER_NEAREST
|
| 79 |
+
).astype(np.int32)
|
| 80 |
+
|
| 81 |
+
return parsed
|
| 82 |
+
|
| 83 |
+
def merge_with_label_map(
|
| 84 |
+
self,
|
| 85 |
+
face_parse: np.ndarray,
|
| 86 |
+
label_map: np.ndarray,
|
| 87 |
+
face_bbox: tuple[int, int, int, int] | None = None,
|
| 88 |
+
) -> np.ndarray:
|
| 89 |
+
result = label_map.copy()
|
| 90 |
+
h, w = result.shape
|
| 91 |
+
max_existing = label_map.max() + 1
|
| 92 |
+
|
| 93 |
+
if face_bbox is not None:
|
| 94 |
+
x, y, fw, fh = face_bbox
|
| 95 |
+
expand = 0.3
|
| 96 |
+
x = max(0, int(x - fw * expand))
|
| 97 |
+
y = max(0, int(y - fh * expand))
|
| 98 |
+
fw = min(w - x, int(fw * (1 + 2 * expand)))
|
| 99 |
+
fh = min(h - y, int(fh * (1 + 2 * expand)))
|
| 100 |
+
face_region = np.zeros((h, w), dtype=bool)
|
| 101 |
+
face_region[y:y + fh, x:x + fw] = True
|
| 102 |
+
else:
|
| 103 |
+
face_region = face_parse > 0
|
| 104 |
+
|
| 105 |
+
grouped = np.full_like(face_parse, -1)
|
| 106 |
+
for group_idx, (group_name, raw_labels) in enumerate(
|
| 107 |
+
FACE_REGION_GROUPS.items()
|
| 108 |
+
):
|
| 109 |
+
for raw_lbl in raw_labels:
|
| 110 |
+
grouped[face_parse == raw_lbl] = max_existing + group_idx
|
| 111 |
+
|
| 112 |
+
valid = face_region & (grouped >= 0)
|
| 113 |
+
result[valid] = grouped[valid]
|
| 114 |
+
return result
|
| 115 |
+
|
| 116 |
+
def is_portrait(self, image: np.ndarray) -> bool:
|
| 117 |
+
faces = self.detect_faces(image)
|
| 118 |
+
if not faces:
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
h, w = image.shape[:2]
|
| 122 |
+
img_area = h * w
|
| 123 |
+
|
| 124 |
+
for x, y, fw, fh in faces:
|
| 125 |
+
face_area = fw * fh
|
| 126 |
+
# 1. Size check
|
| 127 |
+
if face_area / img_area < 0.05:
|
| 128 |
+
continue
|
| 129 |
+
|
| 130 |
+
# 2. skin-tone check (Haar cascade fix for flowers)
|
| 131 |
+
face_crop = image[y:y+fh, x:x+fw]
|
| 132 |
+
if face_crop.size == 0: continue
|
| 133 |
+
|
| 134 |
+
# Simple YCbCr skin detection
|
| 135 |
+
ycrcb = cv2.cvtColor(face_crop, cv2.COLOR_BGR2YCrCb)
|
| 136 |
+
mask = cv2.inRange(ycrcb, (0, 133, 77), (255, 173, 127))
|
| 137 |
+
skin_ratio = np.count_nonzero(mask) / mask.size
|
| 138 |
+
|
| 139 |
+
if skin_ratio > 0.4: # Only if it's reasonably skin-colored
|
| 140 |
+
return True
|
| 141 |
+
|
| 142 |
+
return False
|
app/models/generator.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI template generation using Stable Diffusion / Flux API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import base64
|
| 7 |
+
import asyncio
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
import cv2
|
| 11 |
+
import numpy as np
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TemplateGenerator:
|
| 16 |
+
"""Generate coloring page templates from text prompts."""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
api_key: str = "",
|
| 21 |
+
provider: str = "replicate",
|
| 22 |
+
base_url: str = "",
|
| 23 |
+
):
|
| 24 |
+
self.api_key = api_key
|
| 25 |
+
self.provider = provider
|
| 26 |
+
self.base_url = base_url or self._default_url()
|
| 27 |
+
|
| 28 |
+
def _default_url(self) -> str:
|
| 29 |
+
urls = {
|
| 30 |
+
"replicate": "https://api.replicate.com/v1/predictions",
|
| 31 |
+
"together": "https://api.together.xyz/v1/images/generations",
|
| 32 |
+
"stability": "https://api.stability.ai/v2beta/stable-image/generate/core",
|
| 33 |
+
}
|
| 34 |
+
return urls.get(self.provider, "")
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def available(self) -> bool:
|
| 38 |
+
return bool(self.api_key)
|
| 39 |
+
|
| 40 |
+
async def generate_from_prompt(
|
| 41 |
+
self,
|
| 42 |
+
prompt: str,
|
| 43 |
+
style: str = "coloring_book",
|
| 44 |
+
width: int = 1024,
|
| 45 |
+
height: int = 1024,
|
| 46 |
+
) -> np.ndarray | None:
|
| 47 |
+
style_suffixes = {
|
| 48 |
+
"coloring_book": (
|
| 49 |
+
"clean line art, coloring book page, black outlines on white "
|
| 50 |
+
"background, no shading, no color fill, simple shapes, "
|
| 51 |
+
"clear boundaries between regions, vector art style"
|
| 52 |
+
),
|
| 53 |
+
"realistic": (
|
| 54 |
+
"detailed illustration, clear edges, distinct color regions, "
|
| 55 |
+
"flat colors, no gradients, poster style"
|
| 56 |
+
),
|
| 57 |
+
"cartoon": (
|
| 58 |
+
"cartoon style, bold black outlines, flat colors, "
|
| 59 |
+
"simple shapes, children's coloring book"
|
| 60 |
+
),
|
| 61 |
+
"mandala": (
|
| 62 |
+
"mandala pattern, symmetrical, intricate line art, "
|
| 63 |
+
"black outlines on white, no fill colors"
|
| 64 |
+
),
|
| 65 |
+
"pixel": (
|
| 66 |
+
"pixel art style, clear grid, distinct color blocks, "
|
| 67 |
+
"retro game aesthetic, flat colors"
|
| 68 |
+
),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
enhanced_prompt = f"{prompt}, {style_suffixes.get(style, style_suffixes['coloring_book'])}"
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
if self.provider == "replicate":
|
| 75 |
+
return await self._generate_replicate(enhanced_prompt, width, height)
|
| 76 |
+
elif self.provider == "together":
|
| 77 |
+
return await self._generate_together(enhanced_prompt, width, height)
|
| 78 |
+
elif self.provider == "stability":
|
| 79 |
+
return await self._generate_stability(enhanced_prompt, width, height)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"Generation failed: {e}")
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
async def _generate_replicate(
|
| 85 |
+
self, prompt: str, width: int, height: int
|
| 86 |
+
) -> np.ndarray | None:
|
| 87 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 88 |
+
resp = await client.post(
|
| 89 |
+
self.base_url,
|
| 90 |
+
headers={
|
| 91 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 92 |
+
"Content-Type": "application/json",
|
| 93 |
+
},
|
| 94 |
+
json={
|
| 95 |
+
"version": "39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b",
|
| 96 |
+
"input": {
|
| 97 |
+
"prompt": prompt,
|
| 98 |
+
"width": width,
|
| 99 |
+
"height": height,
|
| 100 |
+
"num_inference_steps": 28,
|
| 101 |
+
"guidance_scale": 7.5,
|
| 102 |
+
},
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
+
prediction = resp.json()
|
| 106 |
+
poll_url = prediction.get("urls", {}).get("get", "")
|
| 107 |
+
for _ in range(60):
|
| 108 |
+
await asyncio.sleep(2)
|
| 109 |
+
status_resp = await client.get(
|
| 110 |
+
poll_url,
|
| 111 |
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
| 112 |
+
)
|
| 113 |
+
status = status_resp.json()
|
| 114 |
+
if status["status"] == "succeeded":
|
| 115 |
+
image_url = status["output"][0]
|
| 116 |
+
img_resp = await client.get(image_url)
|
| 117 |
+
return self._bytes_to_cv2(img_resp.content)
|
| 118 |
+
elif status["status"] == "failed":
|
| 119 |
+
return None
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
async def _generate_together(
|
| 123 |
+
self, prompt: str, width: int, height: int
|
| 124 |
+
) -> np.ndarray | None:
|
| 125 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 126 |
+
resp = await client.post(
|
| 127 |
+
self.base_url,
|
| 128 |
+
headers={
|
| 129 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 130 |
+
"Content-Type": "application/json",
|
| 131 |
+
},
|
| 132 |
+
json={
|
| 133 |
+
"model": "black-forest-labs/FLUX.1-schnell",
|
| 134 |
+
"prompt": prompt,
|
| 135 |
+
"width": width,
|
| 136 |
+
"height": height,
|
| 137 |
+
"n": 1,
|
| 138 |
+
"response_format": "b64_json",
|
| 139 |
+
},
|
| 140 |
+
)
|
| 141 |
+
data = resp.json()
|
| 142 |
+
b64 = data["data"][0]["b64_json"]
|
| 143 |
+
img_bytes = base64.b64decode(b64)
|
| 144 |
+
return self._bytes_to_cv2(img_bytes)
|
| 145 |
+
|
| 146 |
+
async def _generate_stability(
|
| 147 |
+
self, prompt: str, width: int, height: int
|
| 148 |
+
) -> np.ndarray | None:
|
| 149 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 150 |
+
resp = await client.post(
|
| 151 |
+
self.base_url,
|
| 152 |
+
headers={
|
| 153 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 154 |
+
"Accept": "image/png",
|
| 155 |
+
},
|
| 156 |
+
files={"none": ""},
|
| 157 |
+
data={
|
| 158 |
+
"prompt": prompt,
|
| 159 |
+
"output_format": "png",
|
| 160 |
+
"aspect_ratio": f"{width}:{height}",
|
| 161 |
+
},
|
| 162 |
+
)
|
| 163 |
+
if resp.status_code == 200:
|
| 164 |
+
return self._bytes_to_cv2(resp.content)
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
def _bytes_to_cv2(self, img_bytes: bytes) -> np.ndarray:
|
| 168 |
+
nparr = np.frombuffer(img_bytes, np.uint8)
|
| 169 |
+
return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
app/models/line_art.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-model line art extraction with intelligent fusion.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
import onnxruntime as ort
|
| 8 |
+
|
| 9 |
+
from app.models.registry import ModelRegistry
|
| 10 |
+
from app.config import LineArtStyle
|
| 11 |
+
from app.processing.utils import (
|
| 12 |
+
multi_scale_close,
|
| 13 |
+
directional_close,
|
| 14 |
+
bridge_endpoints,
|
| 15 |
+
hysteresis_threshold,
|
| 16 |
+
declutter_lines,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class MultiLineArtExtractor:
|
| 21 |
+
"""Extracts line art using one or more AI models."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, registry: ModelRegistry):
|
| 24 |
+
self.registry = registry
|
| 25 |
+
|
| 26 |
+
def _run_model(
|
| 27 |
+
self,
|
| 28 |
+
session: ort.InferenceSession,
|
| 29 |
+
image_bgr: np.ndarray,
|
| 30 |
+
target_size: int = 512,
|
| 31 |
+
) -> np.ndarray:
|
| 32 |
+
"""Run a single ONNX line art model."""
|
| 33 |
+
original_h, original_w = image_bgr.shape[:2]
|
| 34 |
+
|
| 35 |
+
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
| 36 |
+
resized = cv2.resize(rgb, (target_size, target_size))
|
| 37 |
+
blob = resized.astype(np.float32) / 255.0
|
| 38 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 39 |
+
blob = np.expand_dims(blob, axis=0)
|
| 40 |
+
|
| 41 |
+
input_name = session.get_inputs()[0].name
|
| 42 |
+
outputs = session.run(None, {input_name: blob})
|
| 43 |
+
raw = np.squeeze(outputs[0])
|
| 44 |
+
raw = np.clip(raw, 0.0, 1.0)
|
| 45 |
+
|
| 46 |
+
return cv2.resize(raw, (original_w, original_h))
|
| 47 |
+
|
| 48 |
+
def _preprocess(self, image: np.ndarray) -> np.ndarray:
|
| 49 |
+
"""Bilateral filter + CLAHE for cleaner model input."""
|
| 50 |
+
filtered = cv2.bilateralFilter(image, d=9, sigmaColor=75, sigmaSpace=75)
|
| 51 |
+
lab = cv2.cvtColor(filtered, cv2.COLOR_BGR2LAB)
|
| 52 |
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 53 |
+
lab[:, :, 0] = clahe.apply(lab[:, :, 0])
|
| 54 |
+
return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
|
| 55 |
+
|
| 56 |
+
def extract(
|
| 57 |
+
self,
|
| 58 |
+
image: np.ndarray,
|
| 59 |
+
style: LineArtStyle = LineArtStyle.FUSED,
|
| 60 |
+
density: str = "normal",
|
| 61 |
+
bridge_gaps: bool = True,
|
| 62 |
+
) -> np.ndarray:
|
| 63 |
+
"""
|
| 64 |
+
Extract line art using specified style.
|
| 65 |
+
"""
|
| 66 |
+
preprocessed = self._preprocess(image)
|
| 67 |
+
outputs: list[np.ndarray] = []
|
| 68 |
+
|
| 69 |
+
# ── Decide which models to run ──
|
| 70 |
+
if style == LineArtStyle.AUTO:
|
| 71 |
+
style = self._detect_best_style(image)
|
| 72 |
+
|
| 73 |
+
if style == LineArtStyle.FUSED:
|
| 74 |
+
models_to_run = ["informative_drawings", "anyline"]
|
| 75 |
+
elif style == LineArtStyle.MANGA:
|
| 76 |
+
models_to_run = ["manga_line"]
|
| 77 |
+
elif style == LineArtStyle.ANYLINE:
|
| 78 |
+
models_to_run = ["anyline"]
|
| 79 |
+
else:
|
| 80 |
+
models_to_run = ["informative_drawings"]
|
| 81 |
+
|
| 82 |
+
print(f" [LineArt] Styles to run: {models_to_run}")
|
| 83 |
+
|
| 84 |
+
# ── Run each available model ──
|
| 85 |
+
for model_name in models_to_run:
|
| 86 |
+
session = self.registry.get(model_name)
|
| 87 |
+
if session is None:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
sizes = self._get_inference_sizes(model_name, density)
|
| 91 |
+
scale_outputs = []
|
| 92 |
+
for size in sizes:
|
| 93 |
+
out = self._run_model(session, preprocessed, target_size=size)
|
| 94 |
+
scale_outputs.append(out)
|
| 95 |
+
|
| 96 |
+
merged = scale_outputs[0]
|
| 97 |
+
for o in scale_outputs[1:]:
|
| 98 |
+
merged = np.maximum(merged, o)
|
| 99 |
+
|
| 100 |
+
outputs.append(merged)
|
| 101 |
+
|
| 102 |
+
# ── Fallback: Canny if no models available ──
|
| 103 |
+
if not outputs:
|
| 104 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 105 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 106 |
+
outputs.append(edges.astype(np.float32) / 255.0)
|
| 107 |
+
|
| 108 |
+
# ── Fuse multiple model outputs ──
|
| 109 |
+
if len(outputs) == 1:
|
| 110 |
+
fused = outputs[0]
|
| 111 |
+
else:
|
| 112 |
+
print(f" [LineArt] Fusing {len(outputs)} model outputs...")
|
| 113 |
+
fused = self._fuse_outputs(outputs)
|
| 114 |
+
|
| 115 |
+
# ── To uint8 ──
|
| 116 |
+
gray = (fused * 255).astype(np.uint8)
|
| 117 |
+
|
| 118 |
+
# ── Hysteresis threshold ──
|
| 119 |
+
thresholds = {
|
| 120 |
+
"sparse": (100, 200),
|
| 121 |
+
"normal": (90, 180),
|
| 122 |
+
"dense": (80, 160),
|
| 123 |
+
}
|
| 124 |
+
lo, hi = thresholds.get(density, (90, 180))
|
| 125 |
+
binary = hysteresis_threshold(gray, low=lo, high=hi)
|
| 126 |
+
binary = cv2.bitwise_not(binary) # lines = 0, bg = 255
|
| 127 |
+
|
| 128 |
+
# ── Aggressive closing pipeline ──
|
| 129 |
+
print(" [LineArt] Running closing pipeline...")
|
| 130 |
+
binary = self._close_pipeline(binary, density, bridge_gaps=bridge_gaps)
|
| 131 |
+
|
| 132 |
+
return binary
|
| 133 |
+
|
| 134 |
+
def _fuse_outputs(self, outputs: list[np.ndarray]) -> np.ndarray:
|
| 135 |
+
"""Intelligent fusion of multiple model outputs."""
|
| 136 |
+
if len(outputs) == 2:
|
| 137 |
+
a, b = outputs[0], outputs[1]
|
| 138 |
+
agreement = np.minimum(a, b)
|
| 139 |
+
union = np.maximum(a, b)
|
| 140 |
+
fused = 0.7 * agreement + 0.3 * union
|
| 141 |
+
both_strong = (a > 0.3) & (b > 0.3)
|
| 142 |
+
fused[both_strong] = np.maximum(fused[both_strong], 0.8)
|
| 143 |
+
return np.clip(fused, 0.0, 1.0)
|
| 144 |
+
else:
|
| 145 |
+
stacked = np.stack(outputs)
|
| 146 |
+
avg = np.mean(stacked, axis=0)
|
| 147 |
+
agree_count = np.sum(stacked > 0.3, axis=0).astype(np.float32)
|
| 148 |
+
boost = agree_count / len(outputs)
|
| 149 |
+
fused = avg * (0.5 + 0.5 * boost)
|
| 150 |
+
return np.clip(fused, 0.0, 1.0)
|
| 151 |
+
|
| 152 |
+
def _close_pipeline(self, binary: np.ndarray, density: str, bridge_gaps: bool = True) -> np.ndarray:
|
| 153 |
+
"""Full morphological closing pipeline."""
|
| 154 |
+
h, w = binary.shape
|
| 155 |
+
scale_factor = min(w, h) / 1024 # Normalize based on 1024px baseline
|
| 156 |
+
|
| 157 |
+
configs = {
|
| 158 |
+
"sparse": {"kernels": [3], "dir_len": 5, "iters": 1, "bridge": 5},
|
| 159 |
+
"normal": {"kernels": [3, 5], "dir_len": 7, "iters": 1, "bridge": 10},
|
| 160 |
+
"dense": {"kernels": [3, 5, 7], "dir_len": 9, "iters": 2, "bridge": 15},
|
| 161 |
+
}
|
| 162 |
+
cfg = configs.get(density, configs["normal"])
|
| 163 |
+
|
| 164 |
+
# Scale bridge radius
|
| 165 |
+
bridge_dist = max(1, int(cfg["bridge"] * scale_factor))
|
| 166 |
+
if scale_factor < 0.5: # Small image
|
| 167 |
+
bridge_dist = min(bridge_dist, 8)
|
| 168 |
+
|
| 169 |
+
print(" - Multi-scale close...")
|
| 170 |
+
result = multi_scale_close(binary, cfg["kernels"], cfg["iters"])
|
| 171 |
+
print(" - Directional close...")
|
| 172 |
+
result = directional_close(result, cfg["dir_len"], cfg["iters"])
|
| 173 |
+
|
| 174 |
+
smooth = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
| 175 |
+
result = cv2.morphologyEx(result, cv2.MORPH_CLOSE, smooth, iterations=1)
|
| 176 |
+
|
| 177 |
+
if bridge_gaps:
|
| 178 |
+
print(f" - Bridging endpoints (radius {bridge_dist})...")
|
| 179 |
+
result = bridge_endpoints(result, search_radius=bridge_dist)
|
| 180 |
+
|
| 181 |
+
# ── Declutter: Merge close lines and thin out ──
|
| 182 |
+
print(" - Decluttering lines (merge & thin)...")
|
| 183 |
+
result = declutter_lines(result, merge_radius=2, thin_out=True)
|
| 184 |
+
|
| 185 |
+
return result
|
| 186 |
+
|
| 187 |
+
def _detect_best_style(self, image: np.ndarray) -> LineArtStyle:
|
| 188 |
+
"""Auto-detect content type to pick the best model."""
|
| 189 |
+
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
| 190 |
+
saturation = hsv[:, :, 1].mean()
|
| 191 |
+
if saturation > 120:
|
| 192 |
+
if self.registry.available("manga_line"):
|
| 193 |
+
return LineArtStyle.MANGA
|
| 194 |
+
return LineArtStyle.FUSED
|
| 195 |
+
|
| 196 |
+
def _get_inference_sizes(self, model_name: str, density: str) -> list[int]:
|
| 197 |
+
"""Get multi-scale inference sizes per model and density."""
|
| 198 |
+
base = {
|
| 199 |
+
"informative_drawings": 512,
|
| 200 |
+
"anyline": 768,
|
| 201 |
+
"manga_line": 512,
|
| 202 |
+
}.get(model_name, 512)
|
| 203 |
+
|
| 204 |
+
if density == "sparse":
|
| 205 |
+
return [base]
|
| 206 |
+
elif density == "dense":
|
| 207 |
+
return [max(256, base - 128), base, base + 256]
|
| 208 |
+
else:
|
| 209 |
+
return [base, base + 256]
|
app/models/registry.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lazy-loading model registry.
|
| 3 |
+
|
| 4 |
+
Models load on first use and stay in memory.
|
| 5 |
+
Gracefully degrades when optional models are missing.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import onnxruntime as ort
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from app.config import ModelPaths
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ModelRegistry:
|
| 16 |
+
"""Singleton that manages all ONNX model sessions."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, paths: ModelPaths | None = None):
|
| 19 |
+
self.paths = paths or ModelPaths()
|
| 20 |
+
self._sessions: dict[str, ort.InferenceSession | None] = {}
|
| 21 |
+
self._availability: dict[str, bool] = {}
|
| 22 |
+
|
| 23 |
+
# Check what's available at startup
|
| 24 |
+
for attr in vars(self.paths):
|
| 25 |
+
if not attr.startswith("_"):
|
| 26 |
+
path = getattr(self.paths, attr)
|
| 27 |
+
self._availability[attr] = Path(path).exists()
|
| 28 |
+
|
| 29 |
+
# Safely detect providers
|
| 30 |
+
available = ort.get_available_providers()
|
| 31 |
+
print(f" Available ONNX Providers: {available}")
|
| 32 |
+
|
| 33 |
+
self._providers = []
|
| 34 |
+
if "CUDAExecutionProvider" in available:
|
| 35 |
+
self._providers.append("CUDAExecutionProvider")
|
| 36 |
+
if "CPUExecutionProvider" in available:
|
| 37 |
+
self._providers.append("CPUExecutionProvider")
|
| 38 |
+
|
| 39 |
+
if not self._providers:
|
| 40 |
+
self._providers = ["CPUExecutionProvider"]
|
| 41 |
+
|
| 42 |
+
def available(self, name: str) -> bool:
|
| 43 |
+
return self._availability.get(name, False)
|
| 44 |
+
|
| 45 |
+
def get(self, name: str) -> ort.InferenceSession | None:
|
| 46 |
+
"""Get or lazily load an ONNX session."""
|
| 47 |
+
if name in self._sessions:
|
| 48 |
+
return self._sessions[name]
|
| 49 |
+
|
| 50 |
+
if not self.available(name):
|
| 51 |
+
self._sessions[name] = None
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
path = getattr(self.paths, name)
|
| 55 |
+
print(f" [Registry] Loading {name}...")
|
| 56 |
+
print(f" Path: {path}")
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
# Add a timeout check or more logs if it hangs here
|
| 60 |
+
session = ort.InferenceSession(path, providers=self._providers)
|
| 61 |
+
print(f" [Registry] ✓ {name} loaded successfully.")
|
| 62 |
+
self._sessions[name] = session
|
| 63 |
+
return session
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f" [Registry] ✗ Failed to load {name}: {e}")
|
| 66 |
+
self._sessions[name] = None
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
def status(self) -> dict[str, str]:
|
| 70 |
+
"""Return availability status of all models."""
|
| 71 |
+
result = {}
|
| 72 |
+
for name, avail in self._availability.items():
|
| 73 |
+
if name in self._sessions:
|
| 74 |
+
result[name] = "loaded" if self._sessions[name] else "failed"
|
| 75 |
+
elif avail:
|
| 76 |
+
result[name] = "available"
|
| 77 |
+
else:
|
| 78 |
+
result[name] = "missing"
|
| 79 |
+
return result
|
app/models/sam_segmenter.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SAM2 (Segment Anything Model 2) integration. Refined for SAM 2.1 ONNX.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
import onnxruntime as ort
|
| 8 |
+
from scipy import ndimage
|
| 9 |
+
|
| 10 |
+
from app.models.registry import ModelRegistry
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SAMSegmenter:
|
| 14 |
+
"""Segment Anything Model 2 for semantic region extraction."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, registry: ModelRegistry):
|
| 17 |
+
self.registry = registry
|
| 18 |
+
self._encoder: ort.InferenceSession | None = None
|
| 19 |
+
self._decoder: ort.InferenceSession | None = None
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def available(self) -> bool:
|
| 23 |
+
return (
|
| 24 |
+
self.registry.available("sam2_encoder")
|
| 25 |
+
and self.registry.available("sam2_decoder")
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def _load(self):
|
| 29 |
+
if self._encoder is None:
|
| 30 |
+
self._encoder = self.registry.get("sam2_encoder")
|
| 31 |
+
self._decoder = self.registry.get("sam2_decoder")
|
| 32 |
+
|
| 33 |
+
def _encode_image(self, image: np.ndarray) -> dict[str, np.ndarray]:
|
| 34 |
+
"""Run SAM2 image encoder to get embeddings and high-res features."""
|
| 35 |
+
resized = cv2.resize(image, (1024, 1024))
|
| 36 |
+
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
|
| 37 |
+
|
| 38 |
+
# Standard ImageNet normalization
|
| 39 |
+
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 40 |
+
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 41 |
+
|
| 42 |
+
blob = rgb.astype(np.float32) / 255.0
|
| 43 |
+
blob = (blob - mean) / std
|
| 44 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 45 |
+
blob = np.expand_dims(blob, axis=0)
|
| 46 |
+
|
| 47 |
+
input_name = self._encoder.get_inputs()[0].name
|
| 48 |
+
outputs = self._encoder.run(None, {input_name: blob})
|
| 49 |
+
|
| 50 |
+
# In SAM 2.1 ONNX, we often have multiple outputs:
|
| 51 |
+
# [image_embeddings, high_res_feat_1, high_res_feat_2]
|
| 52 |
+
out_names = [o.name for o in self._encoder.get_outputs()]
|
| 53 |
+
return dict(zip(out_names, outputs))
|
| 54 |
+
|
| 55 |
+
def _generate_grid_points(
|
| 56 |
+
self, h: int, w: int, n_points_per_side: int = 32
|
| 57 |
+
) -> np.ndarray:
|
| 58 |
+
"""Generate a grid of prompt points."""
|
| 59 |
+
xs = np.linspace(0, w, n_points_per_side + 2)[1:-1]
|
| 60 |
+
ys = np.linspace(0, h, n_points_per_side + 2)[1:-1]
|
| 61 |
+
xx, yy = np.meshgrid(xs, ys)
|
| 62 |
+
points = np.column_stack([xx.ravel(), yy.ravel()])
|
| 63 |
+
return points.astype(np.float32)
|
| 64 |
+
|
| 65 |
+
def _decode_mask(
|
| 66 |
+
self,
|
| 67 |
+
encoded_dict: dict[str, np.ndarray],
|
| 68 |
+
point: np.ndarray,
|
| 69 |
+
original_size: tuple[int, int],
|
| 70 |
+
) -> np.ndarray | None:
|
| 71 |
+
"""Run SAM2 decoder for a single point prompt."""
|
| 72 |
+
h, w = original_size
|
| 73 |
+
point_scaled = point.copy()
|
| 74 |
+
point_scaled[0] *= 1024 / w
|
| 75 |
+
point_scaled[1] *= 1024 / h
|
| 76 |
+
|
| 77 |
+
# Use explicit reshapes to ensure Rank 4/3 exactly as required by SAM 2.1 ONNX
|
| 78 |
+
point_coords = np.array([point_scaled], dtype=np.float32).reshape((1, 1, 1, 2))
|
| 79 |
+
point_labels = np.array([1], dtype=np.int64).reshape((1, 1, 1))
|
| 80 |
+
|
| 81 |
+
# Map encoder outputs to decoder inputs
|
| 82 |
+
decoder_inputs = {}
|
| 83 |
+
|
| 84 |
+
# Detect decoder input names
|
| 85 |
+
input_names = [i.name for i in self._decoder.get_inputs()]
|
| 86 |
+
|
| 87 |
+
# 1. Fill image features (embeddings and high-res if present)
|
| 88 |
+
for name in input_names:
|
| 89 |
+
if name in encoded_dict:
|
| 90 |
+
decoder_inputs[name] = encoded_dict[name]
|
| 91 |
+
elif "image_embedding" in name: # Fallback names
|
| 92 |
+
decoder_inputs[name] = encoded_dict.get("image_embeddings", list(encoded_dict.values())[0])
|
| 93 |
+
elif "point_coords" in name or "input_points" in name:
|
| 94 |
+
decoder_inputs[name] = point_coords
|
| 95 |
+
elif "point_labels" in name or "input_labels" in name:
|
| 96 |
+
decoder_inputs[name] = point_labels
|
| 97 |
+
elif "input_boxes" in name:
|
| 98 |
+
# Provide empty box [1, 1, 4] for SAM 2.1
|
| 99 |
+
decoder_inputs[name] = np.zeros((1, 1, 4), dtype=np.float32)
|
| 100 |
+
elif "mask_input" in name:
|
| 101 |
+
decoder_inputs[name] = np.zeros((1, 1, 212, 212), dtype=np.float32) # Standard mask size for some exports
|
| 102 |
+
# Some exports want 256, 256. We check shape if possible.
|
| 103 |
+
for inp in self._decoder.get_inputs():
|
| 104 |
+
if inp.name == name:
|
| 105 |
+
if isinstance(inp.shape[2], int):
|
| 106 |
+
decoder_inputs[name] = np.zeros((1, 1, inp.shape[2], inp.shape[3]), dtype=np.float32)
|
| 107 |
+
elif "has_mask" in name:
|
| 108 |
+
decoder_inputs[name] = np.array([0], dtype=np.float32).reshape((1,))
|
| 109 |
+
elif "orig_im_size" in name:
|
| 110 |
+
# SAM 2.1 often expects float32 [H, W]
|
| 111 |
+
decoder_inputs[name] = np.array([h, w], dtype=np.float32)
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
outputs = self._decoder.run(None, decoder_inputs)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f" [SAM decoder] ✗ Decoder run failed: {e}")
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
masks = outputs[0]
|
| 120 |
+
scores = outputs[1]
|
| 121 |
+
|
| 122 |
+
# Robust score indexing
|
| 123 |
+
flat_scores = scores.flatten()
|
| 124 |
+
best_idx = int(np.argmax(flat_scores))
|
| 125 |
+
|
| 126 |
+
if flat_scores[best_idx] < 0.2: # Support very weak features in expert mode
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
# Robust mask extraction regardless of Rank 4/5 variations
|
| 130 |
+
# masks is usually [1, 3, H, W] or [1, 1, 3, H, W]
|
| 131 |
+
m = np.squeeze(masks)
|
| 132 |
+
if m.ndim == 3: # (C, H, W)
|
| 133 |
+
# If C is 1 but best_idx is 0, this is fine.
|
| 134 |
+
# If C is 3, pick the best.
|
| 135 |
+
idx = min(best_idx, m.shape[0] - 1)
|
| 136 |
+
mask = m[idx]
|
| 137 |
+
elif m.ndim == 2: # (H, W)
|
| 138 |
+
mask = m
|
| 139 |
+
else:
|
| 140 |
+
# Fallback for complex shapes
|
| 141 |
+
masks_reshaped = masks.reshape(-1, masks.shape[-2], masks.shape[-1])
|
| 142 |
+
idx = min(best_idx, masks_reshaped.shape[0] - 1)
|
| 143 |
+
mask = masks_reshaped[idx]
|
| 144 |
+
|
| 145 |
+
mask = (mask > 0.0).astype(np.uint8)
|
| 146 |
+
mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
|
| 147 |
+
return mask
|
| 148 |
+
|
| 149 |
+
def segment(
|
| 150 |
+
self,
|
| 151 |
+
image: np.ndarray,
|
| 152 |
+
n_points: int = 24, # Reduced for speed
|
| 153 |
+
min_area_ratio: float = 0.001,
|
| 154 |
+
max_segments: int = 50,
|
| 155 |
+
) -> np.ndarray:
|
| 156 |
+
self._load()
|
| 157 |
+
if self._encoder is None or self._decoder is None:
|
| 158 |
+
raise RuntimeError("SAM2 models not available")
|
| 159 |
+
|
| 160 |
+
h, w = image.shape[:2]
|
| 161 |
+
min_area = int(h * w * min_area_ratio)
|
| 162 |
+
encoded_dict = self._encode_image(image)
|
| 163 |
+
points = self._generate_grid_points(h, w, n_points)
|
| 164 |
+
|
| 165 |
+
all_masks: list[np.ndarray] = []
|
| 166 |
+
all_areas: list[int] = []
|
| 167 |
+
|
| 168 |
+
for point in points:
|
| 169 |
+
mask = self._decode_mask(encoded_dict, point, (h, w))
|
| 170 |
+
if mask is None:
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
area = np.count_nonzero(mask)
|
| 174 |
+
if area < min_area:
|
| 175 |
+
continue
|
| 176 |
+
|
| 177 |
+
# NMS for masks
|
| 178 |
+
is_duplicate = False
|
| 179 |
+
for existing in all_masks:
|
| 180 |
+
iou = self._compute_iou(mask, existing)
|
| 181 |
+
if iou > 0.7: # Slightly lower threshold for merge
|
| 182 |
+
is_duplicate = True
|
| 183 |
+
break
|
| 184 |
+
if not is_duplicate:
|
| 185 |
+
all_masks.append(mask)
|
| 186 |
+
all_areas.append(area)
|
| 187 |
+
|
| 188 |
+
if not all_masks:
|
| 189 |
+
return np.zeros((h, w), dtype=np.int32)
|
| 190 |
+
|
| 191 |
+
if len(all_masks) > max_segments:
|
| 192 |
+
indices = np.argsort(all_areas)[::-1][:max_segments]
|
| 193 |
+
all_masks = [all_masks[i] for i in indices]
|
| 194 |
+
|
| 195 |
+
# Painter's algorithm
|
| 196 |
+
sorted_pairs = sorted(
|
| 197 |
+
enumerate(all_masks),
|
| 198 |
+
key=lambda x: np.count_nonzero(x[1]),
|
| 199 |
+
reverse=True,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
label_map = np.full((h, w), -1, dtype=np.int32)
|
| 203 |
+
for new_idx, (_, mask) in enumerate(sorted_pairs):
|
| 204 |
+
label_map[mask > 0] = new_idx
|
| 205 |
+
|
| 206 |
+
if np.any(label_map == -1):
|
| 207 |
+
label_map = self._fill_unlabeled(label_map)
|
| 208 |
+
|
| 209 |
+
return label_map
|
| 210 |
+
|
| 211 |
+
def _compute_iou(self, mask1: np.ndarray, mask2: np.ndarray) -> float:
|
| 212 |
+
intersection = np.count_nonzero(mask1 & mask2)
|
| 213 |
+
union = np.count_nonzero(mask1 | mask2)
|
| 214 |
+
return intersection / max(union, 1)
|
| 215 |
+
|
| 216 |
+
def _fill_unlabeled(self, label_map: np.ndarray) -> np.ndarray:
|
| 217 |
+
unlabeled = label_map == -1
|
| 218 |
+
labeled = label_map >= 0
|
| 219 |
+
if not np.any(unlabeled) or not np.any(labeled):
|
| 220 |
+
return label_map
|
| 221 |
+
|
| 222 |
+
_, nearest_indices = ndimage.distance_transform_edt(
|
| 223 |
+
unlabeled, return_distances=True, return_indices=True
|
| 224 |
+
)
|
| 225 |
+
result = label_map.copy()
|
| 226 |
+
result[unlabeled] = label_map[
|
| 227 |
+
nearest_indices[0][unlabeled],
|
| 228 |
+
nearest_indices[1][unlabeled],
|
| 229 |
+
]
|
| 230 |
+
return result
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class HybridSegmenter:
|
| 234 |
+
"""Combines SAM2 (semantic) + K-Means (color) segmentation."""
|
| 235 |
+
|
| 236 |
+
def __init__(self, sam: SAMSegmenter):
|
| 237 |
+
self.sam = sam
|
| 238 |
+
|
| 239 |
+
def segment(
|
| 240 |
+
self,
|
| 241 |
+
image: np.ndarray,
|
| 242 |
+
k_colors: int = 12,
|
| 243 |
+
spatial_weight: float = 0.1,
|
| 244 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 245 |
+
from app.processing.quantizer import quantize_colors
|
| 246 |
+
|
| 247 |
+
h, w = image.shape[:2]
|
| 248 |
+
|
| 249 |
+
# 1. Semantic segmentation - use more points for complex scenes
|
| 250 |
+
print(f" [HybridSeg] Running SAM with 32x32 grid...")
|
| 251 |
+
sam_labels = self.sam.segment(image, n_points=32, max_segments=400)
|
| 252 |
+
|
| 253 |
+
# 2. Color segmentation - use spatial weight to separate distant same-color regions
|
| 254 |
+
print(f" [HybridSeg] Running K-Means (k={k_colors}, spatial={spatial_weight})...")
|
| 255 |
+
_, km_palette, km_labels = quantize_colors(image, k_colors=k_colors, spatial_weight=spatial_weight)
|
| 256 |
+
|
| 257 |
+
# 3. Intersect (Hybrid)
|
| 258 |
+
n_km = km_labels.max() + 1
|
| 259 |
+
compound = sam_labels.astype(np.int64) * n_km + km_labels.astype(np.int64)
|
| 260 |
+
|
| 261 |
+
unique_compounds, inverse = np.unique(compound, return_inverse=True)
|
| 262 |
+
hybrid_labels = inverse.reshape(h, w).astype(np.int32)
|
| 263 |
+
|
| 264 |
+
n_regions = len(unique_compounds)
|
| 265 |
+
print(f" [HybridSeg] Found {n_regions} initial regions.")
|
| 266 |
+
|
| 267 |
+
palette = np.zeros((n_regions, 3), dtype=np.uint8)
|
| 268 |
+
# Calculate palette for new regions - use median for robustness to boundaries
|
| 269 |
+
for region_id in range(n_regions):
|
| 270 |
+
mask = hybrid_labels == region_id
|
| 271 |
+
if np.any(mask):
|
| 272 |
+
palette[region_id] = np.median(image[mask], axis=0).astype(np.uint8)
|
| 273 |
+
|
| 274 |
+
# 4. Merge if excessively many regions
|
| 275 |
+
limit = max(k_colors * 12, 400)
|
| 276 |
+
if n_regions > limit:
|
| 277 |
+
print(f" [HybridSeg] Merging {n_regions} down to {limit}...")
|
| 278 |
+
hybrid_labels, palette = self._merge_similar(
|
| 279 |
+
hybrid_labels, palette, target_k=limit
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
return hybrid_labels, palette
|
| 283 |
+
|
| 284 |
+
def _merge_similar(
|
| 285 |
+
self,
|
| 286 |
+
labels: np.ndarray,
|
| 287 |
+
palette: np.ndarray,
|
| 288 |
+
target_k: int,
|
| 289 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 290 |
+
from sklearn.cluster import AgglomerativeClustering
|
| 291 |
+
|
| 292 |
+
n_current = len(palette)
|
| 293 |
+
if n_current <= target_k:
|
| 294 |
+
return labels, palette
|
| 295 |
+
|
| 296 |
+
clustering = AgglomerativeClustering(
|
| 297 |
+
n_clusters=target_k,
|
| 298 |
+
metric="euclidean",
|
| 299 |
+
linkage="ward",
|
| 300 |
+
)
|
| 301 |
+
color_labels = clustering.fit_predict(palette.astype(np.float32))
|
| 302 |
+
|
| 303 |
+
new_labels = color_labels[labels]
|
| 304 |
+
new_palette = np.zeros((target_k, 3), dtype=np.uint8)
|
| 305 |
+
|
| 306 |
+
for i in range(target_k):
|
| 307 |
+
old_indices = np.where(color_labels == i)[0]
|
| 308 |
+
if len(old_indices) == 0: continue
|
| 309 |
+
|
| 310 |
+
# Weighted average by area
|
| 311 |
+
areas = np.array([np.count_nonzero(labels == idx) for idx in old_indices])
|
| 312 |
+
weights = areas / max(areas.sum(), 1)
|
| 313 |
+
new_palette[i] = np.average(
|
| 314 |
+
palette[old_indices].astype(np.float32),
|
| 315 |
+
weights=weights,
|
| 316 |
+
axis=0,
|
| 317 |
+
).astype(np.uint8)
|
| 318 |
+
|
| 319 |
+
return new_labels, new_palette
|
app/models/super_resolution.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Real-ESRGAN 4x super resolution.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from app.models.registry import ModelRegistry
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SuperResolution:
|
| 12 |
+
"""Real-ESRGAN 4x upscaling."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, registry: ModelRegistry):
|
| 15 |
+
self.registry = registry
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def available(self) -> bool:
|
| 19 |
+
return self.registry.available("real_esrgan")
|
| 20 |
+
|
| 21 |
+
def upscale(
|
| 22 |
+
self,
|
| 23 |
+
image: np.ndarray,
|
| 24 |
+
tile_size: int = 256,
|
| 25 |
+
overlap: int = 16,
|
| 26 |
+
) -> np.ndarray:
|
| 27 |
+
session = self.registry.get("real_esrgan")
|
| 28 |
+
if session is None:
|
| 29 |
+
h, w = image.shape[:2]
|
| 30 |
+
return cv2.resize(image, (w * 4, h * 4), interpolation=cv2.INTER_CUBIC)
|
| 31 |
+
|
| 32 |
+
h, w = image.shape[:2]
|
| 33 |
+
scale = 4
|
| 34 |
+
out_h, out_w = h * scale, w * scale
|
| 35 |
+
output = np.zeros((out_h, out_w, 3), dtype=np.float32)
|
| 36 |
+
weight = np.zeros((out_h, out_w, 1), dtype=np.float32)
|
| 37 |
+
input_name = session.get_inputs()[0].name
|
| 38 |
+
|
| 39 |
+
for y in range(0, h, tile_size - overlap):
|
| 40 |
+
for x in range(0, w, tile_size - overlap):
|
| 41 |
+
y2 = min(y + tile_size, h)
|
| 42 |
+
x2 = min(x + tile_size, w)
|
| 43 |
+
tile = image[y:y2, x:x2]
|
| 44 |
+
th, tw = tile.shape[:2]
|
| 45 |
+
if th < tile_size or tw < tile_size:
|
| 46 |
+
padded = np.zeros((tile_size, tile_size, 3), dtype=np.uint8)
|
| 47 |
+
padded[:th, :tw] = tile
|
| 48 |
+
tile = padded
|
| 49 |
+
blob = tile.astype(np.float32) / 255.0
|
| 50 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 51 |
+
blob = np.expand_dims(blob, axis=0)
|
| 52 |
+
sr_tile = session.run(None, {input_name: blob})[0]
|
| 53 |
+
sr_tile = np.squeeze(sr_tile)
|
| 54 |
+
sr_tile = np.transpose(sr_tile, (1, 2, 0))
|
| 55 |
+
sr_tile = np.clip(sr_tile, 0, 1)
|
| 56 |
+
oy, ox = y * scale, x * scale
|
| 57 |
+
oth, otw = th * scale, tw * scale
|
| 58 |
+
output[oy:oy + oth, ox:ox + otw] += sr_tile[:oth, :otw]
|
| 59 |
+
weight[oy:oy + oth, ox:ox + otw] += 1.0
|
| 60 |
+
|
| 61 |
+
weight = np.maximum(weight, 1.0)
|
| 62 |
+
output = output / weight
|
| 63 |
+
output = (output * 255).astype(np.uint8)
|
| 64 |
+
return output[:out_h, :out_w]
|
| 65 |
+
|
| 66 |
+
def should_upscale(self, image: np.ndarray, min_dim: int = 400) -> bool:
|
| 67 |
+
h, w = image.shape[:2]
|
| 68 |
+
return min(h, w) < min_dim
|
app/pipeline.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Master pipeline combining all models.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from app.config import PipelineConfig, ModelPaths, ContentType, LineArtStyle, Quality
|
| 10 |
+
from app.models.registry import ModelRegistry
|
| 11 |
+
from app.models.line_art import MultiLineArtExtractor
|
| 12 |
+
from app.models.sam_segmenter import SAMSegmenter, HybridSegmenter
|
| 13 |
+
from app.models.depth_estimator import DepthEstimator
|
| 14 |
+
from app.models.super_resolution import SuperResolution
|
| 15 |
+
from app.models.background import BackgroundRemover
|
| 16 |
+
from app.models.face_parser import FaceParser
|
| 17 |
+
from app.models.generator import TemplateGenerator
|
| 18 |
+
from app.processing.quantizer import (
|
| 19 |
+
quantize_colors,
|
| 20 |
+
merge_line_art_with_regions,
|
| 21 |
+
merge_small_regions_into_neighbors,
|
| 22 |
+
smooth_label_map,
|
| 23 |
+
estimate_k,
|
| 24 |
+
)
|
| 25 |
+
from app.processing.utils import get_adjacency_matrix, get_pole_of_inaccessibility
|
| 26 |
+
from app.processing.vectorizer import generate_svg
|
| 27 |
+
from app.processing.difficulty import compute_region_difficulty
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ColorByNumberPipeline:
|
| 31 |
+
"""Full multi-model pipeline."""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
model_paths: ModelPaths | None = None,
|
| 36 |
+
generation_api_key: str = "",
|
| 37 |
+
generation_provider: str = "replicate",
|
| 38 |
+
):
|
| 39 |
+
self.paths = model_paths or ModelPaths()
|
| 40 |
+
self.registry = ModelRegistry(self.paths)
|
| 41 |
+
|
| 42 |
+
self.line_art = MultiLineArtExtractor(self.registry)
|
| 43 |
+
self.sam = SAMSegmenter(self.registry)
|
| 44 |
+
self.hybrid_seg = HybridSegmenter(self.sam)
|
| 45 |
+
self.depth = DepthEstimator(self.registry)
|
| 46 |
+
self.super_res = SuperResolution(self.registry)
|
| 47 |
+
self.bg_remover = BackgroundRemover(self.registry)
|
| 48 |
+
self.face_parser = FaceParser(self.registry)
|
| 49 |
+
self.generator = TemplateGenerator(
|
| 50 |
+
api_key=generation_api_key,
|
| 51 |
+
provider=generation_provider,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
def _detect_content_type(self, image: np.ndarray) -> ContentType:
|
| 55 |
+
if self.face_parser.available and self.face_parser.is_portrait(image):
|
| 56 |
+
return ContentType.PORTRAIT
|
| 57 |
+
|
| 58 |
+
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
| 59 |
+
avg_sat = hsv[:, :, 1].mean()
|
| 60 |
+
sat_std = hsv[:, :, 1].std()
|
| 61 |
+
|
| 62 |
+
if avg_sat > 100 and sat_std < 50:
|
| 63 |
+
return ContentType.ANIME
|
| 64 |
+
|
| 65 |
+
h, w = image.shape[:2]
|
| 66 |
+
if w / h > 1.3:
|
| 67 |
+
blue_mask = (hsv[:, :, 0] > 90) & (hsv[:, :, 0] < 130)
|
| 68 |
+
green_mask = (hsv[:, :, 0] > 35) & (hsv[:, :, 0] < 85)
|
| 69 |
+
nature_ratio = (blue_mask.sum() + green_mask.sum()) / (h * w)
|
| 70 |
+
if nature_ratio > 0.3:
|
| 71 |
+
return ContentType.LANDSCAPE
|
| 72 |
+
|
| 73 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 74 |
+
unique_vals = len(np.unique(gray[::4, ::4]))
|
| 75 |
+
if unique_vals < 50:
|
| 76 |
+
return ContentType.ILLUSTRATION
|
| 77 |
+
|
| 78 |
+
return ContentType.PHOTO
|
| 79 |
+
|
| 80 |
+
def _estimate_density(self, image: np.ndarray) -> str:
|
| 81 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 82 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 83 |
+
ratio = np.count_nonzero(edges) / edges.size
|
| 84 |
+
if ratio < 0.03:
|
| 85 |
+
return "sparse"
|
| 86 |
+
elif ratio < 0.08:
|
| 87 |
+
return "normal"
|
| 88 |
+
return "dense"
|
| 89 |
+
|
| 90 |
+
def _save_debug(self, name: str, img: np.ndarray, debug_dir: str):
|
| 91 |
+
Path(debug_dir).mkdir(parents=True, exist_ok=True)
|
| 92 |
+
cv2.imwrite(str(Path(debug_dir) / f"{name}.png"), img)
|
| 93 |
+
|
| 94 |
+
def process(
|
| 95 |
+
self,
|
| 96 |
+
image: np.ndarray,
|
| 97 |
+
config: PipelineConfig | None = None,
|
| 98 |
+
output_path: str = "outputs/result.svg",
|
| 99 |
+
) -> dict:
|
| 100 |
+
if config is None:
|
| 101 |
+
config = PipelineConfig()
|
| 102 |
+
|
| 103 |
+
config.apply_quality()
|
| 104 |
+
config.apply_difficulty()
|
| 105 |
+
|
| 106 |
+
if config.save_debug:
|
| 107 |
+
self._save_debug("00_original_input", image, config.debug_dir)
|
| 108 |
+
|
| 109 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
|
| 111 |
+
if config.content_type == ContentType.AUTO:
|
| 112 |
+
config.content_type = self._detect_content_type(image)
|
| 113 |
+
if config.line_style == LineArtStyle.AUTO:
|
| 114 |
+
style_map = {
|
| 115 |
+
ContentType.ANIME: LineArtStyle.MANGA,
|
| 116 |
+
ContentType.ILLUSTRATION: LineArtStyle.INFORMATIVE,
|
| 117 |
+
ContentType.PORTRAIT: LineArtStyle.FUSED,
|
| 118 |
+
ContentType.LANDSCAPE: LineArtStyle.FUSED,
|
| 119 |
+
ContentType.PHOTO: LineArtStyle.FUSED,
|
| 120 |
+
}
|
| 121 |
+
config.line_style = style_map.get(config.content_type, LineArtStyle.FUSED)
|
| 122 |
+
|
| 123 |
+
print(f" [Pipeline] Detected content type: {config.content_type.value}")
|
| 124 |
+
if config.density == "auto":
|
| 125 |
+
config.density = self._estimate_density(image)
|
| 126 |
+
print(f" [Pipeline] Estimated density: {config.density}")
|
| 127 |
+
|
| 128 |
+
if config.k_colors is None:
|
| 129 |
+
config.k_colors = estimate_k(image)
|
| 130 |
+
print(f" [Pipeline] Target colors: {config.k_colors} (Spatial: {config.spatial_weight})")
|
| 131 |
+
|
| 132 |
+
if (
|
| 133 |
+
config.upscale_if_small
|
| 134 |
+
and self.super_res.available
|
| 135 |
+
and self.super_res.should_upscale(image)
|
| 136 |
+
):
|
| 137 |
+
print(" [Pipeline] Upscaling small image...")
|
| 138 |
+
image = self.super_res.upscale(image)
|
| 139 |
+
if config.save_debug:
|
| 140 |
+
self._save_debug("01_upscaled", image, config.debug_dir)
|
| 141 |
+
|
| 142 |
+
h, w = image.shape[:2]
|
| 143 |
+
if max(h, w) > config.max_dimension:
|
| 144 |
+
print(f" [Pipeline] Resizing to {config.max_dimension}px...")
|
| 145 |
+
scale = config.max_dimension / max(h, w)
|
| 146 |
+
image = cv2.resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
| 147 |
+
|
| 148 |
+
original_for_colors = image.copy()
|
| 149 |
+
|
| 150 |
+
fg_mask = None
|
| 151 |
+
if config.remove_background and self.bg_remover.available:
|
| 152 |
+
print(" [Pipeline] Removing background...")
|
| 153 |
+
image, fg_mask = self.bg_remover.remove(image)
|
| 154 |
+
if config.save_debug:
|
| 155 |
+
self._save_debug("02_bg_removed", image, config.debug_dir)
|
| 156 |
+
|
| 157 |
+
line_input = image.copy()
|
| 158 |
+
if config.density == "dense":
|
| 159 |
+
line_input = cv2.bilateralFilter(line_input, d=15, sigmaColor=100, sigmaSpace=100)
|
| 160 |
+
|
| 161 |
+
print(" [Pipeline] Extracting line art...")
|
| 162 |
+
line_art = self.line_art.extract(
|
| 163 |
+
line_input,
|
| 164 |
+
style=config.line_style,
|
| 165 |
+
density=config.density,
|
| 166 |
+
bridge_gaps=config.bridge_gaps,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if config.save_debug:
|
| 170 |
+
self._save_debug("03_line_art", line_art, config.debug_dir)
|
| 171 |
+
|
| 172 |
+
if config.use_sam and self.sam.available:
|
| 173 |
+
try:
|
| 174 |
+
print(f" [Pipeline] Segmenting with SAM (Expert Detail)...")
|
| 175 |
+
label_map, palette = self.hybrid_seg.segment(
|
| 176 |
+
original_for_colors,
|
| 177 |
+
k_colors=config.k_colors,
|
| 178 |
+
spatial_weight=config.spatial_weight
|
| 179 |
+
)
|
| 180 |
+
except Exception as e:
|
| 181 |
+
import traceback
|
| 182 |
+
print(f" [Pipeline] ✗ SAM failed dramatically:")
|
| 183 |
+
traceback.print_exc()
|
| 184 |
+
print(f" [Pipeline] Falling back to K-Means quantization...")
|
| 185 |
+
_, palette, label_map = quantize_colors(
|
| 186 |
+
original_for_colors,
|
| 187 |
+
k_colors=config.k_colors,
|
| 188 |
+
spatial_weight=config.spatial_weight
|
| 189 |
+
)
|
| 190 |
+
else:
|
| 191 |
+
_, palette, label_map = quantize_colors(
|
| 192 |
+
original_for_colors,
|
| 193 |
+
k_colors=config.k_colors,
|
| 194 |
+
spatial_weight=config.spatial_weight
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
if label_map is None:
|
| 198 |
+
_, palette, label_map = quantize_colors(
|
| 199 |
+
original_for_colors,
|
| 200 |
+
k_colors=config.k_colors,
|
| 201 |
+
spatial_weight=config.spatial_weight
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
if config.save_debug:
|
| 205 |
+
debug_q = palette[np.clip(label_map, 0, len(palette) - 1)]
|
| 206 |
+
self._save_debug("04_segmented", debug_q.reshape(image.shape), config.debug_dir)
|
| 207 |
+
|
| 208 |
+
if (
|
| 209 |
+
config.detect_faces
|
| 210 |
+
and config.content_type == ContentType.PORTRAIT
|
| 211 |
+
and self.face_parser.available
|
| 212 |
+
):
|
| 213 |
+
print(" [Pipeline] Parsing facial features...")
|
| 214 |
+
face_map = self.face_parser.parse(original_for_colors)
|
| 215 |
+
if face_map is not None:
|
| 216 |
+
faces = self.face_parser.detect_faces(original_for_colors)
|
| 217 |
+
bbox = faces[0] if faces else None
|
| 218 |
+
label_map = self.face_parser.merge_with_label_map(face_map, label_map, face_bbox=bbox)
|
| 219 |
+
max_lbl = label_map.max() + 1
|
| 220 |
+
if max_lbl > len(palette):
|
| 221 |
+
extra = np.zeros((max_lbl - len(palette), 3), dtype=np.uint8)
|
| 222 |
+
for idx in range(len(palette), max_lbl):
|
| 223 |
+
mask = label_map == idx
|
| 224 |
+
if np.any(mask):
|
| 225 |
+
extra[idx - len(palette)] = np.median(original_for_colors[mask], axis=0).astype(np.uint8)
|
| 226 |
+
palette = np.vstack([palette, extra])
|
| 227 |
+
if config.save_debug:
|
| 228 |
+
vis_labels = (label_map * 15 % 255).astype(np.uint8)
|
| 229 |
+
self._save_debug("05_face_parsed", vis_labels, config.debug_dir)
|
| 230 |
+
|
| 231 |
+
print(" [Pipeline] Merging line art and regions...")
|
| 232 |
+
merged = merge_line_art_with_regions(label_map, line_art, -1)
|
| 233 |
+
|
| 234 |
+
# Safety check: if line art wiped out >80% of regions, skip it
|
| 235 |
+
n_orig = len(np.unique(label_map))
|
| 236 |
+
n_merged = len(np.unique(merged[merged >= 0]))
|
| 237 |
+
if n_merged < n_orig * 0.2 and n_orig > 5:
|
| 238 |
+
print(f" [Pipeline] ⚠ Line art merge lost too much detail ({n_merged}/{n_orig}). Skipping lines for better detail.")
|
| 239 |
+
merged = label_map.copy()
|
| 240 |
+
|
| 241 |
+
# Save merged final (after fallback)
|
| 242 |
+
if config.save_debug:
|
| 243 |
+
vis_merged = palette[np.clip(merged, 0, len(palette) - 1)]
|
| 244 |
+
vis_merged = vis_merged.reshape(image.shape)
|
| 245 |
+
vis_merged[merged == -1] = [255, 255, 255]
|
| 246 |
+
self._save_debug("06_merged_final", vis_merged, config.debug_dir)
|
| 247 |
+
|
| 248 |
+
# Adaptive min area for small images
|
| 249 |
+
effective_min_area = config.min_region_area
|
| 250 |
+
img_size = max(image.shape[:2])
|
| 251 |
+
if img_size < 512:
|
| 252 |
+
effective_min_area = min(effective_min_area, 50)
|
| 253 |
+
print(f" [Pipeline] Small image detected, reducing min_area to {effective_min_area}")
|
| 254 |
+
|
| 255 |
+
print(f" [Pipeline] Cleaning regions (min_area={effective_min_area})...")
|
| 256 |
+
cleaned = merge_small_regions_into_neighbors(merged, min_area=effective_min_area, palette=palette)
|
| 257 |
+
|
| 258 |
+
# ── FILL GAPS (New) ──
|
| 259 |
+
print(" [Pipeline] Smoothing map and filling internal gaps...")
|
| 260 |
+
cleaned = smooth_label_map(cleaned)
|
| 261 |
+
|
| 262 |
+
if fg_mask is not None:
|
| 263 |
+
# Re-apply background mask after smoothing if needed
|
| 264 |
+
cleaned[fg_mask == 0] = -1
|
| 265 |
+
|
| 266 |
+
if config.save_debug:
|
| 267 |
+
debug_c = palette[np.clip(cleaned, 0, len(palette) - 1)]
|
| 268 |
+
debug_c = debug_c.reshape(image.shape)
|
| 269 |
+
debug_c[cleaned == -1] = [255, 255, 255]
|
| 270 |
+
self._save_debug("07_cleaned_final", debug_c, config.debug_dir)
|
| 271 |
+
|
| 272 |
+
depth_map = None
|
| 273 |
+
difficulty_layers = None
|
| 274 |
+
coloring_order = None
|
| 275 |
+
region_info = None
|
| 276 |
+
|
| 277 |
+
if config.use_depth and self.depth.available:
|
| 278 |
+
print(" [Pipeline] Calculating depth and difficulty...")
|
| 279 |
+
try:
|
| 280 |
+
depth_map = self.depth.estimate(original_for_colors)
|
| 281 |
+
difficulty_layers = self.depth.assign_difficulty_layers(depth_map, cleaned, n_layers=config.depth_layers)
|
| 282 |
+
coloring_order = self.depth.create_ordering(depth_map, cleaned)
|
| 283 |
+
region_info = compute_region_difficulty(cleaned, palette, depth_map, config.depth_layers)
|
| 284 |
+
if config.save_debug:
|
| 285 |
+
depth_vis = (depth_map * 255).astype(np.uint8)
|
| 286 |
+
depth_vis = cv2.applyColorMap(depth_vis, cv2.COLORMAP_VIRIDIS)
|
| 287 |
+
self._save_debug("06_depth", depth_vis, config.debug_dir)
|
| 288 |
+
except Exception as e:
|
| 289 |
+
print(f"Depth estimation failed: {e}")
|
| 290 |
+
|
| 291 |
+
# ── GENERATE SVGS ──
|
| 292 |
+
# 1. Colored SVG
|
| 293 |
+
svg_colored = generate_svg(
|
| 294 |
+
label_map=cleaned,
|
| 295 |
+
palette=palette,
|
| 296 |
+
output_path=output_path,
|
| 297 |
+
min_area=config.min_region_area // 2,
|
| 298 |
+
difficulty_layers=difficulty_layers,
|
| 299 |
+
fill_color=True,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# 2. Outline SVG
|
| 303 |
+
outline_path = output_path.replace(".svg", "_outline.svg")
|
| 304 |
+
svg_outline = generate_svg(
|
| 305 |
+
label_map=cleaned,
|
| 306 |
+
palette=palette,
|
| 307 |
+
output_path=outline_path,
|
| 308 |
+
min_area=config.min_region_area // 2,
|
| 309 |
+
difficulty_layers=difficulty_layers,
|
| 310 |
+
fill_color=False,
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
num_regions = svg_colored.count('class="fillable"')
|
| 314 |
+
|
| 315 |
+
# ── ADJACENCY (New) ──
|
| 316 |
+
print(" [Pipeline] Calculating region adjacency...")
|
| 317 |
+
adj_matrix = get_adjacency_matrix(cleaned)
|
| 318 |
+
|
| 319 |
+
result = {
|
| 320 |
+
"svg_string": svg_colored,
|
| 321 |
+
"svg_outline_string": svg_outline,
|
| 322 |
+
"svg_path": output_path,
|
| 323 |
+
"svg_outline_path": outline_path,
|
| 324 |
+
"palette": [
|
| 325 |
+
{
|
| 326 |
+
"index": i + 1,
|
| 327 |
+
"hex": f"#{int(c[2]):02X}{int(c[1]):02X}{int(c[0]):02X}",
|
| 328 |
+
}
|
| 329 |
+
for i, c in enumerate(palette)
|
| 330 |
+
],
|
| 331 |
+
"num_regions": int(num_regions),
|
| 332 |
+
"adjacency_matrix": [[int(a), int(b)] for a, b in adj_matrix],
|
| 333 |
+
"k_colors_used": int(len(palette)),
|
| 334 |
+
"density_used": str(config.density),
|
| 335 |
+
"content_type": str(config.content_type.value),
|
| 336 |
+
"models_used": self._get_models_used(config),
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
if coloring_order:
|
| 340 |
+
result["suggested_order"] = [int(x) for x in coloring_order]
|
| 341 |
+
if difficulty_layers:
|
| 342 |
+
result["difficulty_layers"] = {int(k): int(v) for k, v in difficulty_layers.items()}
|
| 343 |
+
if region_info:
|
| 344 |
+
result["regions"] = [
|
| 345 |
+
{
|
| 346 |
+
"label": int(r.label),
|
| 347 |
+
"area": int(r.area),
|
| 348 |
+
"color": str(r.color_hex),
|
| 349 |
+
"difficulty_layer": int(r.difficulty_layer),
|
| 350 |
+
"suggested_order": int(r.suggested_order),
|
| 351 |
+
"centroid": [int(r.centroid[0]), int(r.centroid[1])],
|
| 352 |
+
"seed_point": [int(r.seed_point[0]), int(r.seed_point[1])],
|
| 353 |
+
}
|
| 354 |
+
for r in region_info
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
return result
|
| 358 |
+
|
| 359 |
+
def _get_models_used(self, config: PipelineConfig) -> list[str]:
|
| 360 |
+
used = []
|
| 361 |
+
if config.line_style in (LineArtStyle.FUSED, LineArtStyle.INFORMATIVE):
|
| 362 |
+
if self.registry.available("informative_drawings"):
|
| 363 |
+
used.append("informative-drawings")
|
| 364 |
+
if config.line_style in (LineArtStyle.FUSED, LineArtStyle.ANYLINE):
|
| 365 |
+
if self.registry.available("anyline"):
|
| 366 |
+
used.append("anyline")
|
| 367 |
+
if config.line_style == LineArtStyle.MANGA:
|
| 368 |
+
if self.registry.available("manga_line"):
|
| 369 |
+
used.append("manga-line")
|
| 370 |
+
if config.use_sam and self.sam.available:
|
| 371 |
+
used.append("sam2")
|
| 372 |
+
if config.use_depth and self.depth.available:
|
| 373 |
+
used.append("depth-anything-v2")
|
| 374 |
+
if config.content_type == ContentType.PORTRAIT and self.face_parser.available:
|
| 375 |
+
used.append("face-parser")
|
| 376 |
+
return used
|
| 377 |
+
|
| 378 |
+
async def generate_from_prompt(
|
| 379 |
+
self,
|
| 380 |
+
prompt: str,
|
| 381 |
+
style: str = "coloring_book",
|
| 382 |
+
config: PipelineConfig | None = None,
|
| 383 |
+
output_path: str = "outputs/generated.svg",
|
| 384 |
+
) -> dict | None:
|
| 385 |
+
if not self.generator.available:
|
| 386 |
+
return None
|
| 387 |
+
image = await self.generator.generate_from_prompt(prompt, style)
|
| 388 |
+
if image is None:
|
| 389 |
+
return None
|
| 390 |
+
result = self.process(image, config, output_path)
|
| 391 |
+
result["source"] = "generated"
|
| 392 |
+
result["prompt"] = prompt
|
| 393 |
+
return result
|
app/processing/difficulty.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from app.processing.utils import get_pole_of_inaccessibility
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class RegionInfo:
|
| 8 |
+
label: int
|
| 9 |
+
area: int
|
| 10 |
+
avg_depth: float
|
| 11 |
+
color_hex: str
|
| 12 |
+
difficulty_layer: int
|
| 13 |
+
suggested_order: int
|
| 14 |
+
centroid: tuple[int, int]
|
| 15 |
+
seed_point: tuple[int, int]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def compute_region_difficulty(
|
| 19 |
+
label_map: np.ndarray,
|
| 20 |
+
palette: np.ndarray,
|
| 21 |
+
depth_map: np.ndarray | None = None,
|
| 22 |
+
n_layers: int = 3,
|
| 23 |
+
) -> list[RegionInfo]:
|
| 24 |
+
import cv2
|
| 25 |
+
|
| 26 |
+
unique_labels = np.unique(label_map)
|
| 27 |
+
unique_labels = unique_labels[unique_labels >= 0]
|
| 28 |
+
|
| 29 |
+
h, w = label_map.shape
|
| 30 |
+
total_pixels = h * w
|
| 31 |
+
|
| 32 |
+
regions = []
|
| 33 |
+
for lbl in unique_labels:
|
| 34 |
+
mask = (label_map == lbl).astype(np.uint8)
|
| 35 |
+
area = np.count_nonzero(mask)
|
| 36 |
+
|
| 37 |
+
if area < 10:
|
| 38 |
+
continue
|
| 39 |
+
|
| 40 |
+
moments = cv2.moments(mask)
|
| 41 |
+
if moments["m00"] > 0:
|
| 42 |
+
cx = int(moments["m10"] / moments["m00"])
|
| 43 |
+
cy = int(moments["m01"] / moments["m00"])
|
| 44 |
+
else:
|
| 45 |
+
cy_coords, cx_coords = np.where(mask > 0)
|
| 46 |
+
cy, cx = int(np.mean(cy_coords)), int(np.mean(cx_coords))
|
| 47 |
+
|
| 48 |
+
if depth_map is not None:
|
| 49 |
+
avg_depth = float(depth_map[mask > 0].mean())
|
| 50 |
+
else:
|
| 51 |
+
avg_depth = 0.5
|
| 52 |
+
|
| 53 |
+
size_score = 1.0 - min(area / (total_pixels * 0.1), 1.0)
|
| 54 |
+
|
| 55 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 56 |
+
if contours:
|
| 57 |
+
perim = cv2.arcLength(contours[0], True)
|
| 58 |
+
circularity = (4 * np.pi * area) / max(perim * perim, 1)
|
| 59 |
+
complexity_score = 1.0 - min(circularity, 1.0)
|
| 60 |
+
else:
|
| 61 |
+
complexity_score = 0.5
|
| 62 |
+
|
| 63 |
+
difficulty = 0.4 * avg_depth + 0.3 * size_score + 0.3 * complexity_score
|
| 64 |
+
layer = int(difficulty * n_layers)
|
| 65 |
+
layer = min(layer, n_layers - 1)
|
| 66 |
+
|
| 67 |
+
color_idx = min(int(lbl), len(palette) - 1)
|
| 68 |
+
c = palette[color_idx]
|
| 69 |
+
hex_color = f"#{int(c[2]):02X}{int(c[1]):02X}{int(c[0]):02X}"
|
| 70 |
+
|
| 71 |
+
# Calculate seed_point (Pole of Inaccessibility)
|
| 72 |
+
seed_x, seed_y = get_pole_of_inaccessibility(mask)
|
| 73 |
+
|
| 74 |
+
regions.append(RegionInfo(
|
| 75 |
+
label=int(lbl),
|
| 76 |
+
area=area,
|
| 77 |
+
avg_depth=avg_depth,
|
| 78 |
+
color_hex=hex_color,
|
| 79 |
+
difficulty_layer=layer,
|
| 80 |
+
suggested_order=0,
|
| 81 |
+
centroid=(cx, cy),
|
| 82 |
+
seed_point=(seed_x, seed_y),
|
| 83 |
+
))
|
| 84 |
+
|
| 85 |
+
regions.sort(key=lambda r: (r.difficulty_layer, -r.area))
|
| 86 |
+
for i, r in enumerate(regions):
|
| 87 |
+
r.suggested_order = i + 1
|
| 88 |
+
|
| 89 |
+
return regions
|
app/processing/quantizer.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced color quantization and region management.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
from sklearn.cluster import MiniBatchKMeans
|
| 8 |
+
from collections import Counter
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def estimate_k(image: np.ndarray, min_k: int = 6, max_k: int = 24) -> int:
|
| 12 |
+
"""Estimate optimal number of colors based on image complexity."""
|
| 13 |
+
small = cv2.resize(image, (128, 128))
|
| 14 |
+
hsv = cv2.cvtColor(small, cv2.COLOR_BGR2HSV)
|
| 15 |
+
hist = cv2.calcHist([hsv], [0, 1], None, [30, 30], [0, 180, 0, 256])
|
| 16 |
+
hist = hist.flatten()
|
| 17 |
+
hist = hist[hist > 0]
|
| 18 |
+
hist = hist / hist.sum()
|
| 19 |
+
entropy = -np.sum(hist * np.log2(hist))
|
| 20 |
+
k = int(np.interp(entropy, [2.0, 8.0], [min_k, max_k]))
|
| 21 |
+
return np.clip(k, min_k, max_k)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def quantize_colors(
|
| 25 |
+
image: np.ndarray,
|
| 26 |
+
k_colors: int = 12,
|
| 27 |
+
sample_fraction: float = 0.25,
|
| 28 |
+
use_lab: bool = True,
|
| 29 |
+
spatial_weight: float = 0.0,
|
| 30 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 31 |
+
h, w = image.shape[:2]
|
| 32 |
+
if use_lab:
|
| 33 |
+
working_img = cv2.cvtColor(image, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 34 |
+
else:
|
| 35 |
+
working_img = image.astype(np.float32)
|
| 36 |
+
pixels = working_img.reshape(-1, 3)
|
| 37 |
+
if spatial_weight > 0:
|
| 38 |
+
yy, xx = np.mgrid[0:h, 0:w]
|
| 39 |
+
coords = np.column_stack([
|
| 40 |
+
xx.ravel().astype(np.float32) / w * spatial_weight * 100,
|
| 41 |
+
yy.ravel().astype(np.float32) / h * spatial_weight * 100,
|
| 42 |
+
])
|
| 43 |
+
features = np.hstack([pixels, coords])
|
| 44 |
+
else:
|
| 45 |
+
features = pixels
|
| 46 |
+
n_pixels = features.shape[0]
|
| 47 |
+
n_samples = max(int(n_pixels * sample_fraction), k_colors * 200)
|
| 48 |
+
rng = np.random.default_rng(42)
|
| 49 |
+
if n_samples < n_pixels:
|
| 50 |
+
indices = rng.choice(n_pixels, n_samples, replace=False)
|
| 51 |
+
sample = features[indices]
|
| 52 |
+
else:
|
| 53 |
+
sample = features
|
| 54 |
+
kmeans = MiniBatchKMeans(
|
| 55 |
+
n_clusters=k_colors,
|
| 56 |
+
random_state=42,
|
| 57 |
+
batch_size=2048,
|
| 58 |
+
n_init=5,
|
| 59 |
+
max_iter=300,
|
| 60 |
+
)
|
| 61 |
+
kmeans.fit(sample)
|
| 62 |
+
labels = kmeans.predict(features)
|
| 63 |
+
label_map = labels.reshape(h, w)
|
| 64 |
+
centers = kmeans.cluster_centers_[:, :3]
|
| 65 |
+
if use_lab:
|
| 66 |
+
centers_uint8 = np.clip(centers, 0, 255).astype(np.uint8)
|
| 67 |
+
centers_lab = centers_uint8.reshape(-1, 1, 3)
|
| 68 |
+
palette = cv2.cvtColor(centers_lab, cv2.COLOR_LAB2BGR).reshape(-1, 3)
|
| 69 |
+
else:
|
| 70 |
+
palette = np.clip(centers, 0, 255).astype(np.uint8)
|
| 71 |
+
quantized_img = palette[labels].reshape(h, w, 3)
|
| 72 |
+
return quantized_img, palette, label_map
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def merge_line_art_with_regions(
|
| 76 |
+
label_map: np.ndarray,
|
| 77 |
+
line_art_binary: np.ndarray,
|
| 78 |
+
line_color_index: int = -1,
|
| 79 |
+
) -> np.ndarray:
|
| 80 |
+
"""Subtract AI line art from color regions."""
|
| 81 |
+
line_mask = line_art_binary == 0
|
| 82 |
+
merged = label_map.copy()
|
| 83 |
+
merged[line_mask] = line_color_index
|
| 84 |
+
return merged
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def merge_small_regions_into_neighbors(
|
| 88 |
+
label_map: np.ndarray,
|
| 89 |
+
min_area: int = 200,
|
| 90 |
+
max_passes: int = 10,
|
| 91 |
+
boundary_index: int = -1,
|
| 92 |
+
palette: np.ndarray | None = None,
|
| 93 |
+
) -> np.ndarray:
|
| 94 |
+
result = label_map.copy()
|
| 95 |
+
h, w = result.shape
|
| 96 |
+
|
| 97 |
+
# Pre-calculate brightness if palette is provided
|
| 98 |
+
brightness_map = None
|
| 99 |
+
if palette is not None:
|
| 100 |
+
brightness_map = 0.299 * palette[:, 2] + 0.587 * palette[:, 1] + 0.114 * palette[:, 0]
|
| 101 |
+
|
| 102 |
+
for pass_num in range(5): # Reduced passes for speed
|
| 103 |
+
changed_count = 0
|
| 104 |
+
unique_labels = np.unique(result)
|
| 105 |
+
unique_labels = unique_labels[unique_labels != boundary_index]
|
| 106 |
+
|
| 107 |
+
for lbl in unique_labels:
|
| 108 |
+
mask = (result == lbl).astype(np.uint8)
|
| 109 |
+
num_cc, cc_labels, stats, _ = cv2.connectedComponentsWithStats(
|
| 110 |
+
mask, connectivity=8
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Special protection for dark regions (like eyes)
|
| 114 |
+
effective_min = min_area
|
| 115 |
+
if brightness_map is not None and lbl < len(brightness_map):
|
| 116 |
+
if brightness_map[lbl] < 60: # Very dark
|
| 117 |
+
effective_min = max(20, min_area // 4)
|
| 118 |
+
|
| 119 |
+
for cc_id in range(1, num_cc):
|
| 120 |
+
area = stats[cc_id, cv2.CC_STAT_AREA]
|
| 121 |
+
|
| 122 |
+
# NARROWNESS CHECK: Clutter reduction (Optimized)
|
| 123 |
+
is_narrow = False
|
| 124 |
+
if area < 5: # Tiny is always "narrow" enough to merge
|
| 125 |
+
is_narrow = True
|
| 126 |
+
elif area < effective_min * 2: # Only check narrowness for candidate regions
|
| 127 |
+
x, y, bw, bh = stats[cc_id, cv2.CC_STAT_LEFT], stats[cc_id, cv2.CC_STAT_TOP], \
|
| 128 |
+
stats[cc_id, cv2.CC_STAT_WIDTH], stats[cc_id, cv2.CC_STAT_HEIGHT]
|
| 129 |
+
|
| 130 |
+
# Heuristic: If bounding box is very thin, or area is small compared to box
|
| 131 |
+
if min(bw, bh) < 3:
|
| 132 |
+
is_narrow = True
|
| 133 |
+
elif area / (bw * bh) < 0.2: # Very sparse/sliver-like
|
| 134 |
+
# Only then do the expensive check
|
| 135 |
+
cc_mask = (cc_labels == cc_id).astype(np.uint8)
|
| 136 |
+
eroded = cv2.erode(cc_mask, np.ones((3, 3), np.uint8))
|
| 137 |
+
if np.count_nonzero(eroded) / area < 0.1:
|
| 138 |
+
is_narrow = True
|
| 139 |
+
|
| 140 |
+
if area >= effective_min and not is_narrow:
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
x, y, bw, bh = stats[cc_id, cv2.CC_STAT_LEFT], stats[cc_id, cv2.CC_STAT_TOP], \
|
| 144 |
+
stats[cc_id, cv2.CC_STAT_WIDTH], stats[cc_id, cv2.CC_STAT_HEIGHT]
|
| 145 |
+
|
| 146 |
+
y1, y2 = max(0, y - 2), min(h, y + bh + 2)
|
| 147 |
+
x1, x2 = max(0, x - 2), min(w, x + bw + 2)
|
| 148 |
+
|
| 149 |
+
local_cc_labels = cc_labels[y1:y2, x1:x2]
|
| 150 |
+
local_result = result[y1:y2, x1:x2]
|
| 151 |
+
|
| 152 |
+
cc_mask_local = (local_cc_labels == cc_id).astype(np.uint8)
|
| 153 |
+
dilated = cv2.dilate(cc_mask_local, np.ones((3, 3), np.uint8)) # smaller kernel
|
| 154 |
+
|
| 155 |
+
neighbor_mask = (dilated > 0) & (local_cc_labels != cc_id)
|
| 156 |
+
neighbor_labels = local_result[neighbor_mask]
|
| 157 |
+
neighbor_labels = neighbor_labels[neighbor_labels != boundary_index]
|
| 158 |
+
neighbor_labels = neighbor_labels[neighbor_labels != lbl]
|
| 159 |
+
|
| 160 |
+
if neighbor_labels.size == 0:
|
| 161 |
+
continue
|
| 162 |
+
|
| 163 |
+
most_common = Counter(neighbor_labels).most_common(1)[0][0]
|
| 164 |
+
result[y1:y2, x1:x2][local_cc_labels == cc_id] = most_common
|
| 165 |
+
changed_count += 1
|
| 166 |
+
|
| 167 |
+
if changed_count == 0:
|
| 168 |
+
break
|
| 169 |
+
return result
|
| 170 |
+
def smooth_label_map(label_map: np.ndarray, boundary_index: int = -1) -> np.ndarray:
|
| 171 |
+
"""Fill internal gaps (-1) using nearest neighbor approach."""
|
| 172 |
+
from scipy import ndimage
|
| 173 |
+
|
| 174 |
+
unlabeled = label_map == boundary_index
|
| 175 |
+
labeled = label_map != boundary_index
|
| 176 |
+
|
| 177 |
+
if not np.any(unlabeled) or not np.any(labeled):
|
| 178 |
+
return label_map
|
| 179 |
+
|
| 180 |
+
_, nearest_indices = ndimage.distance_transform_edt(
|
| 181 |
+
unlabeled, return_distances=True, return_indices=True
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
result = label_map.copy()
|
| 185 |
+
result[unlabeled] = label_map[
|
| 186 |
+
nearest_indices[0][unlabeled],
|
| 187 |
+
nearest_indices[1][unlabeled]
|
| 188 |
+
]
|
| 189 |
+
return result
|
app/processing/utils.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced image processing utilities.
|
| 3 |
+
|
| 4 |
+
Changes:
|
| 5 |
+
- Multi-pass directional closing (horizontal, vertical, diagonal)
|
| 6 |
+
- Endpoint-aware gap bridging
|
| 7 |
+
- Hysteresis thresholding for cleaner lines
|
| 8 |
+
- Adaptive kernel sizing based on image resolution
|
| 9 |
+
- Connected component noise removal
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def adaptive_kernel_size(image: np.ndarray, base: int = 3) -> int:
|
| 17 |
+
"""
|
| 18 |
+
Scale kernel size based on image resolution.
|
| 19 |
+
Dense/large images need bigger kernels to close proportional gaps.
|
| 20 |
+
"""
|
| 21 |
+
h, w = image.shape[:2]
|
| 22 |
+
scale = max(h, w) / 512.0
|
| 23 |
+
k = max(base, int(base * scale))
|
| 24 |
+
# Ensure odd
|
| 25 |
+
return k if k % 2 == 1 else k + 1
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def multi_scale_close(
|
| 29 |
+
binary_img: np.ndarray,
|
| 30 |
+
kernel_sizes: list[int] | None = None,
|
| 31 |
+
iterations_per_scale: int = 1,
|
| 32 |
+
) -> np.ndarray:
|
| 33 |
+
"""
|
| 34 |
+
Progressive morphological closing at multiple kernel sizes.
|
| 35 |
+
|
| 36 |
+
Small kernels fix tiny gaps without distorting shapes.
|
| 37 |
+
Larger kernels catch wider breaks that small ones miss.
|
| 38 |
+
Running them in sequence (small → large) gives best results.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
binary_img: Single-channel uint8 (0 or 255).
|
| 42 |
+
kernel_sizes: List of odd kernel sizes, ascending.
|
| 43 |
+
iterations_per_scale: Closing iterations per kernel size.
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Closed binary image.
|
| 47 |
+
"""
|
| 48 |
+
if kernel_sizes is None:
|
| 49 |
+
base = adaptive_kernel_size(binary_img, base=3)
|
| 50 |
+
kernel_sizes = [base, base + 2, base + 4]
|
| 51 |
+
|
| 52 |
+
result = binary_img.copy()
|
| 53 |
+
for ks in kernel_sizes:
|
| 54 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ks, ks))
|
| 55 |
+
result = cv2.morphologyEx(
|
| 56 |
+
result, cv2.MORPH_CLOSE, kernel, iterations=iterations_per_scale
|
| 57 |
+
)
|
| 58 |
+
return result
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def directional_close(
|
| 62 |
+
binary_img: np.ndarray,
|
| 63 |
+
length: int = 7,
|
| 64 |
+
iterations: int = 1,
|
| 65 |
+
) -> np.ndarray:
|
| 66 |
+
"""
|
| 67 |
+
Close gaps in all four major orientations separately, then combine.
|
| 68 |
+
|
| 69 |
+
A horizontal gap won't be caught by a square kernel efficiently,
|
| 70 |
+
but a horizontal line kernel seals it perfectly. We run four
|
| 71 |
+
orientations and merge results.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
binary_img: Single-channel uint8 (0 or 255).
|
| 75 |
+
length: Length of the directional structuring element.
|
| 76 |
+
iterations: Closing iterations per direction.
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Binary image with directional gaps sealed.
|
| 80 |
+
"""
|
| 81 |
+
length = max(3, length)
|
| 82 |
+
if length % 2 == 0:
|
| 83 |
+
length += 1
|
| 84 |
+
|
| 85 |
+
results = []
|
| 86 |
+
|
| 87 |
+
# Horizontal
|
| 88 |
+
k_h = cv2.getStructuringElement(cv2.MORPH_RECT, (length, 1))
|
| 89 |
+
results.append(
|
| 90 |
+
cv2.morphologyEx(binary_img, cv2.MORPH_CLOSE, k_h, iterations=iterations)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Vertical
|
| 94 |
+
k_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, length))
|
| 95 |
+
results.append(
|
| 96 |
+
cv2.morphologyEx(binary_img, cv2.MORPH_CLOSE, k_v, iterations=iterations)
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# Diagonal 45°
|
| 100 |
+
k_d1 = np.eye(length, dtype=np.uint8)
|
| 101 |
+
results.append(
|
| 102 |
+
cv2.morphologyEx(binary_img, cv2.MORPH_CLOSE, k_d1, iterations=iterations)
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Diagonal 135°
|
| 106 |
+
k_d2 = np.fliplr(np.eye(length, dtype=np.uint8))
|
| 107 |
+
results.append(
|
| 108 |
+
cv2.morphologyEx(binary_img, cv2.MORPH_CLOSE, k_d2, iterations=iterations)
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Merge: a pixel is closed if ANY direction closed it
|
| 112 |
+
# Lines are black (0), so we take the minimum — if any pass
|
| 113 |
+
# turned a gap pixel black, keep it black
|
| 114 |
+
merged = results[0]
|
| 115 |
+
for r in results[1:]:
|
| 116 |
+
merged = cv2.min(merged, r)
|
| 117 |
+
|
| 118 |
+
return merged
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def bridge_endpoints(
|
| 122 |
+
binary_img: np.ndarray, search_radius: int = 15
|
| 123 |
+
) -> np.ndarray:
|
| 124 |
+
"""
|
| 125 |
+
Find line endpoints and bridge them to nearby line pixels.
|
| 126 |
+
Optimized for performance by avoiding full skeletonization fallback.
|
| 127 |
+
"""
|
| 128 |
+
# Work with inverted image (lines = white = 255)
|
| 129 |
+
inverted = cv2.bitwise_not(binary_img)
|
| 130 |
+
|
| 131 |
+
# 1. Thinning / Skeletonization
|
| 132 |
+
if hasattr(cv2, 'ximgproc'):
|
| 133 |
+
skeleton = cv2.ximgproc.thinning(inverted)
|
| 134 |
+
else:
|
| 135 |
+
# Fast "thinned" version for endpoint detection
|
| 136 |
+
# Just use a simple morphological skeleton for 2 iterations
|
| 137 |
+
element = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
|
| 138 |
+
skeleton = cv2.erode(inverted, element, iterations=1)
|
| 139 |
+
# Difference between original and eroded roughly highlights endpoints
|
| 140 |
+
skeleton = cv2.absdiff(inverted, cv2.dilate(skeleton, element))
|
| 141 |
+
|
| 142 |
+
# 2. Endpoint detection via neighbor count (3x3 window)
|
| 143 |
+
# A pixel is likely an endpoint if it has exactly 1 neighbor in 3x3
|
| 144 |
+
kernel = np.array([[1, 1, 1], [1, 10, 1], [1, 1, 1]], dtype=np.uint8)
|
| 145 |
+
neighbor_counts = cv2.filter2D(skeleton, -1, kernel)
|
| 146 |
+
|
| 147 |
+
# Value 11 means: self (10) + 1 neighbor (1)
|
| 148 |
+
endpoints = (neighbor_counts == 11).astype(np.uint8) * 255
|
| 149 |
+
|
| 150 |
+
ep_coords = np.column_stack(np.where(endpoints > 0))
|
| 151 |
+
result = binary_img.copy()
|
| 152 |
+
|
| 153 |
+
if len(ep_coords) < 2:
|
| 154 |
+
return result
|
| 155 |
+
|
| 156 |
+
# Limit endpoints to process if there are thousands
|
| 157 |
+
if len(ep_coords) > 1000:
|
| 158 |
+
indices = np.random.choice(len(ep_coords), 1000, replace=False)
|
| 159 |
+
ep_coords = ep_coords[indices]
|
| 160 |
+
|
| 161 |
+
# Pre-calculate search windows to be faster
|
| 162 |
+
h, w = inverted.shape
|
| 163 |
+
for y, x in ep_coords:
|
| 164 |
+
y1, y2 = max(0, y - search_radius), min(h, y + search_radius + 1)
|
| 165 |
+
x1, x2 = max(0, x - search_radius), min(w, x + search_radius + 1)
|
| 166 |
+
|
| 167 |
+
window = inverted[y1:y2, x1:x2]
|
| 168 |
+
|
| 169 |
+
# Look for the nearest point that isn't the current pixel or immediate neighbor
|
| 170 |
+
# Use distance transform for faster "nearest point" lookup in window
|
| 171 |
+
local_y, local_x = y - y1, x - x1
|
| 172 |
+
|
| 173 |
+
# Find points in window
|
| 174 |
+
pts = np.column_stack(np.where(window > 128))
|
| 175 |
+
if len(pts) < 2: continue # Only self found
|
| 176 |
+
|
| 177 |
+
# Distances to (local_y, local_x)
|
| 178 |
+
dists_sq = (pts[:, 0] - local_y)**2 + (pts[:, 1] - local_x)**2
|
| 179 |
+
|
| 180 |
+
# Filter out points too close (neighbors)
|
| 181 |
+
valid = dists_sq > 16 # sqrt(16) = 4 pixels away
|
| 182 |
+
if not np.any(valid): continue
|
| 183 |
+
|
| 184 |
+
nearest_idx = np.argmin(dists_sq[valid])
|
| 185 |
+
target_y, target_x = pts[valid][nearest_idx]
|
| 186 |
+
|
| 187 |
+
if dists_sq[valid][nearest_idx] <= search_radius**2:
|
| 188 |
+
cv2.line(result, (x, y), (target_x + x1, target_y + y1), 0, 1)
|
| 189 |
+
|
| 190 |
+
return result
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def hysteresis_threshold(
|
| 194 |
+
grayscale: np.ndarray, low: int = 80, high: int = 160
|
| 195 |
+
) -> np.ndarray:
|
| 196 |
+
"""
|
| 197 |
+
Dual-threshold binarization (like Canny's hysteresis).
|
| 198 |
+
|
| 199 |
+
Strong edges (above high) are always kept.
|
| 200 |
+
Weak edges (between low and high) are kept only if connected
|
| 201 |
+
to a strong edge. This preserves faint but important lines
|
| 202 |
+
while rejecting isolated noise.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
grayscale: Single-channel uint8 image.
|
| 206 |
+
low: Lower threshold.
|
| 207 |
+
high: Upper threshold.
|
| 208 |
+
|
| 209 |
+
Returns:
|
| 210 |
+
Binary uint8 image.
|
| 211 |
+
"""
|
| 212 |
+
strong = grayscale >= high
|
| 213 |
+
weak = (grayscale >= low) & (grayscale < high)
|
| 214 |
+
|
| 215 |
+
# Use connected components: weak pixels touching strong ones survive
|
| 216 |
+
result = np.zeros_like(grayscale)
|
| 217 |
+
result[strong] = 255
|
| 218 |
+
|
| 219 |
+
# Dilate strong regions and intersect with weak
|
| 220 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
| 221 |
+
for _ in range(5): # Propagate connectivity
|
| 222 |
+
dilated = cv2.dilate(result, kernel)
|
| 223 |
+
connected_weak = (dilated > 0) & weak
|
| 224 |
+
result[connected_weak] = 255
|
| 225 |
+
if not np.any(connected_weak):
|
| 226 |
+
break
|
| 227 |
+
|
| 228 |
+
return result
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def binarize(grayscale: np.ndarray, threshold: int = 128) -> np.ndarray:
|
| 232 |
+
"""Simple global threshold."""
|
| 233 |
+
_, binary = cv2.threshold(grayscale, threshold, 255, cv2.THRESH_BINARY)
|
| 234 |
+
return binary
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def adaptive_binarize(grayscale: np.ndarray, block_size: int = 21) -> np.ndarray:
|
| 238 |
+
"""
|
| 239 |
+
Adaptive thresholding handles uneven lighting / AI output intensity.
|
| 240 |
+
Different parts of the image get different threshold values.
|
| 241 |
+
"""
|
| 242 |
+
if block_size % 2 == 0:
|
| 243 |
+
block_size += 1
|
| 244 |
+
return cv2.adaptiveThreshold(
|
| 245 |
+
grayscale, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 246 |
+
cv2.THRESH_BINARY, block_size, 5
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def remove_small_regions(
|
| 251 |
+
mask: np.ndarray, min_area: int = 100
|
| 252 |
+
) -> np.ndarray:
|
| 253 |
+
"""Remove noise blobs below min_area."""
|
| 254 |
+
contours, _ = cv2.findContours(
|
| 255 |
+
mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
| 256 |
+
)
|
| 257 |
+
cleaned = mask.copy()
|
| 258 |
+
for cnt in contours:
|
| 259 |
+
if cv2.contourArea(cnt) < min_area:
|
| 260 |
+
cv2.drawContours(cleaned, [cnt], -1, 0, cv2.FILLED)
|
| 261 |
+
return cleaned
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def remove_small_components(
|
| 265 |
+
mask: np.ndarray, min_area: int = 100
|
| 266 |
+
) -> np.ndarray:
|
| 267 |
+
"""
|
| 268 |
+
Connected-component based cleanup. Faster and more accurate
|
| 269 |
+
than contour-based removal for dense images with many tiny blobs.
|
| 270 |
+
"""
|
| 271 |
+
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
|
| 272 |
+
mask, connectivity=8
|
| 273 |
+
)
|
| 274 |
+
cleaned = np.zeros_like(mask)
|
| 275 |
+
for label_id in range(1, num_labels):
|
| 276 |
+
area = stats[label_id, cv2.CC_STAT_AREA]
|
| 277 |
+
if area >= min_area:
|
| 278 |
+
cleaned[labels == label_id] = 255
|
| 279 |
+
return cleaned
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def skeletonize(binary_img: np.ndarray) -> np.ndarray:
|
| 283 |
+
"""
|
| 284 |
+
Thins lines to 1-pixel width. Optimized for speed.
|
| 285 |
+
Expects: lines = 255, background = 0.
|
| 286 |
+
"""
|
| 287 |
+
if hasattr(cv2, "ximgproc"):
|
| 288 |
+
return cv2.ximgproc.thinning(binary_img)
|
| 289 |
+
|
| 290 |
+
# Fast iteration-limited morphological thinning
|
| 291 |
+
size = np.size(binary_img)
|
| 292 |
+
skel = np.zeros(binary_img.shape, np.uint8)
|
| 293 |
+
element = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
|
| 294 |
+
|
| 295 |
+
img = binary_img.copy()
|
| 296 |
+
# 32 iterations is typically enough for 1024px lines
|
| 297 |
+
for _ in range(32):
|
| 298 |
+
if cv2.countNonZero(img) == 0:
|
| 299 |
+
break
|
| 300 |
+
eroded = cv2.erode(img, element)
|
| 301 |
+
temp = cv2.dilate(eroded, element)
|
| 302 |
+
temp = cv2.subtract(img, temp)
|
| 303 |
+
skel = cv2.bitwise_or(skel, temp)
|
| 304 |
+
img = eroded.copy()
|
| 305 |
+
|
| 306 |
+
return skel
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def declutter_lines(
|
| 310 |
+
binary_img: np.ndarray,
|
| 311 |
+
merge_radius: int = 1, # Default smaller
|
| 312 |
+
thin_out: bool = True
|
| 313 |
+
) -> np.ndarray:
|
| 314 |
+
"""
|
| 315 |
+
Merge lines that are very close and then thin them back down.
|
| 316 |
+
Expects: lines = 0, background = 255.
|
| 317 |
+
"""
|
| 318 |
+
# 1. Work with inverted image (lines = 255)
|
| 319 |
+
inverted = cv2.bitwise_not(binary_img)
|
| 320 |
+
|
| 321 |
+
# 2. Dilation to merge close boundaries
|
| 322 |
+
if merge_radius > 0:
|
| 323 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (merge_radius * 2 + 1, merge_radius * 2 + 1))
|
| 324 |
+
inverted = cv2.dilate(inverted, kernel, iterations=1)
|
| 325 |
+
|
| 326 |
+
# 3. Skeletonize to restore thin lines
|
| 327 |
+
if thin_out:
|
| 328 |
+
inverted = skeletonize(inverted)
|
| 329 |
+
|
| 330 |
+
# 4. Invert back
|
| 331 |
+
return cv2.bitwise_not(inverted)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def get_pole_of_inaccessibility(mask: np.ndarray) -> tuple[int, int]:
|
| 335 |
+
"""
|
| 336 |
+
Find the point farthest from any boundary in a binary mask.
|
| 337 |
+
Perfect for label placement to avoid touching borders.
|
| 338 |
+
"""
|
| 339 |
+
dist = cv2.distanceTransform(mask, cv2.DIST_L2, 5)
|
| 340 |
+
_, max_val, _, max_loc = cv2.minMaxLoc(dist)
|
| 341 |
+
return max_loc # (x, y)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def get_adjacency_matrix(label_map: np.ndarray) -> list[tuple[int, int]]:
|
| 345 |
+
"""
|
| 346 |
+
Calculate which regions are neighbors.
|
| 347 |
+
Uses 4-way connectivity pixel check for efficiency.
|
| 348 |
+
Returns list of (label_a, label_b) pairs where label_a < label_b.
|
| 349 |
+
"""
|
| 350 |
+
h, w = label_map.shape
|
| 351 |
+
adj = set()
|
| 352 |
+
|
| 353 |
+
# Horizontal adjacencies
|
| 354 |
+
# [0, 0, 1, 1] -> neighbors are (0, 1)
|
| 355 |
+
# Use array slicing to check left/right neighbors
|
| 356 |
+
left = label_map[:, :-1]
|
| 357 |
+
right = label_map[:, 1:]
|
| 358 |
+
mask = (left != right) & (left >= 0) & (right >= 0)
|
| 359 |
+
|
| 360 |
+
# Extract pairs
|
| 361 |
+
pairs = np.column_stack((left[mask], right[mask]))
|
| 362 |
+
# Sort each pair to ensure (a, b) where a < b
|
| 363 |
+
pairs.sort(axis=1)
|
| 364 |
+
for p in np.unique(pairs, axis=0):
|
| 365 |
+
adj.add(tuple(p))
|
| 366 |
+
|
| 367 |
+
# Vertical adjacencies
|
| 368 |
+
top = label_map[:-1, :]
|
| 369 |
+
bottom = label_map[1:, :]
|
| 370 |
+
mask = (top != bottom) & (top >= 0) & (bottom >= 0)
|
| 371 |
+
|
| 372 |
+
pairs = np.column_stack((top[mask], bottom[mask]))
|
| 373 |
+
pairs.sort(axis=1)
|
| 374 |
+
for p in np.unique(pairs, axis=0):
|
| 375 |
+
adj.add(tuple(p))
|
| 376 |
+
|
| 377 |
+
return sorted(list(adj))
|
app/processing/vectorizer.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced SVG vectorizer with difficulty layer support.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
import svgwrite
|
| 8 |
+
from app.processing.utils import get_pole_of_inaccessibility
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _contour_to_svg_path(contour: np.ndarray) -> str:
|
| 12 |
+
points = contour.squeeze()
|
| 13 |
+
if points.ndim == 1:
|
| 14 |
+
return ""
|
| 15 |
+
parts = [f"M {points[0][0]},{points[0][1]}"]
|
| 16 |
+
for pt in points[1:]:
|
| 17 |
+
parts.append(f"L {pt[0]},{pt[1]}")
|
| 18 |
+
parts.append("Z")
|
| 19 |
+
return " ".join(parts)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _bgr_to_hex(bgr: np.ndarray) -> str:
|
| 23 |
+
b, g, r = int(bgr[0]), int(bgr[1]), int(bgr[2])
|
| 24 |
+
return f"#{r:02X}{g:02X}{b:02X}"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def generate_svg(
|
| 28 |
+
label_map: np.ndarray,
|
| 29 |
+
palette: np.ndarray,
|
| 30 |
+
output_path: str = "output.svg",
|
| 31 |
+
min_area: int = 100,
|
| 32 |
+
simplify_epsilon_ratio: float = 0.002,
|
| 33 |
+
stroke_width: float = 1.0,
|
| 34 |
+
font_size: int = 10,
|
| 35 |
+
boundary_index: int = -1,
|
| 36 |
+
difficulty_layers: dict[int, int] | None = None,
|
| 37 |
+
fill_color: bool = True,
|
| 38 |
+
) -> str:
|
| 39 |
+
"""Generate color-by-number SVG with optional color filling."""
|
| 40 |
+
try:
|
| 41 |
+
h, w = label_map.shape[:2]
|
| 42 |
+
n_colors = len(palette)
|
| 43 |
+
|
| 44 |
+
# Ensure output_path is a string
|
| 45 |
+
output_path = str(output_path)
|
| 46 |
+
|
| 47 |
+
# Debug info
|
| 48 |
+
unique_labels = np.unique(label_map)
|
| 49 |
+
print(f" [Vectorizer] Unique labels in map: {len(unique_labels)}")
|
| 50 |
+
print(f" [Vectorizer] Dimensions: {w}x{h}, Target: {output_path}")
|
| 51 |
+
|
| 52 |
+
dwg = svgwrite.Drawing(
|
| 53 |
+
output_path,
|
| 54 |
+
size=(f"{w}px", f"{h}px"),
|
| 55 |
+
profile='full',
|
| 56 |
+
debug=False
|
| 57 |
+
)
|
| 58 |
+
dwg.viewbox(0, 0, w, h)
|
| 59 |
+
|
| 60 |
+
# Background
|
| 61 |
+
dwg.add(dwg.rect(insert=(0, 0), size=(w, h), fill="white"))
|
| 62 |
+
|
| 63 |
+
# Create layer groups
|
| 64 |
+
groups = {}
|
| 65 |
+
if difficulty_layers:
|
| 66 |
+
layers = list(difficulty_layers.values())
|
| 67 |
+
n_layers = max(layers) + 1 if layers else 1
|
| 68 |
+
for i in range(n_layers):
|
| 69 |
+
name = ["easy", "medium", "hard", "expert"][min(i, 3)]
|
| 70 |
+
g = dwg.g(id=f"layer_{name}")
|
| 71 |
+
groups[i] = g
|
| 72 |
+
else:
|
| 73 |
+
groups[0] = dwg.g(id="regions")
|
| 74 |
+
|
| 75 |
+
labels_group = dwg.g(id="labels")
|
| 76 |
+
region_counter = 0
|
| 77 |
+
unique_labels = np.unique(label_map)
|
| 78 |
+
print(f" [Vectorizer] Unique labels in map: {len(unique_labels)}")
|
| 79 |
+
# Filter out boundary, but if we have only 1 label, keep it so it's not a blank page
|
| 80 |
+
if len(unique_labels) > 2:
|
| 81 |
+
valid_labels = unique_labels[unique_labels != boundary_index]
|
| 82 |
+
else:
|
| 83 |
+
# If we only have 2 colors, keep both
|
| 84 |
+
valid_labels = unique_labels
|
| 85 |
+
|
| 86 |
+
for color_idx in valid_labels:
|
| 87 |
+
if color_idx == boundary_index:
|
| 88 |
+
hex_color = "#FFFFFF" # Draw boundary as white
|
| 89 |
+
elif color_idx < 0 or color_idx >= n_colors:
|
| 90 |
+
continue
|
| 91 |
+
else:
|
| 92 |
+
hex_color = _bgr_to_hex(palette[color_idx])
|
| 93 |
+
mask = np.zeros((h, w), dtype=np.uint8)
|
| 94 |
+
mask[label_map == color_idx] = 255
|
| 95 |
+
|
| 96 |
+
contours, hierarchy = cv2.findContours(
|
| 97 |
+
mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if hierarchy is None:
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
hierarchy = hierarchy[0]
|
| 104 |
+
difficulty = 0
|
| 105 |
+
if difficulty_layers and int(color_idx) in difficulty_layers:
|
| 106 |
+
difficulty = difficulty_layers[int(color_idx)]
|
| 107 |
+
|
| 108 |
+
target_group = groups.get(difficulty, list(groups.values())[0])
|
| 109 |
+
|
| 110 |
+
for i, (cnt, hier) in enumerate(zip(contours, hierarchy)):
|
| 111 |
+
# Only process external contours (those with no parent in CCOMP)
|
| 112 |
+
if hier[3] != -1:
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
area = cv2.contourArea(cnt)
|
| 116 |
+
if area < 2: # Keep almost everything
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
epsilon = simplify_epsilon_ratio * cv2.arcLength(cnt, True)
|
| 120 |
+
approx = cv2.approxPolyDP(cnt, epsilon, True)
|
| 121 |
+
|
| 122 |
+
if len(approx) >= 3:
|
| 123 |
+
path_d = _contour_to_svg_path(approx)
|
| 124 |
+
|
| 125 |
+
# Add holes
|
| 126 |
+
child_idx = hier[2]
|
| 127 |
+
while child_idx != -1:
|
| 128 |
+
hole_cnt = contours[child_idx]
|
| 129 |
+
hole_area = cv2.contourArea(hole_cnt)
|
| 130 |
+
if hole_area > 5: # Small holes ok
|
| 131 |
+
hole_eps = simplify_epsilon_ratio * cv2.arcLength(hole_cnt, True)
|
| 132 |
+
hole_approx = cv2.approxPolyDP(hole_cnt, hole_eps, True)
|
| 133 |
+
if len(hole_approx) >= 3:
|
| 134 |
+
path_d += " " + _contour_to_svg_path(hole_approx)
|
| 135 |
+
child_idx = hierarchy[child_idx][0]
|
| 136 |
+
|
| 137 |
+
region_counter += 1
|
| 138 |
+
|
| 139 |
+
final_fill = hex_color if fill_color else "none"
|
| 140 |
+
if color_idx == boundary_index and not fill_color:
|
| 141 |
+
final_fill = "none" # or white
|
| 142 |
+
|
| 143 |
+
path_elem = dwg.path(
|
| 144 |
+
d=path_d,
|
| 145 |
+
fill=final_fill,
|
| 146 |
+
stroke="black",
|
| 147 |
+
stroke_width=stroke_width,
|
| 148 |
+
fill_rule="evenodd"
|
| 149 |
+
)
|
| 150 |
+
path_elem.attribs['id'] = f"region_{region_counter}"
|
| 151 |
+
path_elem.attribs['data-color'] = hex_color
|
| 152 |
+
path_elem.attribs['data-index'] = str(int(color_idx) + 1)
|
| 153 |
+
path_elem.attribs['class'] = "fillable"
|
| 154 |
+
target_group.add(path_elem)
|
| 155 |
+
|
| 156 |
+
# Add number label (Perfect center placement)
|
| 157 |
+
cx, cy = get_pole_of_inaccessibility(mask)
|
| 158 |
+
bgr = palette[color_idx]
|
| 159 |
+
brightness = 0.299*bgr[2] + 0.587*bgr[1] + 0.114*bgr[0]
|
| 160 |
+
text_color = "white" if brightness < 128 else "#333333"
|
| 161 |
+
|
| 162 |
+
labels_group.add(dwg.text(
|
| 163 |
+
str(int(color_idx) + 1),
|
| 164 |
+
insert=(cx, cy),
|
| 165 |
+
font_size=font_size,
|
| 166 |
+
fill=text_color,
|
| 167 |
+
text_anchor="middle",
|
| 168 |
+
dominant_baseline="central"
|
| 169 |
+
))
|
| 170 |
+
|
| 171 |
+
for g in groups.values():
|
| 172 |
+
dwg.add(g)
|
| 173 |
+
dwg.add(labels_group)
|
| 174 |
+
|
| 175 |
+
print(f" [Vectorizer] Total regions drawn: {region_counter}")
|
| 176 |
+
|
| 177 |
+
# Palette Legend at bottom
|
| 178 |
+
legend_y = h + 10
|
| 179 |
+
swatch_h = 30
|
| 180 |
+
for i in range(n_colors):
|
| 181 |
+
x = 10 + i * (swatch_h + 10)
|
| 182 |
+
swatch_fill = _bgr_to_hex(palette[i]) if fill_color else "none"
|
| 183 |
+
dwg.add(dwg.rect(insert=(x, legend_y), size=(swatch_h, swatch_h),
|
| 184 |
+
fill=swatch_fill, stroke="black", stroke_width=0.5))
|
| 185 |
+
dwg.add(dwg.text(str(i+1), insert=(x + swatch_h/2, legend_y + swatch_h + 12),
|
| 186 |
+
font_size=10, text_anchor="middle", fill="black"))
|
| 187 |
+
|
| 188 |
+
dwg.save()
|
| 189 |
+
return dwg.tostring()
|
| 190 |
+
except Exception as e:
|
| 191 |
+
print(f" [Vectorizer] Error generating SVG: {e}")
|
| 192 |
+
import traceback
|
| 193 |
+
traceback.print_exc()
|
| 194 |
+
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="10" y="50">Error: {e}</text></svg>'
|
download_model.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Download the informative-drawings line-art ONNX model from HuggingFace."""
|
| 3 |
+
|
| 4 |
+
from huggingface_hub import hf_hub_download
|
| 5 |
+
import shutil
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def download_model(dest_dir: str = "models") -> str:
|
| 10 |
+
dest = Path(dest_dir)
|
| 11 |
+
dest.mkdir(exist_ok=True)
|
| 12 |
+
|
| 13 |
+
output_path = dest / "model.onnx"
|
| 14 |
+
if output_path.exists():
|
| 15 |
+
print(f"Model already exists at {output_path}")
|
| 16 |
+
return str(output_path)
|
| 17 |
+
|
| 18 |
+
print("Downloading informative-drawings-line-art-onnx...")
|
| 19 |
+
downloaded = hf_hub_download(
|
| 20 |
+
repo_id="rocca/informative-drawings-line-art-onnx",
|
| 21 |
+
filename="model.onnx",
|
| 22 |
+
)
|
| 23 |
+
shutil.copy(downloaded, output_path)
|
| 24 |
+
print(f"Model saved to {output_path}")
|
| 25 |
+
return str(output_path)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
download_model()
|
download_models.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Download all ONNX models needed for the multi-model pipeline.
|
| 4 |
+
Handles split ONNX models and uses verified repositories.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import shutil
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from huggingface_hub import hf_hub_download
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
MODELS = {
|
| 13 |
+
"informative-drawings": {
|
| 14 |
+
"repo": "rocca/informative-drawings-line-art-onnx",
|
| 15 |
+
"file": "model.onnx",
|
| 16 |
+
"local": "models/informative_drawings.onnx",
|
| 17 |
+
"size_mb": 22,
|
| 18 |
+
"required": True,
|
| 19 |
+
},
|
| 20 |
+
"sam2-encoder": {
|
| 21 |
+
"repo": "onnx-community/sam2.1-hiera-small-ONNX",
|
| 22 |
+
"file": "onnx/vision_encoder.onnx",
|
| 23 |
+
"local": "models/sam2_encoder.onnx",
|
| 24 |
+
"extra_files": ["onnx/vision_encoder.onnx_data"],
|
| 25 |
+
"size_mb": 130,
|
| 26 |
+
"required": True,
|
| 27 |
+
},
|
| 28 |
+
"sam2-decoder": {
|
| 29 |
+
"repo": "onnx-community/sam2.1-hiera-small-ONNX",
|
| 30 |
+
"file": "onnx/prompt_encoder_mask_decoder.onnx",
|
| 31 |
+
"local": "models/sam2_decoder.onnx",
|
| 32 |
+
"extra_files": ["onnx/prompt_encoder_mask_decoder.onnx_data"],
|
| 33 |
+
"size_mb": 16,
|
| 34 |
+
"required": True,
|
| 35 |
+
},
|
| 36 |
+
"depth-anything-v2": {
|
| 37 |
+
"repo": "onnx-community/depth-anything-v2-small",
|
| 38 |
+
"file": "onnx/model_fp16.onnx",
|
| 39 |
+
"local": "models/depth_anything_v2.onnx",
|
| 40 |
+
"size_mb": 49,
|
| 41 |
+
"required": True,
|
| 42 |
+
},
|
| 43 |
+
"real-esrgan": {
|
| 44 |
+
"repo": "AXERA-TECH/Real-ESRGAN",
|
| 45 |
+
"file": "realesrgan-x4.onnx",
|
| 46 |
+
"local": "models/real_esrgan_x4.onnx",
|
| 47 |
+
"size_mb": 64,
|
| 48 |
+
"required": False,
|
| 49 |
+
},
|
| 50 |
+
"rmbg": {
|
| 51 |
+
"repo": "briaai/RMBG-2.0",
|
| 52 |
+
"file": "onnx/model.onnx", # Verified common path for this repo
|
| 53 |
+
"local": "models/rmbg2.onnx",
|
| 54 |
+
"size_mb": 170,
|
| 55 |
+
"required": False,
|
| 56 |
+
},
|
| 57 |
+
"face-parsing": {
|
| 58 |
+
"repo": "bluefoxcreation/Face_parsing_onnx",
|
| 59 |
+
"file": "faceparser.onnx",
|
| 60 |
+
"local": "models/face_parsing.onnx",
|
| 61 |
+
"size_mb": 52,
|
| 62 |
+
"required": False,
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def download_all(required_only: bool = False):
|
| 68 |
+
"""Download models from HuggingFace Hub."""
|
| 69 |
+
Path("models").mkdir(exist_ok=True)
|
| 70 |
+
|
| 71 |
+
for name, info in MODELS.items():
|
| 72 |
+
local_path = Path(info["local"])
|
| 73 |
+
|
| 74 |
+
if local_path.exists():
|
| 75 |
+
# For split models, ensure extra files exist even if .onnx exists
|
| 76 |
+
if "extra_files" in info:
|
| 77 |
+
all_extras_exist = True
|
| 78 |
+
for extra in info["extra_files"]:
|
| 79 |
+
if not (Path("models") / Path(extra).name).exists():
|
| 80 |
+
all_extras_exist = False
|
| 81 |
+
break
|
| 82 |
+
if all_extras_exist:
|
| 83 |
+
print(f" ✓ {name} already exists")
|
| 84 |
+
continue
|
| 85 |
+
elif local_path.stat().st_size > 1024 * 1024:
|
| 86 |
+
print(f" ✓ {name} already exists")
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
if required_only and not info["required"]:
|
| 90 |
+
print(f" ⊘ {name} skipped (optional)")
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
print(f" ↓ Downloading {name} from {info['repo']}...")
|
| 94 |
+
try:
|
| 95 |
+
downloaded = hf_hub_download(
|
| 96 |
+
repo_id=info["repo"],
|
| 97 |
+
filename=info["file"],
|
| 98 |
+
)
|
| 99 |
+
shutil.copy(downloaded, local_path)
|
| 100 |
+
print(f" ✓ {name} saved to {local_path}")
|
| 101 |
+
|
| 102 |
+
if "extra_files" in info:
|
| 103 |
+
for extra in info["extra_files"]:
|
| 104 |
+
extra_local = Path("models") / Path(extra).name
|
| 105 |
+
print(f" ↓ Downloading extra file {extra}...")
|
| 106 |
+
downloaded_extra = hf_hub_download(repo_id=info["repo"], filename=extra)
|
| 107 |
+
shutil.copy(downloaded_extra, extra_local)
|
| 108 |
+
print(f" ✓ Saved to {extra_local}")
|
| 109 |
+
|
| 110 |
+
except Exception as e:
|
| 111 |
+
if info["required"]:
|
| 112 |
+
print(f" ✗ Failed to download REQUIRED model {name}: {e}")
|
| 113 |
+
else:
|
| 114 |
+
print(f" ✗ {name} failed (optional): {e}")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
import argparse
|
| 119 |
+
|
| 120 |
+
parser = argparse.ArgumentParser()
|
| 121 |
+
parser.add_argument(
|
| 122 |
+
"--required-only",
|
| 123 |
+
action="store_true",
|
| 124 |
+
help="Only download required models",
|
| 125 |
+
)
|
| 126 |
+
args = parser.parse_args()
|
| 127 |
+
download_all(required_only=args.required_only)
|
inspect_sam.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# inspect_sam.py
|
| 2 |
+
import onnxruntime as ort
|
| 3 |
+
path = "models/sam2_decoder.onnx"
|
| 4 |
+
session = ort.InferenceSession(path)
|
| 5 |
+
print("SAM2 Decoder Inputs:")
|
| 6 |
+
for i in session.get_inputs():
|
| 7 |
+
print(f" Name: {i.name}")
|
| 8 |
+
print(f" Shape: {i.shape}")
|
| 9 |
+
print(f" Type: {i.type}")
|
models/.gitkeep
ADDED
|
File without changes
|
outputs/colo.code-workspace
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"folders": [
|
| 3 |
+
{
|
| 4 |
+
"path": "../.."
|
| 5 |
+
}
|
| 6 |
+
],
|
| 7 |
+
"settings": {}
|
| 8 |
+
}
|
packages.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libgl1-mesa-glx
|
| 2 |
+
libglib2.0-0
|
render_build.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
def build():
|
| 6 |
+
# 1. Install requirements
|
| 7 |
+
print("Installing requirements...")
|
| 8 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
|
| 9 |
+
|
| 10 |
+
# 2. Download required models
|
| 11 |
+
print("Downloading required models...")
|
| 12 |
+
from download_models import download_all
|
| 13 |
+
download_all(required_only=True)
|
| 14 |
+
|
| 15 |
+
print("Build step complete!")
|
| 16 |
+
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
build()
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.34.0
|
| 3 |
+
python-multipart==0.0.18
|
| 4 |
+
onnxruntime==1.19.2
|
| 5 |
+
opencv-python-headless==4.10.0.84
|
| 6 |
+
numpy==1.26.4
|
| 7 |
+
scikit-learn==1.5.2
|
| 8 |
+
svgwrite==1.4.3
|
| 9 |
+
Pillow==10.4.0
|
| 10 |
+
huggingface-hub==0.27.1
|
| 11 |
+
torch==2.4.1
|
| 12 |
+
torchvision==0.19.1
|
| 13 |
+
httpx==0.28.1
|
| 14 |
+
scipy==1.14.1
|
sam_info.txt
ADDED
|
Binary file (2.88 kB). View file
|
|
|
test_model.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import onnxruntime as ort
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
path = "models/informative_drawings.onnx"
|
| 7 |
+
if not Path(path).exists():
|
| 8 |
+
print(f"Error: {path} not found")
|
| 9 |
+
sys.exit(1)
|
| 10 |
+
|
| 11 |
+
print(f"Testing {path}...")
|
| 12 |
+
try:
|
| 13 |
+
session = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
|
| 14 |
+
print("✓ Model loaded successfully")
|
| 15 |
+
print(f"Inputs: {[i.name for i in session.get_inputs()]}")
|
| 16 |
+
print(f"Outputs: {[o.name for o in session.get_outputs()]}")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
print(f"✗ Failed: {e}")
|