Lonelyguyse1 commited on
Commit
e994c16
·
verified ·
1 Parent(s): 0a1f3d6

Deploy Project Halide Gradio Space

Browse files
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,13 +1,13 @@
1
- ---
2
  title: Project Halide
3
- emoji: 🔥
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
+ ---
2
  title: Project Halide
 
 
 
3
  sdk: gradio
4
+ sdk_version: 6.10.0
 
5
  app_file: app.py
6
+ license: apache-2.0
7
  ---
8
 
9
+ # Project Halide
10
+
11
+ Edge-native diagnostic engine for analog film scans. Runtime uses MiniCPM-V 4.6 for defect extraction and Nemotron-Mini-4B-Instruct for few-shot diagnostic reasoning.
12
+
13
+ The Space runs without cloud inference APIs. Model execution is on the Space GPU runtime.
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()
config.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime configuration for Project Halide.
2
+
3
+ This module intentionally contains no model imports. It is safe to import in
4
+ local CPU-only tooling, tests, and dataset preparation scripts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ REPO_ROOT = Path(__file__).resolve().parent
14
+ DATA_DIR = REPO_ROOT / "data"
15
+ STORAGE_DIR = REPO_ROOT / "storage"
16
+ CHECKPOINT_DIR = REPO_ROOT / "checkpoints"
17
+
18
+ CANONICAL_VISION_MODEL_ID = "openbmb/MiniCPM-V-4.6"
19
+ VISION_MODEL_ALIASES = {
20
+ "openbmb/MiniCPM-V-4_6": CANONICAL_VISION_MODEL_ID,
21
+ }
22
+ DEFAULT_FINETUNED_MODEL_ID = "build-small-hackathon/halide-vision"
23
+ DEFAULT_REASONING_MODEL_ID = "nvidia/Nemotron-Mini-4B-Instruct"
24
+
25
+
26
+ def env_bool(name: str, default: bool = False) -> bool:
27
+ value = os.getenv(name)
28
+ if value is None:
29
+ return default
30
+ return value.strip().lower() in {"1", "true", "yes", "on"}
31
+
32
+
33
+ def env_int(name: str, default: int) -> int:
34
+ value = os.getenv(name)
35
+ if value is None or value.strip() == "":
36
+ return default
37
+ return int(value)
38
+
39
+
40
+ def env_path(name: str, default: Path) -> Path:
41
+ value = os.getenv(name)
42
+ return Path(value) if value else default
43
+
44
+
45
+ def normalize_model_id(model_id: str) -> str:
46
+ return VISION_MODEL_ALIASES.get(model_id, model_id)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class VisionConfig:
51
+ base_model_id: str
52
+ finetuned_model_id: str
53
+ local_model_path: Path
54
+ use_finetuned: bool
55
+ downsample_mode: str
56
+ max_slice_nums: int
57
+ max_new_tokens: int
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ReasoningConfig:
62
+ model_id: str
63
+ max_new_tokens: int
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class AppConfig:
68
+ db_path: Path
69
+ cache_size: int
70
+ cache_ttl_seconds: int
71
+ gpu_duration_seconds: int
72
+ max_history_items: int
73
+
74
+
75
+ def get_vision_config() -> VisionConfig:
76
+ return VisionConfig(
77
+ base_model_id=normalize_model_id(
78
+ os.getenv("HALIDE_VISION_BASE_MODEL_ID", CANONICAL_VISION_MODEL_ID)
79
+ ),
80
+ finetuned_model_id=os.getenv(
81
+ "HALIDE_VISION_FINETUNED_MODEL_ID", DEFAULT_FINETUNED_MODEL_ID
82
+ ),
83
+ local_model_path=env_path(
84
+ "HALIDE_VISION_LOCAL_MODEL_PATH",
85
+ CHECKPOINT_DIR / "minicpm-v-4.6-merged-v3",
86
+ ),
87
+ use_finetuned=env_bool("HALIDE_USE_FINETUNED_VISION", False),
88
+ downsample_mode=os.getenv("HALIDE_DOWNSAMPLE_MODE", "4x"),
89
+ max_slice_nums=env_int("HALIDE_MAX_SLICE_NUMS", 36),
90
+ max_new_tokens=env_int("HALIDE_MAX_NEW_TOKENS", 2048),
91
+ )
92
+
93
+
94
+ def get_reasoning_config() -> ReasoningConfig:
95
+ return ReasoningConfig(
96
+ model_id=os.getenv("HALIDE_REASONING_MODEL_ID", DEFAULT_REASONING_MODEL_ID),
97
+ max_new_tokens=env_int("HALIDE_NEMOTRON_MAX_TOKENS", 768),
98
+ )
99
+
100
+
101
+ def get_app_config() -> AppConfig:
102
+ return AppConfig(
103
+ db_path=env_path("HALIDE_DB_PATH", STORAGE_DIR / "halide.db"),
104
+ cache_size=env_int("HALIDE_CACHE_SIZE", 64),
105
+ cache_ttl_seconds=env_int("HALIDE_CACHE_TTL_SECONDS", 3600),
106
+ gpu_duration_seconds=env_int("HALIDE_GPU_DURATION_SECONDS", 300),
107
+ max_history_items=env_int("HALIDE_HISTORY_LIMIT", 10),
108
+ )
109
+
110
+
111
+ def running_on_hugging_face_space() -> bool:
112
+ return bool(os.getenv("SPACE_ID") or os.getenv("SPACE_HOST"))
113
+
114
+
115
+ def require_gpu_for_inference(stage: str) -> None:
116
+ """Refuse model inference unless a CUDA device is visible.
117
+
118
+ Local CPU use is allowed for file I/O, JSON parsing, image resizing, tests,
119
+ and dataset preparation. It is not allowed for loading or running the
120
+ vision or reasoning models.
121
+ """
122
+ import torch
123
+
124
+ if torch.cuda.is_available():
125
+ return
126
+
127
+ raise RuntimeError(
128
+ f"Halide refused to run {stage} model inference because no CUDA GPU "
129
+ "is visible. Run inference on Modal, Hugging Face ZeroGPU, or another "
130
+ "GPU runtime. Local CPU is reserved for editing, parsing, and tests."
131
+ )
132
+
133
+
134
+ __all__ = [
135
+ "AppConfig",
136
+ "CHECKPOINT_DIR",
137
+ "CANONICAL_VISION_MODEL_ID",
138
+ "DATA_DIR",
139
+ "DEFAULT_FINETUNED_MODEL_ID",
140
+ "DEFAULT_REASONING_MODEL_ID",
141
+ "REPO_ROOT",
142
+ "ReasoningConfig",
143
+ "STORAGE_DIR",
144
+ "VisionConfig",
145
+ "env_bool",
146
+ "env_int",
147
+ "env_path",
148
+ "get_app_config",
149
+ "get_reasoning_config",
150
+ "get_vision_config",
151
+ "normalize_model_id",
152
+ "require_gpu_for_inference",
153
+ "running_on_hugging_face_space",
154
+ ]
data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Data package. Dataset loading, preprocessing, and schemas."""
data/augmentation.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic defect augmentation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ from PIL import Image, ImageEnhance, ImageOps
11
+
12
+ from data.preprocessing import load_image
13
+
14
+ SYNTHETIC_LABEL_MAP = {
15
+ "dust": "dust",
16
+ "dirt": "dirt",
17
+ "dots": "dust",
18
+ "hair": "long_hair",
19
+ "hair-short": "short_hair",
20
+ "lint": "short_hair",
21
+ "scratches": "scratch",
22
+ "scratch": "scratch",
23
+ "smut": "dirt",
24
+ "spots": "dirt",
25
+ "sprinkles": "dust",
26
+ "stain": "dirt",
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class OverlayDefect:
32
+ path: Path
33
+ label: str
34
+
35
+
36
+ def discover_overlays(root: str | Path) -> list[OverlayDefect]:
37
+ """Find transparent PNG overlays and infer labels from folder names."""
38
+ root = Path(root)
39
+ overlays: list[OverlayDefect] = []
40
+ if not root.exists():
41
+ return overlays
42
+ for path in sorted(root.rglob("*.png")):
43
+ label = SYNTHETIC_LABEL_MAP.get(path.parent.name.lower())
44
+ if label:
45
+ overlays.append(OverlayDefect(path=path, label=label))
46
+ return overlays
47
+
48
+
49
+ def _visible_bbox(alpha: Image.Image) -> tuple[int, int, int, int] | None:
50
+ bbox = alpha.getbbox()
51
+ if bbox is None:
52
+ return None
53
+ x_min, y_min, x_max, y_max = bbox
54
+ if x_max <= x_min or y_max <= y_min:
55
+ return None
56
+ return bbox
57
+
58
+
59
+ def _normalized_bbox(
60
+ paste_x: int,
61
+ paste_y: int,
62
+ visible_bbox: tuple[int, int, int, int],
63
+ width: int,
64
+ height: int,
65
+ ) -> list[float]:
66
+ x_min, y_min, x_max, y_max = visible_bbox
67
+ return [
68
+ round((paste_x + x_min) / width, 6),
69
+ round((paste_y + y_min) / height, 6),
70
+ round((paste_x + x_max) / width, 6),
71
+ round((paste_y + y_max) / height, 6),
72
+ ]
73
+
74
+
75
+ def apply_overlay(
76
+ base: Image.Image,
77
+ overlay: OverlayDefect,
78
+ *,
79
+ rng: random.Random,
80
+ scale_range: tuple[float, float] = (0.35, 1.4),
81
+ opacity_range: tuple[float, float] = (0.55, 0.95),
82
+ ) -> tuple[Image.Image, dict] | None:
83
+ """Paste one defect overlay onto a copy of base and return annotation."""
84
+ out = load_image(base).convert("RGBA")
85
+ width, height = out.size
86
+
87
+ layer = Image.open(overlay.path).convert("RGBA")
88
+ if rng.random() < 0.5:
89
+ layer = ImageOps.mirror(layer)
90
+ if rng.random() < 0.35:
91
+ layer = layer.rotate(rng.uniform(-22, 22), expand=True, resample=Image.Resampling.BICUBIC)
92
+
93
+ scale = rng.uniform(*scale_range)
94
+ new_size = (
95
+ max(2, int(layer.width * scale)),
96
+ max(2, int(layer.height * scale)),
97
+ )
98
+ layer = layer.resize(new_size, Image.Resampling.LANCZOS)
99
+ if layer.width >= width or layer.height >= height:
100
+ layer.thumbnail((width // 2, height // 2), Image.Resampling.LANCZOS)
101
+
102
+ alpha = layer.getchannel("A")
103
+ alpha = ImageEnhance.Brightness(alpha).enhance(rng.uniform(*opacity_range))
104
+ layer.putalpha(alpha)
105
+ visible = _visible_bbox(alpha)
106
+ if visible is None:
107
+ return None
108
+
109
+ max_x = max(0, width - layer.width)
110
+ max_y = max(0, height - layer.height)
111
+ paste_x = rng.randint(0, max_x) if max_x else 0
112
+ paste_y = rng.randint(0, max_y) if max_y else 0
113
+
114
+ out.alpha_composite(layer, (paste_x, paste_y))
115
+ annotation = {
116
+ "label": overlay.label,
117
+ "bbox": _normalized_bbox(paste_x, paste_y, visible, width, height),
118
+ }
119
+ return out.convert("RGB"), annotation
120
+
121
+
122
+ def augment_image(
123
+ base: Image.Image,
124
+ overlays: Iterable[OverlayDefect],
125
+ *,
126
+ seed: int,
127
+ defects_per_image: tuple[int, int] = (3, 9),
128
+ ) -> tuple[Image.Image, list[dict]]:
129
+ """Create one augmented image and its generated annotations."""
130
+ rng = random.Random(seed)
131
+ overlay_list = list(overlays)
132
+ if not overlay_list:
133
+ return load_image(base), []
134
+
135
+ by_label: dict[str, list[OverlayDefect]] = {}
136
+ for overlay in overlay_list:
137
+ by_label.setdefault(overlay.label, []).append(overlay)
138
+ labels = sorted(by_label)
139
+
140
+ out = load_image(base)
141
+ annotations: list[dict] = []
142
+ target = rng.randint(*defects_per_image)
143
+ for _ in range(target):
144
+ label = rng.choice(labels)
145
+ overlay = rng.choice(by_label[label])
146
+ result = apply_overlay(out, overlay, rng=rng)
147
+ if result is None:
148
+ continue
149
+ out, annotation = result
150
+ annotations.append(annotation)
151
+ return out, annotations
152
+
153
+
154
+ __all__ = [
155
+ "OverlayDefect",
156
+ "SYNTHETIC_LABEL_MAP",
157
+ "apply_overlay",
158
+ "augment_image",
159
+ "discover_overlays",
160
+ ]
data/datasets.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset loading and validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+
10
+ from config import DATA_DIR, REPO_ROOT
11
+ from data.schemas import ALLOWED_LABELS, clean_defects, label_counts
12
+
13
+ TRAINING_JSONL = DATA_DIR / "training_data.jsonl"
14
+ FDS_SCANS_DIR = (
15
+ DATA_DIR
16
+ / "raw"
17
+ / "FilmDamageSimulator"
18
+ / "FilmDamageSimulator"
19
+ / "scans"
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class DatasetIssue:
25
+ image: str
26
+ message: str
27
+
28
+
29
+ def load_jsonl(path: str | Path = TRAINING_JSONL) -> list[dict[str, Any]]:
30
+ path = Path(path)
31
+ rows: list[dict[str, Any]] = []
32
+ if not path.exists():
33
+ return rows
34
+ with path.open("r", encoding="utf-8") as f:
35
+ for line_no, line in enumerate(f, start=1):
36
+ line = line.strip()
37
+ if not line:
38
+ continue
39
+ try:
40
+ rows.append(json.loads(line))
41
+ except json.JSONDecodeError as exc:
42
+ raise ValueError(f"{path}:{line_no}: invalid JSONL row: {exc}") from exc
43
+ return rows
44
+
45
+
46
+ def resolve_image_path(entry: dict[str, Any], scans_dir: Path = FDS_SCANS_DIR) -> Path:
47
+ image = str(entry.get("image", ""))
48
+ path = Path(image)
49
+ if path.is_absolute():
50
+ return path
51
+ candidate = scans_dir / image
52
+ if candidate.exists():
53
+ return candidate
54
+ return REPO_ROOT / image
55
+
56
+
57
+ def validate_entries(
58
+ entries: Iterable[dict[str, Any]],
59
+ *,
60
+ require_images: bool = False,
61
+ scans_dir: Path = FDS_SCANS_DIR,
62
+ ) -> list[DatasetIssue]:
63
+ issues: list[DatasetIssue] = []
64
+ for entry in entries:
65
+ image = str(entry.get("image", ""))
66
+ if not image:
67
+ issues.append(DatasetIssue(image="(missing)", message="missing image field"))
68
+ if require_images and image and not resolve_image_path(entry, scans_dir).exists():
69
+ issues.append(DatasetIssue(image=image, message="image file does not exist"))
70
+
71
+ annotations = entry.get("annotations", [])
72
+ cleaned, dropped = clean_defects(annotations)
73
+ if dropped:
74
+ issues.append(
75
+ DatasetIssue(
76
+ image=image or "(missing)",
77
+ message=f"{dropped} invalid annotations",
78
+ )
79
+ )
80
+ for defect in cleaned:
81
+ if defect["label"] not in ALLOWED_LABELS:
82
+ issues.append(
83
+ DatasetIssue(
84
+ image=image or "(missing)",
85
+ message=f"unknown label {defect['label']}",
86
+ )
87
+ )
88
+ return issues
89
+
90
+
91
+ def dataset_summary(entries: Iterable[dict[str, Any]]) -> dict[str, Any]:
92
+ entries_list = list(entries)
93
+ all_defects: list[dict[str, Any]] = []
94
+ dropped = 0
95
+ sources: dict[str, int] = {}
96
+ for entry in entries_list:
97
+ source = str(entry.get("source", "unknown"))
98
+ sources[source] = sources.get(source, 0) + 1
99
+ cleaned, bad = clean_defects(entry.get("annotations", []))
100
+ all_defects.extend(cleaned)
101
+ dropped += bad
102
+ counts = label_counts(all_defects)
103
+ return {
104
+ "images": len(entries_list),
105
+ "defects": len(all_defects),
106
+ "dropped_annotations": dropped,
107
+ "label_counts": counts,
108
+ "sources": dict(sorted(sources.items())),
109
+ }
110
+
111
+
112
+ def load_training_summary(path: str | Path = TRAINING_JSONL) -> dict[str, Any]:
113
+ entries = load_jsonl(path)
114
+ summary = dataset_summary(entries)
115
+ summary["issues"] = [
116
+ issue.__dict__ for issue in validate_entries(entries, require_images=False)
117
+ ]
118
+ return summary
119
+
120
+
121
+ __all__ = [
122
+ "DatasetIssue",
123
+ "FDS_SCANS_DIR",
124
+ "TRAINING_JSONL",
125
+ "dataset_summary",
126
+ "load_jsonl",
127
+ "load_training_summary",
128
+ "resolve_image_path",
129
+ "validate_entries",
130
+ ]
data/preprocessing.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image preprocessing and visualization utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+
10
+ from PIL import Image, ImageDraw, ImageFont, ImageOps
11
+
12
+ from data.schemas import LABEL_DISPLAY_NAMES, bbox_to_pixels
13
+
14
+ LABEL_STYLE = {
15
+ "dust": ((245, 158, 11), 2),
16
+ "dirt": ((217, 119, 6), 2),
17
+ "scratch": ((220, 38, 38), 3),
18
+ "long_hair": ((124, 58, 237), 2),
19
+ "short_hair": ((8, 145, 178), 2),
20
+ }
21
+ DEFAULT_STYLE = ((255, 255, 255), 2)
22
+
23
+
24
+ def load_image(image: str | Path | Image.Image) -> Image.Image:
25
+ """Load an image-like value and return RGB PIL Image."""
26
+ if isinstance(image, Image.Image):
27
+ pil = image
28
+ else:
29
+ pil = Image.open(image)
30
+ pil = ImageOps.exif_transpose(pil)
31
+ if pil.mode == "RGBA":
32
+ background = Image.new("RGB", pil.size, (24, 22, 20))
33
+ background.paste(pil, mask=pil.getchannel("A"))
34
+ return background
35
+ if pil.mode != "RGB":
36
+ return pil.convert("RGB")
37
+ return pil.copy()
38
+
39
+
40
+ def image_to_png_bytes(image: Image.Image) -> bytes:
41
+ buf = io.BytesIO()
42
+ load_image(image).save(buf, format="PNG", optimize=True)
43
+ return buf.getvalue()
44
+
45
+
46
+ def image_sha256(image: Image.Image | bytes) -> str:
47
+ if isinstance(image, bytes):
48
+ payload = image
49
+ else:
50
+ payload = image_to_png_bytes(image)
51
+ return hashlib.sha256(payload).hexdigest()
52
+
53
+
54
+ def resize_for_preview(image: Image.Image, max_side: int = 1400) -> Image.Image:
55
+ pil = load_image(image)
56
+ if max(pil.size) <= max_side:
57
+ return pil
58
+ out = pil.copy()
59
+ out.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
60
+ return out
61
+
62
+
63
+ def draw_defects(
64
+ image: Image.Image,
65
+ defects: Iterable[dict[str, Any]],
66
+ *,
67
+ title: str | None = None,
68
+ max_boxes: int = 300,
69
+ ) -> Image.Image:
70
+ """Draw normalized defect boxes onto an RGB copy of an image."""
71
+ out = load_image(image)
72
+ draw = ImageDraw.Draw(out)
73
+ width, height = out.size
74
+ font = ImageFont.load_default()
75
+
76
+ if title:
77
+ draw.rectangle((0, 0, min(width, 440), 24), fill=(12, 10, 9))
78
+ draw.text((8, 6), title, fill=(254, 243, 199), font=font)
79
+
80
+ drawn = 0
81
+ for defect in defects:
82
+ if drawn >= max_boxes:
83
+ break
84
+ label = str(defect.get("label", "unknown"))
85
+ pixels = bbox_to_pixels(defect.get("bbox"), width, height)
86
+ if pixels is None:
87
+ continue
88
+ x_min, y_min, x_max, y_max = pixels
89
+ color, line_width = LABEL_STYLE.get(label, DEFAULT_STYLE)
90
+ draw.rectangle((x_min, y_min, x_max, y_max), outline=color, width=line_width)
91
+
92
+ label_text = LABEL_DISPLAY_NAMES.get(label, label)
93
+ text_bbox = draw.textbbox((x_min, max(0, y_min - 16)), label_text, font=font)
94
+ draw.rectangle(text_bbox, fill=(12, 10, 9))
95
+ draw.text((text_bbox[0] + 1, text_bbox[1]), label_text, fill=color, font=font)
96
+ drawn += 1
97
+
98
+ return out
99
+
100
+
101
+ __all__ = [
102
+ "DEFAULT_STYLE",
103
+ "LABEL_STYLE",
104
+ "draw_defects",
105
+ "image_sha256",
106
+ "image_to_png_bytes",
107
+ "load_image",
108
+ "resize_for_preview",
109
+ ]
data/schemas.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Defect schema and geometry helpers for Project Halide."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable
7
+
8
+ ALLOWED_LABELS = frozenset(
9
+ {
10
+ "dust",
11
+ "dirt",
12
+ "scratch",
13
+ "long_hair",
14
+ "short_hair",
15
+ }
16
+ )
17
+
18
+ LABEL_DISPLAY_NAMES = {
19
+ "dust": "Dust",
20
+ "dirt": "Dirt",
21
+ "scratch": "Scratch",
22
+ "long_hair": "Long hair",
23
+ "short_hair": "Short hair",
24
+ }
25
+
26
+ DEFECT_CLASSES_KNOWN = {
27
+ "dust": 0,
28
+ "dirt": 1,
29
+ "scratch": 2,
30
+ "long_hair": 3,
31
+ "short_hair": 4,
32
+ "light_leak": 5,
33
+ "chemical_stain": 6,
34
+ "emulsion_damage": 7,
35
+ }
36
+
37
+ BBox = tuple[float, float, float, float]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Defect:
42
+ label: str
43
+ bbox: BBox
44
+ confidence: float | None = None
45
+
46
+ def to_json(self) -> dict[str, Any]:
47
+ out: dict[str, Any] = {
48
+ "label": self.label,
49
+ "bbox": [round(v, 6) for v in self.bbox],
50
+ }
51
+ if self.confidence is not None:
52
+ out["confidence"] = round(float(self.confidence), 4)
53
+ return out
54
+
55
+
56
+ def _unwrap_bbox(bbox: Any) -> Any:
57
+ """Accept a single nested bbox from imperfect model JSON."""
58
+ if (
59
+ isinstance(bbox, (list, tuple))
60
+ and len(bbox) == 1
61
+ and isinstance(bbox[0], (list, tuple))
62
+ ):
63
+ return bbox[0]
64
+ return bbox
65
+
66
+
67
+ def normalize_bbox(bbox: Any) -> BBox | None:
68
+ """Normalize a bbox to float [0, 1].
69
+
70
+ Accepts either [0, 999] integer grid values or normalized [0, 1] floats.
71
+ Returns None for malformed, reversed, or out-of-range boxes.
72
+ """
73
+ bbox = _unwrap_bbox(bbox)
74
+ if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
75
+ return None
76
+
77
+ try:
78
+ x_min, y_min, x_max, y_max = (float(v) for v in bbox)
79
+ except (TypeError, ValueError):
80
+ return None
81
+
82
+ if x_max <= x_min or y_max <= y_min:
83
+ return None
84
+
85
+ max_val = max(x_min, y_min, x_max, y_max)
86
+ all_whole = all(
87
+ isinstance(v, int) or (isinstance(v, float) and v.is_integer())
88
+ for v in bbox
89
+ )
90
+ scale = 999.0 if all_whole and max_val > 1.5 else 1.0
91
+ if scale == 999.0:
92
+ x_min /= scale
93
+ y_min /= scale
94
+ x_max /= scale
95
+ y_max /= scale
96
+
97
+ if not all(0.0 <= v <= 1.0 for v in (x_min, y_min, x_max, y_max)):
98
+ return None
99
+ if x_max <= x_min or y_max <= y_min:
100
+ return None
101
+
102
+ return (
103
+ round(x_min, 6),
104
+ round(y_min, 6),
105
+ round(x_max, 6),
106
+ round(y_max, 6),
107
+ )
108
+
109
+
110
+ def validate_defect(raw: Any) -> Defect | None:
111
+ if not isinstance(raw, dict):
112
+ return None
113
+ label = raw.get("label")
114
+ if label not in ALLOWED_LABELS:
115
+ return None
116
+ bbox = normalize_bbox(raw.get("bbox"))
117
+ if bbox is None:
118
+ return None
119
+ confidence = raw.get("confidence")
120
+ if confidence is not None:
121
+ try:
122
+ confidence = float(confidence)
123
+ except (TypeError, ValueError):
124
+ confidence = None
125
+ return Defect(label=label, bbox=bbox, confidence=confidence)
126
+
127
+
128
+ def clean_defects(raw_defects: Any) -> tuple[list[dict[str, Any]], int]:
129
+ """Return valid defect dicts and number of dropped records."""
130
+ if not isinstance(raw_defects, list):
131
+ return [], 1 if raw_defects else 0
132
+
133
+ cleaned: list[dict[str, Any]] = []
134
+ dropped = 0
135
+ for raw in raw_defects:
136
+ defect = validate_defect(raw)
137
+ if defect is None:
138
+ dropped += 1
139
+ else:
140
+ cleaned.append(defect.to_json())
141
+ return cleaned, dropped
142
+
143
+
144
+ def label_counts(defects: Iterable[dict[str, Any]]) -> dict[str, int]:
145
+ counts: dict[str, int] = {}
146
+ for defect in defects:
147
+ label = defect.get("label")
148
+ if label in ALLOWED_LABELS:
149
+ counts[label] = counts.get(label, 0) + 1
150
+ return dict(sorted(counts.items()))
151
+
152
+
153
+ def dedupe_defects(defects: Iterable[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
154
+ """Drop exact duplicate label and bbox pairs from already-clean defects."""
155
+ unique: list[dict[str, Any]] = []
156
+ seen: set[tuple[str, tuple[float, float, float, float]]] = set()
157
+ duplicate_count = 0
158
+
159
+ for defect in defects:
160
+ label = str(defect.get("label", ""))
161
+ bbox = normalize_bbox(defect.get("bbox"))
162
+ if label not in ALLOWED_LABELS or bbox is None:
163
+ continue
164
+ key = (label, bbox)
165
+ if key in seen:
166
+ duplicate_count += 1
167
+ continue
168
+ seen.add(key)
169
+ unique.append({"label": label, "bbox": [round(v, 6) for v in bbox]})
170
+
171
+ return unique, duplicate_count
172
+
173
+
174
+ def bbox_area(bbox: Any) -> float:
175
+ norm = normalize_bbox(bbox)
176
+ if norm is None:
177
+ return 0.0
178
+ x_min, y_min, x_max, y_max = norm
179
+ return max(0.0, x_max - x_min) * max(0.0, y_max - y_min)
180
+
181
+
182
+ def bbox_iou(a: Any, b: Any) -> float:
183
+ box_a = normalize_bbox(a)
184
+ box_b = normalize_bbox(b)
185
+ if box_a is None or box_b is None:
186
+ return 0.0
187
+
188
+ ax1, ay1, ax2, ay2 = box_a
189
+ bx1, by1, bx2, by2 = box_b
190
+ ix1 = max(ax1, bx1)
191
+ iy1 = max(ay1, by1)
192
+ ix2 = min(ax2, bx2)
193
+ iy2 = min(ay2, by2)
194
+ if ix2 <= ix1 or iy2 <= iy1:
195
+ return 0.0
196
+
197
+ inter = (ix2 - ix1) * (iy2 - iy1)
198
+ union = bbox_area(box_a) + bbox_area(box_b) - inter
199
+ if union <= 0:
200
+ return 0.0
201
+ return round(inter / union, 6)
202
+
203
+
204
+ def bbox_to_pixels(bbox: Any, width: int, height: int) -> tuple[int, int, int, int] | None:
205
+ norm = normalize_bbox(bbox)
206
+ if norm is None:
207
+ return None
208
+ x_min, y_min, x_max, y_max = norm
209
+ return (
210
+ int(round(x_min * width)),
211
+ int(round(y_min * height)),
212
+ int(round(x_max * width)),
213
+ int(round(y_max * height)),
214
+ )
215
+
216
+
217
+ def spatial_summary(defects: Iterable[dict[str, Any]]) -> dict[str, Any]:
218
+ """Compute compact spatial cues for the reasoning model."""
219
+ defects_list = list(defects)
220
+ if not defects_list:
221
+ return {
222
+ "edge_defects": 0,
223
+ "center_defects": 0,
224
+ "largest_labels": [],
225
+ }
226
+
227
+ edge_count = 0
228
+ center_count = 0
229
+ largest: list[tuple[float, str]] = []
230
+ for defect in defects_list:
231
+ bbox = normalize_bbox(defect.get("bbox"))
232
+ if bbox is None:
233
+ continue
234
+ x_min, y_min, x_max, y_max = bbox
235
+ cx = (x_min + x_max) / 2.0
236
+ cy = (y_min + y_max) / 2.0
237
+ if x_min < 0.08 or y_min < 0.08 or x_max > 0.92 or y_max > 0.92:
238
+ edge_count += 1
239
+ if 0.35 <= cx <= 0.65 and 0.35 <= cy <= 0.65:
240
+ center_count += 1
241
+ largest.append((bbox_area(bbox), str(defect.get("label", "unknown"))))
242
+
243
+ largest_labels = [
244
+ label for _, label in sorted(largest, reverse=True)[:5]
245
+ ]
246
+ return {
247
+ "edge_defects": edge_count,
248
+ "center_defects": center_count,
249
+ "largest_labels": largest_labels,
250
+ }
251
+
252
+
253
+ __all__ = [
254
+ "ALLOWED_LABELS",
255
+ "BBox",
256
+ "DEFECT_CLASSES_KNOWN",
257
+ "Defect",
258
+ "LABEL_DISPLAY_NAMES",
259
+ "bbox_area",
260
+ "bbox_iou",
261
+ "bbox_to_pixels",
262
+ "clean_defects",
263
+ "dedupe_defects",
264
+ "label_counts",
265
+ "normalize_bbox",
266
+ "spatial_summary",
267
+ "validate_defect",
268
+ ]
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Model package. Vision and reasoning model wrappers."""
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/inference.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Convenience entrypoints for diagnostic reasoning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pipeline.diagnoser import diagnose
6
+
7
+ __all__ = ["diagnose"]
models/reasoning/nemotron_wrapper.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ The `generate` method accepts a fully-formed `messages: list[dict]` array
8
+ (system, user, assistant turns). It does NOT pre-process the messages; the
9
+ caller is responsible for assembling the full few-shot + system + current
10
+ request array. This is the only correct way to use a chat-tuned model with
11
+ `tokenizer.apply_chat_template`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from typing import Any
19
+
20
+ from config import get_reasoning_config, require_gpu_for_inference
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _ESCAPED_NEWLINE_PATTERN = re.compile(
25
+ r"(```[\s\S]*?```|`[^`]+`)|(?<!\\)(?:\\r\\n|\\[nr])"
26
+ )
27
+
28
+
29
+ class NemotronReasoner:
30
+ """Lazy-loading wrapper around Nemotron-Mini-4B-Instruct."""
31
+
32
+ def __init__(self, model_path: str | None = None) -> None:
33
+ cfg = get_reasoning_config()
34
+ self._model_path = model_path or cfg.model_id
35
+ self._tokenizer: Any = None
36
+ self._model: Any = None
37
+ self._device: str = "cpu"
38
+ self._dtype: Any = None
39
+
40
+ @property
41
+ def model_path(self) -> str:
42
+ return self._model_path
43
+
44
+ def load(self) -> None:
45
+ if self._model is not None:
46
+ return
47
+ require_gpu_for_inference("reasoning")
48
+ import torch
49
+ from transformers import AutoModelForCausalLM, AutoTokenizer
50
+
51
+ logger.info("Loading Nemotron-Mini-4B from %s", self._model_path)
52
+ self._tokenizer = AutoTokenizer.from_pretrained(self._model_path)
53
+ self._dtype = _select_cuda_dtype(torch)
54
+ self._model = AutoModelForCausalLM.from_pretrained(
55
+ self._model_path,
56
+ torch_dtype=self._dtype,
57
+ device_map="auto",
58
+ )
59
+ self._device = str(next(self._model.parameters()).device)
60
+ logger.info("Nemotron loaded on %s with dtype %s", self._device, self._dtype)
61
+
62
+ def generate(self, messages: list[dict[str, str]]) -> str:
63
+ """Run chat completion on a fully-formed messages array.
64
+
65
+ `messages` must be a list of dicts with `role` in
66
+ {"system", "user", "assistant"} and `content` strings. The caller
67
+ is responsible for assembling the full conversation including any
68
+ few-shot examples. This wrapper just tokenizes and generates.
69
+ """
70
+ if self._model is None:
71
+ self.load()
72
+
73
+ if not messages:
74
+ raise ValueError("messages must be a non-empty list of {role, content} dicts")
75
+
76
+ inputs, prompt_length = _build_chat_inputs(
77
+ self._tokenizer,
78
+ messages,
79
+ self._device,
80
+ )
81
+
82
+ import torch
83
+ with torch.inference_mode():
84
+ output = self._model.generate(
85
+ **inputs,
86
+ max_new_tokens=get_reasoning_config().max_new_tokens,
87
+ do_sample=False,
88
+ pad_token_id=self._tokenizer.eos_token_id,
89
+ )
90
+
91
+ response_ids = output[0][prompt_length:]
92
+ text = self._tokenizer.decode(
93
+ response_ids,
94
+ skip_special_tokens=True,
95
+ clean_up_tokenization_spaces=False,
96
+ )
97
+ return normalize_response_text(text).strip()
98
+
99
+ def close(self) -> None:
100
+ if self._model is not None:
101
+ del self._model
102
+ self._model = None
103
+ if self._tokenizer is not None:
104
+ del self._tokenizer
105
+ self._tokenizer = None
106
+
107
+
108
+ _default_reasoner: NemotronReasoner | None = None
109
+
110
+
111
+ def get_reasoner() -> NemotronReasoner:
112
+ global _default_reasoner
113
+ if _default_reasoner is None:
114
+ _default_reasoner = NemotronReasoner()
115
+ return _default_reasoner
116
+
117
+
118
+ def normalize_response_text(text: str) -> str:
119
+ """Convert literal escaped newlines to display newlines outside code."""
120
+ if not isinstance(text, str) or "\\" not in text:
121
+ return text
122
+ return _ESCAPED_NEWLINE_PATTERN.sub(lambda m: m.group(1) or "\n", text)
123
+
124
+
125
+ def _build_chat_inputs(
126
+ tokenizer: Any,
127
+ messages: list[dict[str, str]],
128
+ device: str,
129
+ ) -> tuple[dict[str, Any], int]:
130
+ """Return generate kwargs across Transformers chat-template variants."""
131
+ try:
132
+ encoded = tokenizer.apply_chat_template(
133
+ messages,
134
+ add_generation_prompt=True,
135
+ return_tensors="pt",
136
+ return_dict=True,
137
+ )
138
+ except TypeError:
139
+ encoded = tokenizer.apply_chat_template(
140
+ messages,
141
+ add_generation_prompt=True,
142
+ return_tensors="pt",
143
+ )
144
+
145
+ if hasattr(encoded, "to"):
146
+ encoded = encoded.to(device)
147
+
148
+ if _has_input_ids(encoded):
149
+ input_ids = encoded["input_ids"]
150
+ return dict(encoded), input_ids.shape[-1]
151
+
152
+ return {"input_ids": encoded}, encoded.shape[-1]
153
+
154
+
155
+ def _has_input_ids(encoded: Any) -> bool:
156
+ try:
157
+ return "input_ids" in encoded
158
+ except (TypeError, RuntimeError):
159
+ return False
160
+
161
+
162
+ def _select_cuda_dtype(torch_module: Any) -> Any:
163
+ major, _minor = torch_module.cuda.get_device_capability()
164
+ if major >= 8:
165
+ return torch_module.bfloat16
166
+ return torch_module.float16
models/reasoning/prompts.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "Use the evidence you are given, do not invent unsupported camera or lab "
14
+ "facts, and separate confirmed observations from likely causes. Do not "
15
+ "carry film-stock-specific advice from examples into the current case "
16
+ "unless the current film stock supports it. For scanners, recommend "
17
+ "scanner-safe cleaning, manufacturer guidance, or professional service, "
18
+ "not household cleaners or detergent. When uncertain, recommend inspection "
19
+ "before high-risk physical cleaning. Treat static as a generic handling "
20
+ "possibility unless the current metadata directly supports a stock-specific "
21
+ "static claim. Return Markdown with these sections: Root cause, Evidence, "
22
+ "Physical fixes, Confidence, Next inspection."
23
+ )
24
+
25
+
26
+ FEW_SHOT_EXAMPLES: list[dict[str, str]] = [
27
+ {
28
+ "role": "user",
29
+ "content": (
30
+ "## Defect report\n"
31
+ "{\n"
32
+ ' "film_type": "Kodak Portra 400 (35mm)",\n'
33
+ ' "film_age_years": 2,\n'
34
+ ' "storage": "fridge, sealed",\n'
35
+ ' "defects": [\n'
36
+ ' {"label": "dust", "bbox_count": 240},\n'
37
+ ' {"label": "dirt", "bbox_count": 80},\n'
38
+ ' {"label": "short_hair", "bbox_count": 18},\n'
39
+ ' {"label": "long_hair", "bbox_count": 4},\n'
40
+ ' {"label": "scratch", "bbox_count": 1}\n'
41
+ ' ],\n'
42
+ ' "scan_resolution_dpi": 4000,\n'
43
+ ' "total_defect_count": 343,\n'
44
+ ' "spatial_evidence": {"edge_defects": 29, "center_defects": 96}\n'
45
+ "}\n\n"
46
+ "What is the root cause and what physical fixes do you recommend?"
47
+ ),
48
+ },
49
+ {
50
+ "role": "assistant",
51
+ "content": (
52
+ "## Root cause\n"
53
+ "Scanner-side contamination is most likely, with a smaller chance "
54
+ "of handling debris on the negative. The fridge-sealed storage "
55
+ "argues against age or heat damage as the primary cause.\n\n"
56
+ "## Evidence\n"
57
+ "- Dust and dirt dominate the report, which points to particles on "
58
+ "the scanner glass, holder, or negative surface.\n"
59
+ "- Hair defects suggest lint in the film holder or transport path.\n"
60
+ "- A single scratch should be inspected physically before blaming "
61
+ "the roll or camera.\n\n"
62
+ "## Physical fixes\n"
63
+ "1. Clean scanner glass, holder, and feed path with scanner-safe "
64
+ "swabs and reagent-grade isopropyl alcohol.\n"
65
+ "2. Use a rocket blower and anti-static brush on the negative, "
66
+ "then re-scan a small crop.\n"
67
+ "3. Enable hardware dust removal only if it is supported for the "
68
+ "film and scanner, then compare against a no-removal scan.\n"
69
+ "4. Inspect the scratch under a 10x loupe. If it is physical, use "
70
+ "wet-gate or wet-mount scanning rather than wiping the emulsion.\n"
71
+ "5. If contamination returns after cleaning, service the holder "
72
+ "or rollers before scanning more frames.\n\n"
73
+ "## Confidence\n"
74
+ "Medium-high. The storage metadata reduces the probability of "
75
+ "storage deterioration, but the negative still needs inspection.\n\n"
76
+ "## Next inspection\n"
77
+ "Check whether particles move between scans. If they move, the "
78
+ "problem is loose dust or scanner contamination."
79
+ ),
80
+ },
81
+ {
82
+ "role": "user",
83
+ "content": (
84
+ "## Defect report\n"
85
+ "{\n"
86
+ ' "film_type": "Ilford HP5 (35mm)",\n'
87
+ ' "film_age_years": 0,\n'
88
+ ' "storage": "fresh",\n'
89
+ ' "defects": [\n'
90
+ ' {"label": "dust", "bbox_count": 5},\n'
91
+ ' {"label": "scratch", "bbox_count": 1}\n'
92
+ ' ],\n'
93
+ ' "scan_resolution_dpi": 3200,\n'
94
+ ' "total_defect_count": 6,\n'
95
+ ' "spatial_evidence": {"edge_defects": 0, "center_defects": 2}\n'
96
+ "}\n\n"
97
+ "What is the root cause and what physical fixes do you recommend?"
98
+ ),
99
+ },
100
+ {
101
+ "role": "assistant",
102
+ "content": (
103
+ "## Root cause\n"
104
+ "A fresh HP5 roll with light dust and one scratch most likely "
105
+ "reflects handling or scanner-side contamination plus one physical "
106
+ "abrasion event. There is not enough evidence to claim HP5 has a "
107
+ "special static problem.\n\n"
108
+ "## Evidence\n"
109
+ "- The film is fresh, so age and chemical deterioration are "
110
+ "unlikely.\n"
111
+ "- Five dust particles is a low count and can come from scanner "
112
+ "glass, the holder, or brief handling.\n"
113
+ "- The single scratch needs loupe inspection before assigning it "
114
+ "to the camera, scanner, or handling.\n\n"
115
+ "## Physical fixes\n"
116
+ "1. Clean scanner glass and holder with scanner-safe swabs, then "
117
+ "re-scan the same crop.\n"
118
+ "2. Use a rocket blower on the negative. Avoid wiping the emulsion "
119
+ "unless a lab confirms the defect is on the base side.\n"
120
+ "3. Inspect the scratch under 10x magnification. If physical, use "
121
+ "wet-gate or wet-mount scanning to reduce its appearance.\n"
122
+ "4. Anti-static treatment may be tested as a generic dust-control "
123
+ "step, but it should not be framed as HP5-specific.\n\n"
124
+ "## Confidence\n"
125
+ "Medium. The defect count is small, so the diagnosis should stay "
126
+ "conservative.\n\n"
127
+ "## Next inspection\n"
128
+ "Re-scan after cleaning. If dust changes position, scanner or "
129
+ "handling contamination is confirmed."
130
+ ),
131
+ },
132
+ {
133
+ "role": "user",
134
+ "content": (
135
+ "## Defect report\n"
136
+ "{\n"
137
+ ' "film_type": "Ilford HP5 (120 medium format)",\n'
138
+ ' "film_age_years": 15,\n'
139
+ ' "storage": "shoe box, attic",\n'
140
+ ' "defects": [\n'
141
+ ' {"label": "long_hair", "bbox_count": 4},\n'
142
+ ' {"label": "scratch", "bbox_count": 1}\n'
143
+ ' ],\n'
144
+ ' "scan_resolution_dpi": 3200\n'
145
+ "}\n\n"
146
+ "What is the root cause and what physical fixes do you recommend?"
147
+ ),
148
+ },
149
+ {
150
+ "role": "assistant",
151
+ "content": (
152
+ "## Root cause\n"
153
+ "Long hair and scratches on an aged, poorly stored roll indicate "
154
+ "physical abrasion from dust and debris inside the storage "
155
+ "container. Attic storage subjects film to temperature and "
156
+ "humidity swings that can make the emulsion more vulnerable.\n\n"
157
+ "## Evidence\n"
158
+ "- The storage field explicitly says attic, so heat and humidity "
159
+ "stress are supported for this case.\n"
160
+ "- Hair plus a scratch indicates physical contact, not only scanner "
161
+ "dust.\n\n"
162
+ "## Physical fixes\n"
163
+ "1. Inspect the negative under a 10x loupe to confirm hair is on "
164
+ "the emulsion side (matte) vs. base side (glossy). Emulsion-side "
165
+ "hair cannot be wet-wiped safely.\n"
166
+ "2. If base-side only: gently wipe with a PEC*PAD soaked in "
167
+ "Edwal Negative Cleaner, then PEC*PAD with distilled water, "
168
+ "then dry.\n"
169
+ "3. If emulsion-side: do not wipe. Use Print File archival "
170
+ "sleeves and ship to a lab with wet-mount scanning (e.g., "
171
+ "Northcoast Photographic).\n"
172
+ "4. The single scratch is most likely from a dirty film "
173
+ "transport. Have the scanner cleaned and the film path "
174
+ "inspected; consider wet-gate scanning to mask the scratch.\n"
175
+ "5. Long-term: transfer remaining unexposed rolls to a "
176
+ "freezer at -18C in sealed bags with desiccant.\n\n"
177
+ "## Confidence\n"
178
+ "Medium. The metadata supports storage stress, but the side of the "
179
+ "scratch and hair must be confirmed under magnification.\n\n"
180
+ "## Next inspection\n"
181
+ "Inspect both base and emulsion sides and note which defects stay "
182
+ "fixed after a gentle air blow."
183
+ ),
184
+ },
185
+ {
186
+ "role": "user",
187
+ "content": (
188
+ "## Defect report\n"
189
+ "{\n"
190
+ ' "film_type": "CineStill 800T (35mm)",\n'
191
+ ' "film_age_years": 0,\n'
192
+ ' "storage": "fresh, refrigerated by manufacturer",\n'
193
+ ' "defects": [\n'
194
+ ' {"label": "dust", "bbox_count": 156},\n'
195
+ ' {"label": "dirt", "bbox_count": 23},\n'
196
+ ' {"label": "short_hair", "bbox_count": 8}\n'
197
+ ' ],\n'
198
+ ' "scan_resolution_dpi": 6000\n'
199
+ "}\n\n"
200
+ "What is the root cause and what physical fixes do you recommend?"
201
+ ),
202
+ },
203
+ {
204
+ "role": "assistant",
205
+ "content": (
206
+ "## Root cause\n"
207
+ "Excessive dust and dirt on a brand-new refrigerated roll at 6000 "
208
+ "dpi scan suggests a combination of static during handling and "
209
+ "scanner-side contamination. The hair count points to the "
210
+ "scanner's film transport path. Because the current film type is "
211
+ "CineStill 800T, anti-static handling is especially worth testing "
212
+ "on a re-scan.\n\n"
213
+ "## Evidence\n"
214
+ "- The film type is CineStill 800T, so static-prone handling is "
215
+ "relevant for this case.\n"
216
+ "- The film is fresh and refrigerated, which makes chemical age "
217
+ "damage unlikely.\n"
218
+ "- Short hairs point to the holder or transport path.\n\n"
219
+ "## Physical fixes\n"
220
+ "1. Test anti-static treatment: discharge "
221
+ "the negative with a Zerostat gun on low setting 30 cm from the "
222
+ "film before scanning.\n"
223
+ "2. Clean the scanner glass, film holder, and feed rollers with "
224
+ "PEC*PAD swabs and reagent-grade isopropyl alcohol.\n"
225
+ "3. Use a static-discharge ionizing bar (e.g., Simco-Ion) at the "
226
+ "scanner input if available.\n"
227
+ "4. If the scanner and film workflow support hardware dust "
228
+ "removal, test it on one crop at moderate strength before a full "
229
+ "batch.\n"
230
+ "5. For the short hairs, inspect the film path under magnification "
231
+ "and remove any visible lint from the rollers with tweezers.\n\n"
232
+ "## Confidence\n"
233
+ "Medium-high because the metadata and defect mix are consistent.\n\n"
234
+ "## Next inspection\n"
235
+ "Re-scan after anti-static treatment. If the dust count drops, "
236
+ "static and handling were major contributors."
237
+ ),
238
+ },
239
+ ]
240
+
241
+
242
+ def build_user_prompt(
243
+ film_type: str,
244
+ film_age_years: int,
245
+ storage: str,
246
+ scan_resolution_dpi: int,
247
+ defect_summary: dict[str, int],
248
+ total_defects: int,
249
+ spatial_evidence: dict[str, Any] | None = None,
250
+ ) -> str:
251
+ """Build the user message for the current diagnosis request."""
252
+ payload = {
253
+ "film_type": film_type,
254
+ "film_age_years": film_age_years,
255
+ "storage": storage,
256
+ "defects": [
257
+ {"label": label, "bbox_count": count}
258
+ for label, count in sorted(defect_summary.items())
259
+ ],
260
+ "scan_resolution_dpi": scan_resolution_dpi,
261
+ "total_defect_count": total_defects,
262
+ "spatial_evidence": spatial_evidence or {},
263
+ }
264
+ return (
265
+ "## Defect report\n"
266
+ f"{json.dumps(payload, indent=2)}\n\n"
267
+ "What is the root cause and what physical fixes do you recommend?"
268
+ )
269
+
270
+
271
+ def build_messages(
272
+ film_type: str,
273
+ film_age_years: int,
274
+ storage: str,
275
+ scan_resolution_dpi: int,
276
+ defect_summary: dict[str, int],
277
+ total_defects: int,
278
+ spatial_evidence: dict[str, Any] | None = None,
279
+ ) -> list[dict[str, str]]:
280
+ """Return full message list for the reasoner.
281
+
282
+ The list is structured for `tokenizer.apply_chat_template`:
283
+ [system, user, assistant, user, assistant, user, assistant, user]
284
+ The system prompt is index 0, the three few-shot examples follow as
285
+ (user, assistant) pairs, and the final user message is the actual
286
+ defect report. This preserves the role structure that
287
+ `Nemotron-Mini-4B-Instruct` was trained on.
288
+ """
289
+ messages: list[dict[str, str]] = [
290
+ {"role": "system", "content": SYSTEM_PROMPT},
291
+ ]
292
+ messages.extend(FEW_SHOT_EXAMPLES)
293
+ messages.append(
294
+ {
295
+ "role": "user",
296
+ "content": build_user_prompt(
297
+ film_type,
298
+ film_age_years,
299
+ storage,
300
+ scan_resolution_dpi,
301
+ defect_summary,
302
+ total_defects,
303
+ spatial_evidence=spatial_evidence,
304
+ ),
305
+ }
306
+ )
307
+ 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/inference.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vision inference pipeline. Takes a film scan and returns defect JSON."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from data.schemas import clean_defects, dedupe_defects, label_counts
10
+ from data.preprocessing import load_image
11
+ from models.vision.minicpm_wrapper import get_detector
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def extract_defects(image: Any) -> dict:
17
+ """Run defect extraction on a PIL image. Returns defect dict + metadata."""
18
+ started = time.perf_counter()
19
+ detector = get_detector()
20
+ raw = detector.detect(load_image(image))
21
+ elapsed = time.perf_counter() - started
22
+
23
+ if not isinstance(raw, dict):
24
+ logger.warning("Model output is not a dict: %r", type(raw))
25
+ raw = {"defects": [], "_parse_error": "non_dict_output"}
26
+
27
+ cleaned, dropped = clean_defects(raw.get("defects", []))
28
+ cleaned, duplicate_count = dedupe_defects(cleaned)
29
+ counts = label_counts(cleaned)
30
+
31
+ return {
32
+ "defects": cleaned,
33
+ "defect_count": len(cleaned),
34
+ "label_counts": counts,
35
+ "dropped_count": dropped,
36
+ "duplicate_count": duplicate_count,
37
+ "inference_seconds": round(elapsed, 3),
38
+ "model_path": detector.model_path,
39
+ "parse_error": raw.get("_parse_error"),
40
+ }
41
+
42
+
43
+ def extract_defects_from_path(image_path: str | Path) -> dict:
44
+ """Convenience: open image from path and run extraction."""
45
+ img = load_image(image_path)
46
+ return extract_defects(img)
models/vision/minicpm_wrapper.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 typing import Any
10
+
11
+ from config import CHECKPOINT_DIR, get_vision_config, require_gpu_for_inference
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DETECTION_PROMPT = (
16
+ "You are a film defect detection engine. Analyze the film scan and detect "
17
+ "all visible defects. Output a JSON object with a 'defects' array. Each "
18
+ "defect has: 'label' (dust, dirt, scratch, long_hair, short_hair), "
19
+ "'bbox' as 4 integers in the [0, 999] grid "
20
+ "[x_min, y_min, x_max, y_max] (multiply by image width/height to get pixels). "
21
+ "Return at most 150 defects. Prefer the clearest defects. Do not repeat "
22
+ "the same label and bbox. "
23
+ "Output JSON only, no explanation."
24
+ )
25
+
26
+
27
+ def _resolve_model_path() -> str:
28
+ """Pick configured fine-tuned model or public base model."""
29
+ cfg = get_vision_config()
30
+ explicit = os.getenv("HALIDE_VISION_MODEL_ID")
31
+ if explicit:
32
+ logger.info("Using explicit vision model %s", explicit)
33
+ return explicit
34
+
35
+ if cfg.use_finetuned:
36
+ local_candidates = [
37
+ cfg.local_model_path,
38
+ CHECKPOINT_DIR / "minicpm-v-4.6-merged",
39
+ ]
40
+ seen: set[str] = set()
41
+ for path in local_candidates:
42
+ key = str(path.resolve())
43
+ if key in seen:
44
+ continue
45
+ seen.add(key)
46
+ if path.exists() and (path / "config.json").exists():
47
+ logger.info("Using local fine-tuned vision model at %s", path)
48
+ return str(path)
49
+ logger.info("Using fine-tuned vision model repo %s", cfg.finetuned_model_id)
50
+ return cfg.finetuned_model_id
51
+
52
+ logger.info("Using base vision model %s", cfg.base_model_id)
53
+ return cfg.base_model_id
54
+
55
+
56
+ class MiniCPMVDetector:
57
+ """Lazy-loading wrapper around MiniCPM-V 4.6 for film defect detection."""
58
+
59
+ def __init__(self, model_path: str | None = None) -> None:
60
+ self._model_path = model_path or _resolve_model_path()
61
+ self._model: Any = None
62
+ self._processor: Any = None
63
+ self._dtype: Any = None
64
+ self._device: str = "cpu"
65
+
66
+ @property
67
+ def model_path(self) -> str:
68
+ return self._model_path
69
+
70
+ def load(self) -> None:
71
+ if self._model is not None:
72
+ return
73
+ require_gpu_for_inference("vision")
74
+ import torch
75
+ from transformers import AutoModelForImageTextToText, AutoProcessor
76
+
77
+ logger.info("Loading MiniCPM-V 4.6 from %s", self._model_path)
78
+ self._processor = AutoProcessor.from_pretrained(
79
+ self._model_path, trust_remote_code=True
80
+ )
81
+ self._dtype = _select_cuda_dtype(torch)
82
+ self._model = AutoModelForImageTextToText.from_pretrained(
83
+ self._model_path,
84
+ torch_dtype=self._dtype,
85
+ device_map="auto",
86
+ trust_remote_code=True,
87
+ )
88
+ self._device = str(next(self._model.parameters()).device)
89
+ logger.info("Model loaded on %s with dtype %s", self._device, self._dtype)
90
+
91
+ def detect(self, image: Any) -> dict:
92
+ """Run defect detection on a PIL image. Returns parsed JSON dict."""
93
+ import torch
94
+
95
+ if self._model is None:
96
+ self.load()
97
+
98
+ cfg = get_vision_config()
99
+ messages = [
100
+ {
101
+ "role": "user",
102
+ "content": [
103
+ {"type": "image", "image": image},
104
+ {"type": "text", "text": DETECTION_PROMPT},
105
+ ],
106
+ }
107
+ ]
108
+
109
+ inputs = _apply_chat_template(
110
+ self._processor,
111
+ messages,
112
+ downsample_mode=cfg.downsample_mode,
113
+ max_slice_nums=cfg.max_slice_nums,
114
+ ).to(self._device)
115
+
116
+ with torch.inference_mode():
117
+ generated = self._model.generate(
118
+ **inputs,
119
+ downsample_mode=cfg.downsample_mode,
120
+ max_new_tokens=cfg.max_new_tokens,
121
+ do_sample=False,
122
+ )
123
+
124
+ trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated)]
125
+ text = self._processor.batch_decode(
126
+ trimmed,
127
+ skip_special_tokens=True,
128
+ clean_up_tokenization_spaces=False,
129
+ )[0]
130
+
131
+ return _parse_defect_json(text)
132
+
133
+ def close(self) -> None:
134
+ if self._model is not None:
135
+ del self._model
136
+ self._model = None
137
+ if self._processor is not None:
138
+ del self._processor
139
+ self._processor = None
140
+
141
+
142
+ def _parse_defect_json(text: str) -> dict:
143
+ """Extract and parse the first JSON object from model output."""
144
+ text = text.strip()
145
+ if text.startswith("```"):
146
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
147
+ text = re.sub(r"\s*```$", "", text)
148
+ try:
149
+ parsed = json.loads(text)
150
+ if isinstance(parsed, list):
151
+ return {"defects": parsed}
152
+ if isinstance(parsed, dict):
153
+ return parsed
154
+ return {"defects": [], "_raw": text, "_parse_error": "json_not_object"}
155
+ except json.JSONDecodeError:
156
+ pass
157
+
158
+ match = re.search(r"\{[\s\S]*\}", text)
159
+ if not match:
160
+ logger.warning("No JSON found in model output: %r", text[:200])
161
+ return {"defects": [], "_raw": text, "_parse_error": "no_json_object"}
162
+ try:
163
+ parsed = json.loads(match.group(0))
164
+ if isinstance(parsed, dict):
165
+ return parsed
166
+ return {"defects": [], "_raw": text, "_parse_error": "json_not_object"}
167
+ except json.JSONDecodeError as exc:
168
+ fragments = _parse_defect_fragments(text)
169
+ if fragments:
170
+ logger.warning(
171
+ "Salvaged %s defect fragments from malformed JSON: %s",
172
+ len(fragments),
173
+ exc,
174
+ )
175
+ return {
176
+ "defects": fragments,
177
+ "_parse_error": str(exc),
178
+ "_parse_warning": "salvaged_defect_fragments",
179
+ }
180
+ logger.warning("JSON parse error: %s; raw: %r", exc, text[:200])
181
+ return {"defects": [], "_raw": text, "_parse_error": str(exc)}
182
+
183
+
184
+ def _parse_defect_fragments(text: str) -> list[dict[str, Any]]:
185
+ """Recover complete defect objects from truncated JSON arrays."""
186
+ fragments: list[dict[str, Any]] = []
187
+ for match in re.finditer(r"\{[^{}]*\"label\"[^{}]*\"bbox\"\s*:\s*\[[^\]]+\][^{}]*\}", text):
188
+ try:
189
+ candidate = json.loads(match.group(0))
190
+ except json.JSONDecodeError:
191
+ continue
192
+ if isinstance(candidate, dict):
193
+ fragments.append(candidate)
194
+ return fragments
195
+
196
+
197
+ def _apply_chat_template(
198
+ processor: Any,
199
+ messages: list[dict],
200
+ *,
201
+ downsample_mode: str,
202
+ max_slice_nums: int,
203
+ ) -> Any:
204
+ """Call MiniCPM chat template across Transformers API variants."""
205
+ kwargs = {
206
+ "tokenize": True,
207
+ "add_generation_prompt": True,
208
+ "return_dict": True,
209
+ "return_tensors": "pt",
210
+ }
211
+ try:
212
+ return processor.apply_chat_template(
213
+ messages,
214
+ **kwargs,
215
+ downsample_mode=downsample_mode,
216
+ max_slice_nums=max_slice_nums,
217
+ )
218
+ except TypeError:
219
+ return processor.apply_chat_template(
220
+ messages,
221
+ **kwargs,
222
+ processor_kwargs={
223
+ "downsample_mode": downsample_mode,
224
+ "max_slice_nums": max_slice_nums,
225
+ },
226
+ )
227
+
228
+
229
+ def _select_cuda_dtype(torch_module: Any) -> Any:
230
+ major, _minor = torch_module.cuda.get_device_capability()
231
+ if major >= 8:
232
+ return torch_module.bfloat16
233
+ return torch_module.float16
234
+
235
+
236
+ _default_detector: MiniCPMVDetector | None = None
237
+
238
+
239
+ def get_detector() -> MiniCPMVDetector:
240
+ global _default_detector
241
+ if _default_detector is None:
242
+ _default_detector = MiniCPMVDetector()
243
+ return _default_detector
pipeline/.gitkeep ADDED
File without changes
pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Pipeline package. Orchestrates vision + reasoning stages."""
pipeline/diagnoser.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 data.schemas import spatial_summary
10
+ from models.reasoning.nemotron_wrapper import get_reasoner
11
+ from models.reasoning.prompts import SYSTEM_PROMPT, build_messages
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def diagnose(
17
+ defect_result: dict,
18
+ film_type: str,
19
+ film_age_years: int,
20
+ storage: str,
21
+ scan_resolution_dpi: int,
22
+ ) -> dict:
23
+ """Run Nemotron reasoning over a defect result + user metadata.
24
+
25
+ Returns a dict with the raw text response and timing metadata.
26
+ """
27
+ started = time.perf_counter()
28
+ reasoner = get_reasoner()
29
+
30
+ label_counts = defect_result.get("label_counts", {}) or {}
31
+ defects = defect_result.get("defects", []) or []
32
+ total = defect_result.get("defect_count", 0) or sum(label_counts.values())
33
+ spatial = spatial_summary(defects)
34
+
35
+ messages = build_messages(
36
+ film_type=film_type,
37
+ film_age_years=film_age_years,
38
+ storage=storage,
39
+ scan_resolution_dpi=scan_resolution_dpi,
40
+ defect_summary=label_counts,
41
+ total_defects=total,
42
+ spatial_evidence=spatial,
43
+ )
44
+
45
+ logger.info(
46
+ "Running Nemotron diagnosis (film=%s, age=%d, storage=%s, total_defects=%d)",
47
+ film_type, film_age_years, storage, total,
48
+ )
49
+ text = reasoner.generate(messages=messages)
50
+ elapsed = time.perf_counter() - started
51
+
52
+ return {
53
+ "diagnosis_text": text,
54
+ "reasoning_seconds": round(elapsed, 3),
55
+ "model_path": reasoner.model_path,
56
+ "system_prompt": SYSTEM_PROMPT,
57
+ "input_defect_summary": {
58
+ "label_counts": label_counts,
59
+ "total": total,
60
+ "spatial_evidence": spatial,
61
+ },
62
+ }
63
+
64
+
65
+ __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,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=6.10.0
2
+ spaces>=0.40.0
3
+ torch>=2.4.0
4
+ torchvision>=0.19.0
5
+ transformers>=5.7.0
6
+ accelerate>=1.8.0
7
+ huggingface_hub>=0.20.0
8
+ pillow>=10.0.0
9
+ safetensors>=0.4.0
10
+ 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/cache.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 logging
12
+ import time
13
+ from collections import OrderedDict
14
+ from threading import Lock
15
+ from typing import Any
16
+
17
+ from config import get_app_config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class DiagnosisCache:
23
+ """Thread-safe LRU cache for diagnosis results."""
24
+
25
+ def __init__(self, max_size: int = 64, ttl_seconds: int = 3600) -> None:
26
+ self._max_size = max_size
27
+ self._ttl = ttl_seconds
28
+ self._store: OrderedDict[str, tuple[float, dict]] = OrderedDict()
29
+ self._lock = Lock()
30
+ self._hits = 0
31
+ self._misses = 0
32
+
33
+ @staticmethod
34
+ def hash_image(image_bytes: bytes) -> str:
35
+ return hashlib.sha256(image_bytes).hexdigest()
36
+
37
+ def get(self, image_bytes: bytes) -> dict | None:
38
+ key = self.hash_image(image_bytes)
39
+ now = time.time()
40
+ with self._lock:
41
+ entry = self._store.get(key)
42
+ if entry is None:
43
+ self._misses += 1
44
+ return None
45
+ ts, value = entry
46
+ if now - ts > self._ttl:
47
+ del self._store[key]
48
+ self._misses += 1
49
+ return None
50
+ self._store.move_to_end(key)
51
+ self._hits += 1
52
+ logger.info("Cache hit for %s", key[:12])
53
+ return value
54
+
55
+ def put(self, image_bytes: bytes, value: dict) -> None:
56
+ key = self.hash_image(image_bytes)
57
+ now = time.time()
58
+ with self._lock:
59
+ self._store[key] = (now, value)
60
+ self._store.move_to_end(key)
61
+ while len(self._store) > self._max_size:
62
+ self._store.popitem(last=False)
63
+
64
+ def stats(self) -> dict:
65
+ with self._lock:
66
+ return {
67
+ "size": len(self._store),
68
+ "max_size": self._max_size,
69
+ "hits": self._hits,
70
+ "misses": self._misses,
71
+ }
72
+
73
+ def clear(self) -> None:
74
+ with self._lock:
75
+ self._store.clear()
76
+ self._hits = 0
77
+ self._misses = 0
78
+
79
+
80
+ _default_cache: DiagnosisCache | None = None
81
+
82
+
83
+ def get_cache() -> DiagnosisCache:
84
+ global _default_cache
85
+ if _default_cache is None:
86
+ cfg = get_app_config()
87
+ _default_cache = DiagnosisCache(
88
+ max_size=cfg.cache_size,
89
+ ttl_seconds=cfg.cache_ttl_seconds,
90
+ )
91
+ return _default_cache
92
+
93
+
94
+ __all__ = ["DiagnosisCache", "get_cache"]
storage/database.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from config import get_app_config
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parents[1]
26
+ DEFAULT_DB_PATH = REPO_ROOT / "storage" / "halide.db"
27
+ _INITIALIZED_DB_PATHS: set[Path] = set()
28
+
29
+
30
+ def get_db_path() -> Path:
31
+ db_path = get_app_config().db_path
32
+ db_path.parent.mkdir(parents=True, exist_ok=True)
33
+ return 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
+ conn.execute("PRAGMA foreign_keys = ON")
73
+ conn.execute("PRAGMA journal_mode = WAL")
74
+ try:
75
+ yield conn
76
+ conn.commit()
77
+ finally:
78
+ conn.close()
79
+
80
+
81
+ def init_db() -> None:
82
+ with connect() as conn:
83
+ conn.executescript(SCHEMA)
84
+ db_path = get_db_path()
85
+ _INITIALIZED_DB_PATHS.add(db_path)
86
+ logger.info("DB initialized at %s", db_path)
87
+
88
+
89
+ def _ensure_db_initialized() -> None:
90
+ db_path = get_db_path()
91
+ if db_path in _INITIALIZED_DB_PATHS and db_path.exists():
92
+ return
93
+ init_db()
94
+
95
+
96
+ def record_diagnosis(result: dict) -> str:
97
+ """Persist a full pipeline result. Returns the diagnosis id."""
98
+ _ensure_db_initialized()
99
+ diagnosis_id = str(uuid.uuid4())
100
+ session_id = str(uuid.uuid4())
101
+ now = time.time()
102
+
103
+ meta = result.get("film_metadata", {}) or {}
104
+ defects = result.get("defects", {}) or {}
105
+ diagnosis = result.get("diagnosis", {}) or {}
106
+
107
+ with connect() as conn:
108
+ conn.execute(
109
+ """
110
+ INSERT INTO sessions (id, started_at, film_type, film_age_years,
111
+ storage, scan_dpi)
112
+ VALUES (?, ?, ?, ?, ?, ?)
113
+ """,
114
+ (
115
+ session_id,
116
+ now,
117
+ meta.get("film_type", "Unknown"),
118
+ int(meta.get("film_age_years", 0) or 0),
119
+ meta.get("storage", "unknown"),
120
+ int(meta.get("scan_resolution_dpi", 0) or 0),
121
+ ),
122
+ )
123
+ conn.execute(
124
+ """
125
+ INSERT INTO diagnoses (id, session_id, created_at, defect_count,
126
+ label_counts_json, diagnosis_text,
127
+ vision_seconds, reasoning_seconds,
128
+ total_seconds, vision_model, reasoning_model,
129
+ raw_json)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
131
+ """,
132
+ (
133
+ diagnosis_id,
134
+ session_id,
135
+ now,
136
+ int(defects.get("defect_count", 0) or 0),
137
+ json.dumps(defects.get("label_counts", {}) or {}),
138
+ diagnosis.get("diagnosis_text", ""),
139
+ float(defects.get("inference_seconds", 0.0) or 0.0),
140
+ float(diagnosis.get("reasoning_seconds", 0.0) or 0.0),
141
+ float(result.get("total_seconds", 0.0) or 0.0),
142
+ defects.get("model_path", ""),
143
+ diagnosis.get("model_path", ""),
144
+ json.dumps(result),
145
+ ),
146
+ )
147
+ logger.info("Recorded diagnosis %s (session %s)", diagnosis_id, session_id)
148
+ return diagnosis_id
149
+
150
+
151
+ def list_recent(limit: int = 20) -> list[dict]:
152
+ _ensure_db_initialized()
153
+ with connect() as conn:
154
+ rows = conn.execute(
155
+ """
156
+ SELECT d.id, d.created_at, s.film_type, s.film_age_years,
157
+ s.storage, s.scan_dpi, d.defect_count, d.label_counts_json,
158
+ d.diagnosis_text, d.total_seconds
159
+ FROM diagnoses d
160
+ JOIN sessions s ON s.id = d.session_id
161
+ ORDER BY d.created_at DESC
162
+ LIMIT ?
163
+ """,
164
+ (limit,),
165
+ ).fetchall()
166
+ out: list[dict] = []
167
+ for r in rows:
168
+ out.append(
169
+ {
170
+ "id": r["id"],
171
+ "created_at": r["created_at"],
172
+ "film_type": r["film_type"],
173
+ "film_age_years": r["film_age_years"],
174
+ "storage": r["storage"],
175
+ "scan_dpi": r["scan_dpi"],
176
+ "defect_count": r["defect_count"],
177
+ "label_counts": json.loads(r["label_counts_json"]),
178
+ "diagnosis_text": r["diagnosis_text"],
179
+ "total_seconds": r["total_seconds"],
180
+ }
181
+ )
182
+ return out
183
+
184
+
185
+ __all__ = [
186
+ "init_db",
187
+ "record_diagnosis",
188
+ "list_recent",
189
+ "get_db_path",
190
+ ]
ui/.gitkeep ADDED
File without changes
ui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """UI package. Gradio app, theme, and components."""
ui/app.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app. Main UI definition and layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ import gradio as gr
11
+
12
+ from config import get_app_config
13
+ from data.preprocessing import draw_defects, image_to_png_bytes, load_image
14
+ from pipeline.pipeline import run_diagnosis
15
+ from storage.cache import get_cache
16
+ from storage.database import init_db, list_recent, record_diagnosis
17
+ from ui.components import (
18
+ HEADER_HTML,
19
+ defect_pills_html,
20
+ diagnosis_html,
21
+ render_history,
22
+ stats_html,
23
+ )
24
+ from ui.theme import THEME_CSS, build_theme
25
+
26
+ logger = logging.getLogger(__name__)
27
+ logging.basicConfig(level=logging.INFO)
28
+
29
+
30
+ def _gpu_decorator():
31
+ try:
32
+ import spaces
33
+ except ImportError:
34
+ return lambda fn: fn
35
+ return spaces.GPU(duration=get_app_config().gpu_duration_seconds)
36
+
37
+
38
+ DEFAULT_FILM_TYPES = [
39
+ "Kodak Portra 400 (35mm)",
40
+ "Kodak Tri-X 400 (35mm)",
41
+ "Kodak Ektar 100 (35mm)",
42
+ "Ilford HP5 Plus (35mm)",
43
+ "Ilford Delta 100 (35mm)",
44
+ "Ilford FP4 Plus (120)",
45
+ "CineStill 800T (35mm)",
46
+ "Fujifilm Pro 400H (35mm)",
47
+ "Fomapan 400 (35mm)",
48
+ "Other / Unknown",
49
+ ]
50
+
51
+ STORAGE_OPTIONS = [
52
+ "fridge, sealed",
53
+ "freezer, sealed",
54
+ "room temp, sealed",
55
+ "room temp, loose",
56
+ "shoe box, attic",
57
+ "shoe box, basement",
58
+ "unknown",
59
+ ]
60
+
61
+ RESOLUTION_OPTIONS = [2000, 3000, 4000, 5000, 6000, 8000]
62
+
63
+
64
+ @_gpu_decorator()
65
+ def run_pipeline(
66
+ image: Any,
67
+ film_type: str,
68
+ film_age_years: int,
69
+ storage: str,
70
+ scan_dpi: int,
71
+ progress: gr.Progress = gr.Progress(),
72
+ ) -> tuple[Any, str, str, str, str, str]:
73
+ """Gradio handler for the diagnose button."""
74
+ if image is None:
75
+ empty = '<p style="color: var(--halide-crimson);">No image provided.</p>'
76
+ return None, empty, empty, empty, "{}", render_history(list_recent(limit=10))
77
+
78
+ try:
79
+ progress(0.0, "Hashing image for cache lookup...")
80
+ pil_image = load_image(image)
81
+ cache = get_cache()
82
+ image_bytes = image_to_png_bytes(pil_image)
83
+ cached = cache.get(image_bytes)
84
+ if cached is not None:
85
+ logger.info("Returning cached diagnosis")
86
+ result = cached
87
+ else:
88
+ progress(0.1, "Stage 1/2: running vision defect extraction...")
89
+ result = run_diagnosis(
90
+ image=pil_image,
91
+ film_type=film_type or "Unknown 35mm",
92
+ film_age_years=int(film_age_years or 0),
93
+ storage=storage or "unknown",
94
+ scan_resolution_dpi=int(scan_dpi or 4000),
95
+ )
96
+ progress(0.85, "Stage 2/2: persisting diagnosis...")
97
+ try:
98
+ record_diagnosis(result)
99
+ except Exception as exc: # pragma: no cover
100
+ logger.warning("Failed to record diagnosis: %s", exc)
101
+ cache.put(image_bytes, result)
102
+
103
+ progress(1.0, "Done.")
104
+
105
+ counts = result.get("defects", {}).get("label_counts", {}) or {}
106
+ defects = result.get("defects", {}).get("defects", []) or []
107
+ annotated = draw_defects(
108
+ pil_image,
109
+ defects,
110
+ title=f"Halide: {len(defects)} validated defects",
111
+ )
112
+ stats = stats_html(result)
113
+ pills = defect_pills_html(counts)
114
+ diag = diagnosis_html(result.get("diagnosis", {}).get("diagnosis_text", ""))
115
+ raw_json = json.dumps(result, indent=2, sort_keys=True)
116
+ history = render_history(list_recent(limit=get_app_config().max_history_items))
117
+ return annotated, stats, pills, diag, raw_json, history
118
+ except Exception as exc: # pragma: no cover
119
+ logger.exception("Pipeline failed")
120
+ err = (
121
+ '<div class="halide-panel" style="border-color: var(--halide-crimson);">'
122
+ f'<div class="halide-section-title" style="color: var(--halide-red);">'
123
+ f"Pipeline error</div>"
124
+ f"<pre style=\"color: var(--halide-parchment); white-space: pre-wrap;\">"
125
+ f"{html.escape(str(exc))}</pre></div>"
126
+ )
127
+ return None, err, "", "", "{}", render_history(list_recent(limit=10))
128
+
129
+
130
+ def refresh_history() -> str:
131
+ return render_history(list_recent(limit=get_app_config().max_history_items))
132
+
133
+
134
+ def build_app() -> gr.Blocks:
135
+ init_db()
136
+ theme = build_theme()
137
+
138
+ with gr.Blocks(title="Project Halide") as app:
139
+ gr.HTML(HEADER_HTML)
140
+
141
+ with gr.Row():
142
+ with gr.Column(scale=1):
143
+ with gr.Group(elem_classes="halide-panel"):
144
+ gr.Markdown('<div class="halide-section-title">Scan upload</div>')
145
+ image_input = gr.Image(
146
+ label="Film scan",
147
+ type="pil",
148
+ height=380,
149
+ sources=["upload", "clipboard"],
150
+ )
151
+
152
+ with gr.Group(elem_classes="halide-panel"):
153
+ gr.Markdown('<div class="halide-section-title">Film metadata</div>')
154
+ film_type = gr.Dropdown(
155
+ choices=DEFAULT_FILM_TYPES,
156
+ value=DEFAULT_FILM_TYPES[0],
157
+ label="Film stock",
158
+ )
159
+ with gr.Row():
160
+ film_age = gr.Slider(
161
+ minimum=0,
162
+ maximum=80,
163
+ step=1,
164
+ value=2,
165
+ label="Age (years)",
166
+ )
167
+ scan_dpi = gr.Dropdown(
168
+ choices=RESOLUTION_OPTIONS,
169
+ value=4000,
170
+ label="Scan resolution (dpi)",
171
+ )
172
+ storage = gr.Radio(
173
+ choices=STORAGE_OPTIONS,
174
+ value=STORAGE_OPTIONS[0],
175
+ label="Storage condition",
176
+ )
177
+
178
+ run_btn = gr.Button("Diagnose scan", variant="primary", size="lg")
179
+
180
+ with gr.Column(scale=2):
181
+ with gr.Group(elem_classes="halide-panel"):
182
+ gr.Markdown('<div class="halide-section-title">Annotated scan</div>')
183
+ annotated_output = gr.Image(
184
+ label="Defect overlay",
185
+ type="pil",
186
+ height=430,
187
+ interactive=False,
188
+ )
189
+
190
+ with gr.Group(elem_classes="halide-panel"):
191
+ gr.Markdown('<div class="halide-section-title">Defect summary</div>')
192
+ defect_summary = gr.HTML(
193
+ value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
194
+ )
195
+
196
+ with gr.Group(elem_classes="halide-panel"):
197
+ gr.Markdown('<div class="halide-section-title">Diagnosis & fixes</div>')
198
+ diagnosis_output = gr.HTML(
199
+ value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
200
+ )
201
+
202
+ with gr.Group(elem_classes="halide-panel"):
203
+ gr.Markdown('<div class="halide-section-title">Session stats</div>')
204
+ stats_output = gr.HTML(
205
+ value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
206
+ )
207
+
208
+ with gr.Column(scale=1):
209
+ with gr.Group(elem_classes="halide-panel"):
210
+ gr.Markdown('<div class="halide-section-title">Recent diagnoses</div>')
211
+ history_output = gr.HTML(value=refresh_history())
212
+ refresh_btn = gr.Button("Refresh history", size="sm")
213
+
214
+ with gr.Group(elem_classes="halide-panel"):
215
+ gr.Markdown('<div class="halide-section-title">Structured result</div>')
216
+ raw_output = gr.Code(value="{}", language="json", label="Pipeline JSON")
217
+
218
+ gr.HTML(
219
+ "<footer>Project Halide. Edge-native, no cloud APIs. "
220
+ "Vision: MiniCPM-V 4.6 (1.3B). Reasoning: Nemotron-Mini-4B-Instruct (few-shot).</footer>"
221
+ )
222
+
223
+ run_btn.click(
224
+ fn=run_pipeline,
225
+ inputs=[image_input, film_type, film_age, storage, scan_dpi],
226
+ outputs=[
227
+ annotated_output,
228
+ stats_output,
229
+ defect_summary,
230
+ diagnosis_output,
231
+ raw_output,
232
+ history_output,
233
+ ],
234
+ )
235
+ refresh_btn.click(fn=refresh_history, outputs=[history_output])
236
+
237
+ return app
238
+
239
+
240
+ def main() -> None:
241
+ app = build_app()
242
+ app.queue(max_size=8).launch(
243
+ server_name="0.0.0.0",
244
+ server_port=7860,
245
+ theme=build_theme(),
246
+ css=THEME_CSS,
247
+ )
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
ui/components.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI components. Defect list rendering and shared visual helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import time
7
+ from typing import Iterable
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 {html.escape(label)}">'
26
+ f"{html.escape(label)}: {int(count)}</span>"
27
+ )
28
+ return '<div>' + "".join(pills) + "</div>"
29
+
30
+
31
+ def stats_html(result: dict) -> str:
32
+ """Render a stats card with defect counts and timing."""
33
+ defects = result.get("defects", {}) or {}
34
+ diagnosis = result.get("diagnosis", {}) or {}
35
+ total = result.get("total_seconds", 0.0) or 0.0
36
+ vision_s = defects.get("inference_seconds", 0.0) or 0.0
37
+ reasoning_s = diagnosis.get("reasoning_seconds", 0.0) or 0.0
38
+
39
+ rows: list[str] = []
40
+ rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
41
+ rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
42
+ rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
43
+ rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
44
+ rows.append(_stat_row("Total", f"{total:.2f}s"))
45
+ rows.append(_stat_row("Vision model", _truncate(defects.get("model_path", ""), 50)))
46
+ rows.append(_stat_row("Reasoning model", _truncate(diagnosis.get("model_path", ""), 50)))
47
+ return f'<div class="halide-stats">{"".join(rows)}</div>'
48
+
49
+
50
+ def _stat_row(label: str, value: str) -> str:
51
+ return (
52
+ '<div class="halide-stat">'
53
+ f'<span class="halide-stat-label">{html.escape(label)}</span>'
54
+ f'<span>{html.escape(value)}</span>'
55
+ "</div>"
56
+ )
57
+
58
+
59
+ def _truncate(s: str, n: int) -> str:
60
+ if len(s) <= n:
61
+ return s
62
+ return "..." + s[-(n - 3):]
63
+
64
+
65
+ def diagnosis_html(text: str) -> str:
66
+ """Wrap diagnosis text in the styled card."""
67
+ safe = html.escape(text or "(no diagnosis produced)").replace("\n", "<br>")
68
+ return f'<div class="halide-diagnosis">{safe}</div>'
69
+
70
+
71
+ def history_row_html(entry: dict) -> str:
72
+ """Render a single row in the recent-diagnoses sidebar."""
73
+ counts = entry.get("label_counts", {}) or {}
74
+ total = entry.get("defect_count", 0) or 0
75
+ film = entry.get("film_type", "Unknown")
76
+ age = entry.get("film_age_years", "?")
77
+ storage = entry.get("storage", "?")
78
+ ts = entry.get("created_at", 0)
79
+ seconds = entry.get("total_seconds", 0.0) or 0.0
80
+ stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts or 0)))
81
+ return (
82
+ f'<div class="halide-history-item">'
83
+ f'<div class="halide-section-title" style="font-size: 0.95rem;">'
84
+ f"{html.escape(str(film))} (age {html.escape(str(age))}y, "
85
+ f"{html.escape(str(storage))})</div>"
86
+ f"{defect_pills_html(counts)}"
87
+ f'<div style="color: var(--halide-slate); font-size: 0.8rem; margin-top: 0.4rem;">'
88
+ f"defects: {int(total)} | {seconds:.2f}s | {html.escape(stamp)}"
89
+ f"</div></div>"
90
+ )
91
+
92
+
93
+ def render_history(entries: Iterable[dict]) -> str:
94
+ items = "".join(history_row_html(e) for e in entries)
95
+ if not items:
96
+ return '<p style="color: var(--halide-slate);">No diagnoses yet.</p>'
97
+ return items
98
+
99
+
100
+ __all__ = [
101
+ "HEADER_HTML",
102
+ "defect_pills_html",
103
+ "stats_html",
104
+ "diagnosis_html",
105
+ "render_history",
106
+ ]
ui/server.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio Server wrapper for Project Halide."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gradio as gr
6
+
7
+ from config import CANONICAL_VISION_MODEL_ID, DEFAULT_REASONING_MODEL_ID
8
+ from ui.app import build_app
9
+ from ui.theme import THEME_CSS, build_theme
10
+
11
+
12
+ def build_server(blocks: gr.Blocks | None = None) -> gr.Server:
13
+ """Build a gr.Server with the UI mounted at root and health metadata."""
14
+ server = gr.Server(
15
+ title="Project Halide",
16
+ version="0.1.0",
17
+ description="Edge-native analog film diagnostic engine.",
18
+ )
19
+
20
+ @server.get("/healthz")
21
+ def healthz() -> dict[str, str]:
22
+ return {
23
+ "status": "ok",
24
+ "vision_model": CANONICAL_VISION_MODEL_ID,
25
+ "reasoning_model": DEFAULT_REASONING_MODEL_ID,
26
+ }
27
+
28
+ gr.mount_gradio_app(
29
+ server,
30
+ blocks or build_app(),
31
+ path="/",
32
+ theme=build_theme(),
33
+ css=THEME_CSS,
34
+ show_error=True,
35
+ )
36
+ return server
37
+
38
+
39
+ __all__ = ["build_server"]
ui/theme.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ TEAL = "#0891b2"
14
+ VIOLET = "#7c3aed"
15
+
16
+ INK = "#0c0a09"
17
+ INK_SOFT = "#1c1917"
18
+ GRAPHITE = "#292524"
19
+ PARCHMENT = "#fef3c7"
20
+ PARCHMENT_DEEP = "#fde68a"
21
+ SLATE = "#44403c"
22
+
23
+ THEME_CSS = f"""
24
+ :root {{
25
+ --halide-amber: {AMBER};
26
+ --halide-amber-deep: {AMBER_DEEP};
27
+ --halide-orange: {ORANGE};
28
+ --halide-red: {RED};
29
+ --halide-crimson: {CRIMSON};
30
+ --halide-ember: {EMBER};
31
+ --halide-teal: {TEAL};
32
+ --halide-violet: {VIOLET};
33
+ --halide-ink: {INK};
34
+ --halide-ink-soft: {INK_SOFT};
35
+ --halide-graphite: {GRAPHITE};
36
+ --halide-parchment: {PARCHMENT};
37
+ --halide-parchment-deep: {PARCHMENT_DEEP};
38
+ --halide-slate: {SLATE};
39
+ }}
40
+
41
+ body, .gradio-container {{
42
+ background: #0c0a09;
43
+ color: var(--halide-parchment);
44
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
45
+ }}
46
+
47
+ #halide-header {{
48
+ background: linear-gradient(90deg, #1c1917 0%, #431407 50%, #78350f 100%);
49
+ padding: 1rem 1.2rem;
50
+ border-radius: 0 0 8px 8px;
51
+ margin-bottom: 1rem;
52
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
53
+ border-bottom: 1px solid var(--halide-amber);
54
+ }}
55
+
56
+ #halide-header h1 {{
57
+ color: var(--halide-parchment);
58
+ font-size: 2rem;
59
+ margin: 0;
60
+ letter-spacing: 0;
61
+ text-shadow: 0 2px 4px rgba(0,0,0,0.4);
62
+ }}
63
+
64
+ #halide-header p {{
65
+ color: var(--halide-parchment-deep);
66
+ margin: 0.4rem 0 0 0;
67
+ font-size: 1.05rem;
68
+ font-style: italic;
69
+ }}
70
+
71
+ .halide-panel {{
72
+ background: rgba(28, 25, 23, 0.85);
73
+ border: 1px solid var(--halide-amber-deep);
74
+ border-radius: 8px;
75
+ padding: 1rem;
76
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
77
+ }}
78
+
79
+ .halide-history-item {{
80
+ background: rgba(41, 37, 36, 0.92);
81
+ border: 1px solid rgba(180, 83, 9, 0.7);
82
+ border-radius: 8px;
83
+ margin-bottom: 0.6rem;
84
+ padding: 0.85rem;
85
+ }}
86
+
87
+ .halide-section-title {{
88
+ color: var(--halide-amber);
89
+ font-size: 1.15rem;
90
+ font-weight: 600;
91
+ letter-spacing: 0;
92
+ text-transform: uppercase;
93
+ margin-bottom: 0.6rem;
94
+ border-bottom: 1px solid var(--halide-amber-deep);
95
+ padding-bottom: 0.3rem;
96
+ }}
97
+
98
+ .halide-stat {{
99
+ display: flex;
100
+ justify-content: space-between;
101
+ padding: 0.4rem 0;
102
+ border-bottom: 1px dotted var(--halide-slate);
103
+ color: var(--halide-parchment);
104
+ }}
105
+
106
+ .halide-stat-label {{
107
+ color: var(--halide-amber);
108
+ font-weight: 600;
109
+ }}
110
+
111
+ .halide-diagnosis {{
112
+ background: rgba(217, 119, 6, 0.08);
113
+ border-left: 4px solid var(--halide-amber);
114
+ padding: 1rem 1.2rem;
115
+ border-radius: 6px;
116
+ white-space: pre-wrap;
117
+ font-size: 0.98rem;
118
+ line-height: 1.6;
119
+ color: var(--halide-parchment);
120
+ }}
121
+
122
+ .halide-defect-pill {{
123
+ display: inline-block;
124
+ background: var(--halide-amber);
125
+ color: var(--halide-ink);
126
+ padding: 0.2rem 0.7rem;
127
+ border-radius: 999px;
128
+ font-size: 0.85rem;
129
+ font-weight: 600;
130
+ margin: 0 0.3rem 0.3rem 0;
131
+ }}
132
+
133
+ .halide-defect-pill.dust {{ background: var(--halide-amber); color: var(--halide-ink); }}
134
+ .halide-defect-pill.dirt {{ background: var(--halide-orange); color: var(--halide-parchment); }}
135
+ .halide-defect-pill.scratch {{ background: var(--halide-red); color: var(--halide-parchment); }}
136
+ .halide-defect-pill.long_hair {{ background: var(--halide-crimson); color: var(--halide-parchment); }}
137
+ .halide-defect-pill.short_hair {{ background: var(--halide-teal); color: var(--halide-parchment); }}
138
+
139
+ button.primary, .primary button {{
140
+ background: linear-gradient(135deg, var(--halide-orange), var(--halide-red)) !important;
141
+ color: var(--halide-parchment) !important;
142
+ border: 1px solid var(--halide-amber) !important;
143
+ font-weight: 600 !important;
144
+ letter-spacing: 0 !important;
145
+ box-shadow: 0 2px 12px rgba(234, 88, 12, 0.4) !important;
146
+ }}
147
+
148
+ button.primary:hover, .primary button:hover {{
149
+ background: linear-gradient(135deg, var(--halide-red), var(--halide-crimson)) !important;
150
+ }}
151
+
152
+ input, textarea, select {{
153
+ background: var(--halide-ink-soft) !important;
154
+ color: var(--halide-parchment) !important;
155
+ border: 1px solid var(--halide-amber-deep) !important;
156
+ }}
157
+
158
+ input:focus, textarea:focus, select:focus {{
159
+ border-color: var(--halide-amber) !important;
160
+ box-shadow: 0 0 0 2px rgba(217, 119, 6, 0.3) !important;
161
+ }}
162
+
163
+ label, .label, .gradio-radio label, .gradio-checkbox label {{
164
+ color: var(--halide-parchment-deep) !important;
165
+ font-weight: 500 !important;
166
+ }}
167
+
168
+ footer {{
169
+ color: var(--halide-slate) !important;
170
+ text-align: center;
171
+ padding: 1rem;
172
+ font-size: 0.85rem;
173
+ }}
174
+ """
175
+
176
+
177
+ def build_theme() -> gr.Theme:
178
+ """Build the autumn-themed Gradio theme."""
179
+ return gr.themes.Base(
180
+ primary_hue=gr.themes.Color(
181
+ c50="#fef3c7",
182
+ c100="#fde68a",
183
+ c200="#fcd34d",
184
+ c300="#fbbf24",
185
+ c400="#f59e0b",
186
+ c500=AMBER,
187
+ c600=AMBER_DEEP,
188
+ c700="#92400e",
189
+ c800="#78350f",
190
+ c900=CRIMSON,
191
+ c950="#7c2d12",
192
+ ),
193
+ secondary_hue=gr.themes.Color(
194
+ c50="#fef3c7",
195
+ c100="#fde68a",
196
+ c200="#fcd34d",
197
+ c300="#fbbf24",
198
+ c400=EMBER,
199
+ c500=AMBER,
200
+ c600=ORANGE,
201
+ c700=RED,
202
+ c800=CRIMSON,
203
+ c900="#7c2d12",
204
+ c950="#431407",
205
+ ),
206
+ neutral_hue=gr.themes.Color(
207
+ c50="#fafaf9",
208
+ c100="#f5f5f4",
209
+ c200="#e7e5e4",
210
+ c300="#d6d3d1",
211
+ c400=SLATE,
212
+ c500="#57534e",
213
+ c600="#44403c",
214
+ c700="#292524",
215
+ c800=INK_SOFT,
216
+ c900=INK,
217
+ c950="#0c0a09",
218
+ ),
219
+ font=gr.themes.GoogleFont("Iowan Old Style"),
220
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
221
+ ).set(
222
+ body_background_fill=INK,
223
+ body_background_fill_dark=INK,
224
+ body_text_color=PARCHMENT,
225
+ body_text_color_dark=PARCHMENT,
226
+ button_primary_background_fill=ORANGE,
227
+ button_primary_background_fill_dark=ORANGE,
228
+ button_primary_text_color=PARCHMENT,
229
+ button_primary_text_color_dark=PARCHMENT,
230
+ block_background_fill=INK_SOFT,
231
+ block_background_fill_dark=INK_SOFT,
232
+ block_border_color=AMBER_DEEP,
233
+ block_border_color_dark=AMBER_DEEP,
234
+ input_background_fill=INK,
235
+ input_background_fill_dark=INK,
236
+ input_border_color=AMBER_DEEP,
237
+ input_border_color_dark=AMBER_DEEP,
238
+ )