| """Verified CPU/GPU inference runtime for the public DefectForge Space.""" |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import math |
| import os |
| import time |
| from contextlib import nullcontext |
| from dataclasses import dataclass |
| from functools import lru_cache |
| from pathlib import Path, PurePosixPath |
| from typing import Any |
|
|
| import numpy as np |
| import timm |
| import torch |
| import torch.nn.functional as nnf |
| from PIL import Image, ImageOps |
| from safetensors.torch import load_file |
| from torchvision import transforms |
| from torchvision.transforms import InterpolationMode |
| from torchvision.transforms import functional as tvf |
| from transformers import SegformerForSemanticSegmentation |
|
|
| SPACE_ROOT = Path(__file__).resolve().parent |
| MODEL_ROOT = SPACE_ROOT / "models" |
| MANIFEST_PATH = MODEL_ROOT / "manifest.json" |
| MAX_IMAGE_PIXELS = 25_000_000 |
| SUPPORTED_OBJECTS = ("pcb1", "capsules") |
|
|
|
|
| class SpaceContractError(RuntimeError): |
| """Raised when a public-demo integrity or inference contract fails.""" |
|
|
|
|
| def require(condition: bool, message: str) -> None: |
| if not condition: |
| raise SpaceContractError(message) |
|
|
|
|
| def sha256_file(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| def _safe_relative_path(value: object, *, space_root: Path = SPACE_ROOT) -> Path: |
| require(isinstance(value, str) and bool(value), "Model path is missing") |
| portable = PurePosixPath(value) |
| require(not portable.is_absolute(), "Model path must be relative") |
| require(".." not in portable.parts, "Model path cannot escape the model root") |
| resolved_root = space_root.resolve(strict=False) |
| candidate = (resolved_root / Path(*portable.parts)).resolve(strict=False) |
| require(candidate.is_relative_to(resolved_root), "Model path escaped the Space root") |
| return candidate |
|
|
|
|
| def _verified_artifact( |
| record: dict[str, Any], |
| *, |
| label: str, |
| space_root: Path = SPACE_ROOT, |
| ) -> Path: |
| path = _safe_relative_path(record.get("path"), space_root=space_root) |
| require(path.is_file(), f"Missing {label}: {path.name}") |
| expected_bytes = record.get("bytes") |
| require(isinstance(expected_bytes, int) and expected_bytes > 0, f"Invalid {label} size") |
| require(path.stat().st_size == expected_bytes, f"{label} size changed") |
| expected_sha256 = record.get("sha256") |
| require( |
| isinstance(expected_sha256, str) |
| and len(expected_sha256) == 64 |
| and all(character in "0123456789abcdef" for character in expected_sha256), |
| f"Invalid {label} SHA256", |
| ) |
| require(sha256_file(path) == expected_sha256, f"{label} SHA256 changed") |
| return path |
|
|
|
|
| def load_manifest(path: Path = MANIFEST_PATH) -> dict[str, Any]: |
| require(path.is_file(), "The verified model manifest is missing") |
| payload = json.loads(path.read_text(encoding="utf-8")) |
| require(isinstance(payload, dict), "The model manifest must be a JSON object") |
| require(payload.get("schema_version") == 1, "Unsupported model manifest schema") |
| require(payload.get("status") == "passed", "The model manifest did not pass packaging") |
| objects = payload.get("objects") |
| require(isinstance(objects, dict), "The model manifest has no object inventory") |
| require(tuple(objects) == SUPPORTED_OBJECTS, "The public object inventory changed") |
| space_root = path.parent.parent |
| for object_name, object_record in objects.items(): |
| require(isinstance(object_record, dict), f"Invalid manifest entry for {object_name}") |
| for role in ("classifier", "segmenter"): |
| role_record = object_record.get(role) |
| require(isinstance(role_record, dict), f"Missing {object_name}/{role} record") |
| _verified_artifact( |
| role_record, |
| label=f"{object_name} {role}", |
| space_root=space_root, |
| ) |
| return payload |
|
|
|
|
| def preferred_device() -> torch.device: |
| requested = os.environ.get("DEFECTFORGE_DEVICE", "auto").strip().lower() |
| require(requested in {"auto", "cpu", "cuda"}, "DEFECTFORGE_DEVICE must be auto/cpu/cuda") |
| if requested == "cuda": |
| require(torch.cuda.is_available(), "CUDA was requested but is unavailable") |
| return torch.device("cuda") |
| if requested == "cpu": |
| return torch.device("cpu") |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
|
|
| @dataclass(slots=True) |
| class ModelBundle: |
| """One object-matched classifier and segmenter pair.""" |
|
|
| object_name: str |
| classifier: torch.nn.Module |
| segmenter: torch.nn.Module |
| classifier_transform: transforms.Compose |
| segmenter_size: int |
| segmenter_mean: tuple[float, float, float] |
| segmenter_std: tuple[float, float, float] |
| formal_threshold: float |
| device: torch.device |
| evidence: dict[str, Any] |
|
|
|
|
| def _load_classifier(record: dict[str, Any], device: torch.device) -> tuple[Any, Any]: |
| weight = _verified_artifact(record, label="classifier") |
| architecture = str(record["architecture"]) |
| model = timm.create_model(architecture, pretrained=False) |
| model.reset_classifier(int(record["num_classes"])) |
| model.load_state_dict(load_file(str(weight), device="cpu"), strict=True) |
| model = model.eval().to(device) |
| size = int(record["input_size"]) |
| mean = tuple(float(value) for value in record["mean"]) |
| std = tuple(float(value) for value in record["std"]) |
| transform = transforms.Compose( |
| [ |
| transforms.Resize( |
| (size, size), |
| interpolation=InterpolationMode.BICUBIC, |
| antialias=True, |
| ), |
| transforms.ToTensor(), |
| transforms.Normalize(mean=mean, std=std), |
| ] |
| ) |
| return model, transform |
|
|
|
|
| def _load_segmenter(record: dict[str, Any], device: torch.device) -> torch.nn.Module: |
| weight = _verified_artifact(record, label="segmenter") |
| model = SegformerForSemanticSegmentation.from_pretrained( |
| str(weight.parent), |
| local_files_only=True, |
| use_safetensors=True, |
| ) |
| return model.eval().to(device) |
|
|
|
|
| @lru_cache(maxsize=2) |
| def load_bundle(object_name: str) -> ModelBundle: |
| require(object_name in SUPPORTED_OBJECTS, f"Unsupported object: {object_name}") |
| manifest = load_manifest() |
| object_record = manifest["objects"][object_name] |
| device = preferred_device() |
| if device.type == "cpu": |
| torch.set_num_threads(max(1, min(4, os.cpu_count() or 1))) |
| classifier, classifier_transform = _load_classifier(object_record["classifier"], device) |
| segmenter = _load_segmenter(object_record["segmenter"], device) |
| segmenter_record = object_record["segmenter"] |
| evidence = { |
| "object": object_name, |
| "device": device.type, |
| "source_commit": manifest["source_commit"], |
| "classification_results_sha256": manifest["classification_results_sha256"], |
| "segmentation_results_sha256": manifest["segmentation_results_sha256"], |
| "classifier": { |
| key: object_record["classifier"][key] |
| for key in ("run_name", "group_name", "primary_metric", "primary_value", "sha256") |
| }, |
| "segmenter": { |
| key: segmenter_record[key] |
| for key in ("run_name", "group_name", "primary_metric", "primary_value", "sha256") |
| }, |
| } |
| return ModelBundle( |
| object_name=object_name, |
| classifier=classifier, |
| segmenter=segmenter, |
| classifier_transform=classifier_transform, |
| segmenter_size=int(segmenter_record["input_size"]), |
| segmenter_mean=tuple(float(value) for value in segmenter_record["mean"]), |
| segmenter_std=tuple(float(value) for value in segmenter_record["std"]), |
| formal_threshold=float(segmenter_record["formal_threshold"]), |
| device=device, |
| evidence=evidence, |
| ) |
|
|
|
|
| def _as_rgb_array(image: Image.Image | np.ndarray) -> np.ndarray: |
| if isinstance(image, Image.Image): |
| pil = ImageOps.exif_transpose(image).convert("RGB") |
| require(pil.width * pil.height <= MAX_IMAGE_PIXELS, "Image exceeds the 25 MP limit") |
| return np.asarray(pil, dtype=np.uint8) |
| array = np.asarray(image) |
| require(array.ndim in {2, 3}, "Input image must be HxW or HxWxC") |
| require(array.shape[0] * array.shape[1] <= MAX_IMAGE_PIXELS, "Image exceeds the 25 MP limit") |
| if array.ndim == 2: |
| array = np.repeat(array[:, :, None], 3, axis=2) |
| if array.shape[2] == 4: |
| array = array[:, :, :3] |
| require(array.shape[2] == 3, "Input image must have one, three, or four channels") |
| if np.issubdtype(array.dtype, np.floating): |
| maximum = float(np.nanmax(array)) if array.size else 0.0 |
| array = array * 255.0 if maximum <= 1.0 else array |
| return np.clip(array, 0, 255).astype(np.uint8) |
|
|
|
|
| def _colorize_probability(probability: np.ndarray) -> np.ndarray: |
| values = np.clip(np.asarray(probability, dtype=np.float32), 0.0, 1.0) |
| stops = np.asarray( |
| [ |
| [5, 18, 27], |
| [13, 91, 120], |
| [61, 202, 170], |
| [250, 194, 63], |
| [239, 73, 51], |
| ], |
| dtype=np.float32, |
| ) |
| position = values * (len(stops) - 1) |
| lower = np.floor(position).astype(np.int64) |
| upper = np.minimum(lower + 1, len(stops) - 1) |
| fraction = (position - lower)[..., None] |
| return np.rint(stops[lower] * (1.0 - fraction) + stops[upper] * fraction).astype(np.uint8) |
|
|
|
|
| def localization_statistics( |
| pixel_probability: np.ndarray, |
| *, |
| threshold: float, |
| ) -> dict[str, float]: |
| """Summarize the raw pixel scores used to render a localization result.""" |
| probability = np.asarray(pixel_probability, dtype=np.float32) |
| require(probability.ndim == 2 and probability.size > 0, "Pixel probability map is invalid") |
| require(np.isfinite(probability).all(), "Pixel probability contains non-finite values") |
| require(0.0 <= threshold <= 1.0, "Visualization threshold is outside [0, 1]") |
| require( |
| float(probability.min()) >= 0.0 and float(probability.max()) <= 1.0, |
| "Pixel probability is outside [0, 1]", |
| ) |
| return { |
| "minimum": float(probability.min()), |
| "mean": float(probability.mean()), |
| "p95": float(np.quantile(probability, 0.95)), |
| "p99": float(np.quantile(probability, 0.99)), |
| "maximum": float(probability.max()), |
| "coverage_percent": float((probability >= threshold).mean()) * 100.0, |
| } |
|
|
|
|
| def render_outputs( |
| image: Image.Image | np.ndarray, |
| *, |
| anomaly_probability: float, |
| pixel_probability: np.ndarray, |
| threshold: float, |
| heatmap_mode: str = "overlay", |
| ) -> tuple[dict[str, float], np.ndarray, np.ndarray]: |
| rgb = _as_rgb_array(image) |
| probability = np.asarray(pixel_probability, dtype=np.float32) |
| require(probability.shape == rgb.shape[:2], "Pixel probability shape changed") |
| require(np.isfinite(probability).all(), "Pixel probability contains non-finite values") |
| require(math.isfinite(anomaly_probability), "Classification probability is not finite") |
| require(0.0 <= anomaly_probability <= 1.0, "Classification probability is outside [0, 1]") |
| require(0.0 <= threshold <= 1.0, "Visualization threshold is outside [0, 1]") |
| require( |
| heatmap_mode in {"overlay", "probability"}, |
| "Heatmap display mode is unsupported", |
| ) |
| mask = (probability >= threshold).astype(np.uint8) * 255 |
| color = _colorize_probability(probability) |
| if heatmap_mode == "probability": |
| heatmap = color |
| else: |
| alpha = (0.20 + 0.64 * np.clip(probability, 0.0, 1.0))[..., None] |
| heatmap = np.rint(rgb * (1.0 - alpha) + color * alpha).astype(np.uint8) |
| return ( |
| { |
| "Defect(異常)": anomaly_probability, |
| "Normal(正常)": 1.0 - anomaly_probability, |
| }, |
| mask, |
| heatmap, |
| ) |
|
|
|
|
| @torch.inference_mode() |
| def predict( |
| image: Image.Image | np.ndarray | None, |
| object_name: str, |
| visualization_threshold: float, |
| heatmap_mode: str = "overlay", |
| ) -> tuple[dict[str, float], np.ndarray, np.ndarray, str, dict[str, Any]]: |
| require(image is not None, "請先上傳一張待檢影像") |
| bundle = load_bundle(object_name) |
| rgb = _as_rgb_array(image) |
| pil = Image.fromarray(rgb, mode="RGB") |
| height, width = rgb.shape[:2] |
| classifier_input = bundle.classifier_transform(pil).unsqueeze(0).to(bundle.device) |
| segmenter_input = tvf.resize( |
| pil, |
| [bundle.segmenter_size, bundle.segmenter_size], |
| interpolation=InterpolationMode.BILINEAR, |
| antialias=True, |
| ) |
| segmenter_input = ( |
| tvf.normalize( |
| tvf.to_tensor(segmenter_input), |
| mean=bundle.segmenter_mean, |
| std=bundle.segmenter_std, |
| ) |
| .unsqueeze(0) |
| .to(bundle.device) |
| ) |
| if bundle.device.type == "cuda": |
| torch.cuda.synchronize() |
| autocast = torch.autocast(device_type="cuda", dtype=torch.float16) |
| else: |
| autocast = nullcontext() |
| started = time.perf_counter() |
| with autocast: |
| classifier_logits = bundle.classifier(classifier_input) |
| segmenter_logits = bundle.segmenter(pixel_values=segmenter_input).logits |
| segmenter_logits = nnf.interpolate( |
| segmenter_logits, |
| size=(height, width), |
| mode="bilinear", |
| align_corners=False, |
| ) |
| anomaly_probability = float(torch.softmax(classifier_logits.float(), dim=1)[0, 1].cpu()) |
| pixel_probability = torch.sigmoid(segmenter_logits.float())[0, 0].cpu().numpy() |
| if bundle.device.type == "cuda": |
| torch.cuda.synchronize() |
| elapsed_ms = (time.perf_counter() - started) * 1000.0 |
| probabilities, mask, heatmap = render_outputs( |
| rgb, |
| anomaly_probability=anomaly_probability, |
| pixel_probability=pixel_probability, |
| threshold=float(visualization_threshold), |
| heatmap_mode=heatmap_mode, |
| ) |
| localization = localization_statistics( |
| pixel_probability, |
| threshold=float(visualization_threshold), |
| ) |
| coverage = localization["coverage_percent"] |
| decision = "Defect(異常)" if anomaly_probability >= 0.5 else "Normal(正常)" |
| summary = ( |
| f"### 檢測完成:{decision}\n" |
| f"物件 `{object_name}` · 執行裝置 `{bundle.device.type.upper()}` · " |
| f"耗時 **{elapsed_ms:.1f} ms** \n" |
| f"在顯示 threshold **{float(visualization_threshold):.2f}** 下," |
| f"binary mask 覆蓋影像 **{coverage:.2f}%**。" |
| f"正式 preregistered threshold 為 **{bundle.formal_threshold:.2f}**。" |
| ) |
| evidence = { |
| **bundle.evidence, |
| "input": {"width": width, "height": height}, |
| "inference": { |
| "elapsed_ms": round(elapsed_ms, 3), |
| "visualization_threshold": float(visualization_threshold), |
| "formal_threshold": bundle.formal_threshold, |
| "heatmap_mode": heatmap_mode, |
| "mask_coverage_percent": round(coverage, 4), |
| "pixel_probability_minimum": round(localization["minimum"], 6), |
| "pixel_probability_mean": round(localization["mean"], 6), |
| "pixel_probability_p95": round(localization["p95"], 6), |
| "pixel_probability_p99": round(localization["p99"], 6), |
| "pixel_probability_maximum": round(localization["maximum"], 6), |
| }, |
| } |
| return probabilities, mask, heatmap, summary, evidence |
|
|