Deploy Project Halide Gradio Space
Browse files- README.md +39 -3
- config.py +4 -0
- data/schemas.py +52 -0
- models/vision/classical_assist.py +274 -0
- models/vision/inference.py +33 -1
- ui/app.py +8 -16
- ui/components.py +49 -1
- ui/theme.py +145 -67
README.md
CHANGED
|
@@ -35,9 +35,10 @@ Project Halide is an edge-native diagnostic workbench for analog film scans by
|
|
| 35 |
|
| 36 |
The runtime uses MiniCPM-V 4.6 for defect extraction and
|
| 37 |
Nemotron-Mini-4B-Instruct for diagnostic reasoning. The vision pass combines
|
| 38 |
-
full-frame inspection
|
| 39 |
-
|
| 40 |
-
|
|
|
|
| 41 |
|
| 42 |
Fine-tuned vision model:
|
| 43 |
<https://huggingface.co/Lonelyguyse1/halide-vision>
|
|
@@ -55,6 +56,41 @@ Modal was used for offline training, held-out GPU evaluation, checkpoint upload,
|
|
| 55 |
GGUF conversion, and Space deployment. The runtime app itself does not call
|
| 56 |
Modal or any hosted inference API.
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
Held-out validation summary:
|
| 59 |
|
| 60 |
- Four visibly damaged private negatives were detected with scratch and
|
|
|
|
| 35 |
|
| 36 |
The runtime uses MiniCPM-V 4.6 for defect extraction and
|
| 37 |
Nemotron-Mini-4B-Instruct for diagnostic reasoning. The vision pass combines
|
| 38 |
+
full-frame inspection, tiled fallback for large scans, a conservative
|
| 39 |
+
image-analysis validator for obvious scratches, and geometric filtering for
|
| 40 |
+
sprocket or frame-edge artifacts. Model inference runs on the Space GPU runtime
|
| 41 |
+
without cloud inference APIs.
|
| 42 |
|
| 43 |
Fine-tuned vision model:
|
| 44 |
<https://huggingface.co/Lonelyguyse1/halide-vision>
|
|
|
|
| 56 |
GGUF conversion, and Space deployment. The runtime app itself does not call
|
| 57 |
Modal or any hosted inference API.
|
| 58 |
|
| 59 |
+
## How It Works
|
| 60 |
+
|
| 61 |
+
1. Upload a film scan, negative photo, or contact-sheet crop.
|
| 62 |
+
2. MiniCPM-V 4.6 extracts candidate defects as structured JSON.
|
| 63 |
+
3. The validator normalizes boxes, filters bad geometry, removes duplicate or
|
| 64 |
+
sprocket-like edge artifacts, and adds high-precision scratch candidates
|
| 65 |
+
when clear linear evidence is visible.
|
| 66 |
+
4. Nemotron-Mini-4B-Instruct reads the validated evidence plus user metadata and
|
| 67 |
+
writes a lab-style diagnosis with physical fixes.
|
| 68 |
+
5. SQLite stores local diagnostic history so earlier runs can be reopened.
|
| 69 |
+
|
| 70 |
+
## Sponsor Usage
|
| 71 |
+
|
| 72 |
+
- OpenBMB: MiniCPM-V 4.6 is the primary vision model, fine-tuned for film defect
|
| 73 |
+
extraction and published at `Lonelyguyse1/halide-vision`.
|
| 74 |
+
- NVIDIA: Nemotron-Mini-4B-Instruct produces the diagnostic report and keeps
|
| 75 |
+
uncertain film metadata lower priority than visible evidence.
|
| 76 |
+
- Modal: used offline for training, evaluation, checkpoint export, GGUF
|
| 77 |
+
conversion, model upload, and Space deployment support.
|
| 78 |
+
- OpenAI Codex: used for implementation, testing, documentation, and
|
| 79 |
+
source-control commits in the linked GitHub repository.
|
| 80 |
+
|
| 81 |
+
## Field Guide Alignment
|
| 82 |
+
|
| 83 |
+
- Gradio Space under the official `build-small-hackathon` organization.
|
| 84 |
+
- All runtime inference uses open weights on the Space GPU, with no hosted model
|
| 85 |
+
API calls.
|
| 86 |
+
- Model sizes stay under the 32B limit, with MiniCPM-V 4.6 at 1.3B parameters
|
| 87 |
+
and Nemotron-Mini-4B-Instruct at 4B parameters.
|
| 88 |
+
- Custom autumn-themed UI with a purpose-built compare viewer and diagnostic
|
| 89 |
+
history.
|
| 90 |
+
- Fine-tuned vision model and GGUF artifact are published on the author's
|
| 91 |
+
Hugging Face profile.
|
| 92 |
+
- Demo video, public launch post, and field notes are linked from this Space.
|
| 93 |
+
|
| 94 |
Held-out validation summary:
|
| 95 |
|
| 96 |
- Four visibly damaged private negatives were detected with scratch and
|
config.py
CHANGED
|
@@ -69,6 +69,8 @@ class VisionConfig:
|
|
| 69 |
tile_max_side: int
|
| 70 |
tile_overlap: float
|
| 71 |
tile_max_tiles: int
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
@dataclass(frozen=True)
|
|
@@ -109,6 +111,8 @@ def get_vision_config() -> VisionConfig:
|
|
| 109 |
tile_max_side=env_int("HALIDE_TILE_MAX_SIDE", 960),
|
| 110 |
tile_overlap=env_float("HALIDE_TILE_OVERLAP", 0.35),
|
| 111 |
tile_max_tiles=env_int("HALIDE_TILE_MAX_TILES", 9),
|
|
|
|
|
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
|
|
|
| 69 |
tile_max_side: int
|
| 70 |
tile_overlap: float
|
| 71 |
tile_max_tiles: int
|
| 72 |
+
classical_assist_enabled: bool
|
| 73 |
+
classical_assist_max_defects: int
|
| 74 |
|
| 75 |
|
| 76 |
@dataclass(frozen=True)
|
|
|
|
| 111 |
tile_max_side=env_int("HALIDE_TILE_MAX_SIDE", 960),
|
| 112 |
tile_overlap=env_float("HALIDE_TILE_OVERLAP", 0.35),
|
| 113 |
tile_max_tiles=env_int("HALIDE_TILE_MAX_TILES", 9),
|
| 114 |
+
classical_assist_enabled=env_bool("HALIDE_ENABLE_CLASSICAL_ASSIST", True),
|
| 115 |
+
classical_assist_max_defects=env_int("HALIDE_CLASSICAL_ASSIST_MAX_DEFECTS", 32),
|
| 116 |
)
|
| 117 |
|
| 118 |
|
data/schemas.py
CHANGED
|
@@ -269,6 +269,57 @@ def dedupe_defects(
|
|
| 269 |
return merged, duplicate_count
|
| 270 |
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
def bbox_area(bbox: Any) -> float:
|
| 273 |
norm = normalize_bbox(bbox)
|
| 274 |
if norm is None:
|
|
@@ -361,6 +412,7 @@ __all__ = [
|
|
| 361 |
"bbox_to_pixels",
|
| 362 |
"clean_defects",
|
| 363 |
"dedupe_defects",
|
|
|
|
| 364 |
"label_counts",
|
| 365 |
"normalize_bbox",
|
| 366 |
"spatial_summary",
|
|
|
|
| 269 |
return merged, duplicate_count
|
| 270 |
|
| 271 |
|
| 272 |
+
def filter_edge_artifacts(defects: Iterable[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
|
| 273 |
+
"""Drop repeated edge artifacts that look like film borders or sprockets."""
|
| 274 |
+
filtered: list[dict[str, Any]] = []
|
| 275 |
+
dropped = 0
|
| 276 |
+
for defect in defects:
|
| 277 |
+
label = str(defect.get("label", ""))
|
| 278 |
+
bbox = normalize_bbox(defect.get("bbox"))
|
| 279 |
+
if bbox is None:
|
| 280 |
+
dropped += 1
|
| 281 |
+
continue
|
| 282 |
+
if _is_edge_artifact(label, bbox, defect.get("confidence")):
|
| 283 |
+
dropped += 1
|
| 284 |
+
continue
|
| 285 |
+
filtered.append(_serialize_defect(label, bbox, defect))
|
| 286 |
+
return filtered, dropped
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _is_edge_artifact(label: str, bbox: BBox, confidence: Any) -> bool:
|
| 290 |
+
x_min, y_min, x_max, y_max = bbox
|
| 291 |
+
width = x_max - x_min
|
| 292 |
+
height = y_max - y_min
|
| 293 |
+
area = width * height
|
| 294 |
+
center_x = (x_min + x_max) / 2.0
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
confidence_value = float(confidence) if confidence is not None else None
|
| 298 |
+
except (TypeError, ValueError):
|
| 299 |
+
confidence_value = None
|
| 300 |
+
|
| 301 |
+
low_evidence = confidence_value is None or confidence_value < 0.62
|
| 302 |
+
if not low_evidence:
|
| 303 |
+
return False
|
| 304 |
+
|
| 305 |
+
if label == "dust" and area < 0.0016 and (center_x < 0.12 or center_x > 0.88):
|
| 306 |
+
return True
|
| 307 |
+
|
| 308 |
+
if width < 0.02 and height > 0.18 and (x_min <= 0.004 or x_max >= 0.996):
|
| 309 |
+
return True
|
| 310 |
+
|
| 311 |
+
if height < 0.02 and width > 0.22 and (y_min <= 0.004 or y_max >= 0.996):
|
| 312 |
+
return True
|
| 313 |
+
|
| 314 |
+
if label in {"scratch", "emulsion_damage"}:
|
| 315 |
+
if width < 0.075 and height > 0.22 and (x_min <= 0.006 or x_max >= 0.994):
|
| 316 |
+
return True
|
| 317 |
+
if height < 0.075 and width > 0.22 and (y_min <= 0.006 or y_max >= 0.994):
|
| 318 |
+
return True
|
| 319 |
+
|
| 320 |
+
return False
|
| 321 |
+
|
| 322 |
+
|
| 323 |
def bbox_area(bbox: Any) -> float:
|
| 324 |
norm = normalize_bbox(bbox)
|
| 325 |
if norm is None:
|
|
|
|
| 412 |
"bbox_to_pixels",
|
| 413 |
"clean_defects",
|
| 414 |
"dedupe_defects",
|
| 415 |
+
"filter_edge_artifacts",
|
| 416 |
"label_counts",
|
| 417 |
"normalize_bbox",
|
| 418 |
"spatial_summary",
|
models/vision/classical_assist.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conservative image-analysis assist for obvious film defects.
|
| 2 |
+
|
| 3 |
+
This module does not run a model. It uses local contrast only to catch clear
|
| 4 |
+
linear scratches and compact bright debris that MiniCPM can miss on real scans.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image, ImageFilter, ImageOps
|
| 14 |
+
|
| 15 |
+
from data.schemas import bbox_area, bbox_iou, normalize_bbox
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class Candidate:
|
| 20 |
+
label: str
|
| 21 |
+
bbox: tuple[float, float, float, float]
|
| 22 |
+
score: float
|
| 23 |
+
|
| 24 |
+
def to_json(self) -> dict[str, Any]:
|
| 25 |
+
confidence = min(0.82, max(0.52, 0.5 + self.score * 0.72))
|
| 26 |
+
return {
|
| 27 |
+
"label": self.label,
|
| 28 |
+
"bbox": [round(v, 6) for v in self.bbox],
|
| 29 |
+
"confidence": round(confidence, 4),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def detect_classical_defects(
|
| 34 |
+
image: Any,
|
| 35 |
+
*,
|
| 36 |
+
max_defects: int = 32,
|
| 37 |
+
include_compact_debris: bool = False,
|
| 38 |
+
) -> list[dict[str, Any]]:
|
| 39 |
+
"""Return high-precision defect candidates from image structure only."""
|
| 40 |
+
pil_image = ImageOps.exif_transpose(image.convert("RGB"))
|
| 41 |
+
width, height = pil_image.size
|
| 42 |
+
if width < 64 or height < 64:
|
| 43 |
+
return []
|
| 44 |
+
|
| 45 |
+
work = _resize_work_image(pil_image)
|
| 46 |
+
gray = _gray_array(work)
|
| 47 |
+
blur = _blur_array(gray, work.size)
|
| 48 |
+
residual = gray - blur
|
| 49 |
+
mask = _bright_residual_mask(gray, residual)
|
| 50 |
+
|
| 51 |
+
candidates: list[Candidate] = []
|
| 52 |
+
candidates.extend(_linear_candidates(mask, residual))
|
| 53 |
+
if include_compact_debris:
|
| 54 |
+
candidates.extend(_compact_debris_candidates(mask, residual))
|
| 55 |
+
|
| 56 |
+
candidates = _dedupe_candidates(candidates, max_defects=max(1, int(max_defects)))
|
| 57 |
+
return [candidate.to_json() for candidate in candidates]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _resize_work_image(image: Image.Image) -> Image.Image:
|
| 61 |
+
width, height = image.size
|
| 62 |
+
max_side = 1100
|
| 63 |
+
scale = min(1.0, max_side / float(max(width, height)))
|
| 64 |
+
if scale >= 1.0:
|
| 65 |
+
return image.copy()
|
| 66 |
+
new_size = (
|
| 67 |
+
max(1, int(round(width * scale))),
|
| 68 |
+
max(1, int(round(height * scale))),
|
| 69 |
+
)
|
| 70 |
+
return image.resize(new_size, Image.Resampling.LANCZOS)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _gray_array(image: Image.Image) -> np.ndarray:
|
| 74 |
+
arr = np.asarray(image).astype("float32") / 255.0
|
| 75 |
+
return (
|
| 76 |
+
0.2126 * arr[:, :, 0]
|
| 77 |
+
+ 0.7152 * arr[:, :, 1]
|
| 78 |
+
+ 0.0722 * arr[:, :, 2]
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _blur_array(gray: np.ndarray, size: tuple[int, int]) -> np.ndarray:
|
| 83 |
+
width, height = size
|
| 84 |
+
radius = max(2, int(round(max(width, height) * 0.006)))
|
| 85 |
+
source = Image.fromarray(np.uint8(np.clip(gray * 255.0, 0, 255)))
|
| 86 |
+
blurred = source.filter(ImageFilter.GaussianBlur(radius=radius))
|
| 87 |
+
return np.asarray(blurred).astype("float32") / 255.0
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _bright_residual_mask(gray: np.ndarray, residual: np.ndarray) -> np.ndarray:
|
| 91 |
+
threshold = max(0.045, float(np.percentile(residual, 99.2)))
|
| 92 |
+
mask = (residual >= threshold) & (gray > float(np.percentile(gray, 42)))
|
| 93 |
+
mask[:2, :] = False
|
| 94 |
+
mask[-2:, :] = False
|
| 95 |
+
mask[:, :2] = False
|
| 96 |
+
mask[:, -2:] = False
|
| 97 |
+
return mask
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _linear_candidates(mask: np.ndarray, residual: np.ndarray) -> list[Candidate]:
|
| 101 |
+
height, width = mask.shape
|
| 102 |
+
min_horizontal = max(22, int(round(width * 0.035)))
|
| 103 |
+
min_vertical = max(22, int(round(height * 0.035)))
|
| 104 |
+
candidates: list[Candidate] = []
|
| 105 |
+
|
| 106 |
+
for y in range(height):
|
| 107 |
+
xs = np.flatnonzero(mask[y])
|
| 108 |
+
if xs.size == 0:
|
| 109 |
+
continue
|
| 110 |
+
for run in _contiguous_runs(xs):
|
| 111 |
+
if run.size < min_horizontal:
|
| 112 |
+
continue
|
| 113 |
+
x0, x1 = int(run[0]), int(run[-1]) + 1
|
| 114 |
+
pad = max(2, int(round(height * 0.003)))
|
| 115 |
+
bbox = (
|
| 116 |
+
x0 / width,
|
| 117 |
+
max(0, y - pad) / height,
|
| 118 |
+
x1 / width,
|
| 119 |
+
min(height, y + pad + 1) / height,
|
| 120 |
+
)
|
| 121 |
+
if _is_border_frame(bbox):
|
| 122 |
+
continue
|
| 123 |
+
candidates.append(
|
| 124 |
+
Candidate("scratch", bbox, float(residual[y, run].mean()))
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
for x in range(width):
|
| 128 |
+
ys = np.flatnonzero(mask[:, x])
|
| 129 |
+
if ys.size == 0:
|
| 130 |
+
continue
|
| 131 |
+
for run in _contiguous_runs(ys):
|
| 132 |
+
if run.size < min_vertical:
|
| 133 |
+
continue
|
| 134 |
+
y0, y1 = int(run[0]), int(run[-1]) + 1
|
| 135 |
+
pad = max(2, int(round(width * 0.003)))
|
| 136 |
+
bbox = (
|
| 137 |
+
max(0, x - pad) / width,
|
| 138 |
+
y0 / height,
|
| 139 |
+
min(width, x + pad + 1) / width,
|
| 140 |
+
y1 / height,
|
| 141 |
+
)
|
| 142 |
+
if _is_border_frame(bbox):
|
| 143 |
+
continue
|
| 144 |
+
candidates.append(
|
| 145 |
+
Candidate("scratch", bbox, float(residual[run, x].mean()))
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return candidates
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _compact_debris_candidates(mask: np.ndarray, residual: np.ndarray) -> list[Candidate]:
|
| 152 |
+
if int(mask.sum()) > 45_000:
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
height, width = mask.shape
|
| 156 |
+
visited = np.zeros(mask.shape, dtype=bool)
|
| 157 |
+
points = np.argwhere(mask)
|
| 158 |
+
candidates: list[Candidate] = []
|
| 159 |
+
|
| 160 |
+
for y_raw, x_raw in points:
|
| 161 |
+
y = int(y_raw)
|
| 162 |
+
x = int(x_raw)
|
| 163 |
+
if visited[y, x] or not mask[y, x]:
|
| 164 |
+
continue
|
| 165 |
+
coords = _component(mask, visited, y, x, max_pixels=900)
|
| 166 |
+
if len(coords) < 3 or len(coords) > 850:
|
| 167 |
+
continue
|
| 168 |
+
ys = np.array([coord[0] for coord in coords], dtype=np.int32)
|
| 169 |
+
xs = np.array([coord[1] for coord in coords], dtype=np.int32)
|
| 170 |
+
x0 = int(xs.min())
|
| 171 |
+
x1 = int(xs.max()) + 1
|
| 172 |
+
y0 = int(ys.min())
|
| 173 |
+
y1 = int(ys.max()) + 1
|
| 174 |
+
box_width = x1 - x0
|
| 175 |
+
box_height = y1 - y0
|
| 176 |
+
if box_width > width * 0.1 or box_height > height * 0.1:
|
| 177 |
+
continue
|
| 178 |
+
|
| 179 |
+
aspect = max(box_width / max(1, box_height), box_height / max(1, box_width))
|
| 180 |
+
label = "scratch" if aspect >= 8.0 else "dust"
|
| 181 |
+
pad = 3 if label == "scratch" else 2
|
| 182 |
+
bbox = (
|
| 183 |
+
max(0, x0 - pad) / width,
|
| 184 |
+
max(0, y0 - pad) / height,
|
| 185 |
+
min(width, x1 + pad) / width,
|
| 186 |
+
min(height, y1 + pad) / height,
|
| 187 |
+
)
|
| 188 |
+
if _is_tiny_edge_artifact(bbox) or _is_border_frame(bbox):
|
| 189 |
+
continue
|
| 190 |
+
score = float(residual[ys, xs].mean())
|
| 191 |
+
candidates.append(Candidate(label, bbox, score))
|
| 192 |
+
|
| 193 |
+
return candidates
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _component(
|
| 197 |
+
mask: np.ndarray,
|
| 198 |
+
visited: np.ndarray,
|
| 199 |
+
start_y: int,
|
| 200 |
+
start_x: int,
|
| 201 |
+
*,
|
| 202 |
+
max_pixels: int,
|
| 203 |
+
) -> list[tuple[int, int]]:
|
| 204 |
+
height, width = mask.shape
|
| 205 |
+
stack = [(start_y, start_x)]
|
| 206 |
+
visited[start_y, start_x] = True
|
| 207 |
+
coords: list[tuple[int, int]] = []
|
| 208 |
+
while stack and len(coords) < max_pixels:
|
| 209 |
+
y, x = stack.pop()
|
| 210 |
+
coords.append((y, x))
|
| 211 |
+
for next_y in (y - 1, y, y + 1):
|
| 212 |
+
if next_y < 0 or next_y >= height:
|
| 213 |
+
continue
|
| 214 |
+
for next_x in (x - 1, x, x + 1):
|
| 215 |
+
if next_x < 0 or next_x >= width:
|
| 216 |
+
continue
|
| 217 |
+
if visited[next_y, next_x] or not mask[next_y, next_x]:
|
| 218 |
+
continue
|
| 219 |
+
visited[next_y, next_x] = True
|
| 220 |
+
stack.append((next_y, next_x))
|
| 221 |
+
return coords
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _contiguous_runs(values: np.ndarray) -> list[np.ndarray]:
|
| 225 |
+
splits = np.where(np.diff(values) > 1)[0] + 1
|
| 226 |
+
return list(np.split(values, splits))
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _dedupe_candidates(
|
| 230 |
+
candidates: list[Candidate],
|
| 231 |
+
*,
|
| 232 |
+
max_defects: int,
|
| 233 |
+
) -> list[Candidate]:
|
| 234 |
+
kept: list[Candidate] = []
|
| 235 |
+
for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
|
| 236 |
+
if len(kept) >= max_defects:
|
| 237 |
+
break
|
| 238 |
+
bbox = normalize_bbox(candidate.bbox)
|
| 239 |
+
if bbox is None or bbox_area(bbox) <= 0:
|
| 240 |
+
continue
|
| 241 |
+
if any(_overlaps_existing(candidate, existing) for existing in kept):
|
| 242 |
+
continue
|
| 243 |
+
kept.append(candidate)
|
| 244 |
+
return kept
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _overlaps_existing(candidate: Candidate, existing: Candidate) -> bool:
|
| 248 |
+
if bbox_iou(candidate.bbox, existing.bbox) >= 0.15:
|
| 249 |
+
return True
|
| 250 |
+
if candidate.label != "scratch" or existing.label != "scratch":
|
| 251 |
+
return False
|
| 252 |
+
cx0, cy0, cx1, cy1 = candidate.bbox
|
| 253 |
+
ex0, ey0, ex1, ey1 = existing.bbox
|
| 254 |
+
same_row = abs(((cy0 + cy1) / 2.0) - ((ey0 + ey1) / 2.0)) < 0.025
|
| 255 |
+
x_overlap = max(0.0, min(cx1, ex1) - max(cx0, ex0))
|
| 256 |
+
return same_row and x_overlap > 0.05
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _is_border_frame(bbox: tuple[float, float, float, float]) -> bool:
|
| 260 |
+
x0, y0, x1, y1 = bbox
|
| 261 |
+
width = x1 - x0
|
| 262 |
+
height = y1 - y0
|
| 263 |
+
near_outer_edge = x0 < 0.012 or y0 < 0.012 or x1 > 0.988 or y1 > 0.988
|
| 264 |
+
return near_outer_edge and (width > 0.2 or height > 0.2)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _is_tiny_edge_artifact(bbox: tuple[float, float, float, float]) -> bool:
|
| 268 |
+
x0, y0, x1, y1 = bbox
|
| 269 |
+
area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
|
| 270 |
+
center_x = (x0 + x1) / 2.0
|
| 271 |
+
return area < 0.0016 and (center_x < 0.12 or center_x > 0.88)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
__all__ = ["detect_classical_defects"]
|
models/vision/inference.py
CHANGED
|
@@ -7,8 +7,16 @@ from pathlib import Path
|
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
from config import get_vision_config
|
| 10 |
-
from data.schemas import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from data.preprocessing import load_image
|
|
|
|
| 12 |
from models.vision.minicpm_wrapper import get_detector
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
|
@@ -31,6 +39,9 @@ def extract_defects(image: Any) -> dict:
|
|
| 31 |
tile_fallback_used = False
|
| 32 |
tile_count = 0
|
| 33 |
tile_parse_errors: list[str] = []
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
cfg = get_vision_config()
|
| 36 |
if _should_run_tile_fallback(input_image, cleaned):
|
|
@@ -60,6 +71,24 @@ def extract_defects(image: Any) -> dict:
|
|
| 60 |
break
|
| 61 |
cleaned = tile_defects
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
cleaned, duplicate_count = dedupe_defects(cleaned)
|
| 64 |
counts = label_counts(cleaned)
|
| 65 |
elapsed = time.perf_counter() - started
|
|
@@ -70,6 +99,7 @@ def extract_defects(image: Any) -> dict:
|
|
| 70 |
"label_counts": counts,
|
| 71 |
"dropped_count": dropped,
|
| 72 |
"duplicate_count": duplicate_count,
|
|
|
|
| 73 |
"inference_seconds": round(elapsed, 3),
|
| 74 |
"model_path": detector.model_path,
|
| 75 |
"parse_error": raw.get("_parse_error"),
|
|
@@ -78,6 +108,8 @@ def extract_defects(image: Any) -> dict:
|
|
| 78 |
"tile_count": tile_count,
|
| 79 |
"full_frame_defect_count": full_frame_count,
|
| 80 |
"tile_parse_errors": tile_parse_errors,
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
|
|
|
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
from config import get_vision_config
|
| 10 |
+
from data.schemas import (
|
| 11 |
+
BBox,
|
| 12 |
+
clean_defects,
|
| 13 |
+
dedupe_defects,
|
| 14 |
+
filter_edge_artifacts,
|
| 15 |
+
label_counts,
|
| 16 |
+
normalize_bbox,
|
| 17 |
+
)
|
| 18 |
from data.preprocessing import load_image
|
| 19 |
+
from models.vision.classical_assist import detect_classical_defects
|
| 20 |
from models.vision.minicpm_wrapper import get_detector
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
|
|
|
| 39 |
tile_fallback_used = False
|
| 40 |
tile_count = 0
|
| 41 |
tile_parse_errors: list[str] = []
|
| 42 |
+
classical_assist_count = 0
|
| 43 |
+
classical_assist_used = False
|
| 44 |
+
edge_artifact_count = 0
|
| 45 |
|
| 46 |
cfg = get_vision_config()
|
| 47 |
if _should_run_tile_fallback(input_image, cleaned):
|
|
|
|
| 71 |
break
|
| 72 |
cleaned = tile_defects
|
| 73 |
|
| 74 |
+
if cfg.classical_assist_enabled:
|
| 75 |
+
include_compact_debris = len(cleaned) == 0
|
| 76 |
+
classical_raw = detect_classical_defects(
|
| 77 |
+
input_image,
|
| 78 |
+
max_defects=cfg.classical_assist_max_defects,
|
| 79 |
+
include_compact_debris=include_compact_debris,
|
| 80 |
+
)
|
| 81 |
+
classical_cleaned, classical_dropped = clean_defects(classical_raw)
|
| 82 |
+
dropped += classical_dropped
|
| 83 |
+
if cleaned:
|
| 84 |
+
classical_cleaned = [
|
| 85 |
+
defect for defect in classical_cleaned if defect.get("label") == "scratch"
|
| 86 |
+
]
|
| 87 |
+
classical_assist_count = len(classical_cleaned)
|
| 88 |
+
classical_assist_used = bool(classical_cleaned)
|
| 89 |
+
cleaned.extend(classical_cleaned)
|
| 90 |
+
|
| 91 |
+
cleaned, edge_artifact_count = filter_edge_artifacts(cleaned)
|
| 92 |
cleaned, duplicate_count = dedupe_defects(cleaned)
|
| 93 |
counts = label_counts(cleaned)
|
| 94 |
elapsed = time.perf_counter() - started
|
|
|
|
| 99 |
"label_counts": counts,
|
| 100 |
"dropped_count": dropped,
|
| 101 |
"duplicate_count": duplicate_count,
|
| 102 |
+
"edge_artifact_count": edge_artifact_count,
|
| 103 |
"inference_seconds": round(elapsed, 3),
|
| 104 |
"model_path": detector.model_path,
|
| 105 |
"parse_error": raw.get("_parse_error"),
|
|
|
|
| 108 |
"tile_count": tile_count,
|
| 109 |
"full_frame_defect_count": full_frame_count,
|
| 110 |
"tile_parse_errors": tile_parse_errors,
|
| 111 |
+
"classical_assist_used": classical_assist_used,
|
| 112 |
+
"classical_assist_count": classical_assist_count,
|
| 113 |
}
|
| 114 |
|
| 115 |
|
ui/app.py
CHANGED
|
@@ -24,6 +24,7 @@ from ui.components import (
|
|
| 24 |
LIGHTTABLE_EMPTY_STATE,
|
| 25 |
LIGHTTABLE_RUNNING_STATE,
|
| 26 |
REPORT_EMPTY_STATE,
|
|
|
|
| 27 |
confidence_notice_html,
|
| 28 |
defect_table_rows,
|
| 29 |
defect_pills_html,
|
|
@@ -245,11 +246,7 @@ def run_pipeline(
|
|
| 245 |
|
| 246 |
counts = result.get("defects", {}).get("label_counts", {}) or {}
|
| 247 |
defects = result.get("defects", {}).get("defects", []) or []
|
| 248 |
-
annotated = draw_defects(
|
| 249 |
-
pil_image,
|
| 250 |
-
defects,
|
| 251 |
-
title=f"Halide: {len(defects)} validated defects",
|
| 252 |
-
)
|
| 253 |
result = _attach_preview(result, pil_image, annotated)
|
| 254 |
if not was_cached:
|
| 255 |
try:
|
|
@@ -261,8 +258,10 @@ def run_pipeline(
|
|
| 261 |
elif not result.get("diagnosis_id"):
|
| 262 |
cache.put(image_bytes, result, metadata=metadata)
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
| 266 |
gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
|
| 267 |
review_links = gr.update(
|
| 268 |
value=review_frame_html(pil_image, annotated),
|
|
@@ -446,15 +445,8 @@ def build_app() -> gr.Blocks:
|
|
| 446 |
"</div>"
|
| 447 |
)
|
| 448 |
lighttable_empty = gr.HTML(value=LIGHTTABLE_EMPTY_STATE)
|
| 449 |
-
compare_output = gr.
|
| 450 |
-
value=
|
| 451 |
-
label="Original / overlay",
|
| 452 |
-
type="pil",
|
| 453 |
-
height=620,
|
| 454 |
-
max_height=680,
|
| 455 |
-
slider_position=52,
|
| 456 |
-
interactive=False,
|
| 457 |
-
buttons=["download", "fullscreen"],
|
| 458 |
elem_id="halide-compare",
|
| 459 |
visible=False,
|
| 460 |
)
|
|
|
|
| 24 |
LIGHTTABLE_EMPTY_STATE,
|
| 25 |
LIGHTTABLE_RUNNING_STATE,
|
| 26 |
REPORT_EMPTY_STATE,
|
| 27 |
+
comparison_viewer_html,
|
| 28 |
confidence_notice_html,
|
| 29 |
defect_table_rows,
|
| 30 |
defect_pills_html,
|
|
|
|
| 246 |
|
| 247 |
counts = result.get("defects", {}).get("label_counts", {}) or {}
|
| 248 |
defects = result.get("defects", {}).get("defects", []) or []
|
| 249 |
+
annotated = draw_defects(pil_image, defects)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
result = _attach_preview(result, pil_image, annotated)
|
| 251 |
if not was_cached:
|
| 252 |
try:
|
|
|
|
| 258 |
elif not result.get("diagnosis_id"):
|
| 259 |
cache.put(image_bytes, result, metadata=metadata)
|
| 260 |
|
| 261 |
+
compare = gr.update(
|
| 262 |
+
value=comparison_viewer_html(pil_image, annotated),
|
| 263 |
+
visible=True,
|
| 264 |
+
)
|
| 265 |
gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
|
| 266 |
review_links = gr.update(
|
| 267 |
value=review_frame_html(pil_image, annotated),
|
|
|
|
| 445 |
"</div>"
|
| 446 |
)
|
| 447 |
lighttable_empty = gr.HTML(value=LIGHTTABLE_EMPTY_STATE)
|
| 448 |
+
compare_output = gr.HTML(
|
| 449 |
+
value="",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
elem_id="halide-compare",
|
| 451 |
visible=False,
|
| 452 |
)
|
ui/components.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import base64
|
|
|
|
| 6 |
import html
|
| 7 |
import json
|
| 8 |
import time
|
|
@@ -14,6 +15,7 @@ from data.schemas import LABEL_DISPLAY_NAMES
|
|
| 14 |
from data.preprocessing import image_to_data_uri
|
| 15 |
|
| 16 |
|
|
|
|
| 17 |
def _logo_html() -> str:
|
| 18 |
path = Path(__file__).resolve().parents[1] / "assets" / "logo.jpg"
|
| 19 |
if not path.exists():
|
|
@@ -25,6 +27,9 @@ def _logo_html() -> str:
|
|
| 25 |
)
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
| 28 |
HEADER_HTML = f"""
|
| 29 |
<div id="halide-header">
|
| 30 |
<div class="halide-brand-lockup">
|
|
@@ -126,7 +131,9 @@ def defect_table_rows(result: dict | None) -> list[list[str]]:
|
|
| 126 |
else:
|
| 127 |
box_text = "invalid"
|
| 128 |
confidence = defect.get("confidence")
|
| 129 |
-
confidence_text =
|
|
|
|
|
|
|
| 130 |
rows.append([str(index), display, confidence_text, box_text])
|
| 131 |
return rows
|
| 132 |
|
|
@@ -143,6 +150,8 @@ def stats_html(result: dict) -> str:
|
|
| 143 |
rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
|
| 144 |
rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
|
| 145 |
rows.append(_stat_row("Duplicates removed", str(defects.get("duplicate_count", 0))))
|
|
|
|
|
|
|
| 146 |
rows.append(_stat_row("Resized for model", "yes" if defects.get("resized_for_model") else "no"))
|
| 147 |
rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
|
| 148 |
rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
|
|
@@ -191,6 +200,33 @@ def review_frame_html(original, annotated) -> str:
|
|
| 191 |
)
|
| 192 |
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
def render_markdown_report(text: str) -> str:
|
| 195 |
"""Render the constrained diagnosis Markdown used by Nemotron.
|
| 196 |
|
|
@@ -327,10 +363,21 @@ def confidence_notice_html(result: dict) -> str:
|
|
| 327 |
duplicate = int(defects.get("duplicate_count", 0) or 0)
|
| 328 |
meta = result.get("film_metadata", {}) or {}
|
| 329 |
confidence = str(meta.get("metadata_confidence", "low") or "low").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
if total == 0:
|
| 332 |
message = "No validated boxes were returned. Inspect the scan before assuming a film fault."
|
| 333 |
tone = "neutral"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
elif confidence == "low":
|
| 335 |
message = "Metadata is marked low confidence, so the diagnosis is driven mainly by visible defects."
|
| 336 |
tone = "caution"
|
|
@@ -501,6 +548,7 @@ __all__ = [
|
|
| 501 |
"LIGHTTABLE_RUNNING_STATE",
|
| 502 |
"REPORT_EMPTY_STATE",
|
| 503 |
"compact_label_counts",
|
|
|
|
| 504 |
"confidence_notice_html",
|
| 505 |
"defect_table_rows",
|
| 506 |
"defect_pills_html",
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import base64
|
| 6 |
+
import functools
|
| 7 |
import html
|
| 8 |
import json
|
| 9 |
import time
|
|
|
|
| 15 |
from data.preprocessing import image_to_data_uri
|
| 16 |
|
| 17 |
|
| 18 |
+
@functools.lru_cache(maxsize=1)
|
| 19 |
def _logo_html() -> str:
|
| 20 |
path = Path(__file__).resolve().parents[1] / "assets" / "logo.jpg"
|
| 21 |
if not path.exists():
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
| 30 |
+
COMPARE_DEFAULT_SPLIT = 50
|
| 31 |
+
|
| 32 |
+
|
| 33 |
HEADER_HTML = f"""
|
| 34 |
<div id="halide-header">
|
| 35 |
<div class="halide-brand-lockup">
|
|
|
|
| 131 |
else:
|
| 132 |
box_text = "invalid"
|
| 133 |
confidence = defect.get("confidence")
|
| 134 |
+
confidence_text = (
|
| 135 |
+
"not emitted" if confidence is None else f"{float(confidence):.2f}"
|
| 136 |
+
)
|
| 137 |
rows.append([str(index), display, confidence_text, box_text])
|
| 138 |
return rows
|
| 139 |
|
|
|
|
| 150 |
rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
|
| 151 |
rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
|
| 152 |
rows.append(_stat_row("Duplicates removed", str(defects.get("duplicate_count", 0))))
|
| 153 |
+
rows.append(_stat_row("Edge artifacts removed", str(defects.get("edge_artifact_count", 0))))
|
| 154 |
+
rows.append(_stat_row("CV assist boxes", str(defects.get("classical_assist_count", 0))))
|
| 155 |
rows.append(_stat_row("Resized for model", "yes" if defects.get("resized_for_model") else "no"))
|
| 156 |
rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
|
| 157 |
rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
|
|
|
|
| 200 |
)
|
| 201 |
|
| 202 |
|
| 203 |
+
def comparison_viewer_html(original, annotated) -> str:
|
| 204 |
+
"""Render an aligned before/after viewer using the same image canvas."""
|
| 205 |
+
original_uri = image_to_data_uri(original, max_side=1800, quality=92)
|
| 206 |
+
overlay_uri = image_to_data_uri(annotated, max_side=1800, quality=92)
|
| 207 |
+
return (
|
| 208 |
+
'<div class="halide-compare-viewer" '
|
| 209 |
+
f'style="--halide-split: {COMPARE_DEFAULT_SPLIT}%;">'
|
| 210 |
+
'<div class="halide-compare-stage">'
|
| 211 |
+
f'<img class="halide-compare-base" src="{original_uri}" alt="" />'
|
| 212 |
+
'<div class="halide-compare-overlay">'
|
| 213 |
+
f'<img src="{overlay_uri}" alt="" />'
|
| 214 |
+
"</div>"
|
| 215 |
+
'<div class="halide-compare-divider"></div>'
|
| 216 |
+
'<span class="halide-compare-label original">Original</span>'
|
| 217 |
+
'<span class="halide-compare-label overlay">Validated overlay</span>'
|
| 218 |
+
"</div>"
|
| 219 |
+
'<input class="halide-compare-range" type="range" min="0" max="100" '
|
| 220 |
+
f'value="{COMPARE_DEFAULT_SPLIT}" '
|
| 221 |
+
'aria-label="Compare original and validated overlay" '
|
| 222 |
+
'oninput="const viewer=this.closest('
|
| 223 |
+
"'.halide-compare-viewer'); "
|
| 224 |
+
"if (viewer) { viewer.style.setProperty('--halide-split', this.value + '%'); }"
|
| 225 |
+
'" />'
|
| 226 |
+
"</div>"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
def render_markdown_report(text: str) -> str:
|
| 231 |
"""Render the constrained diagnosis Markdown used by Nemotron.
|
| 232 |
|
|
|
|
| 363 |
duplicate = int(defects.get("duplicate_count", 0) or 0)
|
| 364 |
meta = result.get("film_metadata", {}) or {}
|
| 365 |
confidence = str(meta.get("metadata_confidence", "low") or "low").lower()
|
| 366 |
+
confidence_values = [
|
| 367 |
+
defect.get("confidence")
|
| 368 |
+
for defect in defects.get("defects", []) or []
|
| 369 |
+
if defect.get("confidence") is not None
|
| 370 |
+
]
|
| 371 |
|
| 372 |
if total == 0:
|
| 373 |
message = "No validated boxes were returned. Inspect the scan before assuming a film fault."
|
| 374 |
tone = "neutral"
|
| 375 |
+
elif not confidence_values:
|
| 376 |
+
message = (
|
| 377 |
+
"Defect boxes passed schema validation. MiniCPM did not emit numeric "
|
| 378 |
+
"per-box confidence for this run."
|
| 379 |
+
)
|
| 380 |
+
tone = "good"
|
| 381 |
elif confidence == "low":
|
| 382 |
message = "Metadata is marked low confidence, so the diagnosis is driven mainly by visible defects."
|
| 383 |
tone = "caution"
|
|
|
|
| 548 |
"LIGHTTABLE_RUNNING_STATE",
|
| 549 |
"REPORT_EMPTY_STATE",
|
| 550 |
"compact_label_counts",
|
| 551 |
+
"comparison_viewer_html",
|
| 552 |
"confidence_notice_html",
|
| 553 |
"defect_table_rows",
|
| 554 |
"defect_pills_html",
|
ui/theme.py
CHANGED
|
@@ -4,23 +4,23 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
-
BRASS = "#
|
| 8 |
-
BRASS_DARK = "#
|
| 9 |
-
COPPER = "#
|
| 10 |
-
TEAL = "#
|
| 11 |
-
VIOLET = "#
|
| 12 |
-
RED = "#
|
| 13 |
-
|
| 14 |
-
INK = "#
|
| 15 |
-
CARBON = "#
|
| 16 |
-
SURFACE = "#
|
| 17 |
-
SURFACE_SOFT = "#
|
| 18 |
-
SURFACE_LIFT = "#
|
| 19 |
-
PAPER = "#
|
| 20 |
-
PAPER_SOFT = "#
|
| 21 |
-
MUTED = "#
|
| 22 |
-
BORDER = "#
|
| 23 |
-
BLACK = "#
|
| 24 |
|
| 25 |
THEME_CSS = f"""
|
| 26 |
:root {{
|
|
@@ -60,8 +60,8 @@ body::before {{
|
|
| 60 |
background:
|
| 61 |
repeating-linear-gradient(
|
| 62 |
90deg,
|
| 63 |
-
rgba(255,
|
| 64 |
-
rgba(255,
|
| 65 |
transparent 1px,
|
| 66 |
transparent 16px
|
| 67 |
);
|
|
@@ -127,7 +127,7 @@ body::before {{
|
|
| 127 |
display: flex;
|
| 128 |
flex-wrap: wrap;
|
| 129 |
justify-content: flex-end;
|
| 130 |
-
gap:
|
| 131 |
min-width: min(42vw, 36rem);
|
| 132 |
}}
|
| 133 |
|
|
@@ -137,17 +137,18 @@ body::before {{
|
|
| 137 |
color: var(--halide-paper);
|
| 138 |
background: rgba(24, 23, 21, 0.92);
|
| 139 |
border-radius: 8px;
|
| 140 |
-
padding:
|
| 141 |
font-size: 0.76rem;
|
| 142 |
font-weight: 780;
|
| 143 |
-
line-height: 1;
|
| 144 |
text-decoration: none;
|
| 145 |
white-space: nowrap;
|
| 146 |
}}
|
| 147 |
|
| 148 |
.halide-model-strip a {{
|
| 149 |
-
color: #
|
| 150 |
-
border-color: rgba(
|
|
|
|
| 151 |
}}
|
| 152 |
|
| 153 |
.halide-workbench {{
|
|
@@ -188,8 +189,8 @@ body::before {{
|
|
| 188 |
}}
|
| 189 |
|
| 190 |
.halide-model-card {{
|
| 191 |
-
border: 1px solid rgba(
|
| 192 |
-
background: rgba(
|
| 193 |
border-radius: 8px;
|
| 194 |
padding: 12px;
|
| 195 |
margin-top: 12px;
|
|
@@ -243,11 +244,11 @@ body::before {{
|
|
| 243 |
}}
|
| 244 |
|
| 245 |
.halide-run-state.active {{
|
| 246 |
-
border-color: rgba(
|
| 247 |
}}
|
| 248 |
|
| 249 |
.halide-run-state.quiet {{
|
| 250 |
-
border-color: rgba(
|
| 251 |
}}
|
| 252 |
|
| 253 |
.halide-run-eyebrow {{
|
|
@@ -259,7 +260,7 @@ body::before {{
|
|
| 259 |
}}
|
| 260 |
|
| 261 |
.halide-lighttable {{
|
| 262 |
-
background: #
|
| 263 |
border: 1px solid rgba(197, 154, 82, 0.34) !important;
|
| 264 |
border-radius: 8px !important;
|
| 265 |
padding: 13px !important;
|
|
@@ -358,11 +359,11 @@ body::before {{
|
|
| 358 |
}}
|
| 359 |
|
| 360 |
.halide-empty-lighttable.active {{
|
| 361 |
-
border-color: rgba(
|
| 362 |
}}
|
| 363 |
|
| 364 |
.halide-empty-lighttable.active .halide-empty-center {{
|
| 365 |
-
border-color: rgba(
|
| 366 |
}}
|
| 367 |
|
| 368 |
.halide-empty-lighttable.active .halide-empty-center span {{
|
|
@@ -403,7 +404,6 @@ body::before {{
|
|
| 403 |
line-height: 1;
|
| 404 |
}}
|
| 405 |
|
| 406 |
-
#halide-compare,
|
| 407 |
.halide-review-gallery {{
|
| 408 |
background: var(--halide-black) !important;
|
| 409 |
}}
|
|
@@ -413,12 +413,95 @@ body::before {{
|
|
| 413 |
}}
|
| 414 |
|
| 415 |
.halide-upload img,
|
| 416 |
-
#halide-compare img,
|
| 417 |
.halide-review-gallery img {{
|
| 418 |
background: var(--halide-black) !important;
|
| 419 |
object-fit: contain !important;
|
| 420 |
}}
|
| 421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
#halide-run-button,
|
| 423 |
button.primary,
|
| 424 |
.primary button {{
|
|
@@ -511,8 +594,8 @@ button {{
|
|
| 511 |
}}
|
| 512 |
|
| 513 |
.halide-notice.good {{
|
| 514 |
-
border-color: rgba(
|
| 515 |
-
background: rgba(
|
| 516 |
}}
|
| 517 |
|
| 518 |
.halide-notice.caution {{
|
|
@@ -521,8 +604,8 @@ button {{
|
|
| 521 |
}}
|
| 522 |
|
| 523 |
.halide-notice.neutral {{
|
| 524 |
-
border-color: rgba(
|
| 525 |
-
background: rgba(
|
| 526 |
}}
|
| 527 |
|
| 528 |
.halide-stats {{
|
|
@@ -646,13 +729,13 @@ button {{
|
|
| 646 |
}}
|
| 647 |
|
| 648 |
.halide-defect-pill.long_hair {{
|
| 649 |
-
background: rgba(
|
| 650 |
-
border-color: rgba(
|
| 651 |
}}
|
| 652 |
|
| 653 |
.halide-defect-pill.short_hair {{
|
| 654 |
-
background: rgba(
|
| 655 |
-
border-color: rgba(
|
| 656 |
}}
|
| 657 |
|
| 658 |
.halide-defect-pill.emulsion_damage {{
|
|
@@ -661,8 +744,8 @@ button {{
|
|
| 661 |
}}
|
| 662 |
|
| 663 |
.halide-defect-pill.chemical_stain {{
|
| 664 |
-
background: rgba(
|
| 665 |
-
border-color: rgba(
|
| 666 |
}}
|
| 667 |
|
| 668 |
.halide-defect-pill.light_leak {{
|
|
@@ -888,11 +971,6 @@ footer a:hover {{
|
|
| 888 |
z-index: 1;
|
| 889 |
}}
|
| 890 |
|
| 891 |
-
#halide-compare {{
|
| 892 |
-
min-height: 520px !important;
|
| 893 |
-
border: 1px solid rgba(243, 234, 219, 0.16) !important;
|
| 894 |
-
}}
|
| 895 |
-
|
| 896 |
.halide-review-actions {{
|
| 897 |
display: flex;
|
| 898 |
flex-wrap: wrap;
|
|
@@ -908,9 +986,9 @@ footer a:hover {{
|
|
| 908 |
min-height: 36px;
|
| 909 |
padding: 0 12px;
|
| 910 |
border-radius: 8px;
|
| 911 |
-
border: 1px solid rgba(
|
| 912 |
-
background: rgba(
|
| 913 |
-
color: #
|
| 914 |
text-decoration: none !important;
|
| 915 |
font-size: 0.78rem;
|
| 916 |
font-weight: 820;
|
|
@@ -918,8 +996,8 @@ footer a:hover {{
|
|
| 918 |
|
| 919 |
.halide-review-actions a:hover,
|
| 920 |
.halide-history-preview-actions a:hover {{
|
| 921 |
-
border-color: rgba(
|
| 922 |
-
background: rgba(
|
| 923 |
}}
|
| 924 |
|
| 925 |
.halide-report-subheading {{
|
|
@@ -938,9 +1016,9 @@ footer a:hover {{
|
|
| 938 |
}}
|
| 939 |
|
| 940 |
.halide-report-body code {{
|
| 941 |
-
color: #
|
| 942 |
-
background: rgba(
|
| 943 |
-
border: 1px solid rgba(
|
| 944 |
border-radius: 5px;
|
| 945 |
padding: 1px 5px;
|
| 946 |
}}
|
|
@@ -1104,7 +1182,7 @@ footer a:hover {{
|
|
| 1104 |
height: 190px !important;
|
| 1105 |
}}
|
| 1106 |
|
| 1107 |
-
|
| 1108 |
min-height: 360px !important;
|
| 1109 |
}}
|
| 1110 |
|
|
@@ -1143,17 +1221,17 @@ def build_theme() -> gr.Theme:
|
|
| 1143 |
c950="#1c1209",
|
| 1144 |
),
|
| 1145 |
secondary_hue=gr.themes.Color(
|
| 1146 |
-
c50="#
|
| 1147 |
-
c100="#
|
| 1148 |
-
c200="#
|
| 1149 |
c300=TEAL,
|
| 1150 |
-
c400="#
|
| 1151 |
-
c500="#
|
| 1152 |
-
c600="#
|
| 1153 |
-
c700="#
|
| 1154 |
-
c800="#
|
| 1155 |
-
c900="#
|
| 1156 |
-
c950="#
|
| 1157 |
),
|
| 1158 |
neutral_hue=gr.themes.Color(
|
| 1159 |
c50="#fbf7ef",
|
|
|
|
| 4 |
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
+
BRASS = "#d29a45"
|
| 8 |
+
BRASS_DARK = "#8c5a1f"
|
| 9 |
+
COPPER = "#be5f38"
|
| 10 |
+
TEAL = "#9aae6f"
|
| 11 |
+
VIOLET = "#8b5e4f"
|
| 12 |
+
RED = "#d85c45"
|
| 13 |
+
|
| 14 |
+
INK = "#100d0a"
|
| 15 |
+
CARBON = "#17110d"
|
| 16 |
+
SURFACE = "#1f1711"
|
| 17 |
+
SURFACE_SOFT = "#2b2118"
|
| 18 |
+
SURFACE_LIFT = "#3a2b1f"
|
| 19 |
+
PAPER = "#fff0d8"
|
| 20 |
+
PAPER_SOFT = "#e6d1b6"
|
| 21 |
+
MUTED = "#b89c77"
|
| 22 |
+
BORDER = "#5a432f"
|
| 23 |
+
BLACK = "#080604"
|
| 24 |
|
| 25 |
THEME_CSS = f"""
|
| 26 |
:root {{
|
|
|
|
| 60 |
background:
|
| 61 |
repeating-linear-gradient(
|
| 62 |
90deg,
|
| 63 |
+
rgba(255, 240, 216, 0.018) 0,
|
| 64 |
+
rgba(255, 240, 216, 0.018) 1px,
|
| 65 |
transparent 1px,
|
| 66 |
transparent 16px
|
| 67 |
);
|
|
|
|
| 127 |
display: flex;
|
| 128 |
flex-wrap: wrap;
|
| 129 |
justify-content: flex-end;
|
| 130 |
+
gap: 10px;
|
| 131 |
min-width: min(42vw, 36rem);
|
| 132 |
}}
|
| 133 |
|
|
|
|
| 137 |
color: var(--halide-paper);
|
| 138 |
background: rgba(24, 23, 21, 0.92);
|
| 139 |
border-radius: 8px;
|
| 140 |
+
padding: 9px 13px;
|
| 141 |
font-size: 0.76rem;
|
| 142 |
font-weight: 780;
|
| 143 |
+
line-height: 1.08;
|
| 144 |
text-decoration: none;
|
| 145 |
white-space: nowrap;
|
| 146 |
}}
|
| 147 |
|
| 148 |
.halide-model-strip a {{
|
| 149 |
+
color: #f5dfac;
|
| 150 |
+
border-color: rgba(154, 174, 111, 0.42);
|
| 151 |
+
background: rgba(58, 43, 31, 0.92);
|
| 152 |
}}
|
| 153 |
|
| 154 |
.halide-workbench {{
|
|
|
|
| 189 |
}}
|
| 190 |
|
| 191 |
.halide-model-card {{
|
| 192 |
+
border: 1px solid rgba(154, 174, 111, 0.30);
|
| 193 |
+
background: rgba(154, 174, 111, 0.075);
|
| 194 |
border-radius: 8px;
|
| 195 |
padding: 12px;
|
| 196 |
margin-top: 12px;
|
|
|
|
| 244 |
}}
|
| 245 |
|
| 246 |
.halide-run-state.active {{
|
| 247 |
+
border-color: rgba(154, 174, 111, 0.48);
|
| 248 |
}}
|
| 249 |
|
| 250 |
.halide-run-state.quiet {{
|
| 251 |
+
border-color: rgba(139, 94, 79, 0.38);
|
| 252 |
}}
|
| 253 |
|
| 254 |
.halide-run-eyebrow {{
|
|
|
|
| 260 |
}}
|
| 261 |
|
| 262 |
.halide-lighttable {{
|
| 263 |
+
background: #120d09 !important;
|
| 264 |
border: 1px solid rgba(197, 154, 82, 0.34) !important;
|
| 265 |
border-radius: 8px !important;
|
| 266 |
padding: 13px !important;
|
|
|
|
| 359 |
}}
|
| 360 |
|
| 361 |
.halide-empty-lighttable.active {{
|
| 362 |
+
border-color: rgba(154, 174, 111, 0.36);
|
| 363 |
}}
|
| 364 |
|
| 365 |
.halide-empty-lighttable.active .halide-empty-center {{
|
| 366 |
+
border-color: rgba(154, 174, 111, 0.44);
|
| 367 |
}}
|
| 368 |
|
| 369 |
.halide-empty-lighttable.active .halide-empty-center span {{
|
|
|
|
| 404 |
line-height: 1;
|
| 405 |
}}
|
| 406 |
|
|
|
|
| 407 |
.halide-review-gallery {{
|
| 408 |
background: var(--halide-black) !important;
|
| 409 |
}}
|
|
|
|
| 413 |
}}
|
| 414 |
|
| 415 |
.halide-upload img,
|
|
|
|
| 416 |
.halide-review-gallery img {{
|
| 417 |
background: var(--halide-black) !important;
|
| 418 |
object-fit: contain !important;
|
| 419 |
}}
|
| 420 |
|
| 421 |
+
#halide-compare {{
|
| 422 |
+
min-height: 0 !important;
|
| 423 |
+
}}
|
| 424 |
+
|
| 425 |
+
.halide-compare-viewer {{
|
| 426 |
+
display: grid;
|
| 427 |
+
gap: 12px;
|
| 428 |
+
background: var(--halide-black);
|
| 429 |
+
border: 1px solid rgba(255, 240, 216, 0.16);
|
| 430 |
+
border-radius: 8px;
|
| 431 |
+
padding: 12px;
|
| 432 |
+
}}
|
| 433 |
+
|
| 434 |
+
.halide-compare-stage {{
|
| 435 |
+
position: relative;
|
| 436 |
+
min-height: clamp(380px, 58vh, 720px);
|
| 437 |
+
overflow: hidden;
|
| 438 |
+
display: grid;
|
| 439 |
+
place-items: center;
|
| 440 |
+
background:
|
| 441 |
+
linear-gradient(180deg, rgba(31, 23, 17, 0.82), rgba(8, 6, 4, 0.98)),
|
| 442 |
+
repeating-linear-gradient(
|
| 443 |
+
90deg,
|
| 444 |
+
rgba(210, 154, 69, 0.055) 0,
|
| 445 |
+
rgba(210, 154, 69, 0.055) 1px,
|
| 446 |
+
transparent 1px,
|
| 447 |
+
transparent 34px
|
| 448 |
+
);
|
| 449 |
+
border-radius: 7px;
|
| 450 |
+
}}
|
| 451 |
+
|
| 452 |
+
.halide-compare-stage img {{
|
| 453 |
+
position: absolute;
|
| 454 |
+
inset: 0;
|
| 455 |
+
width: 100%;
|
| 456 |
+
height: 100%;
|
| 457 |
+
object-fit: contain;
|
| 458 |
+
background: var(--halide-black);
|
| 459 |
+
}}
|
| 460 |
+
|
| 461 |
+
.halide-compare-overlay {{
|
| 462 |
+
position: absolute;
|
| 463 |
+
inset: 0;
|
| 464 |
+
clip-path: inset(0 calc(100% - var(--halide-split)) 0 0);
|
| 465 |
+
}}
|
| 466 |
+
|
| 467 |
+
.halide-compare-divider {{
|
| 468 |
+
position: absolute;
|
| 469 |
+
top: 0;
|
| 470 |
+
bottom: 0;
|
| 471 |
+
left: var(--halide-split);
|
| 472 |
+
width: 2px;
|
| 473 |
+
transform: translateX(-1px);
|
| 474 |
+
background: rgba(255, 240, 216, 0.88);
|
| 475 |
+
box-shadow: 0 0 0 1px rgba(8, 6, 4, 0.70), 0 0 18px rgba(190, 95, 56, 0.38);
|
| 476 |
+
}}
|
| 477 |
+
|
| 478 |
+
.halide-compare-label {{
|
| 479 |
+
position: absolute;
|
| 480 |
+
top: 12px;
|
| 481 |
+
z-index: 2;
|
| 482 |
+
border: 1px solid rgba(255, 240, 216, 0.20);
|
| 483 |
+
background: rgba(16, 13, 10, 0.78);
|
| 484 |
+
color: var(--halide-paper);
|
| 485 |
+
border-radius: 999px;
|
| 486 |
+
padding: 6px 9px;
|
| 487 |
+
font-size: 0.70rem;
|
| 488 |
+
font-weight: 820;
|
| 489 |
+
line-height: 1;
|
| 490 |
+
}}
|
| 491 |
+
|
| 492 |
+
.halide-compare-label.original {{
|
| 493 |
+
left: 12px;
|
| 494 |
+
}}
|
| 495 |
+
|
| 496 |
+
.halide-compare-label.overlay {{
|
| 497 |
+
right: 12px;
|
| 498 |
+
}}
|
| 499 |
+
|
| 500 |
+
.halide-compare-range {{
|
| 501 |
+
width: 100%;
|
| 502 |
+
accent-color: var(--halide-copper);
|
| 503 |
+
}}
|
| 504 |
+
|
| 505 |
#halide-run-button,
|
| 506 |
button.primary,
|
| 507 |
.primary button {{
|
|
|
|
| 594 |
}}
|
| 595 |
|
| 596 |
.halide-notice.good {{
|
| 597 |
+
border-color: rgba(154, 174, 111, 0.40);
|
| 598 |
+
background: rgba(154, 174, 111, 0.085);
|
| 599 |
}}
|
| 600 |
|
| 601 |
.halide-notice.caution {{
|
|
|
|
| 604 |
}}
|
| 605 |
|
| 606 |
.halide-notice.neutral {{
|
| 607 |
+
border-color: rgba(139, 94, 79, 0.38);
|
| 608 |
+
background: rgba(139, 94, 79, 0.085);
|
| 609 |
}}
|
| 610 |
|
| 611 |
.halide-stats {{
|
|
|
|
| 729 |
}}
|
| 730 |
|
| 731 |
.halide-defect-pill.long_hair {{
|
| 732 |
+
background: rgba(139, 94, 79, 0.14);
|
| 733 |
+
border-color: rgba(139, 94, 79, 0.40);
|
| 734 |
}}
|
| 735 |
|
| 736 |
.halide-defect-pill.short_hair {{
|
| 737 |
+
background: rgba(154, 174, 111, 0.12);
|
| 738 |
+
border-color: rgba(154, 174, 111, 0.38);
|
| 739 |
}}
|
| 740 |
|
| 741 |
.halide-defect-pill.emulsion_damage {{
|
|
|
|
| 744 |
}}
|
| 745 |
|
| 746 |
.halide-defect-pill.chemical_stain {{
|
| 747 |
+
background: rgba(154, 174, 111, 0.13);
|
| 748 |
+
border-color: rgba(154, 174, 111, 0.38);
|
| 749 |
}}
|
| 750 |
|
| 751 |
.halide-defect-pill.light_leak {{
|
|
|
|
| 971 |
z-index: 1;
|
| 972 |
}}
|
| 973 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
.halide-review-actions {{
|
| 975 |
display: flex;
|
| 976 |
flex-wrap: wrap;
|
|
|
|
| 986 |
min-height: 36px;
|
| 987 |
padding: 0 12px;
|
| 988 |
border-radius: 8px;
|
| 989 |
+
border: 1px solid rgba(154, 174, 111, 0.38);
|
| 990 |
+
background: rgba(154, 174, 111, 0.09);
|
| 991 |
+
color: #f5dfac !important;
|
| 992 |
text-decoration: none !important;
|
| 993 |
font-size: 0.78rem;
|
| 994 |
font-weight: 820;
|
|
|
|
| 996 |
|
| 997 |
.halide-review-actions a:hover,
|
| 998 |
.halide-history-preview-actions a:hover {{
|
| 999 |
+
border-color: rgba(154, 174, 111, 0.62);
|
| 1000 |
+
background: rgba(154, 174, 111, 0.14);
|
| 1001 |
}}
|
| 1002 |
|
| 1003 |
.halide-report-subheading {{
|
|
|
|
| 1016 |
}}
|
| 1017 |
|
| 1018 |
.halide-report-body code {{
|
| 1019 |
+
color: #f5dfac;
|
| 1020 |
+
background: rgba(154, 174, 111, 0.10);
|
| 1021 |
+
border: 1px solid rgba(154, 174, 111, 0.24);
|
| 1022 |
border-radius: 5px;
|
| 1023 |
padding: 1px 5px;
|
| 1024 |
}}
|
|
|
|
| 1182 |
height: 190px !important;
|
| 1183 |
}}
|
| 1184 |
|
| 1185 |
+
.halide-compare-stage {{
|
| 1186 |
min-height: 360px !important;
|
| 1187 |
}}
|
| 1188 |
|
|
|
|
| 1221 |
c950="#1c1209",
|
| 1222 |
),
|
| 1223 |
secondary_hue=gr.themes.Color(
|
| 1224 |
+
c50="#f8faec",
|
| 1225 |
+
c100="#eef3d1",
|
| 1226 |
+
c200="#dbe6a9",
|
| 1227 |
c300=TEAL,
|
| 1228 |
+
c400="#7f9857",
|
| 1229 |
+
c500="#667a43",
|
| 1230 |
+
c600="#506236",
|
| 1231 |
+
c700="#3e4d2b",
|
| 1232 |
+
c800="#303c22",
|
| 1233 |
+
c900="#252f1b",
|
| 1234 |
+
c950="#131a0d",
|
| 1235 |
),
|
| 1236 |
neutral_hue=gr.themes.Color(
|
| 1237 |
c50="#fbf7ef",
|