Deploy Project Halide Gradio Space
Browse files- LICENSE +201 -0
- README.md +8 -8
- app.py +27 -0
- config.py +154 -0
- data/__init__.py +1 -0
- data/augmentation.py +160 -0
- data/datasets.py +130 -0
- data/preprocessing.py +109 -0
- data/schemas.py +268 -0
- models/__init__.py +1 -0
- models/reasoning/.gitkeep +0 -0
- models/reasoning/__init__.py +1 -0
- models/reasoning/inference.py +7 -0
- models/reasoning/nemotron_wrapper.py +166 -0
- models/reasoning/prompts.py +307 -0
- models/vision/.gitkeep +0 -0
- models/vision/__init__.py +1 -0
- models/vision/inference.py +46 -0
- models/vision/minicpm_wrapper.py +243 -0
- pipeline/.gitkeep +0 -0
- pipeline/__init__.py +1 -0
- pipeline/diagnoser.py +65 -0
- pipeline/extractor.py +18 -0
- pipeline/pipeline.py +62 -0
- requirements.txt +10 -0
- storage/.gitkeep +0 -0
- storage/__init__.py +1 -0
- storage/cache.py +94 -0
- storage/database.py +190 -0
- ui/.gitkeep +0 -0
- ui/__init__.py +1 -0
- ui/app.py +251 -0
- ui/components.py +106 -0
- ui/server.py +39 -0
- ui/theme.py +238 -0
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.
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|