Lonelyguyse1 commited on
Commit
f55d589
·
verified ·
1 Parent(s): 33c04ef

Initial Halide Space: pipeline, UI, autumn theme

Browse files
Files changed (48) hide show
  1. README.md +50 -0
  2. app.py +27 -0
  3. models/__init__.py +1 -0
  4. models/__pycache__/__init__.cpython-313.pyc +0 -0
  5. models/reasoning/.gitkeep +0 -0
  6. models/reasoning/__init__.py +1 -0
  7. models/reasoning/__pycache__/__init__.cpython-313.pyc +0 -0
  8. models/reasoning/__pycache__/nemotron_wrapper.cpython-313.pyc +0 -0
  9. models/reasoning/__pycache__/prompts.cpython-313.pyc +0 -0
  10. models/reasoning/inference.py +1 -0
  11. models/reasoning/nemotron_wrapper.py +96 -0
  12. models/reasoning/prompts.py +194 -0
  13. models/vision/.gitkeep +0 -0
  14. models/vision/__init__.py +1 -0
  15. models/vision/__pycache__/__init__.cpython-313.pyc +0 -0
  16. models/vision/__pycache__/inference.cpython-313.pyc +0 -0
  17. models/vision/__pycache__/minicpm_wrapper.cpython-313.pyc +0 -0
  18. models/vision/inference.py +78 -0
  19. models/vision/minicpm_wrapper.py +157 -0
  20. pipeline/.gitkeep +0 -0
  21. pipeline/__init__.py +1 -0
  22. pipeline/__pycache__/__init__.cpython-313.pyc +0 -0
  23. pipeline/__pycache__/diagnoser.cpython-313.pyc +0 -0
  24. pipeline/__pycache__/extractor.cpython-313.pyc +0 -0
  25. pipeline/__pycache__/pipeline.cpython-313.pyc +0 -0
  26. pipeline/diagnoser.py +60 -0
  27. pipeline/extractor.py +18 -0
  28. pipeline/pipeline.py +62 -0
  29. requirements.txt +16 -0
  30. storage/.gitkeep +0 -0
  31. storage/__init__.py +1 -0
  32. storage/__pycache__/__init__.cpython-313.pyc +0 -0
  33. storage/__pycache__/cache.cpython-313.pyc +0 -0
  34. storage/__pycache__/database.cpython-313.pyc +0 -0
  35. storage/cache.py +89 -0
  36. storage/database.py +177 -0
  37. storage/halide.db +0 -0
  38. ui/.gitkeep +0 -0
  39. ui/__init__.py +1 -0
  40. ui/__pycache__/__init__.cpython-313.pyc +0 -0
  41. ui/__pycache__/app.cpython-313.pyc +0 -0
  42. ui/__pycache__/components.cpython-313.pyc +0 -0
  43. ui/__pycache__/theme.cpython-313.pyc +0 -0
  44. ui/app.py +219 -0
  45. ui/components.py +104 -0
  46. ui/static/.gitkeep +0 -0
  47. ui/static/style.css +1 -0
  48. ui/theme.py +224 -0
README.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Project Halide
3
+ emoji: "\U0001F525"
4
+ colorFrom: orange
5
+ colorTo: red
6
+ sdk: gradio
7
+ sdk_version: 6.16.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ short_description: Edge-native diagnostic engine for analog film scans
12
+ ---
13
+
14
+ # Project Halide
15
+
16
+ An edge-native diagnostic engine for analog film. Upload a film scan, fill in
17
+ film stock + storage metadata, and Project Halide runs a two-stage analysis:
18
+
19
+ 1. **Vision Extraction** -- MiniCPM-V 4.6 (1.3B params, fine-tuned with LoRA on
20
+ the FilmDamageSimulator dataset) detects dust, dirt, scratches, and hair
21
+ artifacts as normalized bounding boxes.
22
+ 2. **Diagnostic Reasoning** -- Nemotron-Mini-4B-Instruct (4B params) with
23
+ 3-shot prompting cross-references the defect report against your film stock
24
+ and storage metadata, and prescribes specific physical fixes a lab can
25
+ perform.
26
+
27
+ The full pipeline runs locally in the Space. No external APIs. Models are
28
+ loaded from a private Hugging Face repo at startup.
29
+
30
+ ## How to use
31
+
32
+ 1. Upload a film scan (PNG or JPEG, ideally 35mm or 120 frame).
33
+ 2. Select your film stock from the dropdown.
34
+ 3. Adjust the age and storage condition.
35
+ 4. Pick the scan resolution you used.
36
+ 5. Click **Diagnose scan**.
37
+
38
+ Results are stored in a local SQLite database. The "Recent diagnoses" panel
39
+ shows the last 10 runs in this Space session.
40
+
41
+ ## Models
42
+
43
+ - Vision: `Lonelyguyse1/halide-vision` (private), based on
44
+ `openbmb/MiniCPM-V-4_6`.
45
+ - Reasoning: `nvidia/Nemotron-Mini-4B-Instruct` (public, 4B params, few-shot
46
+ prompting only, no fine-tuning).
47
+
48
+ ## License
49
+
50
+ Apache 2.0.
app.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main entry point. Launches the Gradio app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from ui.app import build_app
8
+
9
+
10
+ def main() -> None:
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
14
+ )
15
+ from ui.theme import THEME_CSS, build_theme
16
+ app = build_app()
17
+ app.queue(max_size=8).launch(
18
+ server_name="0.0.0.0",
19
+ server_port=7860,
20
+ show_error=True,
21
+ theme=build_theme(),
22
+ css=THEME_CSS,
23
+ )
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Model package. Vision and reasoning model wrappers."""
models/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (239 Bytes). View file
 
models/reasoning/.gitkeep ADDED
File without changes
models/reasoning/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Reasoning model package. Nemotron-Mini wrapper for diagnosis."""
models/reasoning/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (259 Bytes). View file
 
models/reasoning/__pycache__/nemotron_wrapper.cpython-313.pyc ADDED
Binary file (4.65 kB). View file
 
models/reasoning/__pycache__/prompts.cpython-313.pyc ADDED
Binary file (6.27 kB). View file
 
models/reasoning/inference.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Reasoning inference pipeline. Takes defect JSON and returns diagnosis."""
models/reasoning/nemotron_wrapper.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nemotron-Mini-4B wrapper. Loads the model and generates diagnoses.
2
+
3
+ Per AGENTS.md, this is the second stage of the dual-model pipeline.
4
+ Receives defect JSON from the vision model plus user metadata, returns
5
+ root cause diagnosis and physical remediation steps.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ NEMOTRON_MODEL_ID = "nvidia/Nemotron-Mini-4B-Instruct"
17
+ MAX_NEW_TOKENS = int(os.getenv("HALIDE_NEMOTRON_MAX_TOKENS", "512"))
18
+
19
+
20
+ class NemotronReasoner:
21
+ """Lazy-loading wrapper around Nemotron-Mini-4B-Instruct."""
22
+
23
+ def __init__(self, model_path: str | None = None) -> None:
24
+ self._model_path = model_path or NEMOTRON_MODEL_ID
25
+ self._tokenizer: Any = None
26
+ self._model: Any = None
27
+ self._device: str = "cpu"
28
+ self._dtype: Any = None
29
+
30
+ @property
31
+ def model_path(self) -> str:
32
+ return self._model_path
33
+
34
+ def load(self) -> None:
35
+ if self._model is not None:
36
+ return
37
+ import torch
38
+ from transformers import AutoModelForCausalLM, AutoTokenizer
39
+
40
+ logger.info("Loading Nemotron-Mini-4B from %s", self._model_path)
41
+ self._tokenizer = AutoTokenizer.from_pretrained(self._model_path)
42
+ self._dtype = torch.bfloat16
43
+ self._model = AutoModelForCausalLM.from_pretrained(
44
+ self._model_path,
45
+ torch_dtype=self._dtype,
46
+ device_map="auto",
47
+ )
48
+ self._device = str(next(self._model.parameters()).device)
49
+ logger.info("Nemotron loaded on %s", self._device)
50
+
51
+ def generate(self, prompt: str, system: str | None = None) -> str:
52
+ if self._model is None:
53
+ self.load()
54
+
55
+ import torch
56
+
57
+ if system:
58
+ messages = [
59
+ {"role": "system", "content": system},
60
+ {"role": "user", "content": prompt},
61
+ ]
62
+ else:
63
+ messages = [{"role": "user", "content": prompt}]
64
+
65
+ input_ids = self._tokenizer.apply_chat_template(
66
+ messages, add_generation_prompt=True, return_tensors="pt"
67
+ ).to(self._device)
68
+
69
+ with torch.inference_mode():
70
+ output = self._model.generate(
71
+ input_ids,
72
+ max_new_tokens=MAX_NEW_TOKENS,
73
+ do_sample=False,
74
+ pad_token_id=self._tokenizer.eos_token_id,
75
+ )
76
+
77
+ response_ids = output[0][input_ids.shape[-1]:]
78
+ return self._tokenizer.decode(response_ids, skip_special_tokens=True)
79
+
80
+ def close(self) -> None:
81
+ if self._model is not None:
82
+ del self._model
83
+ self._model = None
84
+ if self._tokenizer is not None:
85
+ del self._tokenizer
86
+ self._tokenizer = None
87
+
88
+
89
+ _default_reasoner: NemotronReasoner | None = None
90
+
91
+
92
+ def get_reasoner() -> NemotronReasoner:
93
+ global _default_reasoner
94
+ if _default_reasoner is None:
95
+ _default_reasoner = NemotronReasoner()
96
+ return _default_reasoner
models/reasoning/prompts.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Few-shot prompt templates for the Nemotron diagnostic reasoner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ SYSTEM_PROMPT = (
9
+ "You are a senior analog film lab technician with 30 years of experience "
10
+ "in darkroom printing, negative inspection, and equipment repair. You are "
11
+ "diagnosing the physical root cause of degradation in a film scan and "
12
+ "prescribing specific, actionable physical fixes a lab can perform."
13
+ )
14
+
15
+
16
+ FEW_SHOT_EXAMPLES: list[dict[str, str]] = [
17
+ {
18
+ "role": "user",
19
+ "content": (
20
+ "## Defect report\n"
21
+ "{\n"
22
+ ' "film_type": "Kodak Portra 400 (35mm)",\n'
23
+ ' "film_age_years": 2,\n'
24
+ ' "storage": "fridge, sealed",\n'
25
+ ' "defects": [\n'
26
+ ' {"label": "dust", "bbox_count": 87},\n'
27
+ ' {"label": "dirt", "bbox_count": 12}\n'
28
+ ' ],\n'
29
+ ' "scan_resolution_dpi": 4000\n'
30
+ "}\n\n"
31
+ "What is the root cause and what physical fixes do you recommend?"
32
+ ),
33
+ },
34
+ {
35
+ "role": "assistant",
36
+ "content": (
37
+ "## Root cause\n"
38
+ "High dust and dirt count on a recently-shot, properly stored roll "
39
+ "indicates contamination accumulated on the negative during "
40
+ "scanning, not deterioration of the film itself. The scanner's "
41
+ "dust-removal hardware (ICE/DEEP) is either disabled or "
42
+ "ineffective at 4000 dpi.\n\n"
43
+ "## Physical fixes\n"
44
+ "1. Clean the scanner glass and film holder with an anti-static "
45
+ "cloth and PEC*PAD swabs; re-scan.\n"
46
+ "2. Enable hardware-based dust removal in the scanner software "
47
+ "(e.g., Digital ICE for Kodak, DEEP for Noritsu) at a moderate "
48
+ "strength (level 3 of 5).\n"
49
+ "3. If hardware removal is unavailable, apply careful digital "
50
+ "spotting in Photoshop using the healing brush; budget 30-60 "
51
+ "minutes per frame.\n"
52
+ "4. Inspect the original negative under a loupe for any residual "
53
+ "physical particles and gently blow off with a rocket air blower "
54
+ "before re-scanning."
55
+ ),
56
+ },
57
+ {
58
+ "role": "user",
59
+ "content": (
60
+ "## Defect report\n"
61
+ "{\n"
62
+ ' "film_type": "Ilford HP5 (120 medium format)",\n'
63
+ ' "film_age_years": 15,\n'
64
+ ' "storage": "shoe box, attic",\n'
65
+ ' "defects": [\n'
66
+ ' {"label": "long_hair", "bbox_count": 4},\n'
67
+ ' {"label": "scratch", "bbox_count": 1}\n'
68
+ ' ],\n'
69
+ ' "scan_resolution_dpi": 3200\n'
70
+ "}\n\n"
71
+ "What is the root cause and what physical fixes do you recommend?"
72
+ ),
73
+ },
74
+ {
75
+ "role": "assistant",
76
+ "content": (
77
+ "## Root cause\n"
78
+ "Long hair and scratches on an aged, poorly stored roll indicate "
79
+ "physical abrasion from dust and debris inside the storage "
80
+ "container. Attic storage subjects film to temperature and "
81
+ "humidity swings that warp the emulsion and make it brittle.\n\n"
82
+ "## Physical fixes\n"
83
+ "1. Inspect the negative under a 10x loupe to confirm hair is on "
84
+ "the emulsion side (matte) vs. base side (glossy). Emulsion-side "
85
+ "hair cannot be wet-wiped safely.\n"
86
+ "2. If base-side only: gently wipe with a PEC*PAD soaked in "
87
+ "Edwal Negative Cleaner, then PEC*PAD with distilled water, "
88
+ "then dry.\n"
89
+ "3. If emulsion-side: do not wipe. Use Print File archival "
90
+ "sleeves and ship to a lab with wet-mount scanning (e.g., "
91
+ "Northcoast Photographic).\n"
92
+ "4. The single scratch is most likely from a dirty film "
93
+ "transport. Have the scanner cleaned and the film path "
94
+ "inspected; consider wet-gate scanning to mask the scratch.\n"
95
+ "5. Long-term: transfer remaining unexposed rolls to a "
96
+ "freezer at -18C in sealed bags with desiccant."
97
+ ),
98
+ },
99
+ {
100
+ "role": "user",
101
+ "content": (
102
+ "## Defect report\n"
103
+ "{\n"
104
+ ' "film_type": "CineStill 800T (35mm)",\n'
105
+ ' "film_age_years": 0,\n'
106
+ ' "storage": "fresh, refrigerated by manufacturer",\n'
107
+ ' "defects": [\n'
108
+ ' {"label": "dust", "bbox_count": 156},\n'
109
+ ' {"label": "dirt", "bbox_count": 23},\n'
110
+ ' {"label": "short_hair", "bbox_count": 8}\n'
111
+ ' ],\n'
112
+ ' "scan_resolution_dpi": 6000\n'
113
+ "}\n\n"
114
+ "What is the root cause and what physical fixes do you recommend?"
115
+ ),
116
+ },
117
+ {
118
+ "role": "assistant",
119
+ "content": (
120
+ "## Root cause\n"
121
+ "Excessive dust and dirt on a brand-new refrigerated roll at 6000 "
122
+ "dpi scan suggests a combination of (a) the static-prone "
123
+ "CineStill base attracting particles during loading, and (b) "
124
+ "scanner-side contamination. The hair count points to the "
125
+ "scanner's film transport path.\n\n"
126
+ "## Physical fixes\n"
127
+ "1. Anti-static treatment is critical for CineStill: discharge "
128
+ "the negative with a Zerostat gun on low setting 30 cm from the "
129
+ "film before scanning.\n"
130
+ "2. Clean the scanner glass, film holder, and feed rollers with "
131
+ "PEC*PAD swabs and reagent-grade isopropyl alcohol.\n"
132
+ "3. Use a static-discharge ionizing bar (e.g., Simco-Ion) at the "
133
+ "scanner input if available.\n"
134
+ "4. Re-scan with hardware dust removal at level 4 of 5. "
135
+ "CineStill's halated emulsion responds well to Digital ICE.\n"
136
+ "5. For the short hairs, inspect the film path under magnification "
137
+ "and remove any visible lint from the rollers with tweezers."
138
+ ),
139
+ },
140
+ ]
141
+
142
+
143
+ def build_user_prompt(
144
+ film_type: str,
145
+ film_age_years: int,
146
+ storage: str,
147
+ scan_resolution_dpi: int,
148
+ defect_summary: dict[str, int],
149
+ total_defects: int,
150
+ ) -> str:
151
+ """Build the user message for the current diagnosis request."""
152
+ payload = {
153
+ "film_type": film_type,
154
+ "film_age_years": film_age_years,
155
+ "storage": storage,
156
+ "defects": [
157
+ {"label": label, "bbox_count": count}
158
+ for label, count in sorted(defect_summary.items())
159
+ ],
160
+ "scan_resolution_dpi": scan_resolution_dpi,
161
+ "total_defect_count": total_defects,
162
+ }
163
+ return (
164
+ "## Defect report\n"
165
+ f"{json.dumps(payload, indent=2)}\n\n"
166
+ "What is the root cause and what physical fixes do you recommend?"
167
+ )
168
+
169
+
170
+ def build_messages(
171
+ film_type: str,
172
+ film_age_years: int,
173
+ storage: str,
174
+ scan_resolution_dpi: int,
175
+ defect_summary: dict[str, int],
176
+ total_defects: int,
177
+ ) -> list[dict[str, str]]:
178
+ """Return full message list (few-shot + current request) for the reasoner."""
179
+ messages: list[dict[str, str]] = []
180
+ messages.extend(FEW_SHOT_EXAMPLES)
181
+ messages.append(
182
+ {
183
+ "role": "user",
184
+ "content": build_user_prompt(
185
+ film_type,
186
+ film_age_years,
187
+ storage,
188
+ scan_resolution_dpi,
189
+ defect_summary,
190
+ total_defects,
191
+ ),
192
+ }
193
+ )
194
+ return messages
models/vision/.gitkeep ADDED
File without changes
models/vision/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Vision model package. MiniCPM-V 4.6 wrapper for film defect detection."""
models/vision/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (265 Bytes). View file
 
models/vision/__pycache__/inference.cpython-313.pyc ADDED
Binary file (3.49 kB). View file
 
models/vision/__pycache__/minicpm_wrapper.cpython-313.pyc ADDED
Binary file (7.74 kB). View file
 
models/vision/inference.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vision inference pipeline. Takes a film scan and returns defect JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from models.vision.minicpm_wrapper import get_detector
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ ALLOWED_LABELS = {"dust", "dirt", "scratch", "long_hair", "short_hair"}
15
+
16
+
17
+ def extract_defects(image: Any) -> dict:
18
+ """Run defect extraction on a PIL image. Returns defect dict + metadata."""
19
+ started = time.perf_counter()
20
+ detector = get_detector()
21
+ raw = detector.detect(image)
22
+ elapsed = time.perf_counter() - started
23
+
24
+ defects = raw.get("defects", [])
25
+ if not isinstance(defects, list):
26
+ logger.warning("Model output 'defects' is not a list: %r", type(defects))
27
+ defects = []
28
+
29
+ cleaned: list[dict] = []
30
+ dropped = 0
31
+ for d in defects:
32
+ if not isinstance(d, dict):
33
+ dropped += 1
34
+ continue
35
+ label = d.get("label")
36
+ bbox = d.get("bbox")
37
+ if label not in ALLOWED_LABELS:
38
+ dropped += 1
39
+ continue
40
+ if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
41
+ dropped += 1
42
+ continue
43
+ try:
44
+ x_min, y_min, x_max, y_max = (float(v) for v in bbox)
45
+ except (TypeError, ValueError):
46
+ dropped += 1
47
+ continue
48
+ if not (0.0 <= x_min <= 1.0 and 0.0 <= y_min <= 1.0):
49
+ dropped += 1
50
+ continue
51
+ if not (0.0 <= x_max <= 1.0 and 0.0 <= y_max <= 1.0):
52
+ dropped += 1
53
+ continue
54
+ if x_max <= x_min or y_max <= y_min:
55
+ dropped += 1
56
+ continue
57
+ cleaned.append({"label": label, "bbox": [x_min, y_min, x_max, y_max]})
58
+
59
+ label_counts: dict[str, int] = {}
60
+ for d in cleaned:
61
+ label_counts[d["label"]] = label_counts.get(d["label"], 0) + 1
62
+
63
+ return {
64
+ "defects": cleaned,
65
+ "defect_count": len(cleaned),
66
+ "label_counts": label_counts,
67
+ "dropped_count": dropped,
68
+ "inference_seconds": round(elapsed, 3),
69
+ "model_path": detector.model_path,
70
+ }
71
+
72
+
73
+ def extract_defects_from_path(image_path: str | Path) -> dict:
74
+ """Convenience: open image from path and run extraction."""
75
+ from PIL import Image
76
+
77
+ img = Image.open(image_path).convert("RGB")
78
+ return extract_defects(img)
models/vision/minicpm_wrapper.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniCPM-V 4.6 wrapper. Loads the model and runs inference on film scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[2]
15
+ LOCAL_MODEL_PATH = REPO_ROOT / "checkpoints" / "minicpm-v-4.6-merged"
16
+ HF_MODEL_ID = "Lonelyguyse1/halide-vision"
17
+ BASE_MODEL_ID = "openbmb/MiniCPM-V-4_6"
18
+
19
+ DOWNSAMPLE_MODE = os.getenv("HALIDE_DOWNSAMPLE_MODE", "4x")
20
+ MAX_SLICE_NUMS = int(os.getenv("HALIDE_MAX_SLICE_NUMS", "36"))
21
+ MAX_NEW_TOKENS = int(os.getenv("HALIDE_MAX_NEW_TOKENS", "3072"))
22
+
23
+ DETECTION_PROMPT = (
24
+ "You are a film defect detection engine. Analyze the film scan and detect "
25
+ "all visible defects. Output a JSON object with a 'defects' array. Each "
26
+ "defect has: 'label' (dust, dirt, scratch, long_hair, short_hair), 'bbox' "
27
+ "(normalized [x_min, y_min, x_max, y_max] from 0.0 to 1.0). Output JSON "
28
+ "only, no explanation."
29
+ )
30
+
31
+
32
+ def _resolve_model_path() -> str:
33
+ """Pick local merged model if present, else HF repo, else base model id."""
34
+ if LOCAL_MODEL_PATH.exists() and (LOCAL_MODEL_PATH / "config.json").exists():
35
+ logger.info("Using local merged model at %s", LOCAL_MODEL_PATH)
36
+ return str(LOCAL_MODEL_PATH)
37
+ if os.getenv("HF_TOKEN"):
38
+ logger.info("Using HF Hub repo %s", HF_MODEL_ID)
39
+ return HF_MODEL_ID
40
+ logger.info("Falling back to base model %s", BASE_MODEL_ID)
41
+ return BASE_MODEL_ID
42
+
43
+
44
+ class MiniCPMVDetector:
45
+ """Lazy-loading wrapper around MiniCPM-V 4.6 for film defect detection."""
46
+
47
+ def __init__(self, model_path: str | None = None) -> None:
48
+ self._model_path = model_path or _resolve_model_path()
49
+ self._model: Any = None
50
+ self._processor: Any = None
51
+ self._dtype: Any = None
52
+ self._device: str = "cpu"
53
+
54
+ @property
55
+ def model_path(self) -> str:
56
+ return self._model_path
57
+
58
+ def load(self) -> None:
59
+ if self._model is not None:
60
+ return
61
+ import torch
62
+ from transformers import AutoModelForImageTextToText, AutoProcessor
63
+
64
+ logger.info("Loading MiniCPM-V 4.6 from %s", self._model_path)
65
+ self._processor = AutoProcessor.from_pretrained(
66
+ self._model_path, trust_remote_code=True
67
+ )
68
+ self._dtype = torch.bfloat16
69
+ self._model = AutoModelForImageTextToText.from_pretrained(
70
+ self._model_path,
71
+ torch_dtype=self._dtype,
72
+ device_map="auto",
73
+ trust_remote_code=True,
74
+ )
75
+ self._device = str(next(self._model.parameters()).device)
76
+ logger.info("Model loaded on %s", self._device)
77
+
78
+ def detect(self, image: Any) -> dict:
79
+ """Run defect detection on a PIL image. Returns parsed JSON dict."""
80
+ import torch
81
+
82
+ if self._model is None:
83
+ self.load()
84
+
85
+ messages = [
86
+ {
87
+ "role": "user",
88
+ "content": [
89
+ {"type": "image", "image": image},
90
+ {"type": "text", "text": DETECTION_PROMPT},
91
+ ],
92
+ }
93
+ ]
94
+
95
+ inputs = self._processor.apply_chat_template(
96
+ messages,
97
+ tokenize=True,
98
+ add_generation_prompt=True,
99
+ return_dict=True,
100
+ return_tensors="pt",
101
+ downsample_mode=DOWNSAMPLE_MODE,
102
+ max_slice_nums=MAX_SLICE_NUMS,
103
+ ).to(self._device)
104
+
105
+ with torch.inference_mode():
106
+ generated = self._model.generate(
107
+ **inputs,
108
+ downsample_mode=DOWNSAMPLE_MODE,
109
+ max_new_tokens=MAX_NEW_TOKENS,
110
+ do_sample=False,
111
+ )
112
+
113
+ trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated)]
114
+ text = self._processor.batch_decode(
115
+ trimmed,
116
+ skip_special_tokens=True,
117
+ clean_up_tokenization_spaces=False,
118
+ )[0]
119
+
120
+ return _parse_defect_json(text)
121
+
122
+ def close(self) -> None:
123
+ if self._model is not None:
124
+ del self._model
125
+ self._model = None
126
+ if self._processor is not None:
127
+ del self._processor
128
+ self._processor = None
129
+
130
+
131
+ def _parse_defect_json(text: str) -> dict:
132
+ """Extract and parse the first JSON object from model output."""
133
+ text = text.strip()
134
+ try:
135
+ return json.loads(text)
136
+ except json.JSONDecodeError:
137
+ pass
138
+
139
+ match = re.search(r"\{[\s\S]*\}", text)
140
+ if not match:
141
+ logger.warning("No JSON found in model output: %r", text[:200])
142
+ return {"defects": [], "_raw": text, "_parse_error": "no_json_object"}
143
+ try:
144
+ return json.loads(match.group(0))
145
+ except json.JSONDecodeError as exc:
146
+ logger.warning("JSON parse error: %s; raw: %r", exc, text[:200])
147
+ return {"defects": [], "_raw": text, "_parse_error": str(exc)}
148
+
149
+
150
+ _default_detector: MiniCPMVDetector | None = None
151
+
152
+
153
+ def get_detector() -> MiniCPMVDetector:
154
+ global _default_detector
155
+ if _default_detector is None:
156
+ _default_detector = MiniCPMVDetector()
157
+ return _default_detector
pipeline/.gitkeep ADDED
File without changes
pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Pipeline package. Orchestrates vision + reasoning stages."""
pipeline/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (247 Bytes). View file
 
pipeline/__pycache__/diagnoser.cpython-313.pyc ADDED
Binary file (2.21 kB). View file
 
pipeline/__pycache__/extractor.cpython-313.pyc ADDED
Binary file (853 Bytes). View file
 
pipeline/__pycache__/pipeline.cpython-313.pyc ADDED
Binary file (2.07 kB). View file
 
pipeline/diagnoser.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnoser. Takes defect JSON and user metadata, returns diagnosis and fixes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any
8
+
9
+ from models.reasoning.nemotron_wrapper import get_reasoner
10
+ from models.reasoning.prompts import SYSTEM_PROMPT, build_messages
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def diagnose(
16
+ defect_result: dict,
17
+ film_type: str,
18
+ film_age_years: int,
19
+ storage: str,
20
+ scan_resolution_dpi: int,
21
+ ) -> dict:
22
+ """Run Nemotron reasoning over a defect result + user metadata.
23
+
24
+ Returns a dict with the raw text response and timing metadata.
25
+ """
26
+ started = time.perf_counter()
27
+ reasoner = get_reasoner()
28
+
29
+ label_counts = defect_result.get("label_counts", {}) or {}
30
+ total = defect_result.get("defect_count", 0) or sum(label_counts.values())
31
+
32
+ messages = build_messages(
33
+ film_type=film_type,
34
+ film_age_years=film_age_years,
35
+ storage=storage,
36
+ scan_resolution_dpi=scan_resolution_dpi,
37
+ defect_summary=label_counts,
38
+ total_defects=total,
39
+ )
40
+
41
+ logger.info(
42
+ "Running Nemotron diagnosis (film=%s, age=%d, storage=%s, total_defects=%d)",
43
+ film_type, film_age_years, storage, total,
44
+ )
45
+ text = reasoner.generate(prompt=messages, system=SYSTEM_PROMPT)
46
+ elapsed = time.perf_counter() - started
47
+
48
+ return {
49
+ "diagnosis_text": text,
50
+ "reasoning_seconds": round(elapsed, 3),
51
+ "model_path": reasoner.model_path,
52
+ "system_prompt": SYSTEM_PROMPT,
53
+ "input_defect_summary": {
54
+ "label_counts": label_counts,
55
+ "total": total,
56
+ },
57
+ }
58
+
59
+
60
+ __all__ = ["diagnose"]
pipeline/extractor.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Defect extractor. Takes a film scan and returns structured defect JSON.
2
+
3
+ This is a thin wrapper that re-exports `extract_defects` from the vision
4
+ inference module so the pipeline layer has a stable interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from models.vision.inference import extract_defects, extract_defects_from_path
12
+
13
+ __all__ = ["extract_defects", "extract_defects_from_path"]
14
+
15
+
16
+ def extract(image: Any) -> dict:
17
+ """Top-level entry point used by the pipeline orchestrator."""
18
+ return extract_defects(image)
pipeline/pipeline.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main pipeline. Orchestrates vision extraction and diagnostic reasoning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any
8
+
9
+ from pipeline.diagnoser import diagnose
10
+ from pipeline.extractor import extract
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def run_diagnosis(
16
+ image: Any,
17
+ film_type: str = "Unknown 35mm",
18
+ film_age_years: int = 1,
19
+ storage: str = "unknown",
20
+ scan_resolution_dpi: int = 4000,
21
+ ) -> dict:
22
+ """End-to-end: image -> defect JSON -> diagnosis + fixes.
23
+
24
+ Returns a single dict with both stages' outputs and timing info.
25
+ """
26
+ started = time.perf_counter()
27
+
28
+ logger.info("Stage 1: defect extraction")
29
+ defect_result = extract(image)
30
+
31
+ logger.info(
32
+ "Stage 1 complete: %d defects (%s) in %.2fs",
33
+ defect_result.get("defect_count", 0),
34
+ defect_result.get("label_counts", {}),
35
+ defect_result.get("inference_seconds", 0.0),
36
+ )
37
+
38
+ logger.info("Stage 2: Nemotron diagnosis")
39
+ diagnosis_result = diagnose(
40
+ defect_result,
41
+ film_type=film_type,
42
+ film_age_years=film_age_years,
43
+ storage=storage,
44
+ scan_resolution_dpi=scan_resolution_dpi,
45
+ )
46
+
47
+ total_elapsed = time.perf_counter() - started
48
+
49
+ return {
50
+ "film_metadata": {
51
+ "film_type": film_type,
52
+ "film_age_years": film_age_years,
53
+ "storage": storage,
54
+ "scan_resolution_dpi": scan_resolution_dpi,
55
+ },
56
+ "defects": defect_result,
57
+ "diagnosis": diagnosis_result,
58
+ "total_seconds": round(total_elapsed, 3),
59
+ }
60
+
61
+
62
+ __all__ = ["run_diagnosis"]
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Halide Space Dependencies
2
+ # Pinning to versions known to work with MiniCPM-V 4.6
3
+
4
+ # Core
5
+ gradio==6.16.0
6
+ torch==2.7.0
7
+ torchvision==0.22.0
8
+
9
+ # HuggingFace stack
10
+ transformers==5.7.0
11
+ accelerate>=1.0.0
12
+ huggingface_hub>=0.20.0
13
+ pillow>=10.0.0
14
+
15
+ # Nemotron reasoning uses default transformers tokenizer
16
+ numpy>=1.24.0
storage/.gitkeep ADDED
File without changes
storage/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Storage package. SQLite database and inference cache."""
storage/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (242 Bytes). View file
 
storage/__pycache__/cache.cpython-313.pyc ADDED
Binary file (4.81 kB). View file
 
storage/__pycache__/database.cpython-313.pyc ADDED
Binary file (7.61 kB). View file
 
storage/cache.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Caching. In-process LRU cache for diagnosis results keyed by image hash.
2
+
3
+ For privacy, we hash the image bytes; the image itself is never persisted
4
+ in the cache. Identical scans produce identical hashes, giving us a simple
5
+ content-addressed cache.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import time
14
+ from collections import OrderedDict
15
+ from threading import Lock
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DiagnosisCache:
22
+ """Thread-safe LRU cache for diagnosis results."""
23
+
24
+ def __init__(self, max_size: int = 64, ttl_seconds: int = 3600) -> None:
25
+ self._max_size = max_size
26
+ self._ttl = ttl_seconds
27
+ self._store: OrderedDict[str, tuple[float, dict]] = OrderedDict()
28
+ self._lock = Lock()
29
+ self._hits = 0
30
+ self._misses = 0
31
+
32
+ @staticmethod
33
+ def hash_image(image_bytes: bytes) -> str:
34
+ return hashlib.sha256(image_bytes).hexdigest()
35
+
36
+ def get(self, image_bytes: bytes) -> dict | None:
37
+ key = self.hash_image(image_bytes)
38
+ now = time.time()
39
+ with self._lock:
40
+ entry = self._store.get(key)
41
+ if entry is None:
42
+ self._misses += 1
43
+ return None
44
+ ts, value = entry
45
+ if now - ts > self._ttl:
46
+ del self._store[key]
47
+ self._misses += 1
48
+ return None
49
+ self._store.move_to_end(key)
50
+ self._hits += 1
51
+ logger.info("Cache hit for %s", key[:12])
52
+ return value
53
+
54
+ def put(self, image_bytes: bytes, value: dict) -> None:
55
+ key = self.hash_image(image_bytes)
56
+ now = time.time()
57
+ with self._lock:
58
+ self._store[key] = (now, value)
59
+ self._store.move_to_end(key)
60
+ while len(self._store) > self._max_size:
61
+ self._store.popitem(last=False)
62
+
63
+ def stats(self) -> dict:
64
+ with self._lock:
65
+ return {
66
+ "size": len(self._store),
67
+ "max_size": self._max_size,
68
+ "hits": self._hits,
69
+ "misses": self._misses,
70
+ }
71
+
72
+ def clear(self) -> None:
73
+ with self._lock:
74
+ self._store.clear()
75
+ self._hits = 0
76
+ self._misses = 0
77
+
78
+
79
+ _default_cache: DiagnosisCache | None = None
80
+
81
+
82
+ def get_cache() -> DiagnosisCache:
83
+ global _default_cache
84
+ if _default_cache is None:
85
+ _default_cache = DiagnosisCache()
86
+ return _default_cache
87
+
88
+
89
+ __all__ = ["DiagnosisCache", "get_cache"]
storage/database.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite database. Stores diagnostic history and user sessions.
2
+
3
+ Schema:
4
+ sessions(id, started_at, film_type, film_age_years, storage, scan_dpi)
5
+ diagnoses(id, session_id, created_at, defect_count, label_counts_json,
6
+ diagnosis_text, vision_seconds, reasoning_seconds, total_seconds,
7
+ vision_model, reasoning_model, raw_json)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import sqlite3
15
+ import time
16
+ import uuid
17
+ from contextlib import contextmanager
18
+ from pathlib import Path
19
+ from typing import Any, Iterator
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[1]
24
+ DEFAULT_DB_PATH = REPO_ROOT / "storage" / "halide.db"
25
+
26
+
27
+ def get_db_path() -> Path:
28
+ import os
29
+ custom = os.getenv("HALIDE_DB_PATH")
30
+ if custom:
31
+ return Path(custom)
32
+ DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
33
+ return DEFAULT_DB_PATH
34
+
35
+
36
+ SCHEMA = """
37
+ CREATE TABLE IF NOT EXISTS sessions (
38
+ id TEXT PRIMARY KEY,
39
+ started_at REAL NOT NULL,
40
+ film_type TEXT NOT NULL,
41
+ film_age_years INTEGER NOT NULL,
42
+ storage TEXT NOT NULL,
43
+ scan_dpi INTEGER NOT NULL
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS diagnoses (
47
+ id TEXT PRIMARY KEY,
48
+ session_id TEXT NOT NULL,
49
+ created_at REAL NOT NULL,
50
+ defect_count INTEGER NOT NULL,
51
+ label_counts_json TEXT NOT NULL,
52
+ diagnosis_text TEXT NOT NULL,
53
+ vision_seconds REAL NOT NULL,
54
+ reasoning_seconds REAL NOT NULL,
55
+ total_seconds REAL NOT NULL,
56
+ vision_model TEXT NOT NULL,
57
+ reasoning_model TEXT NOT NULL,
58
+ raw_json TEXT NOT NULL,
59
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
60
+ );
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_diagnoses_session ON diagnoses(session_id);
63
+ CREATE INDEX IF NOT EXISTS idx_diagnoses_created ON diagnoses(created_at);
64
+ """
65
+
66
+
67
+ @contextmanager
68
+ def connect() -> Iterator[sqlite3.Connection]:
69
+ db_path = get_db_path()
70
+ conn = sqlite3.connect(str(db_path))
71
+ conn.row_factory = sqlite3.Row
72
+ try:
73
+ yield conn
74
+ conn.commit()
75
+ finally:
76
+ conn.close()
77
+
78
+
79
+ def init_db() -> None:
80
+ with connect() as conn:
81
+ conn.executescript(SCHEMA)
82
+ logger.info("DB initialized at %s", get_db_path())
83
+
84
+
85
+ def record_diagnosis(result: dict) -> str:
86
+ """Persist a full pipeline result. Returns the diagnosis id."""
87
+ diagnosis_id = str(uuid.uuid4())
88
+ session_id = str(uuid.uuid4())
89
+ now = time.time()
90
+
91
+ meta = result.get("film_metadata", {}) or {}
92
+ defects = result.get("defects", {}) or {}
93
+ diagnosis = result.get("diagnosis", {}) or {}
94
+
95
+ with connect() as conn:
96
+ conn.execute(
97
+ """
98
+ INSERT INTO sessions (id, started_at, film_type, film_age_years,
99
+ storage, scan_dpi)
100
+ VALUES (?, ?, ?, ?, ?, ?)
101
+ """,
102
+ (
103
+ session_id,
104
+ now,
105
+ meta.get("film_type", "Unknown"),
106
+ int(meta.get("film_age_years", 0) or 0),
107
+ meta.get("storage", "unknown"),
108
+ int(meta.get("scan_resolution_dpi", 0) or 0),
109
+ ),
110
+ )
111
+ conn.execute(
112
+ """
113
+ INSERT INTO diagnoses (id, session_id, created_at, defect_count,
114
+ label_counts_json, diagnosis_text,
115
+ vision_seconds, reasoning_seconds,
116
+ total_seconds, vision_model, reasoning_model,
117
+ raw_json)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
119
+ """,
120
+ (
121
+ diagnosis_id,
122
+ session_id,
123
+ now,
124
+ int(defects.get("defect_count", 0) or 0),
125
+ json.dumps(defects.get("label_counts", {}) or {}),
126
+ diagnosis.get("diagnosis_text", ""),
127
+ float(defects.get("inference_seconds", 0.0) or 0.0),
128
+ float(diagnosis.get("reasoning_seconds", 0.0) or 0.0),
129
+ float(result.get("total_seconds", 0.0) or 0.0),
130
+ defects.get("model_path", ""),
131
+ diagnosis.get("model_path", ""),
132
+ json.dumps(result),
133
+ ),
134
+ )
135
+ logger.info("Recorded diagnosis %s (session %s)", diagnosis_id, session_id)
136
+ return diagnosis_id
137
+
138
+
139
+ def list_recent(limit: int = 20) -> list[dict]:
140
+ with connect() as conn:
141
+ rows = conn.execute(
142
+ """
143
+ SELECT d.id, d.created_at, s.film_type, s.film_age_years,
144
+ s.storage, s.scan_dpi, d.defect_count, d.label_counts_json,
145
+ d.diagnosis_text, d.total_seconds
146
+ FROM diagnoses d
147
+ JOIN sessions s ON s.id = d.session_id
148
+ ORDER BY d.created_at DESC
149
+ LIMIT ?
150
+ """,
151
+ (limit,),
152
+ ).fetchall()
153
+ out: list[dict] = []
154
+ for r in rows:
155
+ out.append(
156
+ {
157
+ "id": r["id"],
158
+ "created_at": r["created_at"],
159
+ "film_type": r["film_type"],
160
+ "film_age_years": r["film_age_years"],
161
+ "storage": r["storage"],
162
+ "scan_dpi": r["scan_dpi"],
163
+ "defect_count": r["defect_count"],
164
+ "label_counts": json.loads(r["label_counts_json"]),
165
+ "diagnosis_text": r["diagnosis_text"],
166
+ "total_seconds": r["total_seconds"],
167
+ }
168
+ )
169
+ return out
170
+
171
+
172
+ __all__ = [
173
+ "init_db",
174
+ "record_diagnosis",
175
+ "list_recent",
176
+ "get_db_path",
177
+ ]
storage/halide.db ADDED
Binary file (28.7 kB). View file
 
ui/.gitkeep ADDED
File without changes
ui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """UI package. Gradio app, theme, and components."""
ui/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (230 Bytes). View file
 
ui/__pycache__/app.cpython-313.pyc ADDED
Binary file (9.99 kB). View file
 
ui/__pycache__/components.cpython-313.pyc ADDED
Binary file (5.84 kB). View file
 
ui/__pycache__/theme.cpython-313.pyc ADDED
Binary file (6.62 kB). View file
 
ui/app.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app. Main UI definition and layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import io
7
+ import logging
8
+ from typing import Any
9
+
10
+ import gradio as gr
11
+
12
+ from pipeline.pipeline import run_diagnosis
13
+ from storage.cache import get_cache
14
+ from storage.database import init_db, list_recent, record_diagnosis
15
+ from ui.components import (
16
+ HEADER_HTML,
17
+ THEME_CSS,
18
+ defect_pills_html,
19
+ diagnosis_html,
20
+ render_history,
21
+ stats_html,
22
+ )
23
+ from ui.theme import build_theme
24
+
25
+ logger = logging.getLogger(__name__)
26
+ logging.basicConfig(level=logging.INFO)
27
+
28
+
29
+ DEFAULT_FILM_TYPES = [
30
+ "Kodak Portra 400 (35mm)",
31
+ "Kodak Tri-X 400 (35mm)",
32
+ "Kodak Ektar 100 (35mm)",
33
+ "Ilford HP5 Plus (35mm)",
34
+ "Ilford Delta 100 (35mm)",
35
+ "Ilford FP4 Plus (120)",
36
+ "CineStill 800T (35mm)",
37
+ "Fujifilm Pro 400H (35mm)",
38
+ "Fomapan 400 (35mm)",
39
+ "Other / Unknown",
40
+ ]
41
+
42
+ STORAGE_OPTIONS = [
43
+ "fridge, sealed",
44
+ "freezer, sealed",
45
+ "room temp, sealed",
46
+ "room temp, loose",
47
+ "shoe box, attic",
48
+ "shoe box, basement",
49
+ "unknown",
50
+ ]
51
+
52
+ RESOLUTION_OPTIONS = [2000, 3000, 4000, 5000, 6000, 8000]
53
+
54
+
55
+ def _image_to_bytes(pil_image: Any) -> bytes:
56
+ buf = io.BytesIO()
57
+ pil_image.save(buf, format="PNG")
58
+ return buf.getvalue()
59
+
60
+
61
+ def run_pipeline(
62
+ image: Any,
63
+ film_type: str,
64
+ film_age_years: int,
65
+ storage: str,
66
+ scan_dpi: int,
67
+ progress: gr.Progress = gr.Progress(),
68
+ ) -> tuple[str, str, str, str]:
69
+ """Gradio handler for the diagnose button."""
70
+ if image is None:
71
+ empty = '<p style="color: var(--halide-crimson);">No image provided.</p>'
72
+ return empty, empty, empty, render_history(list_recent(limit=10))
73
+
74
+ try:
75
+ progress(0.0, "Hashing image for cache lookup...")
76
+ cache = get_cache()
77
+ image_bytes = _image_to_bytes(image)
78
+ cached = cache.get(image_bytes)
79
+ if cached is not None:
80
+ logger.info("Returning cached diagnosis")
81
+ result = cached
82
+ else:
83
+ progress(0.1, "Stage 1/2: running vision defect extraction...")
84
+ result = run_diagnosis(
85
+ image=image,
86
+ film_type=film_type or "Unknown 35mm",
87
+ film_age_years=int(film_age_years or 0),
88
+ storage=storage or "unknown",
89
+ scan_resolution_dpi=int(scan_dpi or 4000),
90
+ )
91
+ progress(0.85, "Stage 2/2: persisting diagnosis...")
92
+ try:
93
+ record_diagnosis(result)
94
+ except Exception as exc: # pragma: no cover
95
+ logger.warning("Failed to record diagnosis: %s", exc)
96
+ cache.put(image_bytes, result)
97
+
98
+ progress(1.0, "Done.")
99
+
100
+ counts = result.get("defects", {}).get("label_counts", {}) or {}
101
+ stats = stats_html(result)
102
+ pills = defect_pills_html(counts)
103
+ diag = diagnosis_html(result.get("diagnosis", {}).get("diagnosis_text", ""))
104
+ history = render_history(list_recent(limit=10))
105
+ return stats, pills, diag, history
106
+ except Exception as exc: # pragma: no cover
107
+ logger.exception("Pipeline failed")
108
+ err = (
109
+ '<div class="halide-card" style="border-color: var(--halide-crimson);">'
110
+ f'<div class="halide-section-title" style="color: var(--halide-red);">'
111
+ f"Pipeline error</div>"
112
+ f"<pre style=\"color: var(--halide-parchment); white-space: pre-wrap;\">"
113
+ f"{html.escape(str(exc))}</pre></div>"
114
+ )
115
+ return err, "", "", render_history(list_recent(limit=10))
116
+
117
+
118
+ def refresh_history() -> str:
119
+ return render_history(list_recent(limit=10))
120
+
121
+
122
+ def build_app() -> gr.Blocks:
123
+ init_db()
124
+ theme = build_theme()
125
+
126
+ with gr.Blocks(title="Project Halide") as app:
127
+ gr.HTML(HEADER_HTML)
128
+
129
+ with gr.Row():
130
+ with gr.Column(scale=1):
131
+ with gr.Group(elem_classes="halide-card"):
132
+ gr.Markdown('<div class="halide-section-title">Scan upload</div>')
133
+ image_input = gr.Image(
134
+ label="Film scan",
135
+ type="pil",
136
+ height=380,
137
+ sources=["upload", "clipboard"],
138
+ )
139
+
140
+ with gr.Group(elem_classes="halide-card"):
141
+ gr.Markdown('<div class="halide-section-title">Film metadata</div>')
142
+ film_type = gr.Dropdown(
143
+ choices=DEFAULT_FILM_TYPES,
144
+ value=DEFAULT_FILM_TYPES[0],
145
+ label="Film stock",
146
+ )
147
+ with gr.Row():
148
+ film_age = gr.Slider(
149
+ minimum=0,
150
+ maximum=80,
151
+ step=1,
152
+ value=2,
153
+ label="Age (years)",
154
+ )
155
+ scan_dpi = gr.Dropdown(
156
+ choices=RESOLUTION_OPTIONS,
157
+ value=4000,
158
+ label="Scan resolution (dpi)",
159
+ )
160
+ storage = gr.Radio(
161
+ choices=STORAGE_OPTIONS,
162
+ value=STORAGE_OPTIONS[0],
163
+ label="Storage condition",
164
+ )
165
+
166
+ run_btn = gr.Button("Diagnose scan", variant="primary", size="lg")
167
+
168
+ with gr.Column(scale=2):
169
+ with gr.Group(elem_classes="halide-card"):
170
+ gr.Markdown('<div class="halide-section-title">Defect summary</div>')
171
+ defect_summary = gr.HTML(
172
+ value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
173
+ )
174
+
175
+ with gr.Group(elem_classes="halide-card"):
176
+ gr.Markdown('<div class="halide-section-title">Diagnosis & fixes</div>')
177
+ diagnosis_output = gr.HTML(
178
+ value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
179
+ )
180
+
181
+ with gr.Group(elem_classes="halide-card"):
182
+ gr.Markdown('<div class="halide-section-title">Session stats</div>')
183
+ stats_output = gr.HTML(
184
+ value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
185
+ )
186
+
187
+ with gr.Column(scale=1):
188
+ with gr.Group(elem_classes="halide-card"):
189
+ gr.Markdown('<div class="halide-section-title">Recent diagnoses</div>')
190
+ history_output = gr.HTML(value=render_history(list_recent(limit=10)))
191
+ refresh_btn = gr.Button("Refresh history", size="sm")
192
+
193
+ gr.HTML(
194
+ "<footer>Project Halide. Edge-native, no cloud APIs. "
195
+ "Vision: MiniCPM-V 4.6 (1.3B). Reasoning: Nemotron-Mini-4B-Instruct (few-shot).</footer>"
196
+ )
197
+
198
+ run_btn.click(
199
+ fn=run_pipeline,
200
+ inputs=[image_input, film_type, film_age, storage, scan_dpi],
201
+ outputs=[stats_output, defect_summary, diagnosis_output, history_output],
202
+ )
203
+ refresh_btn.click(fn=refresh_history, outputs=[history_output])
204
+
205
+ return app
206
+
207
+
208
+ def main() -> None:
209
+ app = build_app()
210
+ app.queue(max_size=8).launch(
211
+ server_name="0.0.0.0",
212
+ server_port=7860,
213
+ theme=build_theme(),
214
+ css=THEME_CSS,
215
+ )
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
ui/components.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI components. Defect list rendering and shared visual helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from ui.theme import THEME_CSS
8
+
9
+
10
+ HEADER_HTML = """
11
+ <div id="halide-header">
12
+ <h1>Project Halide</h1>
13
+ <p>Edge-native diagnostic engine for analog film scans</p>
14
+ </div>
15
+ """
16
+
17
+
18
+ def defect_pills_html(label_counts: dict[str, int]) -> str:
19
+ """Render defect counts as colored pills."""
20
+ if not label_counts:
21
+ return '<p style="color: var(--halide-slate);">No defects detected.</p>'
22
+ pills: list[str] = []
23
+ for label, count in sorted(label_counts.items(), key=lambda kv: -kv[1]):
24
+ pills.append(
25
+ f'<span class="halide-defect-pill {label}">{label}: {count}</span>'
26
+ )
27
+ return '<div>' + "".join(pills) + "</div>"
28
+
29
+
30
+ def stats_html(result: dict) -> str:
31
+ """Render a stats card with defect counts and timing."""
32
+ defects = result.get("defects", {}) or {}
33
+ diagnosis = result.get("diagnosis", {}) or {}
34
+ total = result.get("total_seconds", 0.0) or 0.0
35
+ vision_s = defects.get("inference_seconds", 0.0) or 0.0
36
+ reasoning_s = diagnosis.get("reasoning_seconds", 0.0) or 0.0
37
+
38
+ rows: list[str] = []
39
+ rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
40
+ rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
41
+ rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
42
+ rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
43
+ rows.append(_stat_row("Total", f"{total:.2f}s"))
44
+ rows.append(_stat_row("Vision model", _truncate(defects.get("model_path", ""), 50)))
45
+ rows.append(_stat_row("Reasoning model", _truncate(diagnosis.get("model_path", ""), 50)))
46
+ return f'<div class="halide-card">{"".join(rows)}</div>'
47
+
48
+
49
+ def _stat_row(label: str, value: str) -> str:
50
+ return (
51
+ '<div class="halide-stat">'
52
+ f'<span class="halide-stat-label">{label}</span>'
53
+ f'<span>{value}</span>'
54
+ "</div>"
55
+ )
56
+
57
+
58
+ def _truncate(s: str, n: int) -> str:
59
+ if len(s) <= n:
60
+ return s
61
+ return "..." + s[-(n - 3):]
62
+
63
+
64
+ def diagnosis_html(text: str) -> str:
65
+ """Wrap diagnosis text in the styled card."""
66
+ safe = (text or "(no diagnosis produced)").replace("\n", "<br>")
67
+ return f'<div class="halide-diagnosis">{safe}</div>'
68
+
69
+
70
+ def history_row_html(entry: dict) -> str:
71
+ """Render a single row in the recent-diagnoses sidebar."""
72
+ counts = entry.get("label_counts", {}) or {}
73
+ total = entry.get("defect_count", 0) or 0
74
+ film = entry.get("film_type", "Unknown")
75
+ age = entry.get("film_age_years", "?")
76
+ storage = entry.get("storage", "?")
77
+ ts = entry.get("created_at", 0)
78
+ seconds = entry.get("total_seconds", 0.0) or 0.0
79
+ return (
80
+ f'<div class="halide-card" style="margin-bottom: 0.6rem;">'
81
+ f'<div class="halide-section-title" style="font-size: 0.95rem;">'
82
+ f"{film} (age {age}y, {storage})</div>"
83
+ f"{defect_pills_html(counts)}"
84
+ f'<div style="color: var(--halide-slate); font-size: 0.8rem; margin-top: 0.4rem;">'
85
+ f"defects: {total} | {seconds:.2f}s | {ts:.0f}"
86
+ f"</div></div>"
87
+ )
88
+
89
+
90
+ def render_history(entries: Iterable[dict]) -> str:
91
+ items = "".join(history_row_html(e) for e in entries)
92
+ if not items:
93
+ return '<p style="color: var(--halide-slate);">No diagnoses yet.</p>'
94
+ return items
95
+
96
+
97
+ __all__ = [
98
+ "HEADER_HTML",
99
+ "THEME_CSS",
100
+ "defect_pills_html",
101
+ "stats_html",
102
+ "diagnosis_html",
103
+ "render_history",
104
+ ]
ui/static/.gitkeep ADDED
File without changes
ui/static/style.css ADDED
@@ -0,0 +1 @@
 
 
1
+ /* Custom CSS for the autumn theme. Inlined into Gradio Blocks at runtime. */
ui/theme.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autumn theme. Colors derived from the project logo (orange-to-red on black)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gradio as gr
6
+
7
+ AMBER = "#d97706"
8
+ AMBER_DEEP = "#b45309"
9
+ ORANGE = "#ea580c"
10
+ RED = "#dc2626"
11
+ CRIMSON = "#991b1b"
12
+ EMBER = "#f59e0b"
13
+
14
+ INK = "#0c0a09"
15
+ INK_SOFT = "#1c1917"
16
+ PARCHMENT = "#fef3c7"
17
+ PARCHMENT_DEEP = "#fde68a"
18
+ SLATE = "#44403c"
19
+
20
+ THEME_CSS = f"""
21
+ :root {{
22
+ --halide-amber: {AMBER};
23
+ --halide-amber-deep: {AMBER_DEEP};
24
+ --halide-orange: {ORANGE};
25
+ --halide-red: {RED};
26
+ --halide-crimson: {CRIMSON};
27
+ --halide-ember: {EMBER};
28
+ --halide-ink: {INK};
29
+ --halide-ink-soft: {INK_SOFT};
30
+ --halide-parchment: {PARCHMENT};
31
+ --halide-parchment-deep: {PARCHMENT_DEEP};
32
+ --halide-slate: {SLATE};
33
+ }}
34
+
35
+ body, .gradio-container {{
36
+ background: linear-gradient(180deg, #0c0a09 0%, #1c1917 50%, #0c0a09 100%);
37
+ color: var(--halide-parchment);
38
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
39
+ }}
40
+
41
+ #halide-header {{
42
+ background: linear-gradient(90deg, var(--halide-crimson) 0%, var(--halide-orange) 50%, var(--halide-amber) 100%);
43
+ padding: 1.4rem 2rem;
44
+ border-radius: 0 0 18px 18px;
45
+ margin-bottom: 1.5rem;
46
+ box-shadow: 0 8px 32px rgba(217, 119, 6, 0.25);
47
+ border-bottom: 1px solid var(--halide-amber);
48
+ }}
49
+
50
+ #halide-header h1 {{
51
+ color: var(--halide-parchment);
52
+ font-size: 2.4rem;
53
+ margin: 0;
54
+ letter-spacing: 0.02em;
55
+ text-shadow: 0 2px 4px rgba(0,0,0,0.4);
56
+ }}
57
+
58
+ #halide-header p {{
59
+ color: var(--halide-parchment-deep);
60
+ margin: 0.4rem 0 0 0;
61
+ font-size: 1.05rem;
62
+ font-style: italic;
63
+ }}
64
+
65
+ .halide-card {{
66
+ background: rgba(28, 25, 23, 0.85);
67
+ border: 1px solid var(--halide-amber-deep);
68
+ border-radius: 12px;
69
+ padding: 1.2rem;
70
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
71
+ }}
72
+
73
+ .halide-section-title {{
74
+ color: var(--halide-amber);
75
+ font-size: 1.15rem;
76
+ font-weight: 600;
77
+ letter-spacing: 0.05em;
78
+ text-transform: uppercase;
79
+ margin-bottom: 0.6rem;
80
+ border-bottom: 1px solid var(--halide-amber-deep);
81
+ padding-bottom: 0.3rem;
82
+ }}
83
+
84
+ .halide-stat {{
85
+ display: flex;
86
+ justify-content: space-between;
87
+ padding: 0.4rem 0;
88
+ border-bottom: 1px dotted var(--halide-slate);
89
+ color: var(--halide-parchment);
90
+ }}
91
+
92
+ .halide-stat-label {{
93
+ color: var(--halide-amber);
94
+ font-weight: 600;
95
+ }}
96
+
97
+ .halide-diagnosis {{
98
+ background: rgba(217, 119, 6, 0.08);
99
+ border-left: 4px solid var(--halide-amber);
100
+ padding: 1rem 1.2rem;
101
+ border-radius: 6px;
102
+ white-space: pre-wrap;
103
+ font-size: 0.98rem;
104
+ line-height: 1.6;
105
+ color: var(--halide-parchment);
106
+ }}
107
+
108
+ .halide-defect-pill {{
109
+ display: inline-block;
110
+ background: var(--halide-amber);
111
+ color: var(--halide-ink);
112
+ padding: 0.2rem 0.7rem;
113
+ border-radius: 999px;
114
+ font-size: 0.85rem;
115
+ font-weight: 600;
116
+ margin: 0 0.3rem 0.3rem 0;
117
+ }}
118
+
119
+ .halide-defect-pill.dust {{ background: var(--halide-amber); color: var(--halide-ink); }}
120
+ .halide-defect-pill.dirt {{ background: var(--halide-orange); color: var(--halide-parchment); }}
121
+ .halide-defect-pill.scratch {{ background: var(--halide-red); color: var(--halide-parchment); }}
122
+ .halide-defect-pill.long_hair {{ background: var(--halide-crimson); color: var(--halide-parchment); }}
123
+ .halide-defect-pill.short_hair {{ background: var(--halide-ember); color: var(--halide-ink); }}
124
+
125
+ button.primary, .primary button {{
126
+ background: linear-gradient(135deg, var(--halide-orange), var(--halide-red)) !important;
127
+ color: var(--halide-parchment) !important;
128
+ border: 1px solid var(--halide-amber) !important;
129
+ font-weight: 600 !important;
130
+ letter-spacing: 0.02em !important;
131
+ box-shadow: 0 2px 12px rgba(234, 88, 12, 0.4) !important;
132
+ }}
133
+
134
+ button.primary:hover, .primary button:hover {{
135
+ background: linear-gradient(135deg, var(--halide-red), var(--halide-crimson)) !important;
136
+ }}
137
+
138
+ input, textarea, select {{
139
+ background: var(--halide-ink-soft) !important;
140
+ color: var(--halide-parchment) !important;
141
+ border: 1px solid var(--halide-amber-deep) !important;
142
+ }}
143
+
144
+ input:focus, textarea:focus, select:focus {{
145
+ border-color: var(--halide-amber) !important;
146
+ box-shadow: 0 0 0 2px rgba(217, 119, 6, 0.3) !important;
147
+ }}
148
+
149
+ label, .label, .gradio-radio label, .gradio-checkbox label {{
150
+ color: var(--halide-parchment-deep) !important;
151
+ font-weight: 500 !important;
152
+ }}
153
+
154
+ footer {{
155
+ color: var(--halide-slate) !important;
156
+ text-align: center;
157
+ padding: 1rem;
158
+ font-size: 0.85rem;
159
+ }}
160
+ """
161
+
162
+
163
+ def build_theme() -> gr.Theme:
164
+ """Build the autumn-themed Gradio theme."""
165
+ return gr.themes.Base(
166
+ primary_hue=gr.themes.Color(
167
+ c50="#fef3c7",
168
+ c100="#fde68a",
169
+ c200="#fcd34d",
170
+ c300="#fbbf24",
171
+ c400="#f59e0b",
172
+ c500=AMBER,
173
+ c600=AMBER_DEEP,
174
+ c700="#92400e",
175
+ c800="#78350f",
176
+ c900=CRIMSON,
177
+ c950="#7c2d12",
178
+ ),
179
+ secondary_hue=gr.themes.Color(
180
+ c50="#fef3c7",
181
+ c100="#fde68a",
182
+ c200="#fcd34d",
183
+ c300="#fbbf24",
184
+ c400=EMBER,
185
+ c500=AMBER,
186
+ c600=ORANGE,
187
+ c700=RED,
188
+ c800=CRIMSON,
189
+ c900="#7c2d12",
190
+ c950="#431407",
191
+ ),
192
+ neutral_hue=gr.themes.Color(
193
+ c50="#fafaf9",
194
+ c100="#f5f5f4",
195
+ c200="#e7e5e4",
196
+ c300="#d6d3d1",
197
+ c400=SLATE,
198
+ c500="#57534e",
199
+ c600="#44403c",
200
+ c700="#292524",
201
+ c800=INK_SOFT,
202
+ c900=INK,
203
+ c950="#0c0a09",
204
+ ),
205
+ font=gr.themes.GoogleFont("Iowan Old Style"),
206
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
207
+ ).set(
208
+ body_background_fill=INK,
209
+ body_background_fill_dark=INK,
210
+ body_text_color=PARCHMENT,
211
+ body_text_color_dark=PARCHMENT,
212
+ button_primary_background_fill=ORANGE,
213
+ button_primary_background_fill_dark=ORANGE,
214
+ button_primary_text_color=PARCHMENT,
215
+ button_primary_text_color_dark=PARCHMENT,
216
+ block_background_fill=INK_SOFT,
217
+ block_background_fill_dark=INK_SOFT,
218
+ block_border_color=AMBER_DEEP,
219
+ block_border_color_dark=AMBER_DEEP,
220
+ input_background_fill=INK,
221
+ input_background_fill_dark=INK,
222
+ input_border_color=AMBER_DEEP,
223
+ input_border_color_dark=AMBER_DEEP,
224
+ )