s0sp commited on
Commit
a9e6328
·
0 Parent(s):

cpu intensive without tweak best result

Browse files
.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}")