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
File size: 11,328 Bytes
ebeed67 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 | """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}
|