Spaces:
Paused
Paused
| """PCB defect detection - Hugging Face Space (Gradio). | |
| Self-contained: no torch/ultralytics/pcb_defect import, only onnxruntime + cv2 + | |
| gradio + huggingface_hub. The letterbox/preprocess/postprocess functions below are | |
| a deliberate near-duplicate (~60 lines) of src/pcb_defect/e2e_onnx.py's logic - | |
| the Space can't easily depend on the full project package, so this file stands | |
| alone. scripts/verify_onnx_parity.py's parity gate covers this same logic (see | |
| plan.md SS 2.3/2.6): any change here should be mirrored there and re-verified. | |
| Env vars: | |
| MODEL_REPO HF model repo id to pull best.onnx from (default below). | |
| MODEL_PATH_OVERRIDE local file path - skips the HF download, for testing | |
| this Space before MODEL_REPO actually has anything | |
| uploaded to it (see plan.md SS 2.6/2.7 ordering). | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import time | |
| from dataclasses import dataclass | |
| import cv2 | |
| import gradio as gr | |
| import numpy as np | |
| from PIL import Image | |
| MODEL_REPO = os.environ.get("MODEL_REPO", "betty0/pcb-defect-detection") | |
| MODEL_PATH_OVERRIDE = os.environ.get("MODEL_PATH_OVERRIDE") | |
| CLASSES = ["missing_hole", "mouse_bite", "open_circuit", "short", "spur", "spurious_copper"] | |
| IMG_SIZE = 640 | |
| PAD_VALUE = 114 | |
| DEFAULT_CONF = 0.25 | |
| EXAMPLES_DIR = os.path.join(os.path.dirname(__file__), "examples") | |
| class LetterboxInfo: | |
| gain: float | |
| pad_left: float | |
| pad_top: float | |
| class Detection: | |
| cls_id: int | |
| xyxy: tuple[float, float, float, float] | |
| conf: float | |
| def letterbox(image: Image.Image, size: int = IMG_SIZE) -> tuple[np.ndarray, LetterboxInfo]: | |
| """Matches ultralytics' LetterBox (cv2.resize + cv2.copyMakeBorder) exactly - | |
| PIL's resize is NOT numerically interchangeable with cv2's (verified empirically, | |
| see plan.md SS 2.3).""" | |
| rgb = np.asarray(image.convert("RGB")) | |
| h, w = rgb.shape[:2] | |
| gain = min(size / w, size / h) | |
| new_w, new_h = round(w * gain), round(h * gain) | |
| resized = cv2.resize(rgb, (new_w, new_h), interpolation=cv2.INTER_LINEAR) | |
| dw, dh = (size - new_w) / 2, (size - new_h) / 2 | |
| top, bottom = round(dh - 0.1), round(dh + 0.1) | |
| left, right = round(dw - 0.1), round(dw + 0.1) | |
| canvas = cv2.copyMakeBorder( | |
| resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(PAD_VALUE,) * 3 | |
| ) | |
| return canvas, LetterboxInfo(gain, left, top) | |
| def preprocess(image: Image.Image) -> tuple[np.ndarray, LetterboxInfo]: | |
| canvas, info = letterbox(image) | |
| chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0 | |
| batch = np.ascontiguousarray(np.expand_dims(chw, axis=0)) | |
| return batch, info | |
| def postprocess( | |
| output: np.ndarray, info: LetterboxInfo, orig_size: tuple[int, int], conf: float | |
| ) -> list[Detection]: | |
| """(1, 300, 6) letterboxed-space rows -> Detection list in original-image pixel coords.""" | |
| rows = output[0] | |
| rows = rows[rows[:, 4] >= conf] | |
| orig_w, orig_h = orig_size | |
| detections = [] | |
| for x1, y1, x2, y2, score, cls in rows: | |
| ox1 = float(max(0.0, min((x1 - info.pad_left) / info.gain, orig_w))) | |
| oy1 = float(max(0.0, min((y1 - info.pad_top) / info.gain, orig_h))) | |
| ox2 = float(max(0.0, min((x2 - info.pad_left) / info.gain, orig_w))) | |
| oy2 = float(max(0.0, min((y2 - info.pad_top) / info.gain, orig_h))) | |
| detections.append(Detection(int(cls), (ox1, oy1, ox2, oy2), float(score))) | |
| return detections | |
| def _load_session(): | |
| import onnxruntime as ort | |
| if MODEL_PATH_OVERRIDE: | |
| model_path = MODEL_PATH_OVERRIDE | |
| else: | |
| from huggingface_hub import hf_hub_download | |
| model_path = hf_hub_download(repo_id=MODEL_REPO, filename="best.onnx") | |
| session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) | |
| return session, session.get_inputs()[0].name | |
| _SESSION, _INPUT_NAME = _load_session() | |
| def _to_annotations(detections: list[Detection]) -> list[tuple[tuple[int, int, int, int], str]]: | |
| return [ | |
| ( | |
| (round(d.xyxy[0]), round(d.xyxy[1]), round(d.xyxy[2]), round(d.xyxy[3])), | |
| f"{CLASSES[d.cls_id]} {d.conf:.2f}", | |
| ) | |
| for d in detections | |
| ] | |
| def _to_table(detections: list[Detection]) -> list[list]: | |
| return [ | |
| [CLASSES[d.cls_id], round(d.conf, 3), *[round(c, 1) for c in d.xyxy]] for d in detections | |
| ] | |
| def run_inference(image: Image.Image | None, conf: float): | |
| if image is None: | |
| return None, None, "", [] | |
| t0 = time.perf_counter() | |
| batch, info = preprocess(image) | |
| (raw_output,) = _SESSION.run(None, {_INPUT_NAME: batch}) | |
| elapsed_ms = (time.perf_counter() - t0) * 1000 | |
| cache = {"raw_output": raw_output, "info": info, "orig_size": image.size, "image": image} | |
| detections = postprocess(raw_output, info, image.size, conf) | |
| annotated = (image, _to_annotations(detections)) | |
| latency_text = f"推論時間:{elapsed_ms:.0f} ms(僅第一次上傳/換圖需要;拖曳滑桿不重新推論)" | |
| return annotated, cache, latency_text, _to_table(detections) | |
| def rerender(cache: dict | None, conf: float): | |
| if cache is None: | |
| return None, [] | |
| detections = postprocess(cache["raw_output"], cache["info"], cache["orig_size"], conf) | |
| return (cache["image"], _to_annotations(detections)), _to_table(detections) | |
| with gr.Blocks(title="PCB 裸板瑕疵偵測 - YOLO26 Demo") as demo: | |
| gr.Markdown( | |
| "# PCB 裸板瑕疵偵測(YOLO26,NMS-free e2e,ONNX Runtime CPU)\n\n" | |
| "上傳一張 PCB 裸板照片,或點選下方範例。拖曳信心值滑桿只會重新篩選、" | |
| "不會重新跑推論(後端快取了原始 (300,6) 輸出)。" | |
| f"6 類瑕疵:{'、'.join(CLASSES)}。" | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| image_input = gr.Image(type="pil", label="上傳 PCB 影像") | |
| conf_slider = gr.Slider( | |
| minimum=0.05, maximum=0.90, value=DEFAULT_CONF, step=0.01, label="信心值門檻" | |
| ) | |
| with gr.Column(): | |
| annotated_output = gr.AnnotatedImage(label="偵測結果") | |
| latency_text = gr.Markdown() | |
| results_table = gr.Dataframe( | |
| headers=["類別", "信心值", "x1", "y1", "x2", "y2"], | |
| label="偵測列表", | |
| interactive=False, | |
| ) | |
| raw_state = gr.State(value=None) | |
| image_input.upload( | |
| fn=run_inference, | |
| inputs=[image_input, conf_slider], | |
| outputs=[annotated_output, raw_state, latency_text, results_table], | |
| ) | |
| # gr.Examples only sets image_input's value - it does not fire .upload() (that event is | |
| # specifically for actual file uploads), so without run_on_click=True, clicking an example | |
| # would show no detections until some other action ran inference (confirmed against | |
| # gradio/helpers.py: cache_examples=False + run_on_click=False silently skips fn entirely). | |
| # run_on_click=True runs inference live on each click - explicit rather than relying on | |
| # HF Spaces' implicit cache_examples=True default, so behavior matches local testing too. | |
| gr.Examples( | |
| examples=[[os.path.join(EXAMPLES_DIR, f"04_{cls}_01.jpg")] for cls in CLASSES], | |
| inputs=[image_input], | |
| outputs=[annotated_output, raw_state, latency_text, results_table], | |
| fn=lambda img: run_inference(img, DEFAULT_CONF), | |
| run_on_click=True, | |
| cache_examples=False, | |
| label="範例(每類一張)", | |
| ) | |
| conf_slider.release( | |
| fn=rerender, | |
| inputs=[raw_state, conf_slider], | |
| outputs=[annotated_output, results_table], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |