Samidha21 commited on
Commit
42b209f
·
1 Parent(s): 3b06af7

add app files

Browse files
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