Instructions to use camstack/camstack-trainer with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- ultralytics
How to use camstack/camstack-trainer with ultralytics:
from huggingface_hub import hf_hub_download from ultralytics import YOLO # pick the weights file from this repo's "Files and versions" tab weights = hf_hub_download("camstack/camstack-trainer", "<weights>.pt") model = YOLO(weights) source = 'http://images.cocodataset.org/val2017/000000039769.jpg' model.predict(source=source, save=True) - Notebooks
- Google Colab
- Kaggle
camstack-train 0.1.0: YOLOv9 root detector fine-tune (job.json in, result.json + bundle.tar out)
ebeed67 verified Download camstack_train/_vendor/pipeline_python/postprocessors/yunet.py from camstack/camstack-trainer: direct link, hf CLI and curl.
- Browser
- Download file 11.3 kB
-
https://huggingface.co/camstack/camstack-trainer/resolve/main/camstack_train/_vendor/pipeline_python/postprocessors/yunet.py
- Command line
-
hf download hf://camstack/camstack-trainer/camstack_train/_vendor/pipeline_python/postprocessors/yunet.py
-
curl -L -o yunet.py https://huggingface.co/camstack/camstack-trainer/resolve/main/camstack_train/_vendor/pipeline_python/postprocessors/yunet.py
11.3 kB
| """YuNet face detection postprocessor (OpenCV Zoo `face_detection_yunet`, 2023mar). | |
| Used by the `yunet-2023mar` catalog entry on the `face-detection` step through a | |
| per-MODEL `postprocessor: 'yunet'` override (the step default stays `scrfd`). | |
| Model contract, as published by `scripts/build-yunet-model.py`: | |
| input "image" [1, 3, 640, 640] RGB float in [0,1] — the pool's plain | |
| `/255` tensor. Upstream YuNet wants BGR | |
| 0..255; `Mul(255)` + `Gather([2,1,0])` | |
| are BAKED into the graph ahead of the | |
| first Conv, identically in all three | |
| formats, so no pool normalisation runs | |
| (the entry declares none). | |
| outputs cls_{8,16,32} [1, N, 1] classification probability | |
| obj_{8,16,32} [1, N, 1] objectness probability | |
| bbox_{8,16,32} [1, N, 4] (dx, dy, log w, log h), stride units | |
| kps_{8,16,32} [1, N, 10] 5 × (dx, dy), stride units | |
| Why it cannot share `scrfd.py` (measured 2026-09-26: the SCRFD decoder finds 0 | |
| faces on 413 person crops with YuNet's outputs): | |
| 1. ONE prior per grid cell, not two; anchor-free — the prior is the cell's | |
| top-left corner, `(col, row) * stride`, row-major, `N = ceil(640/s)^2`. | |
| 2. The box is centre + size, with `exp()` on width and height — SCRFD's is four | |
| distances from the anchor. | |
| 3. The score is `sqrt(cls * obj)` over TWO tensors, both clamped to [0,1]. | |
| This is a port of OpenCV's `FaceDetectorYN::postProcess` | |
| (`modules/objdetect/src/face_detect.cpp`). | |
| Landmarks come out in YuNet's order — right eye, left eye, nose tip, right | |
| mouth corner, left mouth corner, where right/left are the SUBJECT'S. That is | |
| image-left eye first, the same order SCRFD emits and `face-align.ts` expects | |
| (ARCFACE_TEMPLATE_112), so they are passed through unchanged. Measured on 196 | |
| COCO faces: index 0 is the image-left eye except on 3 profile faces, the same | |
| rate as SCRFD's 2. | |
| The per-candidate arithmetic (`prior_cell`, `decode_prior`, `to_source`, `nms`) | |
| is plain Python so the layout, decode and landmark-order tests run on CI's bare | |
| interpreter; numpy only vectorises the score threshold over the 8400 priors. | |
| Output matches the scrfd postprocessor: | |
| ``{"kind": "detections", "detections": [{"class": "face", "score", "bbox": [x1,y1,x2,y2], "landmarks"}]}`` | |
| in ORIGINAL (pre-letterbox) pixel coordinates. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import sys | |
| import time | |
| import numpy as np | |
| STRIDES: tuple[int, ...] = (8, 16, 32) | |
| NUM_LANDMARKS = 5 | |
| DEFAULT_NMS_IOU = 0.45 | |
| # At most this many candidates, best first, reach the NMS. OpenCV's | |
| # FaceDetectorYN caps at 5000 (`top_k`), but its NMS is C++; `nms` below is | |
| # pure Python and O(n·kept), measured on this Mac at 28 ms for 500 disjoint | |
| # candidates, 113 ms for 1000 and 2.8 s for 5000 — an unbounded frame (every | |
| # prior passing a low floor: 8400) would hold a pool worker for ~8 s. Real | |
| # frames are far below the cap: on the spike's 413 person crops the most | |
| # candidates any frame produced was 38 at the step's 0.5 floor and 110 at 0.1. | |
| TOP_K = 750 | |
| # A frame that hits the cap is reported, at most once per this interval, on | |
| # stderr (the pool's log): dropped work is never silent, and a pathological | |
| # model/threshold must not flood the log either. | |
| TOP_K_LOG_INTERVAL_S = 60.0 | |
| _last_top_k_log = 0.0 | |
| def grid_cols(stride: int, input_size: int) -> int: | |
| """Priors per row (and per column — the input is square) at `stride`.""" | |
| return int(math.ceil(input_size / stride)) | |
| def prior_cell(index: int, cols: int) -> tuple[int, int]: | |
| """`(row, col)` of prior `index` — row-major, one prior per cell.""" | |
| return divmod(index, cols) | |
| def score_of(cls: float, obj: float) -> float: | |
| """YuNet's score: geometric mean of the two probabilities, each clamped.""" | |
| return math.sqrt(min(max(cls, 0.0), 1.0) * min(max(obj, 0.0), 1.0)) | |
| def decode_prior( | |
| stride: int, | |
| row: int, | |
| col: int, | |
| bbox: "list[float] | tuple[float, ...]", | |
| kps: "list[float] | tuple[float, ...]", | |
| ) -> tuple[tuple[float, float, float, float], list[tuple[float, float]]]: | |
| """One prior → `(x1, y1, x2, y2)` and 5 landmark points, in NETWORK space.""" | |
| cx = (col + bbox[0]) * stride | |
| cy = (row + bbox[1]) * stride | |
| w = math.exp(bbox[2]) * stride | |
| h = math.exp(bbox[3]) * stride | |
| box = (cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0) | |
| points = [ | |
| ((kps[2 * p] + col) * stride, (kps[2 * p + 1] + row) * stride) | |
| for p in range(NUM_LANDMARKS) | |
| ] | |
| return box, points | |
| def to_source(x: float, y: float, scale: float, pad: tuple[int, int]) -> tuple[float, float]: | |
| """Undo the pool's letterbox: network pixels → original frame pixels.""" | |
| return (x - pad[0]) / scale, (y - pad[1]) / scale | |
| def _iou(a: tuple, b: tuple) -> float: | |
| ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0])) | |
| iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1])) | |
| inter = ix * iy | |
| if inter == 0: | |
| return 0.0 | |
| union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter | |
| return inter / union if union > 0 else 0.0 | |
| def cap_top_k(candidates: list[dict], k: int = TOP_K) -> tuple[list[dict], int]: | |
| """The `k` best candidates by score, and how many were dropped.""" | |
| if len(candidates) <= k: | |
| return candidates, 0 | |
| best = sorted(candidates, key=lambda c: c["score"], reverse=True) | |
| return best[:k], len(candidates) - k | |
| def _log_top_k_drop(dropped: int, total: int) -> None: | |
| global _last_top_k_log | |
| now = time.monotonic() | |
| if now - _last_top_k_log < TOP_K_LOG_INTERVAL_S: | |
| return | |
| _last_top_k_log = now | |
| sys.stderr.write( | |
| f"yunet: {total} candidates above the confidence floor, kept the best {TOP_K} " | |
| f"for NMS and dropped {dropped} (logged at most once per " | |
| f"{TOP_K_LOG_INTERVAL_S:.0f}s)\n" | |
| ) | |
| sys.stderr.flush() | |
| def nms(candidates: list[dict], iou_threshold: float) -> list[dict]: | |
| """Greedy NMS on `_xyxy`, highest score first.""" | |
| kept: list[dict] = [] | |
| for cand in sorted(candidates, key=lambda c: c["score"], reverse=True): | |
| if all(_iou(cand["_xyxy"], k["_xyxy"]) <= iou_threshold for k in kept): | |
| kept.append(cand) | |
| return kept | |
| _OUTPUT_WIDTHS: tuple[tuple[str, int], ...] = ( | |
| ("cls", 1), ("obj", 1), ("bbox", 4), ("kps", 2 * NUM_LANDMARKS), | |
| ) | |
| def missing_outputs(output_names) -> list[str]: | |
| """The named outputs this decoder needs that the model did not produce. | |
| Matched by NAME only, with no shape fallback: `cls_*` and `obj_*` have | |
| identical shapes, so a fallback could only guess. Every published format | |
| names its outputs (the CoreML conversion names them explicitly, the | |
| OpenVINO IR has its internal aliases cleared).""" | |
| present = set(output_names) | |
| return [ | |
| f"{kind}_{stride}" for stride in STRIDES for kind, _ in _OUTPUT_WIDTHS | |
| if f"{kind}_{stride}" not in present | |
| ] | |
| def value_count(value) -> int: | |
| """Number of scalars in an ndarray-like (`.size`) or a nested sequence. | |
| Pure Python, so the size gate below needs no numpy.""" | |
| size = getattr(value, "size", None) | |
| if isinstance(size, int): | |
| return size | |
| if isinstance(value, (list, tuple)): | |
| return sum(value_count(v) for v in value) | |
| return 1 | |
| def check_outputs(predictions: dict, input_size: int) -> None: | |
| """RAISE unless all twelve outputs are present and sized for `input_size`. | |
| Returning `[]` instead would read as "no faces" forever with no line | |
| saying why — which is exactly what the SCRFD decoder did on this graph. A | |
| size mismatch means the model's input is not `inputSize` (e.g. a 320 | |
| export under a 640 catalog entry): decoding it would put every prior in the | |
| wrong cell.""" | |
| missing = missing_outputs(predictions.keys()) | |
| if missing: | |
| raise ValueError( | |
| f"yunet: outputs {missing} missing (model outputs: {sorted(predictions)})" | |
| ) | |
| for stride in STRIDES: | |
| priors = grid_cols(stride, input_size) ** 2 | |
| for kind, width in _OUTPUT_WIDTHS: | |
| key = f"{kind}_{stride}" | |
| got = value_count(predictions[key]) | |
| if got != priors * width: | |
| raise ValueError( | |
| f"yunet: output '{key}' has {got} values, expected {priors * width} " | |
| f"({priors} priors × {width}) — inputSize {input_size} does not match the model" | |
| ) | |
| def _stride_tensors(predictions: dict, stride: int) -> dict: | |
| """The four flattened tensors of one stride (sizes already checked).""" | |
| return { | |
| kind: np.asarray(predictions[f"{kind}_{stride}"], dtype=np.float32).reshape(-1) | |
| for kind, _ in _OUTPUT_WIDTHS | |
| } | |
| def postprocess_yunet( | |
| predictions: dict, | |
| config: dict, | |
| orig_w: int, | |
| orig_h: int, | |
| scale: float, | |
| pad: tuple[int, int], | |
| ) -> dict: | |
| """Postprocess YuNet output to structured face detections.""" | |
| conf_threshold = float(config.get("confidence", 0.5) or 0.5) | |
| input_size = int(config.get("inputSize", 640)) | |
| nms_iou = float(config.get("nmsIouThreshold", DEFAULT_NMS_IOU)) | |
| check_outputs(predictions, input_size) | |
| candidates: list[dict] = [] | |
| for stride in STRIDES: | |
| cols = grid_cols(stride, input_size) | |
| t = _stride_tensors(predictions, stride) | |
| bbox = t["bbox"].reshape(-1, 4) | |
| kps = t["kps"].reshape(-1, 2 * NUM_LANDMARKS) | |
| # numpy only PRE-filters, conservatively: sqrt(cls·obj) <= max(cls, obj), | |
| # so a prior below the threshold on both tensors cannot pass. The | |
| # decision itself is `score_of`, per prior. | |
| maybe = np.flatnonzero(np.maximum(t["cls"], t["obj"]) >= conf_threshold) | |
| for index in maybe.tolist(): | |
| score = score_of(float(t["cls"][index]), float(t["obj"][index])) | |
| if score < conf_threshold: | |
| continue | |
| row, col = prior_cell(index, cols) | |
| (x1, y1, x2, y2), points = decode_prior( | |
| stride, row, col, bbox[index].tolist(), kps[index].tolist() | |
| ) | |
| sx1, sy1 = to_source(x1, y1, scale, pad) | |
| sx2, sy2 = to_source(x2, y2, scale, pad) | |
| ox1, oy1 = max(0.0, min(orig_w, sx1)), max(0.0, min(orig_h, sy1)) | |
| ox2, oy2 = max(0.0, min(orig_w, sx2)), max(0.0, min(orig_h, sy2)) | |
| if (ox2 - ox1) < 1 or (oy2 - oy1) < 1: | |
| continue | |
| landmarks = [] | |
| for px, py in points: | |
| lx, ly = to_source(px, py, scale, pad) | |
| landmarks.append({"x": round(lx, 1), "y": round(ly, 1)}) | |
| candidates.append({ | |
| "class": "face", | |
| "score": round(score, 4), | |
| "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)], | |
| "landmarks": landmarks, | |
| "_xyxy": (ox1, oy1, ox2, oy2), | |
| }) | |
| capped, dropped = cap_top_k(candidates) | |
| if dropped: | |
| _log_top_k_drop(dropped, len(candidates)) | |
| kept = nms(capped, nms_iou) | |
| for det in kept: | |
| del det["_xyxy"] | |
| return {"kind": "detections", "detections": kept} | |