| """Standalone inference returning OPDB identifiers and the ``__unknown__`` sentinel.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import os |
| import re |
| from pathlib import Path |
| from typing import Any, BinaryIO |
|
|
| import numpy as np |
| import onnxruntime as ort |
| from PIL import Image |
|
|
| HEADS = ("group", "machine", "exact") |
| OUTPUT_NAMES = tuple(f"{head}_logits" for head in HEADS) |
| MEAN = np.asarray([0.485, 0.456, 0.406], dtype=np.float32)[:, None, None] |
| STD = np.asarray([0.229, 0.224, 0.225], dtype=np.float32)[:, None, None] |
| _TOKEN = r"[A-Za-z0-9]+" |
| _GROUP_PATTERN = re.compile(rf"^G({_TOKEN})$") |
| _MACHINE_PATTERN = re.compile(rf"^G({_TOKEN})-M({_TOKEN})$") |
| _EXACT_PATTERN = re.compile(rf"^G({_TOKEN})-M({_TOKEN})(?:-A({_TOKEN}))?$") |
|
|
|
|
| def _validate_canonical_vocabularies(vocabularies: dict[str, Any]) -> None: |
| """Validate cumulative OPDB IDs and their hierarchy without reordering them.""" |
| if not isinstance(vocabularies, dict) or set(vocabularies) != set(HEADS): |
| raise ValueError( |
| "classifier vocabularies must contain group, machine, and exact" |
| ) |
| if any(not isinstance(vocabularies[head], list) for head in HEADS): |
| raise ValueError("classifier vocabularies must be arrays") |
|
|
| groups = set(vocabularies["group"]) |
| machines = set(vocabularies["machine"]) |
| for group_id in vocabularies["group"]: |
| if not isinstance(group_id, str) or _GROUP_PATTERN.fullmatch(group_id) is None: |
| raise ValueError(f"noncanonical group vocabulary ID: {group_id!r}") |
| for machine_id in vocabularies["machine"]: |
| if not isinstance(machine_id, str): |
| raise ValueError(f"noncanonical machine vocabulary ID: {machine_id!r}") |
| match = _MACHINE_PATTERN.fullmatch(machine_id) |
| if match is None: |
| raise ValueError(f"noncanonical machine vocabulary ID: {machine_id!r}") |
| if f"G{match.group(1)}" not in groups: |
| raise ValueError( |
| f"machine vocabulary is missing parent group: {machine_id!r}" |
| ) |
| for exact_id in vocabularies["exact"]: |
| if exact_id == "__unknown__": |
| continue |
| if not isinstance(exact_id, str): |
| raise ValueError(f"invalid canonical OPDB exact ID: {exact_id!r}") |
| match = _EXACT_PATTERN.fullmatch(exact_id) |
| if match is None: |
| raise ValueError(f"invalid canonical OPDB exact ID: {exact_id!r}") |
| group_id = f"G{match.group(1)}" |
| machine_id = f"{group_id}-M{match.group(2)}" |
| if group_id not in groups: |
| raise ValueError(f"exact vocabulary is missing parent group: {exact_id!r}") |
| if machine_id not in machines: |
| raise ValueError( |
| f"exact vocabulary is missing parent machine: {exact_id!r}" |
| ) |
|
|
|
|
| def _stable_softmax(logits: np.ndarray) -> np.ndarray: |
| shifted = logits - logits.max(axis=1, keepdims=True) |
| exponentials = np.exp(shifted) |
| return exponentials / exponentials.sum(axis=1, keepdims=True) |
|
|
|
|
| def _verify_sha256(path: Path, expected: str) -> None: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(chunk) |
| if digest.hexdigest() != expected: |
| raise ValueError(f"classifier model checksum mismatch: {path}") |
|
|
|
|
| def preprocess_image( |
| source: str | Path | BinaryIO, image_size: int = 256 |
| ) -> np.ndarray: |
| """Decode, resize, center-crop, and normalize an image for the classifier.""" |
| with Image.open(source) as opened: |
| image = opened.convert("RGB") |
| width, height = image.size |
| if width <= height: |
| resized_size = (image_size, int(image_size * height / width)) |
| else: |
| resized_size = (int(image_size * width / height), image_size) |
| resized = image.resize(resized_size, Image.Resampling.BICUBIC) |
| left = round((resized.width - image_size) / 2.0) |
| top = round((resized.height - image_size) / 2.0) |
| cropped = resized.crop((left, top, left + image_size, top + image_size)) |
| tensor = np.asarray(cropped, dtype=np.float32).transpose(2, 0, 1) / 255.0 |
| return np.ascontiguousarray((tensor - MEAN) / STD, dtype=np.float32) |
|
|
|
|
| def _provider_name(provider: Any) -> str: |
| return provider[0] if isinstance(provider, tuple) else provider |
|
|
|
|
| def _resolve_providers(device: str | None) -> list[Any]: |
| available = set(ort.get_available_providers()) |
| cpu = "CPUExecutionProvider" |
| cuda = "CUDAExecutionProvider" |
| if device in (None, "cpu"): |
| if device is None and cuda in available: |
| return [(cuda, {"device_id": 0}), cpu] |
| if cpu not in available: |
| raise RuntimeError("ONNX Runtime CPUExecutionProvider is unavailable") |
| return [cpu] |
| if device == "cuda": |
| device_id = 0 |
| elif device.startswith("cuda:") and device[5:].isdecimal(): |
| device_id = int(device[5:]) |
| else: |
| raise ValueError("device must be 'cpu', 'cuda', or 'cuda:N'") |
| if cuda not in available: |
| raise RuntimeError("CUDA was requested but CUDAExecutionProvider is unavailable") |
| return [(cuda, {"device_id": device_id}), cpu] |
|
|
|
|
| class PinballClassifier: |
| """A validated ONNX session for hierarchical pinball classification.""" |
|
|
| def __init__( |
| self, |
| models_dir: str | Path = Path(__file__).parent, |
| device: str | None = None, |
| threads: int | None = None, |
| ) -> None: |
| if threads is not None and threads <= 0: |
| raise ValueError("threads must be positive") |
|
|
| models_path = Path(models_dir) |
| metadata = json.loads((models_path / "onnx-metadata.json").read_text()) |
| self.model_version = metadata["model_version"] |
| self.encoder_model = metadata["encoder_model"] |
| self.label_schema_version = metadata["label_schema_version"] |
| self.vocabularies = metadata["vocabularies"] |
| if self.label_schema_version != 2: |
| raise ValueError( |
| "classifier metadata must use canonical label schema version 2" |
| ) |
| _validate_canonical_vocabularies(self.vocabularies) |
| if tuple(metadata["outputs"]) != OUTPUT_NAMES: |
| raise ValueError("classifier metadata output order is invalid") |
|
|
| model_filename = metadata["onnx"]["file"] |
| if not isinstance(model_filename, str) or Path(model_filename).name != model_filename: |
| raise ValueError("classifier metadata ONNX file must be a basename") |
| model_path = models_path / model_filename |
| _verify_sha256(model_path, metadata["onnx"]["sha256"]) |
|
|
| providers = _resolve_providers(device) |
| options = ort.SessionOptions() |
| if _provider_name(providers[0]) == "CPUExecutionProvider": |
| configured_threads = threads |
| if configured_threads is None: |
| configured_threads = int( |
| os.environ.get( |
| "PINBALL_CLASSIFIER_THREADS", min(4, os.cpu_count() or 1) |
| ) |
| ) |
| if configured_threads <= 0: |
| raise ValueError("threads must be positive") |
| options.intra_op_num_threads = configured_threads |
| options.inter_op_num_threads = 1 |
| options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL |
| options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL |
| self.output_names = list(OUTPUT_NAMES) |
| self.session = ort.InferenceSession( |
| str(model_path), sess_options=options, providers=providers |
| ) |
| self._validate_model_contract() |
|
|
| def _validate_model_contract(self) -> None: |
| inputs = self.session.get_inputs() |
| outputs = self.session.get_outputs() |
| if len(inputs) != 1 or inputs[0].name != "images": |
| raise ValueError("classifier must expose one input named 'images'") |
| input_shape = list(inputs[0].shape) |
| if ( |
| inputs[0].type != "tensor(float)" |
| or len(input_shape) != 4 |
| or input_shape[1:] != [3, 256, 256] |
| ): |
| raise ValueError("classifier input must be float32 [B,3,256,256]") |
| if not (input_shape[0] is None or isinstance(input_shape[0], str)): |
| raise ValueError("classifier batch dimension must be dynamic") |
| if [output.name for output in outputs] != self.output_names: |
| raise ValueError("classifier ONNX output names do not match metadata") |
| for head, output in zip(HEADS, outputs, strict=True): |
| shape = list(output.shape) |
| expected_classes = len(self.vocabularies[head]) |
| if ( |
| output.type != "tensor(float)" |
| or len(shape) != 2 |
| or shape[1] != expected_classes |
| ): |
| raise ValueError( |
| f"classifier {head} output does not match its vocabulary" |
| ) |
|
|
| def predict( |
| self, |
| image_source: str | Path | BinaryIO, |
| top_count: int = 5, |
| ) -> dict[str, Any]: |
| """Rank OPDB identifiers, plus the exact head's ``__unknown__`` sentinel.""" |
| if not 1 <= top_count <= 20: |
| raise ValueError("top_count must be between 1 and 20") |
| batch = preprocess_image(image_source)[None] |
| logits = self.session.run(self.output_names, {"images": batch}) |
| result: dict[str, Any] = { |
| "model_version": self.model_version, |
| "encoder_model": self.encoder_model, |
| "label_schema_version": self.label_schema_version, |
| } |
| for head, values in zip(HEADS, logits, strict=True): |
| probabilities = _stable_softmax(values)[0] |
| indices = np.argsort(-probabilities, kind="stable")[:top_count] |
| vocabulary = self.vocabularies[head] |
| result[head] = [ |
| { |
| "id": vocabulary[int(index)], |
| "confidence": round(float(probabilities[index]), 6), |
| } |
| for index in indices |
| ] |
| return result |
|
|
|
|
| def _positive_int(value: str) -> int: |
| parsed = int(value) |
| if parsed <= 0: |
| raise argparse.ArgumentTypeError("must be positive") |
| return parsed |
|
|
|
|
| def _main() -> None: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("image", type=Path) |
| parser.add_argument( |
| "--model-dir", type=Path, default=Path(__file__).parent |
| ) |
| parser.add_argument("--top-k", type=int, default=5) |
| parser.add_argument("--device", default="cpu") |
| parser.add_argument("--threads", type=_positive_int) |
| args = parser.parse_args() |
| classifier = PinballClassifier( |
| models_dir=args.model_dir, device=args.device, threads=args.threads |
| ) |
| prediction = classifier.predict(args.image, top_count=args.top_k) |
| print(json.dumps(prediction, indent=2, ensure_ascii=False)) |
|
|
|
|
| if __name__ == "__main__": |
| _main() |
|
|