Spaces:
Configuration error
Configuration error
add app files
Browse files- app.py +22 -0
- app/__init__.py +0 -0
- app/color_detector.py +73 -0
- app/emoji_mapper.py +140 -0
- app/image_processor.py +39 -0
- app/main.py +17 -0
- app/routes.py +134 -0
- app/themes.py +174 -0
- app/utils.py +0 -0
- checkpoints/model.pth +3 -0
- data/emoji_colors.json +0 -0
- ml/__init__.py +0 -0
- ml/dataset.py +235 -0
- ml/model.py +44 -0
- ml/predict.py +81 -0
- ml/segmentor.py +61 -0
- ml/train.py +145 -0
- requirements.txt +10 -0
app.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
import threading
|
| 3 |
+
import gradio as gr
|
| 4 |
+
from app.main import app as fastapi_app
|
| 5 |
+
|
| 6 |
+
# run FastAPI in a background thread
|
| 7 |
+
def run_fastapi():
|
| 8 |
+
uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)
|
| 9 |
+
|
| 10 |
+
thread = threading.Thread(target=run_fastapi, daemon=True)
|
| 11 |
+
thread.start()
|
| 12 |
+
|
| 13 |
+
# Gradio needs a UI to stay alive — minimal dummy interface
|
| 14 |
+
demo = gr.Interface(
|
| 15 |
+
fn=lambda x: "Emosaic backend is running!",
|
| 16 |
+
inputs=gr.Textbox(label="ping"),
|
| 17 |
+
outputs=gr.Textbox(label="status"),
|
| 18 |
+
title="🌸 Emosaic Backend",
|
| 19 |
+
description="FastAPI backend for Emosaic. Use the /generate endpoint."
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
demo.launch(server_port=7860)
|
app/__init__.py
ADDED
|
File without changes
|
app/color_detector.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
COLOR_CENTROIDS_LAB = {
|
| 5 |
+
"dark": [10, 0, 0 ],
|
| 6 |
+
"light": [95, 0, 0 ],
|
| 7 |
+
"red": [40, 55, 35 ],
|
| 8 |
+
"pink": [65, 35, -5 ],
|
| 9 |
+
"orange": [60, 25, 45 ],
|
| 10 |
+
"brown": [35, 15, 20 ],
|
| 11 |
+
"yellow": [85, -5, 60 ],
|
| 12 |
+
"green": [50, -40, 25 ],
|
| 13 |
+
"blue": [45, 5, -40 ],
|
| 14 |
+
"neutral": [70, 2, 4 ],
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def rgb_to_lab(r, g, b):
|
| 19 |
+
def linearize(c):
|
| 20 |
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
| 21 |
+
|
| 22 |
+
r, g, b = linearize(r), linearize(g), linearize(b)
|
| 23 |
+
|
| 24 |
+
X = r * 0.4124564 + g * 0.3575761 + b * 0.1804375
|
| 25 |
+
Y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750
|
| 26 |
+
Z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041
|
| 27 |
+
|
| 28 |
+
X /= 0.95047
|
| 29 |
+
Y /= 1.00000
|
| 30 |
+
Z /= 1.08883
|
| 31 |
+
|
| 32 |
+
def f(t):
|
| 33 |
+
return t ** (1/3) if t > 0.008856 else (7.787 * t + 16/116)
|
| 34 |
+
|
| 35 |
+
fx, fy, fz = f(X), f(Y), f(Z)
|
| 36 |
+
|
| 37 |
+
L = 116 * fy - 16
|
| 38 |
+
a = 500 * (fx - fy)
|
| 39 |
+
b_val = 200 * (fy - fz)
|
| 40 |
+
|
| 41 |
+
return L, a, b_val
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_color_group(pixel):
|
| 45 |
+
r, g, b = [x / 255.0 for x in pixel[:3]]
|
| 46 |
+
L, a, b_val = rgb_to_lab(r, g, b)
|
| 47 |
+
|
| 48 |
+
if L < 18:
|
| 49 |
+
return "dark"
|
| 50 |
+
if L > 92 and abs(a) < 5 and abs(b_val) < 5:
|
| 51 |
+
return "light"
|
| 52 |
+
|
| 53 |
+
lab = [L, a, b_val]
|
| 54 |
+
best_color = min(
|
| 55 |
+
COLOR_CENTROIDS_LAB,
|
| 56 |
+
key=lambda c: (
|
| 57 |
+
(lab[0] - COLOR_CENTROIDS_LAB[c][0]) ** 2 * 0.3 + # weight L less
|
| 58 |
+
(lab[1] - COLOR_CENTROIDS_LAB[c][1]) ** 2 * 1.5 + # weight a more
|
| 59 |
+
(lab[2] - COLOR_CENTROIDS_LAB[c][2]) ** 2 * 1.5 # weight b more
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
return best_color
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def build_color_grid(pixels):
|
| 67 |
+
color_grid = []
|
| 68 |
+
for row in pixels:
|
| 69 |
+
color_row = []
|
| 70 |
+
for pixel in row:
|
| 71 |
+
color_row.append(get_color_group(pixel))
|
| 72 |
+
color_grid.append(color_row)
|
| 73 |
+
return color_grid
|
app/emoji_mapper.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from app.themes import THEMES
|
| 6 |
+
|
| 7 |
+
with open("data/emoji_colors.json", "r", encoding="utf-8") as f:
|
| 8 |
+
_raw_colors = json.load(f)
|
| 9 |
+
|
| 10 |
+
EMOJI_COLOR_LOOKUP = {
|
| 11 |
+
char: data["lab"] for char, data in _raw_colors.items()
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def rgb_to_lab_batch(pixels):
|
| 16 |
+
"""
|
| 17 |
+
Vectorized RGB -> LAB conversion.
|
| 18 |
+
pixels: numpy array (N, 3) in 0-255
|
| 19 |
+
Returns: numpy array (N, 3) LAB
|
| 20 |
+
"""
|
| 21 |
+
rgb = pixels.astype(np.float64) / 255.0
|
| 22 |
+
|
| 23 |
+
def linearize(c):
|
| 24 |
+
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
| 25 |
+
|
| 26 |
+
rgb_lin = linearize(rgb)
|
| 27 |
+
|
| 28 |
+
# sRGB -> XYZ matrix
|
| 29 |
+
M = np.array([
|
| 30 |
+
[0.4124564, 0.3575761, 0.1804375],
|
| 31 |
+
[0.2126729, 0.7151522, 0.0721750],
|
| 32 |
+
[0.0193339, 0.1191920, 0.9503041],
|
| 33 |
+
])
|
| 34 |
+
|
| 35 |
+
xyz = rgb_lin @ M.T
|
| 36 |
+
|
| 37 |
+
xyz[:, 0] /= 0.95047
|
| 38 |
+
xyz[:, 1] /= 1.00000
|
| 39 |
+
xyz[:, 2] /= 1.08883
|
| 40 |
+
|
| 41 |
+
def f(t):
|
| 42 |
+
return np.where(t > 0.008856, np.cbrt(t), 7.787 * t + 16 / 116)
|
| 43 |
+
|
| 44 |
+
fxyz = f(xyz)
|
| 45 |
+
|
| 46 |
+
L = 116 * fxyz[:, 1] - 16
|
| 47 |
+
a = 500 * (fxyz[:, 0] - fxyz[:, 1])
|
| 48 |
+
b = 200 * (fxyz[:, 1] - fxyz[:, 2])
|
| 49 |
+
|
| 50 |
+
return np.stack([L, a, b], axis=1)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build_candidate_lab_matrix(candidates):
|
| 54 |
+
"""
|
| 55 |
+
candidates: list of emoji chars
|
| 56 |
+
Returns: (valid_chars, lab_matrix) where lab_matrix is (M, 3)
|
| 57 |
+
"""
|
| 58 |
+
valid_chars = []
|
| 59 |
+
labs = []
|
| 60 |
+
for char in candidates:
|
| 61 |
+
lab = EMOJI_COLOR_LOOKUP.get(char)
|
| 62 |
+
if lab is not None:
|
| 63 |
+
valid_chars.append(char)
|
| 64 |
+
labs.append(lab)
|
| 65 |
+
|
| 66 |
+
if not valid_chars:
|
| 67 |
+
# fallback — no color data, just return first candidate with dummy lab
|
| 68 |
+
return [candidates[0]], np.array([[50.0, 0.0, 0.0]])
|
| 69 |
+
|
| 70 |
+
return valid_chars, np.array(labs)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# cache candidate LAB matrices per (theme, category) so we don't rebuild every request
|
| 74 |
+
_candidate_cache = {}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_candidate_matrix(theme_name, label, cell_type):
|
| 78 |
+
key = (theme_name, label, cell_type)
|
| 79 |
+
if key not in _candidate_cache:
|
| 80 |
+
theme = THEMES.get(theme_name, THEMES["pastel"])
|
| 81 |
+
if cell_type == "segment":
|
| 82 |
+
candidates = theme.get(label, theme.get("background", ["⬜"]))
|
| 83 |
+
else:
|
| 84 |
+
candidates = theme.get(label, theme["neutral"])
|
| 85 |
+
_candidate_cache[key] = build_candidate_lab_matrix(candidates)
|
| 86 |
+
return _candidate_cache[key]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def build_emoji_grid(segment_grid, theme_name, chaos):
|
| 90 |
+
"""
|
| 91 |
+
Vectorized version: groups cells by (cell_type, label) so we batch-process
|
| 92 |
+
all pixels needing the same candidate pool at once.
|
| 93 |
+
"""
|
| 94 |
+
theme_name = theme_name.lower()
|
| 95 |
+
chaos = max(0.0, min(1.0, chaos))
|
| 96 |
+
|
| 97 |
+
H = len(segment_grid)
|
| 98 |
+
W = len(segment_grid[0]) if H else 0
|
| 99 |
+
|
| 100 |
+
# flatten grid into arrays for batch processing
|
| 101 |
+
flat_cells = [cell for row in segment_grid for cell in row]
|
| 102 |
+
cell_types = [c[0] for c in flat_cells]
|
| 103 |
+
labels = [c[1] for c in flat_cells]
|
| 104 |
+
pixels = np.array([c[2] for c in flat_cells]) # (N, 3)
|
| 105 |
+
|
| 106 |
+
N = len(flat_cells)
|
| 107 |
+
result = [None] * N
|
| 108 |
+
|
| 109 |
+
# group indices by (cell_type, label) so each group shares a candidate pool
|
| 110 |
+
groups = {}
|
| 111 |
+
for i in range(N):
|
| 112 |
+
key = (cell_types[i], labels[i])
|
| 113 |
+
groups.setdefault(key, []).append(i)
|
| 114 |
+
|
| 115 |
+
for (cell_type, label), indices in groups.items():
|
| 116 |
+
valid_chars, lab_matrix = get_candidate_matrix(theme_name, label, cell_type)
|
| 117 |
+
|
| 118 |
+
idx_array = np.array(indices)
|
| 119 |
+
group_pixels = pixels[idx_array] # (G, 3)
|
| 120 |
+
|
| 121 |
+
target_labs = rgb_to_lab_batch(group_pixels) # (G, 3)
|
| 122 |
+
|
| 123 |
+
# pairwise squared distance: (G, 1, 3) - (1, M, 3) -> (G, M, 3) -> sum -> (G, M)
|
| 124 |
+
diffs = target_labs[:, None, :] - lab_matrix[None, :, :]
|
| 125 |
+
dists = np.sum(diffs ** 2, axis=2)
|
| 126 |
+
|
| 127 |
+
best_idx = np.argmin(dists, axis=1) # (G,)
|
| 128 |
+
|
| 129 |
+
for j, i in enumerate(indices):
|
| 130 |
+
if chaos > 0 and random.random() < chaos:
|
| 131 |
+
result[i] = random.choice(valid_chars)
|
| 132 |
+
else:
|
| 133 |
+
result[i] = valid_chars[best_idx[j]]
|
| 134 |
+
|
| 135 |
+
# reshape back into grid
|
| 136 |
+
emoji_grid = []
|
| 137 |
+
for y in range(H):
|
| 138 |
+
emoji_grid.append(result[y * W:(y + 1) * W])
|
| 139 |
+
|
| 140 |
+
return emoji_grid
|
app/image_processor.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from PIL import Image
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def load_image(file):
|
| 6 |
+
image = Image.open(file)
|
| 7 |
+
|
| 8 |
+
# Convert everything to RGB
|
| 9 |
+
image = image.convert("RGB")
|
| 10 |
+
|
| 11 |
+
return image
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def resize_image(image, resolution):
|
| 15 |
+
width, height = image.size
|
| 16 |
+
|
| 17 |
+
# Clamp resolution
|
| 18 |
+
resolution = max(40, min(300, resolution))
|
| 19 |
+
|
| 20 |
+
aspect_ratio = width / height
|
| 21 |
+
|
| 22 |
+
if width >= height:
|
| 23 |
+
new_width = resolution
|
| 24 |
+
new_height = int(resolution / aspect_ratio)
|
| 25 |
+
else:
|
| 26 |
+
new_height = resolution
|
| 27 |
+
new_width = int(resolution * aspect_ratio)
|
| 28 |
+
|
| 29 |
+
resized = image.resize(
|
| 30 |
+
(new_width, new_height),
|
| 31 |
+
Image.LANCZOS
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
return resized
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def image_to_array(image):
|
| 38 |
+
|
| 39 |
+
return np.array(image)
|
app/main.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.routes import router
|
| 4 |
+
|
| 5 |
+
app = FastAPI(
|
| 6 |
+
title="Emoji Mosaic API",
|
| 7 |
+
version="1.0"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
app.add_middleware(
|
| 11 |
+
CORSMiddleware,
|
| 12 |
+
allow_origins=["http://localhost:5173"],
|
| 13 |
+
allow_methods=["*"],
|
| 14 |
+
allow_headers=["*"],
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
app.include_router(router)
|
app/routes.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
|
| 3 |
+
from fastapi import (
|
| 4 |
+
APIRouter,
|
| 5 |
+
UploadFile,
|
| 6 |
+
File,
|
| 7 |
+
Form,
|
| 8 |
+
HTTPException
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
from app.image_processor import (
|
| 12 |
+
load_image,
|
| 13 |
+
resize_image,
|
| 14 |
+
image_to_array
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
from app.emoji_mapper import (
|
| 18 |
+
build_emoji_grid
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
from app.themes import THEMES
|
| 22 |
+
from ml.segmentor import segment_image
|
| 23 |
+
|
| 24 |
+
router = APIRouter()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/generate")
|
| 28 |
+
async def generate(
|
| 29 |
+
|
| 30 |
+
image: UploadFile = File(...),
|
| 31 |
+
|
| 32 |
+
theme: str = Form(...),
|
| 33 |
+
|
| 34 |
+
resolution: int = Form(...),
|
| 35 |
+
|
| 36 |
+
chaos: float = Form(...)
|
| 37 |
+
|
| 38 |
+
):
|
| 39 |
+
|
| 40 |
+
start = time.time()
|
| 41 |
+
|
| 42 |
+
# -----------------------------
|
| 43 |
+
# Validate uploaded file
|
| 44 |
+
# -----------------------------
|
| 45 |
+
|
| 46 |
+
if not image.content_type.startswith("image/"):
|
| 47 |
+
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=400,
|
| 50 |
+
detail="Only image files are allowed."
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# -----------------------------
|
| 54 |
+
# Validate theme
|
| 55 |
+
# -----------------------------
|
| 56 |
+
|
| 57 |
+
theme = theme.lower()
|
| 58 |
+
|
| 59 |
+
if theme not in THEMES:
|
| 60 |
+
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=400,
|
| 63 |
+
detail=f"Theme '{theme}' not found."
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# -----------------------------
|
| 67 |
+
# Validate resolution
|
| 68 |
+
# -----------------------------
|
| 69 |
+
|
| 70 |
+
if resolution < 20 or resolution > 100:
|
| 71 |
+
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=400,
|
| 74 |
+
detail="Resolution must be between 20 and 100."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# -----------------------------
|
| 78 |
+
# Validate chaos
|
| 79 |
+
# -----------------------------
|
| 80 |
+
|
| 81 |
+
if chaos < 0 or chaos > 1:
|
| 82 |
+
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
status_code=400,
|
| 85 |
+
detail="Chaos must be between 0 and 1."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# -----------------------------
|
| 89 |
+
# Process image
|
| 90 |
+
# -----------------------------
|
| 91 |
+
|
| 92 |
+
image_obj = load_image(image.file)
|
| 93 |
+
internal_resolution = int(40 + (resolution - 20) * (300 - 40) / (100 - 20))
|
| 94 |
+
resized = resize_image(image_obj, internal_resolution)
|
| 95 |
+
|
| 96 |
+
pixels = image_to_array(resized)
|
| 97 |
+
|
| 98 |
+
# -----------------------------
|
| 99 |
+
# Segment image (CNN + fallback)
|
| 100 |
+
# -----------------------------
|
| 101 |
+
|
| 102 |
+
segment_grid = segment_image(resized, pixels)
|
| 103 |
+
|
| 104 |
+
# -----------------------------
|
| 105 |
+
# Build emoji grid
|
| 106 |
+
# -----------------------------
|
| 107 |
+
|
| 108 |
+
emoji_grid = build_emoji_grid(
|
| 109 |
+
segment_grid,
|
| 110 |
+
theme,
|
| 111 |
+
chaos
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
end = time.time()
|
| 115 |
+
|
| 116 |
+
print(f"Processing Time: {end-start:.4f} seconds")
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
|
| 120 |
+
"success": True,
|
| 121 |
+
|
| 122 |
+
"theme": theme,
|
| 123 |
+
|
| 124 |
+
"resolution": resolution,
|
| 125 |
+
|
| 126 |
+
"chaos": chaos,
|
| 127 |
+
|
| 128 |
+
"rows": len(emoji_grid),
|
| 129 |
+
|
| 130 |
+
"cols": len(emoji_grid[0]),
|
| 131 |
+
|
| 132 |
+
"grid": emoji_grid
|
| 133 |
+
|
| 134 |
+
}
|
app/themes.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PASTEL = {
|
| 2 |
+
|
| 3 |
+
# ── segment keys ──
|
| 4 |
+
"hair": [
|
| 5 |
+
"🌸", "🎀", "💗", "✨", "🩷", "🍡", "🧁", "🍥", "🌷", "🦄",
|
| 6 |
+
"🍬", "🪞", "🪄", "🎐", "🌺", "🍓", "🩰", "💞", "🐚",
|
| 7 |
+
],
|
| 8 |
+
"skin": [
|
| 9 |
+
"🍑", "💛", "🌼", "🤍", "🫐", "🍈", "🥛", "🍮", "🧈", "🌻",
|
| 10 |
+
"🐤", "🍯", "🌾", "🥮", "🍠", "🍞", "🥐", "🧇",
|
| 11 |
+
],
|
| 12 |
+
"clothes": [
|
| 13 |
+
"🎀", "🩷", "🧁", "💜", "👛", "👗", "👒", "🩱", "🧶", "🎒",
|
| 14 |
+
"👝", "🩲", "💄", "💍", "🛍️", "🪡", "🧵",
|
| 15 |
+
],
|
| 16 |
+
"water": [
|
| 17 |
+
"🫧", "🩵", "🌊", "💙", "💧", "🧊", "🚿", "🪣", "🐬", "🦢",
|
| 18 |
+
"🛁", "🥤", "🧴",
|
| 19 |
+
],
|
| 20 |
+
"plants": [
|
| 21 |
+
"🌿", "🍀", "🌱", "🌸", "🌷", "🍃", "🌹", "🪴", "🌵", "🌳",
|
| 22 |
+
"🥀", "🌾", "🍄", "🌼",
|
| 23 |
+
],
|
| 24 |
+
"ground": [
|
| 25 |
+
"🤎", "🍪", "🧸", "🪵", "🍂", "🥨", "🌰", "🪨", "🍫", "🦴",
|
| 26 |
+
"🥖", "🧺",
|
| 27 |
+
],
|
| 28 |
+
"background": [
|
| 29 |
+
"🤍", "☁️", "💭", "✨", "🌙", "⭐", "🪽", "🤿", "🎐", "🫐",
|
| 30 |
+
"🪞", "🦢",
|
| 31 |
+
],
|
| 32 |
+
|
| 33 |
+
# ── color fallback keys ──
|
| 34 |
+
"pink": ["🌸", "🎀", "🩷", "🍡", "🩰", "🌷", "💐", "🦩", "🧁", "🍓"],
|
| 35 |
+
"red": ["❤️", "🍓", "🌹", "🍒", "🍎", "🟥", "🍅", "🧧"],
|
| 36 |
+
"orange": ["🍊", "🧡", "🦊", "🌅", "🍑", "🥕", "🦁", "🟧"],
|
| 37 |
+
"brown": ["🧸", "🤎", "🍪", "🪵", "🦴", "🥨", "🌰", "🐻", "🍫"],
|
| 38 |
+
"yellow": ["🌼", "⭐", "🐥", "🌻", "🍋", "🧀", "🟨", "🍌", "🌕"],
|
| 39 |
+
"green": ["🌿", "🍀", "🌱", "🥝", "🟩", "🐸", "🍏", "🌳"],
|
| 40 |
+
"blue": ["🫧", "🐳", "🩵", "🌊", "💙", "🦋", "🟦", "🐬"],
|
| 41 |
+
"light": ["☁️", "🤍", "✨", "🩶", "🫧", "🪽", "🦢"],
|
| 42 |
+
"dark": ["🌑", "🖤", "🌌", "🦇", "🐈⬛", "⚫"],
|
| 43 |
+
"neutral": ["🤍", "🩶", "💭", "🪨", "🐚", "🍄"],
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
OCEAN = {
|
| 48 |
+
|
| 49 |
+
# ── segment keys ──
|
| 50 |
+
"hair": [
|
| 51 |
+
"🪸", "🐚", "🌊", "🦀", "🐙", "🌀", "🫧", "🧜♀️", "🦑", "🪼",
|
| 52 |
+
"🧵", "🌫️",
|
| 53 |
+
],
|
| 54 |
+
"skin": [
|
| 55 |
+
"🐠", "🌺", "⭐", "🪸", "🐡", "🦐", "🦞", "🐢", "🌴", "🍍",
|
| 56 |
+
"🦩", "🥥",
|
| 57 |
+
],
|
| 58 |
+
"clothes": [
|
| 59 |
+
"🐬", "🌊", "🪼", "💙", "🩱", "🦈", "⚓", "🧜♂️", "🛟", "🪢",
|
| 60 |
+
],
|
| 61 |
+
"water": [
|
| 62 |
+
"🌊", "🐳", "🫧", "🐬", "💧", "🌀", "🚣", "⛵", "🛶", "🐋",
|
| 63 |
+
"🦭", "🪣", "🧊", "🌧️",
|
| 64 |
+
],
|
| 65 |
+
"plants": [
|
| 66 |
+
"🌿", "🐢", "🌴", "🌱", "🪸", "🥥", "🍃", "🌊", "🦦",
|
| 67 |
+
],
|
| 68 |
+
"ground": [
|
| 69 |
+
"🪨", "🐚", "⚓", "🦑", "🏝️", "🦀", "🪵", "🦂", "🪱",
|
| 70 |
+
],
|
| 71 |
+
"background": [
|
| 72 |
+
"🌌", "💎", "☁️", "🫧", "🌊", "🌙", "⭐", "🐳", "🪞", "🌫️",
|
| 73 |
+
],
|
| 74 |
+
|
| 75 |
+
# ── color fallback keys ──
|
| 76 |
+
"pink": ["🪸", "🌺", "🩷", "🦩", "🐡", "🦐"],
|
| 77 |
+
"red": ["🦀", "🐙", "❤️", "🦞", "🌶️", "🍓"],
|
| 78 |
+
"orange": ["🐠", "🪼", "🧡", "🦞", "🥭", "🐡"],
|
| 79 |
+
"brown": ["🐚", "🪵", "🦑", "🦂", "🪱", "🦦"],
|
| 80 |
+
"yellow": ["⭐", "🐡", "🌞", "🍍", "🟡", "🐤"],
|
| 81 |
+
"green": ["🌿", "🐢", "🌴", "🥥", "🐊", "🦎"],
|
| 82 |
+
"blue": ["🌊", "🐳", "🫧", "🐬", "💙", "🐋", "🦈", "🔵"],
|
| 83 |
+
"light": ["☁️", "💎", "✨", "🫧", "🪽", "🌫️"],
|
| 84 |
+
"dark": ["🌌", "🌑", "⚓", "🦑", "🐙", "⚫"],
|
| 85 |
+
"neutral": ["🪨", "☁️", "💭", "🐚", "🦪"],
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
SPOOKY = {
|
| 90 |
+
|
| 91 |
+
# ── segment keys ──
|
| 92 |
+
"hair": [
|
| 93 |
+
"🕸️", "🖤", "💀", "🦇", "🧛", "🧟", "🕷️", "🐈⬛", "🪦", "🌑",
|
| 94 |
+
],
|
| 95 |
+
"skin": [
|
| 96 |
+
"👻", "🕯️", "💀", "🌕", "☠️", "🧟", "🧛", "🦴", "🫥", "🪱",
|
| 97 |
+
],
|
| 98 |
+
"clothes": [
|
| 99 |
+
"🧛", "🖤", "🎃", "⚰️", "🦇", "🪦", "👹", "🧙", "🩸", "🥀",
|
| 100 |
+
],
|
| 101 |
+
"water": [
|
| 102 |
+
"🌊", "🔮", "🌑", "💀", "🪦", "☠️", "🧪", "🫗",
|
| 103 |
+
],
|
| 104 |
+
"plants": [
|
| 105 |
+
"☠️", "🧪", "🕸️", "🌿", "🥀", "🍄", "🌑", "🪦",
|
| 106 |
+
],
|
| 107 |
+
"ground": [
|
| 108 |
+
"🪦", "🦴", "🌑", "🖤", "⚰️", "🕳️", "🍂", "🪱",
|
| 109 |
+
],
|
| 110 |
+
"background": [
|
| 111 |
+
"🌌", "👻", "🌫️", "🖤", "🌑", "🦇", "🕸️", "🪦", "⚫",
|
| 112 |
+
],
|
| 113 |
+
|
| 114 |
+
# ── color fallback keys ──
|
| 115 |
+
"pink": ["🕸️", "🎃", "💀", "🩸", "🧠"],
|
| 116 |
+
"red": ["🩸", "🧛", "👹", "🔥", "🍷", "🟥"],
|
| 117 |
+
"orange": ["🎃", "🔥", "🦇", "🧡", "🍊"],
|
| 118 |
+
"brown": ["🪵", "🦉", "🦂", "🦴", "🤎", "🪱"],
|
| 119 |
+
"yellow": ["🕯️", "👁️", "🌕", "🟡", "🐍"],
|
| 120 |
+
"green": ["☠️", "🧪", "👻", "🧟", "🐍", "🟢"],
|
| 121 |
+
"blue": ["🌌", "🦇", "🔮", "🌑", "🔵"],
|
| 122 |
+
"light": ["👻", "✨", "🌙", "🤍", "🫥"],
|
| 123 |
+
"dark": ["🖤", "🌑", "⚰️", "⚫", "🦇", "🕳��"],
|
| 124 |
+
"neutral": ["🪦", "💀", "🌫️", "🩶", "🦴"],
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
Y2K = {
|
| 129 |
+
|
| 130 |
+
# ── segment keys ──
|
| 131 |
+
"hair": [
|
| 132 |
+
"💿", "🌸", "💅", "✨", "💎", "🔮", "🎀", "🪩", "💋", "🦋",
|
| 133 |
+
"🩷", "💗",
|
| 134 |
+
],
|
| 135 |
+
"skin": [
|
| 136 |
+
"🍑", "💄", "📱", "👛", "💋", "🦋", "🍓", "🌟", "💫", "🫦",
|
| 137 |
+
],
|
| 138 |
+
"clothes": [
|
| 139 |
+
"👙", "🩱", "👖", "🎧", "📼", "🪩", "💿", "🦋", "🔗", "👑",
|
| 140 |
+
"💍", "🛹",
|
| 141 |
+
],
|
| 142 |
+
"water": [
|
| 143 |
+
"💧", "🫧", "🩵", "🐬", "🌊", "🪩",
|
| 144 |
+
],
|
| 145 |
+
"plants": [
|
| 146 |
+
"🌸", "🦋", "🌺", "🌿", "✨", "🌟",
|
| 147 |
+
],
|
| 148 |
+
"ground": [
|
| 149 |
+
"🪩", "💿", "📼", "🎮", "🕹️", "📟", "💽", "🔗",
|
| 150 |
+
],
|
| 151 |
+
"background": [
|
| 152 |
+
"✨", "💫", "🌟", "🪩", "💜", "🩷", "🔮", "🌌", "💎",
|
| 153 |
+
],
|
| 154 |
+
|
| 155 |
+
# ── color fallback keys ──
|
| 156 |
+
"pink": ["💗", "🩷", "💅", "🎀", "🦋", "💋", "🌸"],
|
| 157 |
+
"red": ["❤️🔥", "🔥", "🍓", "💋", "🟥", "🧨"],
|
| 158 |
+
"orange": ["🧡", "🔥", "🍊", "🟧", "🦊"],
|
| 159 |
+
"brown": ["🤎", "🪵", "🦴", "🟫"],
|
| 160 |
+
"yellow": ["⭐", "🌟", "🟡", "💛", "⚡"],
|
| 161 |
+
"green": ["🟢", "🍏", "💚", "👽", "🐍"],
|
| 162 |
+
"blue": ["🔵", "💙", "🪩", "🐬", "🫧", "💎"],
|
| 163 |
+
"light": ["✨", "🤍", "💫", "🩶", "💎"],
|
| 164 |
+
"dark": ["🖤", "⚫", "🌑", "🕶️"],
|
| 165 |
+
"neutral": ["🩶", "💭", "🪞", "📀"],
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
THEMES = {
|
| 170 |
+
"pastel": PASTEL,
|
| 171 |
+
"ocean": OCEAN,
|
| 172 |
+
"spooky": SPOOKY,
|
| 173 |
+
"y2k": Y2K,
|
| 174 |
+
}
|
app/utils.py
ADDED
|
File without changes
|
checkpoints/model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bb2989c1a8b099c67160d5e1bfa778086bd428ce544841f2c86aac167e57206e
|
| 3 |
+
size 2489050
|
data/emoji_colors.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ml/__init__.py
ADDED
|
File without changes
|
ml/dataset.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from torch.utils.data import Dataset, DataLoader
|
| 5 |
+
from torchvision import transforms
|
| 6 |
+
from collections import Counter
|
| 7 |
+
|
| 8 |
+
CLASS_NAMES = [
|
| 9 |
+
"background", # 0
|
| 10 |
+
"hair", # 1
|
| 11 |
+
"skin", # 2
|
| 12 |
+
"clothes", # 3
|
| 13 |
+
"water", # 4
|
| 14 |
+
"ground", # 5
|
| 15 |
+
"plants", # 6
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
NUM_CLASSES = 7
|
| 19 |
+
|
| 20 |
+
ADE20K_TO_OURS = {
|
| 21 |
+
2: 0, # sky → background
|
| 22 |
+
4: 5, # floor → ground
|
| 23 |
+
9: 6, # tree → plants
|
| 24 |
+
10: 5, # grass → ground
|
| 25 |
+
14: 4, # water
|
| 26 |
+
17: 0, # table → background
|
| 27 |
+
21: 0, # chair → background
|
| 28 |
+
26: 0, # car → background
|
| 29 |
+
30: 6, # bush → plants
|
| 30 |
+
64: 4, # sea → water
|
| 31 |
+
67: 0, # food → background
|
| 32 |
+
96: 0, # bus → background
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
HUMAN_TO_OURS = {
|
| 36 |
+
0: 0, # background
|
| 37 |
+
2: 1, # hair
|
| 38 |
+
4: 3, # upper clothes
|
| 39 |
+
6: 3, # pants → clothes
|
| 40 |
+
7: 3, # dress → clothes
|
| 41 |
+
11: 2, # face → skin
|
| 42 |
+
13: 2, # left leg → skin
|
| 43 |
+
14: 2, # right leg → skin
|
| 44 |
+
15: 2, # left arm → skin
|
| 45 |
+
16: 2, # right arm → skin
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
PATCH_SIZE = 32
|
| 49 |
+
STRIDE = 16
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Dataset class — PyTorch needs this to load data in batches
|
| 53 |
+
|
| 54 |
+
class PatchDataset(Dataset):
|
| 55 |
+
|
| 56 |
+
def __init__(self, samples, transform=None):
|
| 57 |
+
self.samples = samples
|
| 58 |
+
self.transform = transform
|
| 59 |
+
|
| 60 |
+
def __len__(self):
|
| 61 |
+
return len(self.samples)
|
| 62 |
+
|
| 63 |
+
def __getitem__(self, idx):
|
| 64 |
+
patch, label = self.samples[idx]
|
| 65 |
+
|
| 66 |
+
patch = Image.fromarray(patch)
|
| 67 |
+
|
| 68 |
+
if self.transform:
|
| 69 |
+
patch = self.transform(patch)
|
| 70 |
+
|
| 71 |
+
return patch, label
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# Patch extraction — slides window across one image+mask pair
|
| 75 |
+
|
| 76 |
+
def extract_patches_from_image(image_path, mask_path, label_map):
|
| 77 |
+
|
| 78 |
+
image = np.array(Image.open(image_path).convert("RGB"))
|
| 79 |
+
mask = np.array(Image.open(mask_path))
|
| 80 |
+
|
| 81 |
+
H, W, _ = image.shape
|
| 82 |
+
samples = []
|
| 83 |
+
|
| 84 |
+
for y in range(0, H - PATCH_SIZE, STRIDE):
|
| 85 |
+
for x in range(0, W - PATCH_SIZE, STRIDE):
|
| 86 |
+
|
| 87 |
+
# crop patch from image
|
| 88 |
+
patch = image[y:y+PATCH_SIZE, x:x+PATCH_SIZE]
|
| 89 |
+
|
| 90 |
+
# crop same region from mask
|
| 91 |
+
mask_patch = mask[y:y+PATCH_SIZE, x:x+PATCH_SIZE]
|
| 92 |
+
|
| 93 |
+
# check center pixel label
|
| 94 |
+
center_y = PATCH_SIZE // 2
|
| 95 |
+
center_x = PATCH_SIZE // 2
|
| 96 |
+
raw_label = int(mask_patch[center_y, center_x])
|
| 97 |
+
|
| 98 |
+
# translate to our class system
|
| 99 |
+
our_label = label_map.get(raw_label, None)
|
| 100 |
+
|
| 101 |
+
# skip if this label isn't in our mapping
|
| 102 |
+
if our_label is None:
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
samples.append((patch, our_label))
|
| 106 |
+
|
| 107 |
+
return samples
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ADE20K loader — reads from disk
|
| 111 |
+
|
| 112 |
+
def load_ade20k(root, max_images=500):
|
| 113 |
+
|
| 114 |
+
samples = []
|
| 115 |
+
|
| 116 |
+
img_dir = os.path.join(root, "ADEChallengeData2016", "images", "training")
|
| 117 |
+
mask_dir = os.path.join(root, "ADEChallengeData2016", "annotations", "training")
|
| 118 |
+
|
| 119 |
+
files = os.listdir(img_dir)
|
| 120 |
+
files = files[:max_images] #sliced dataset
|
| 121 |
+
|
| 122 |
+
print(f"ADE20K: found {len(files)} images")
|
| 123 |
+
|
| 124 |
+
for fname in files:
|
| 125 |
+
|
| 126 |
+
if not fname.endswith(".jpg"):
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
img_path = os.path.join(img_dir, fname)
|
| 130 |
+
|
| 131 |
+
# mask has same name but .png
|
| 132 |
+
mask_path = os.path.join(mask_dir, fname.replace(".jpg", ".png"))
|
| 133 |
+
|
| 134 |
+
if not os.path.exists(mask_path):
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
patches = extract_patches_from_image(
|
| 138 |
+
img_path,
|
| 139 |
+
mask_path,
|
| 140 |
+
ADE20K_TO_OURS
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
samples.extend(patches)
|
| 144 |
+
|
| 145 |
+
print(f"ADE20K: extracted {len(samples)} patches")
|
| 146 |
+
return samples
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# Human parsing loader — reads from HuggingFace
|
| 150 |
+
|
| 151 |
+
def load_human_parsing(max_images=500):
|
| 152 |
+
|
| 153 |
+
samples = []
|
| 154 |
+
|
| 155 |
+
from datasets import load_dataset
|
| 156 |
+
|
| 157 |
+
print("Loading human parsing dataset from HuggingFace...")
|
| 158 |
+
ds = load_dataset("mattmdjaga/human_parsing_dataset", split="train")
|
| 159 |
+
|
| 160 |
+
print(f"Human parsing: found {max_images} images")
|
| 161 |
+
|
| 162 |
+
for item in ds.select(range(max_images)):
|
| 163 |
+
|
| 164 |
+
image = np.array(item["image"].convert("RGB"))
|
| 165 |
+
mask = np.array(item["mask"])
|
| 166 |
+
|
| 167 |
+
H, W, _ = image.shape
|
| 168 |
+
|
| 169 |
+
for y in range(0, H - PATCH_SIZE, STRIDE):
|
| 170 |
+
for x in range(0, W - PATCH_SIZE, STRIDE):
|
| 171 |
+
|
| 172 |
+
patch = image[y:y+PATCH_SIZE, x:x+PATCH_SIZE]
|
| 173 |
+
mask_patch = mask[y:y+PATCH_SIZE, x:x+PATCH_SIZE]
|
| 174 |
+
|
| 175 |
+
center_y = PATCH_SIZE // 2
|
| 176 |
+
center_x = PATCH_SIZE // 2
|
| 177 |
+
raw_label = int(mask_patch[center_y, center_x])
|
| 178 |
+
|
| 179 |
+
our_label = HUMAN_TO_OURS.get(raw_label, None)
|
| 180 |
+
|
| 181 |
+
if our_label is None:
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
samples.append((patch, our_label))
|
| 185 |
+
|
| 186 |
+
print(f"Human parsing: extracted {len(samples)} patches")
|
| 187 |
+
return samples
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# Main build function — combines both datasets
|
| 191 |
+
|
| 192 |
+
def build_dataset(ade20k_root, transform=None, max_images_per_source=500, max_per_class=5000):
|
| 193 |
+
|
| 194 |
+
print("Building dataset...")
|
| 195 |
+
|
| 196 |
+
ade_samples = load_ade20k(ade20k_root, max_images=max_images_per_source)
|
| 197 |
+
human_samples = load_human_parsing(max_images=max_images_per_source)
|
| 198 |
+
|
| 199 |
+
all_samples = ade_samples + human_samples
|
| 200 |
+
|
| 201 |
+
# cap each class so no single class dominates
|
| 202 |
+
from collections import defaultdict
|
| 203 |
+
import random
|
| 204 |
+
|
| 205 |
+
bucketed = defaultdict(list)
|
| 206 |
+
for patch, label in all_samples:
|
| 207 |
+
bucketed[label].append((patch, label))
|
| 208 |
+
|
| 209 |
+
balanced = []
|
| 210 |
+
for label, items in bucketed.items():
|
| 211 |
+
random.shuffle(items)
|
| 212 |
+
balanced.extend(items[:max_per_class])
|
| 213 |
+
|
| 214 |
+
random.shuffle(balanced)
|
| 215 |
+
|
| 216 |
+
print(f"Total patches after balancing: {len(balanced)}")
|
| 217 |
+
|
| 218 |
+
counts = Counter(label for _, label in balanced)
|
| 219 |
+
for i, name in enumerate(CLASS_NAMES):
|
| 220 |
+
print(f" {name}: {counts.get(i, 0)}")
|
| 221 |
+
|
| 222 |
+
dataset = PatchDataset(balanced, transform=transform)
|
| 223 |
+
return dataset
|
| 224 |
+
|
| 225 |
+
# Transform — normalizes patches for CNN input
|
| 226 |
+
|
| 227 |
+
def get_transform():
|
| 228 |
+
return transforms.Compose([
|
| 229 |
+
transforms.Resize((32, 32)),
|
| 230 |
+
transforms.ToTensor(),
|
| 231 |
+
transforms.Normalize(
|
| 232 |
+
mean=[0.485, 0.456, 0.406],
|
| 233 |
+
std=[0.229, 0.224, 0.225]
|
| 234 |
+
)
|
| 235 |
+
])
|
ml/model.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class PatchCNN(nn.Module):
|
| 6 |
+
|
| 7 |
+
def __init__(self, num_classes=7):
|
| 8 |
+
super(PatchCNN, self).__init__()
|
| 9 |
+
|
| 10 |
+
# ── Feature Extractor ──
|
| 11 |
+
self.features = nn.Sequential(
|
| 12 |
+
|
| 13 |
+
# Block 1 — detects edges and basic textures
|
| 14 |
+
nn.Conv2d(3, 32, kernel_size=3, padding=1),
|
| 15 |
+
nn.BatchNorm2d(32),
|
| 16 |
+
nn.ReLU(),
|
| 17 |
+
nn.MaxPool2d(2, 2), # 32x32 → 16x16
|
| 18 |
+
|
| 19 |
+
# Block 2 — detects patterns from edges
|
| 20 |
+
nn.Conv2d(32, 64, kernel_size=3, padding=1),
|
| 21 |
+
nn.BatchNorm2d(64),
|
| 22 |
+
nn.ReLU(),
|
| 23 |
+
nn.MaxPool2d(2, 2), # 16x16 → 8x8
|
| 24 |
+
|
| 25 |
+
# Block 3 — detects complex textures
|
| 26 |
+
nn.Conv2d(64, 128, kernel_size=3, padding=1),
|
| 27 |
+
nn.BatchNorm2d(128),
|
| 28 |
+
nn.ReLU(),
|
| 29 |
+
nn.MaxPool2d(2, 2), # 8x8 → 4x4
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# ── Classifier ──
|
| 33 |
+
self.classifier = nn.Sequential(
|
| 34 |
+
nn.Flatten(), # (128, 4, 4) → 2048
|
| 35 |
+
nn.Linear(128 * 4 * 4, 256),
|
| 36 |
+
nn.ReLU(),
|
| 37 |
+
nn.Dropout(0.3),
|
| 38 |
+
nn.Linear(256, num_classes) # 256 → 7
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
def forward(self, x):
|
| 42 |
+
x = self.features(x)
|
| 43 |
+
x = self.classifier(x)
|
| 44 |
+
return x
|
ml/predict.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from torchvision import transforms
|
| 5 |
+
|
| 6 |
+
from ml.model import PatchCNN
|
| 7 |
+
from ml.dataset import CLASS_NAMES, PATCH_SIZE
|
| 8 |
+
|
| 9 |
+
CHECKPOINT_PATH = "checkpoints/model.pth"
|
| 10 |
+
CONFIDENCE_THRESHOLD = 0.5 # below this → fall back to color
|
| 11 |
+
|
| 12 |
+
# ── load model once at module level ──
|
| 13 |
+
# this means it loads when FastAPI starts, not on every request
|
| 14 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 15 |
+
|
| 16 |
+
model = PatchCNN(num_classes=7)
|
| 17 |
+
model.load_state_dict(torch.load(CHECKPOINT_PATH, map_location=device))
|
| 18 |
+
model.eval()
|
| 19 |
+
model.to(device)
|
| 20 |
+
|
| 21 |
+
print(f"PatchCNN loaded on {device}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── same transform as training ──
|
| 25 |
+
transform = transforms.Compose([
|
| 26 |
+
transforms.Resize((32, 32)),
|
| 27 |
+
transforms.ToTensor(),
|
| 28 |
+
transforms.Normalize(
|
| 29 |
+
mean=[0.485, 0.456, 0.406],
|
| 30 |
+
std=[0.229, 0.224, 0.225]
|
| 31 |
+
)
|
| 32 |
+
])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def predict_image(image):
|
| 36 |
+
"""
|
| 37 |
+
Takes a PIL image.
|
| 38 |
+
Returns:
|
| 39 |
+
label_grid — 2D list of class name strings
|
| 40 |
+
confidence_grid — 2D list of float confidence scores
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
image_np = np.array(image.convert("RGB"))
|
| 44 |
+
H, W, _ = image_np.shape
|
| 45 |
+
|
| 46 |
+
if H < PATCH_SIZE or W < PATCH_SIZE:
|
| 47 |
+
return [], []
|
| 48 |
+
|
| 49 |
+
label_grid = []
|
| 50 |
+
confidence_grid = []
|
| 51 |
+
|
| 52 |
+
for y in range(0, H - PATCH_SIZE, PATCH_SIZE): # stride = PATCH_SIZE (no overlap at inference)
|
| 53 |
+
label_row = []
|
| 54 |
+
conf_row = []
|
| 55 |
+
|
| 56 |
+
for x in range(0, W - PATCH_SIZE, PATCH_SIZE):
|
| 57 |
+
|
| 58 |
+
# extract patch
|
| 59 |
+
patch = image_np[y:y+PATCH_SIZE, x:x+PATCH_SIZE]
|
| 60 |
+
patch_pil = Image.fromarray(patch)
|
| 61 |
+
|
| 62 |
+
# transform → tensor
|
| 63 |
+
tensor = transform(patch_pil).unsqueeze(0).to(device)
|
| 64 |
+
# unsqueeze(0) adds batch dimension: (3,32,32) → (1,3,32,32)
|
| 65 |
+
|
| 66 |
+
# inference
|
| 67 |
+
with torch.no_grad():
|
| 68 |
+
output = model(tensor) # (1, 7) raw logits
|
| 69 |
+
probs = torch.softmax(output, dim=1) # convert to probabilities
|
| 70 |
+
confidence, pred = probs.max(dim=1) # get top class + its confidence
|
| 71 |
+
|
| 72 |
+
label = CLASS_NAMES[pred.item()]
|
| 73 |
+
conf = confidence.item()
|
| 74 |
+
|
| 75 |
+
label_row.append(label)
|
| 76 |
+
conf_row.append(conf)
|
| 77 |
+
|
| 78 |
+
label_grid.append(label_row)
|
| 79 |
+
confidence_grid.append(conf_row)
|
| 80 |
+
|
| 81 |
+
return label_grid, confidence_grid
|
ml/segmentor.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from PIL import Image
|
| 3 |
+
from ml.predict import predict_image, CONFIDENCE_THRESHOLD
|
| 4 |
+
from app.color_detector import get_color_group
|
| 5 |
+
|
| 6 |
+
CNN_MIN_SIZE = 480
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def segment_image(image, pixels):
|
| 10 |
+
"""
|
| 11 |
+
Returns a fine-grained segment_grid at the SAME resolution as `pixels`.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
H, W, _ = pixels.shape
|
| 15 |
+
|
| 16 |
+
# ── 1. Run CNN on a separately upscaled copy for finer patch coverage ──
|
| 17 |
+
cnn_image = image
|
| 18 |
+
if image.width < CNN_MIN_SIZE or image.height < CNN_MIN_SIZE:
|
| 19 |
+
scale = CNN_MIN_SIZE / min(image.width, image.height)
|
| 20 |
+
new_w = int(image.width * scale)
|
| 21 |
+
new_h = int(image.height * scale)
|
| 22 |
+
cnn_image = image.resize((new_w, new_h), Image.LANCZOS)
|
| 23 |
+
|
| 24 |
+
label_grid, confidence_grid = predict_image(cnn_image)
|
| 25 |
+
|
| 26 |
+
use_cnn = bool(label_grid and label_grid[0])
|
| 27 |
+
|
| 28 |
+
if use_cnn:
|
| 29 |
+
coarse_H = len(label_grid)
|
| 30 |
+
coarse_W = len(label_grid[0])
|
| 31 |
+
|
| 32 |
+
# scale factors to map fine pixel -> coarse CNN patch
|
| 33 |
+
scale_y = coarse_H / H
|
| 34 |
+
scale_x = coarse_W / W
|
| 35 |
+
|
| 36 |
+
segment_grid = []
|
| 37 |
+
|
| 38 |
+
for y in range(H):
|
| 39 |
+
row = []
|
| 40 |
+
for x in range(W):
|
| 41 |
+
|
| 42 |
+
pixel = pixels[y, x]
|
| 43 |
+
color = get_color_group(pixel)
|
| 44 |
+
|
| 45 |
+
if use_cnn:
|
| 46 |
+
coarse_y = min(int(y * scale_y), coarse_H - 1)
|
| 47 |
+
coarse_x = min(int(x * scale_x), coarse_W - 1)
|
| 48 |
+
|
| 49 |
+
label = label_grid[coarse_y][coarse_x]
|
| 50 |
+
confidence = confidence_grid[coarse_y][coarse_x]
|
| 51 |
+
|
| 52 |
+
if confidence >= CONFIDENCE_THRESHOLD and label != "background":
|
| 53 |
+
row.append(("segment", label, pixel))
|
| 54 |
+
else:
|
| 55 |
+
row.append(("color", color, pixel))
|
| 56 |
+
else:
|
| 57 |
+
row.append(("color", color, pixel))
|
| 58 |
+
|
| 59 |
+
segment_grid.append(row)
|
| 60 |
+
|
| 61 |
+
return segment_grid
|
ml/train.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torch.utils.data import DataLoader, random_split
|
| 4 |
+
|
| 5 |
+
from ml.dataset import build_dataset, get_transform
|
| 6 |
+
from ml.model import PatchCNN
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# ── Config ──────────────────────────────────────────
|
| 10 |
+
BATCH_SIZE = 64
|
| 11 |
+
EPOCHS = 15
|
| 12 |
+
LEARNING_RATE = 0.001
|
| 13 |
+
VAL_SPLIT = 0.2
|
| 14 |
+
CHECKPOINT_PATH = "checkpoints/model.pth"
|
| 15 |
+
ADE20K_ROOT = "data/raw/ade20k"
|
| 16 |
+
# ────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def train():
|
| 20 |
+
|
| 21 |
+
# device — use GPU if available, otherwise CPU
|
| 22 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 23 |
+
print(f"Training on: {device}")
|
| 24 |
+
|
| 25 |
+
# ── Data ──
|
| 26 |
+
print("\nLoading dataset...")
|
| 27 |
+
dataset = build_dataset(ADE20K_ROOT, transform=get_transform())
|
| 28 |
+
|
| 29 |
+
val_size = int(len(dataset) * VAL_SPLIT)
|
| 30 |
+
train_size = len(dataset) - val_size
|
| 31 |
+
|
| 32 |
+
train_set, val_set = random_split(dataset, [train_size, val_size])
|
| 33 |
+
|
| 34 |
+
train_loader = DataLoader(
|
| 35 |
+
train_set,
|
| 36 |
+
batch_size=BATCH_SIZE,
|
| 37 |
+
shuffle=True,
|
| 38 |
+
num_workers=0 # keep 0 on Windows to avoid multiprocessing issues
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
val_loader = DataLoader(
|
| 42 |
+
val_set,
|
| 43 |
+
batch_size=BATCH_SIZE,
|
| 44 |
+
shuffle=False,
|
| 45 |
+
num_workers=0
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
print(f"Train samples: {train_size}")
|
| 49 |
+
print(f"Val samples: {val_size}")
|
| 50 |
+
|
| 51 |
+
# ── Model ──
|
| 52 |
+
model = PatchCNN(num_classes=7).to(device)
|
| 53 |
+
|
| 54 |
+
# ── Loss ──
|
| 55 |
+
# CrossEntropyLoss = softmax + negative log likelihood
|
| 56 |
+
# standard for multi-class classification
|
| 57 |
+
criterion = nn.CrossEntropyLoss()
|
| 58 |
+
|
| 59 |
+
# ── Optimizer ──
|
| 60 |
+
# Adam — adaptive learning rate, works well out of the box
|
| 61 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE)
|
| 62 |
+
|
| 63 |
+
# ── LR Scheduler ──
|
| 64 |
+
# reduces learning rate by 0.5 if val loss doesn't improve for 3 epochs
|
| 65 |
+
# prevents overshooting the optimal weights late in training
|
| 66 |
+
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
| 67 |
+
optimizer,
|
| 68 |
+
patience=3,
|
| 69 |
+
factor=0.5
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# ── Training Loop ──
|
| 73 |
+
best_val_acc = 0.0
|
| 74 |
+
|
| 75 |
+
for epoch in range(EPOCHS):
|
| 76 |
+
|
| 77 |
+
# ── Train phase ──
|
| 78 |
+
model.train()
|
| 79 |
+
train_loss = 0.0
|
| 80 |
+
train_correct = 0
|
| 81 |
+
|
| 82 |
+
for batch_idx, (patches, labels) in enumerate(train_loader):
|
| 83 |
+
|
| 84 |
+
patches = patches.to(device)
|
| 85 |
+
labels = labels.to(device)
|
| 86 |
+
|
| 87 |
+
# forward pass
|
| 88 |
+
outputs = model(patches)
|
| 89 |
+
loss = criterion(outputs, labels)
|
| 90 |
+
|
| 91 |
+
# backward pass
|
| 92 |
+
optimizer.zero_grad()
|
| 93 |
+
loss.backward()
|
| 94 |
+
optimizer.step()
|
| 95 |
+
|
| 96 |
+
train_loss += loss.item()
|
| 97 |
+
|
| 98 |
+
# count correct predictions
|
| 99 |
+
predicted = outputs.argmax(dim=1)
|
| 100 |
+
train_correct += (predicted == labels).sum().item()
|
| 101 |
+
|
| 102 |
+
# print progress every 50 batches
|
| 103 |
+
if (batch_idx + 1) % 50 == 0:
|
| 104 |
+
print(f" Epoch {epoch+1} | Batch {batch_idx+1}/{len(train_loader)} | Loss: {loss.item():.4f}")
|
| 105 |
+
|
| 106 |
+
train_acc = train_correct / train_size
|
| 107 |
+
|
| 108 |
+
# ── Validation phase ──
|
| 109 |
+
model.eval()
|
| 110 |
+
val_loss = 0.0
|
| 111 |
+
val_correct = 0
|
| 112 |
+
|
| 113 |
+
with torch.no_grad(): # no gradients needed for validation
|
| 114 |
+
for patches, labels in val_loader:
|
| 115 |
+
patches = patches.to(device)
|
| 116 |
+
labels = labels.to(device)
|
| 117 |
+
|
| 118 |
+
outputs = model(patches)
|
| 119 |
+
loss = criterion(outputs, labels)
|
| 120 |
+
|
| 121 |
+
val_loss += loss.item()
|
| 122 |
+
predicted = outputs.argmax(dim=1)
|
| 123 |
+
val_correct += (predicted == labels).sum().item()
|
| 124 |
+
|
| 125 |
+
val_acc = val_correct / val_size
|
| 126 |
+
|
| 127 |
+
print(f"\nEpoch {epoch+1}/{EPOCHS}")
|
| 128 |
+
print(f" Train Loss: {train_loss/len(train_loader):.4f} | Train Acc: {train_acc:.4f}")
|
| 129 |
+
print(f" Val Loss: {val_loss/len(val_loader):.4f} | Val Acc: {val_acc:.4f}")
|
| 130 |
+
|
| 131 |
+
# step scheduler with val loss
|
| 132 |
+
scheduler.step(val_loss / len(val_loader))
|
| 133 |
+
|
| 134 |
+
# save best model
|
| 135 |
+
if val_acc > best_val_acc:
|
| 136 |
+
best_val_acc = val_acc
|
| 137 |
+
torch.save(model.state_dict(), CHECKPOINT_PATH)
|
| 138 |
+
print(f" ✓ saved best model (val_acc: {val_acc:.4f})")
|
| 139 |
+
|
| 140 |
+
print(f"\nTraining complete. Best val accuracy: {best_val_acc:.4f}")
|
| 141 |
+
print(f"Model saved to {CHECKPOINT_PATH}")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
if __name__ == "__main__":
|
| 145 |
+
train()
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-multipart
|
| 4 |
+
pillow
|
| 5 |
+
numpy
|
| 6 |
+
torch==2.3.0+cpu
|
| 7 |
+
torchvision==0.18.0+cpu
|
| 8 |
+
emoji
|
| 9 |
+
gradio
|
| 10 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|