Instructions to use Banaxi-Tech/face-model with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- ultralytics
How to use Banaxi-Tech/face-model 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("Banaxi-Tech/face-model", "<weights>.pt") model = YOLO(weights) source = 'http://images.cocodataset.org/val2017/000000039769.jpg' model.predict(source=source, save=True) - Notebooks
- Google Colab
- Kaggle
Upload face detector (YOLO11n/s), ONNX exports, scripts, model card
Browse files- README.md +85 -0
- code/blur_video.py +282 -0
- code/convert_wider_to_yolo.py +91 -0
- code/eval_model.py +27 -0
- code/face_id.py +125 -0
- code/quantize_int8.py +71 -0
- code/recall_at_conf.py +56 -0
- code/train.py +93 -0
- yolo11n/face_yolo11n.pt +3 -0
- yolo11n/face_yolo11n_fp16.onnx +3 -0
- yolo11n/face_yolo11n_fp32.onnx +3 -0
- yolo11n/face_yolo11n_int8.onnx +3 -0
- yolo11s/face_yolo11s.pt +3 -0
- yolo11s/face_yolo11s_fp16.onnx +3 -0
- yolo11s/face_yolo11s_fp32.onnx +3 -0
README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: agpl-3.0
|
| 3 |
+
library_name: ultralytics
|
| 4 |
+
pipeline_tag: object-detection
|
| 5 |
+
tags:
|
| 6 |
+
- face-detection
|
| 7 |
+
- yolo11
|
| 8 |
+
- object-detection
|
| 9 |
+
- onnx
|
| 10 |
+
- privacy
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# face-model — single-class face detector for blurring faces in video
|
| 14 |
+
|
| 15 |
+
YOLO11 (nano and small) fine-tuned from the COCO-pretrained Ultralytics weights as a one-class `face` detector on WIDER FACE,
|
| 16 |
+
plus a video script that detects, tracks and pixelates (or blacks out) faces. Built for **recall** (privacy), not leaderboard accuracy.
|
| 17 |
+
|
| 18 |
+
## Important licence notes (read before using commercially)
|
| 19 |
+
|
| 20 |
+
- The code and weights are derived from **Ultralytics YOLO11, which is AGPL-3.0**. Using them in a product or network service means
|
| 21 |
+
complying with AGPL-3.0 or obtaining an Ultralytics enterprise licence.
|
| 22 |
+
- The **training data, WIDER FACE, is listed as non-commercial** (CC BY-NC-ND 4.0 on its Hugging Face card; the original
|
| 23 |
+
WIDER FACE terms are for non-commercial research). Do not assume these weights are cleared for commercial use.
|
| 24 |
+
- `code/face_id.py` (optional `--keep` mode) expects InsightFace `buffalo_l` ONNX models, which are **non-commercial research only**.
|
| 25 |
+
They are *not* included in this repo; download them yourself.
|
| 26 |
+
|
| 27 |
+
## Files
|
| 28 |
+
|
| 29 |
+
| File | Notes |
|
| 30 |
+
|---|---|
|
| 31 |
+
| `yolo11n/face_yolo11n.pt` | PyTorch weights, 5.4 MB |
|
| 32 |
+
| `yolo11n/face_yolo11n_fp32.onnx`, `_fp16.onnx` | ONNX, fixed 640x640, batch 1 |
|
| 33 |
+
| `yolo11n/face_yolo11n_int8.onnx` | INT8 (ONNX Runtime static QDQ). The Detect-head decode nodes are kept in float; plain INT8 returned zero detections |
|
| 34 |
+
| `yolo11s/face_yolo11s.pt`, `_fp32.onnx`, `_fp16.onnx` | Larger, more accurate |
|
| 35 |
+
| `code/` | `blur_video.py`, `train.py`, `convert_wider_to_yolo.py`, `eval_model.py`, `recall_at_conf.py`, `quantize_int8.py`, `face_id.py` |
|
| 36 |
+
|
| 37 |
+
## Results (WIDER FACE val, 3,222 images / 39,112 faces, 640 px, all difficulty levels together)
|
| 38 |
+
|
| 39 |
+
| Model | mAP50 | mAP50-95 |
|
| 40 |
+
|---|---|---|
|
| 41 |
+
| YOLO11n `.pt` | 0.684 | 0.362 |
|
| 42 |
+
| YOLO11n ONNX FP32 | 0.683 | 0.362 |
|
| 43 |
+
| YOLO11n ONNX FP16 | 0.682 | 0.361 |
|
| 44 |
+
| YOLO11n ONNX INT8 | 0.667 | 0.348 |
|
| 45 |
+
| YOLO11s `.pt` | 0.741 | 0.401 |
|
| 46 |
+
| YOLO11s ONNX FP16 | 0.739 | 0.399 |
|
| 47 |
+
|
| 48 |
+
**Recall at confidence 0.25 (IoU >= 0.5)**, i.e. the fraction of labelled faces that get a box:
|
| 49 |
+
|
| 50 |
+
| Face height (px, images stored at <=640 px) | n=39,112 | YOLO11n FP16 | YOLO11n INT8 | YOLO11s FP16 |
|
| 51 |
+
|---|---|---|---|---|
|
| 52 |
+
| < 10 | 16,305 | 33.1% | 31.6% | 42.1% |
|
| 53 |
+
| 10-20 | 10,521 | 72.2% | 70.7% | 79.1% |
|
| 54 |
+
| 20-40 | 7,478 | 86.6% | 85.4% | 90.4% |
|
| 55 |
+
| 40-80 | 3,167 | 93.7% | 92.9% | 95.3% |
|
| 56 |
+
| > 80 | 1,641 | 95.6% | 95.0% | 97.3% |
|
| 57 |
+
| **Overall** | | **61.4%** | **60.0%** | **67.9%** |
|
| 58 |
+
|
| 59 |
+
WIDER FACE is dominated by tiny faces (median face height about 12 px). Performance on tiny, far-away faces is the main weakness;
|
| 60 |
+
on faces >= 20 px the nano finds about 90%. **No detector guarantees every face is caught in every frame** — spot-check any
|
| 61 |
+
video where missing a face matters.
|
| 62 |
+
|
| 63 |
+
Not measured: speed on phones or other hardware. Any such numbers you may see elsewhere in this project were estimates.
|
| 64 |
+
|
| 65 |
+
## Training
|
| 66 |
+
|
| 67 |
+
- Start: COCO `yolo11n.pt` / `yolo11s.pt`, fine-tuned (not from scratch), single class `face`.
|
| 68 |
+
- Data: WIDER FACE train, converted to YOLO format (`code/convert_wider_to_yolo.py`): invalid boxes dropped, small faces kept, images downscaled to <= 640 px long side.
|
| 69 |
+
- 640 px, AMP. Nano: 40 epochs. Small: 40 epochs (resumed from a checkpoint at epoch 34 with the schedule shortened to end at 40).
|
| 70 |
+
- Extra augmentation: random motion blur and JPEG compression to imitate video frames (`train.py`).
|
| 71 |
+
|
| 72 |
+
## Use
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
pip install ultralytics onnxruntime-gpu lap
|
| 76 |
+
python code/blur_video.py in.mp4 out.mp4 --model yolo11n/face_yolo11n_fp16.onnx -n 1 # pixelate
|
| 77 |
+
python code/blur_video.py in.mp4 out.mp4 --model yolo11n/face_yolo11n_fp16.onnx --mode black # solid black
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
Defaults: detect every 3 frames (`-n`) with ByteTrack in between, confidence 0.25, 15% box padding, 8 pixel blocks across the
|
| 81 |
+
shorter side of each face. Use `-n 1` for the strictest coverage. Audio is preserved via ffmpeg. With an ONNX model use
|
| 82 |
+
`--device 0` (GPU); `--device cpu` makes Ultralytics try to pip-install `onnxruntime`.
|
| 83 |
+
|
| 84 |
+
`--keep ref.jpg [...]` leaves one person visible and blurs everyone else using face recognition; every other face stays blurred
|
| 85 |
+
unless confirmed over several frames. It needs the InsightFace models mentioned above.
|
code/blur_video.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Detect, track and pixelate (or black out) faces in a video. Audio is preserved via ffmpeg.
|
| 3 |
+
|
| 4 |
+
Detection runs every N frames; between detections ByteTrack's Kalman filter carries the boxes forward.
|
| 5 |
+
Tuned for recall (privacy): low confidence threshold, box padding, raw detections are blurred immediately
|
| 6 |
+
(without waiting for track confirmation) and recently lost tracks are held for a few frames.
|
| 7 |
+
|
| 8 |
+
--keep: leave ONE person's face visible and blur everybody else. Give one or more reference photos of that
|
| 9 |
+
person. Fail-safe by design: a face stays blurred unless its track was confirmed as the target by several
|
| 10 |
+
consecutive face-recognition matches; a clear non-match, a track box jump, or going too long without a
|
| 11 |
+
successful re-check puts the blur back.
|
| 12 |
+
|
| 13 |
+
Examples:
|
| 14 |
+
python blur_video.py in.mp4 out.mp4 --model export/face_yolo11n_fp16.onnx -n 1
|
| 15 |
+
python blur_video.py in.mp4 out.mp4 --mode black
|
| 16 |
+
python blur_video.py in.mp4 out.mp4 -n 1 --keep me1.jpg me2.jpg
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import queue
|
| 20 |
+
import shutil
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
from types import SimpleNamespace
|
| 26 |
+
|
| 27 |
+
import cv2
|
| 28 |
+
import numpy as np
|
| 29 |
+
from ultralytics import YOLO
|
| 30 |
+
from ultralytics.trackers.byte_tracker import BYTETracker
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def pad_box(x1, y1, x2, y2, pad, w, h):
|
| 34 |
+
bw, bh = x2 - x1, y2 - y1
|
| 35 |
+
x1, x2 = x1 - bw * pad, x2 + bw * pad
|
| 36 |
+
y1, y2 = y1 - bh * pad, y2 + bh * pad
|
| 37 |
+
return max(0, int(np.floor(x1))), max(0, int(np.floor(y1))), min(w, int(np.ceil(x2))), min(h, int(np.ceil(y2)))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def iou(a, b):
|
| 41 |
+
ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
|
| 42 |
+
iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
|
| 43 |
+
inter = ix * iy
|
| 44 |
+
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
|
| 45 |
+
return inter / union if union > 0 else 0.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def pixelate(frame, box, blocks):
|
| 49 |
+
"""Downscale the region so its shorter side has `blocks` cells, then nearest-neighbour upscale."""
|
| 50 |
+
x1, y1, x2, y2 = box
|
| 51 |
+
if x2 - x1 < 2 or y2 - y1 < 2:
|
| 52 |
+
return
|
| 53 |
+
roi = frame[y1:y2, x1:x2]
|
| 54 |
+
h, w = roi.shape[:2]
|
| 55 |
+
cell = max(1.0, min(w, h) / blocks)
|
| 56 |
+
small = cv2.resize(roi, (max(1, round(w / cell)), max(1, round(h / cell))), interpolation=cv2.INTER_AREA)
|
| 57 |
+
frame[y1:y2, x1:x2] = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def blackout(frame, box):
|
| 61 |
+
x1, y1, x2, y2 = box
|
| 62 |
+
frame[y1:y2, x1:x2] = 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def open_ffmpeg_writer(src, dst, w, h, fps, crf):
|
| 66 |
+
cmd = [
|
| 67 |
+
"ffmpeg", "-y", "-loglevel", "error",
|
| 68 |
+
"-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{w}x{h}", "-r", f"{fps}", "-i", "-",
|
| 69 |
+
"-i", src,
|
| 70 |
+
"-map", "0:v:0", "-map", "1:a?",
|
| 71 |
+
"-c:v", "libx264", "-preset", "fast", "-crf", str(crf), "-pix_fmt", "yuv420p",
|
| 72 |
+
"-c:a", "copy", "-shortest", "-movflags", "+faststart", dst,
|
| 73 |
+
]
|
| 74 |
+
return subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class KeepTracker:
|
| 78 |
+
"""Decides which ByteTrack ids are the person to keep visible. Everything not confirmed stays blurred."""
|
| 79 |
+
|
| 80 |
+
def __init__(self, fid, refs, thr, min_size, hits_needed, recheck, expire, budget):
|
| 81 |
+
self.fid, self.refs, self.thr, self.min_size = fid, refs, thr, min_size
|
| 82 |
+
self.hits_needed, self.recheck, self.expire, self.budget = hits_needed, recheck, expire, budget
|
| 83 |
+
self.state = {} # tid -> dict(hits, kept, last_ok, last_try, box)
|
| 84 |
+
self.seen, self.ever_kept = set(), set()
|
| 85 |
+
|
| 86 |
+
def is_kept(self, tid, idx):
|
| 87 |
+
s = self.state.get(tid)
|
| 88 |
+
if s is None or not s["kept"]:
|
| 89 |
+
return False
|
| 90 |
+
if idx - s["last_ok"] > self.expire: # could not re-confirm for too long -> blur again
|
| 91 |
+
s["kept"], s["hits"] = False, 0
|
| 92 |
+
return False
|
| 93 |
+
return True
|
| 94 |
+
|
| 95 |
+
def update(self, frame, idx, tracked):
|
| 96 |
+
"""tracked: list of (xyxy, tid) from a detection frame (clean, un-blurred frame)."""
|
| 97 |
+
todo = []
|
| 98 |
+
for box, tid in tracked:
|
| 99 |
+
self.seen.add(tid)
|
| 100 |
+
s = self.state.setdefault(tid, dict(hits=0, kept=False, last_ok=-10**9, last_try=-10**9, box=box))
|
| 101 |
+
if s["kept"] and iou(s["box"], box) < 0.3: # box jumped: possible ID switch -> distrust
|
| 102 |
+
s["kept"], s["hits"] = False, 0
|
| 103 |
+
s["box"] = box
|
| 104 |
+
if not self.is_kept(tid, idx):
|
| 105 |
+
todo.append((0, box, tid)) # undecided first
|
| 106 |
+
elif idx - s["last_try"] >= self.recheck:
|
| 107 |
+
todo.append((1, box, tid))
|
| 108 |
+
todo.sort(key=lambda t: (t[0], self.state[t[2]]["last_try"]))
|
| 109 |
+
for _, box, tid in todo[:self.budget]:
|
| 110 |
+
s = self.state[tid]
|
| 111 |
+
s["last_try"] = idx
|
| 112 |
+
e = self.fid.embed_box(frame, box, min_size=self.min_size)
|
| 113 |
+
if e is None:
|
| 114 |
+
continue # inconclusive: keep old state (a kept track lapses via `expire`)
|
| 115 |
+
if self.fid.similarity(e, self.refs) >= self.thr:
|
| 116 |
+
s["hits"] += 1
|
| 117 |
+
s["last_ok"] = idx
|
| 118 |
+
if s["hits"] >= self.hits_needed:
|
| 119 |
+
s["kept"] = True
|
| 120 |
+
self.ever_kept.add(tid)
|
| 121 |
+
else: # clear non-match: blur immediately
|
| 122 |
+
s["hits"], s["kept"] = 0, False
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main():
|
| 126 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 127 |
+
ap.add_argument("input")
|
| 128 |
+
ap.add_argument("output")
|
| 129 |
+
ap.add_argument("--model", default="runs/face_yolo11n/weights/best.pt", help=".pt or .onnx face detector")
|
| 130 |
+
ap.add_argument("-n", "--every", type=int, default=3, help="run detector every N frames (default 3)")
|
| 131 |
+
ap.add_argument("--conf", type=float, default=0.25, help="detection confidence threshold (default 0.25)")
|
| 132 |
+
ap.add_argument("--pad", type=float, default=0.15, help="pad each box by this fraction per side (default 0.15)")
|
| 133 |
+
ap.add_argument("--mode", choices=["pixelate", "black"], default="pixelate",
|
| 134 |
+
help="pixelate faces (default) or fill the padded box with solid black")
|
| 135 |
+
ap.add_argument("--blocks", type=int, default=8,
|
| 136 |
+
help="pixel blocks across a face's shorter side; LOWER = stronger pixelation (default 8)")
|
| 137 |
+
ap.add_argument("--hold", type=int, default=8, help="keep blurring a lost track's prediction this many frames")
|
| 138 |
+
ap.add_argument("--imgsz", type=int, default=640)
|
| 139 |
+
ap.add_argument("--device", default=None, help="e.g. 0 or cpu (default: auto)")
|
| 140 |
+
ap.add_argument("--crf", type=int, default=18, help="x264 quality (lower = better)")
|
| 141 |
+
k = ap.add_argument_group("keep one person visible (face recognition)")
|
| 142 |
+
k.add_argument("--keep", nargs="+", metavar="REF.jpg",
|
| 143 |
+
help="reference photo(s) of the person to leave unblurred; everyone else is blurred")
|
| 144 |
+
k.add_argument("--keep-threshold", type=float, default=0.45,
|
| 145 |
+
help="cosine similarity needed to count as the same person; HIGHER = stricter (default 0.45)")
|
| 146 |
+
k.add_argument("--keep-hits", type=int, default=2, help="consecutive matches before a track is unblurred")
|
| 147 |
+
k.add_argument("--keep-min-size", type=int, default=40,
|
| 148 |
+
help="faces smaller than this many px are never recognised, so they stay blurred (default 40)")
|
| 149 |
+
k.add_argument("--keep-recheck", type=int, default=8, help="re-verify a kept track every this many frames")
|
| 150 |
+
k.add_argument("--keep-expire", type=int, default=24,
|
| 151 |
+
help="blur a kept track again if it could not be re-confirmed for this many frames")
|
| 152 |
+
k.add_argument("--keep-budget", type=int, default=4, help="max recognitions per frame (speed cap)")
|
| 153 |
+
a = ap.parse_args()
|
| 154 |
+
|
| 155 |
+
if shutil.which("ffmpeg") is None:
|
| 156 |
+
sys.exit("ffmpeg not found on PATH")
|
| 157 |
+
if a.every < 1 or a.blocks < 1:
|
| 158 |
+
sys.exit("--every and --blocks must be >= 1")
|
| 159 |
+
|
| 160 |
+
keeper = None
|
| 161 |
+
if a.keep:
|
| 162 |
+
from face_id import FaceID
|
| 163 |
+
fid = FaceID("cpu" if a.device == "cpu" else "0")
|
| 164 |
+
refs = fid.reference_embeddings(a.keep)
|
| 165 |
+
print(f"keeping the person in {len(refs)} reference photo(s); threshold {a.keep_threshold}")
|
| 166 |
+
keeper = KeepTracker(fid, refs, a.keep_threshold, a.keep_min_size, a.keep_hits,
|
| 167 |
+
a.keep_recheck, a.keep_expire, a.keep_budget)
|
| 168 |
+
|
| 169 |
+
cap = cv2.VideoCapture(a.input)
|
| 170 |
+
if not cap.isOpened():
|
| 171 |
+
sys.exit(f"cannot open {a.input}")
|
| 172 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
| 173 |
+
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
|
| 174 |
+
ok, frame = cap.read()
|
| 175 |
+
if not ok:
|
| 176 |
+
sys.exit("no frames in input")
|
| 177 |
+
H, W = frame.shape[:2]
|
| 178 |
+
|
| 179 |
+
model = YOLO(a.model, task="detect")
|
| 180 |
+
tracker = BYTETracker(SimpleNamespace(
|
| 181 |
+
track_high_thresh=a.conf, track_low_thresh=0.1, new_track_thresh=a.conf,
|
| 182 |
+
track_buffer=max(30, 4 * a.every), match_thresh=0.8, fuse_score=True))
|
| 183 |
+
writer = open_ffmpeg_writer(a.input, a.output, W, H, fps, a.crf)
|
| 184 |
+
|
| 185 |
+
# Decode and encode run in their own threads so they overlap with detection (frame order is preserved:
|
| 186 |
+
# one reader, one writer, FIFO queues). cv2/ffmpeg pipe I/O release the GIL.
|
| 187 |
+
rq, wq, write_failed = queue.Queue(maxsize=48), queue.Queue(maxsize=48), []
|
| 188 |
+
first_frame = frame
|
| 189 |
+
|
| 190 |
+
def reader():
|
| 191 |
+
rq.put(first_frame)
|
| 192 |
+
while True:
|
| 193 |
+
okr, f = cap.read()
|
| 194 |
+
if not okr:
|
| 195 |
+
rq.put(None)
|
| 196 |
+
return
|
| 197 |
+
rq.put(f)
|
| 198 |
+
|
| 199 |
+
def writer_loop():
|
| 200 |
+
try:
|
| 201 |
+
while True:
|
| 202 |
+
f = wq.get()
|
| 203 |
+
if f is None:
|
| 204 |
+
return
|
| 205 |
+
writer.stdin.write(f.tobytes())
|
| 206 |
+
except (BrokenPipeError, OSError):
|
| 207 |
+
write_failed.append(True)
|
| 208 |
+
while wq.get() is not None: # drain so the main thread never blocks on a full queue
|
| 209 |
+
pass
|
| 210 |
+
|
| 211 |
+
threading.Thread(target=reader, daemon=True).start()
|
| 212 |
+
wthread = threading.Thread(target=writer_loop)
|
| 213 |
+
wthread.start()
|
| 214 |
+
|
| 215 |
+
t0 = time.perf_counter()
|
| 216 |
+
idx = n_det = 0
|
| 217 |
+
while True:
|
| 218 |
+
frame = rq.get()
|
| 219 |
+
if frame is None:
|
| 220 |
+
break
|
| 221 |
+
items = [] # (xyxy, track_id or None)
|
| 222 |
+
if idx % a.every == 0:
|
| 223 |
+
r = model.predict(frame, conf=a.conf, imgsz=a.imgsz, device=a.device, verbose=False)[0]
|
| 224 |
+
det = r.boxes.cpu().numpy()
|
| 225 |
+
n_det += 1
|
| 226 |
+
tracked = tracker.update(det, frame)
|
| 227 |
+
items += [(b[:4], int(b[4])) for b in tracked] # confirmed tracks
|
| 228 |
+
items += [(b, None) for b in det.xyxy] # raw detections: don't wait for track confirmation
|
| 229 |
+
items += [(t.xyxy, t.track_id) for t in tracker.tracked_stracks if not t.is_activated]
|
| 230 |
+
items += [(t.xyxy, t.track_id) for t in tracker.lost_stracks if tracker.frame_id - t.end_frame <= a.hold]
|
| 231 |
+
if keeper: # recognise on the clean frame, before anything is drawn
|
| 232 |
+
keeper.update(frame, idx, [(b[:4], int(b[4])) for b in tracked])
|
| 233 |
+
else:
|
| 234 |
+
# no detector this frame: advance Kalman state of every live track and use the prediction
|
| 235 |
+
tracker.frame_id += 1
|
| 236 |
+
pool = tracker.tracked_stracks + tracker.lost_stracks
|
| 237 |
+
if pool:
|
| 238 |
+
tracker.multi_predict(pool)
|
| 239 |
+
items += [(t.xyxy, t.track_id) for t in tracker.tracked_stracks]
|
| 240 |
+
items += [(t.xyxy, t.track_id) for t in tracker.lost_stracks if tracker.frame_id - t.end_frame <= a.hold]
|
| 241 |
+
|
| 242 |
+
if keeper:
|
| 243 |
+
kept_boxes = [np.asarray(b, dtype=float)[:4] for b, tid in items
|
| 244 |
+
if tid is not None and keeper.is_kept(tid, idx)]
|
| 245 |
+
items = [(b, tid) for b, tid in items
|
| 246 |
+
if not (tid is not None and keeper.is_kept(tid, idx))
|
| 247 |
+
and not (tid is None and any(iou(np.asarray(b, dtype=float)[:4], kb) >= 0.5 for kb in kept_boxes))]
|
| 248 |
+
|
| 249 |
+
for b, _ in items:
|
| 250 |
+
box = pad_box(*np.asarray(b, dtype=float)[:4], a.pad, W, H)
|
| 251 |
+
if a.mode == "black":
|
| 252 |
+
blackout(frame, box)
|
| 253 |
+
else:
|
| 254 |
+
pixelate(frame, box, a.blocks)
|
| 255 |
+
wq.put(frame)
|
| 256 |
+
|
| 257 |
+
idx += 1
|
| 258 |
+
if idx % 100 == 0:
|
| 259 |
+
el = time.perf_counter() - t0
|
| 260 |
+
print(f"\r{idx}/{total or '?'} frames {idx / el:.1f} FPS", end="", flush=True)
|
| 261 |
+
|
| 262 |
+
wq.put(None)
|
| 263 |
+
wthread.join()
|
| 264 |
+
cap.release()
|
| 265 |
+
try:
|
| 266 |
+
writer.stdin.close()
|
| 267 |
+
except (BrokenPipeError, OSError):
|
| 268 |
+
pass
|
| 269 |
+
rc = writer.wait()
|
| 270 |
+
if write_failed:
|
| 271 |
+
rc = rc or 1
|
| 272 |
+
el = time.perf_counter() - t0
|
| 273 |
+
print(f"\rdone: {idx} frames in {el:.1f}s = {idx / el:.1f} FPS processing "
|
| 274 |
+
f"(detector ran on {n_det} frames, every {a.every}) -> {a.output}")
|
| 275 |
+
if keeper:
|
| 276 |
+
print(f"keep: {len(keeper.ever_kept)} of {len(keeper.seen)} tracks were recognised as the target and left visible")
|
| 277 |
+
if rc != 0:
|
| 278 |
+
sys.exit(f"ffmpeg exited with code {rc}")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
if __name__ == "__main__":
|
| 282 |
+
main()
|
code/convert_wider_to_yolo.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Convert WIDER FACE (HF parquet, CUHK-CSE/wider_face @ refs/convert/parquet) to single-class YOLO format.
|
| 3 |
+
|
| 4 |
+
- Skips boxes flagged `invalid` and degenerate boxes (<1px).
|
| 5 |
+
- Keeps small faces (no min-size filter) since blurring needs high recall.
|
| 6 |
+
- Drops images whose only boxes are invalid (they would teach the model to ignore real faces).
|
| 7 |
+
- Downscales images so the long side is <= --max-side (matches training imgsz; boxes are normalized).
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python convert_wider_to_yolo.py --raw raw/default --out dataset
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import io
|
| 14 |
+
import os
|
| 15 |
+
from concurrent.futures import ProcessPoolExecutor
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import pyarrow.parquet as pq
|
| 19 |
+
from PIL import Image
|
| 20 |
+
|
| 21 |
+
SPLITS = {"train": "train", "validation": "val"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def convert_row(args):
|
| 25 |
+
name, img_bytes, bboxes, invalid, out_img, out_lbl, max_side = args
|
| 26 |
+
valid = [b for b, inv in zip(bboxes, invalid) if not inv and b[2] >= 1 and b[3] >= 1]
|
| 27 |
+
if not valid:
|
| 28 |
+
return "dropped", 0, 0
|
| 29 |
+
im = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 30 |
+
w, h = im.size
|
| 31 |
+
scale = min(1.0, max_side / max(w, h))
|
| 32 |
+
if scale < 1.0:
|
| 33 |
+
im = im.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.LANCZOS)
|
| 34 |
+
stem = Path(name).stem
|
| 35 |
+
im.save(out_img / f"{stem}.jpg", quality=95)
|
| 36 |
+
lines = []
|
| 37 |
+
for x, y, bw, bh in valid:
|
| 38 |
+
x1, y1 = max(0.0, x), max(0.0, y)
|
| 39 |
+
x2, y2 = min(float(w), x + bw), min(float(h), y + bh)
|
| 40 |
+
if x2 - x1 < 1 or y2 - y1 < 1:
|
| 41 |
+
continue
|
| 42 |
+
lines.append(f"0 {(x1 + x2) / 2 / w:.6f} {(y1 + y2) / 2 / h:.6f} {(x2 - x1) / w:.6f} {(y2 - y1) / h:.6f}")
|
| 43 |
+
(out_lbl / f"{stem}.txt").write_text("\n".join(lines) + "\n")
|
| 44 |
+
return "ok", len(lines), len(bboxes) - len(valid)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def rows(files, out_img, out_lbl, max_side):
|
| 48 |
+
for f in files:
|
| 49 |
+
pf = pq.ParquetFile(f)
|
| 50 |
+
for batch in pf.iter_batches(batch_size=64):
|
| 51 |
+
for r in batch.to_pylist():
|
| 52 |
+
yield (r["image"]["path"], r["image"]["bytes"], r["faces"]["bbox"],
|
| 53 |
+
r["faces"]["invalid"], out_img, out_lbl, max_side)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def main():
|
| 57 |
+
ap = argparse.ArgumentParser()
|
| 58 |
+
ap.add_argument("--raw", default="raw/default")
|
| 59 |
+
ap.add_argument("--out", default="dataset")
|
| 60 |
+
ap.add_argument("--max-side", type=int, default=640)
|
| 61 |
+
ap.add_argument("--limit", type=int, default=0, help="max images per split (0 = all; for testing)")
|
| 62 |
+
ap.add_argument("--workers", type=int, default=os.cpu_count())
|
| 63 |
+
a = ap.parse_args()
|
| 64 |
+
|
| 65 |
+
out = Path(a.out)
|
| 66 |
+
for src, dst in SPLITS.items():
|
| 67 |
+
files = sorted((Path(a.raw) / src).glob("*.parquet"))
|
| 68 |
+
out_img, out_lbl = out / "images" / dst, out / "labels" / dst
|
| 69 |
+
out_img.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
out_lbl.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
gen = rows(files, out_img, out_lbl, a.max_side)
|
| 72 |
+
if a.limit:
|
| 73 |
+
import itertools
|
| 74 |
+
gen = itertools.islice(gen, a.limit)
|
| 75 |
+
ok = dropped = faces = 0
|
| 76 |
+
with ProcessPoolExecutor(a.workers) as ex:
|
| 77 |
+
for status, n, _ in ex.map(convert_row, gen, chunksize=16):
|
| 78 |
+
if status == "ok":
|
| 79 |
+
ok += 1
|
| 80 |
+
faces += n
|
| 81 |
+
else:
|
| 82 |
+
dropped += 1
|
| 83 |
+
print(f"{dst}: {ok} images, {faces} valid faces, {dropped} images dropped (no valid boxes)")
|
| 84 |
+
|
| 85 |
+
(out / "data.yaml").write_text(
|
| 86 |
+
f"path: {out.resolve()}\ntrain: images/train\nval: images/val\nnames:\n 0: face\n")
|
| 87 |
+
print("wrote", out / "data.yaml")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
main()
|
code/eval_model.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate one or more face-detector weights (.pt / .onnx) on the converted WIDER FACE val split.
|
| 3 |
+
|
| 4 |
+
Usage: python eval_model.py export/face_yolo11n.pt export/face_yolo11n_fp32.onnx export/face_yolo11n_fp16.onnx
|
| 5 |
+
"""
|
| 6 |
+
import argparse
|
| 7 |
+
|
| 8 |
+
from ultralytics import YOLO
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main():
|
| 12 |
+
ap = argparse.ArgumentParser()
|
| 13 |
+
ap.add_argument("models", nargs="+")
|
| 14 |
+
ap.add_argument("--data", default="dataset/data.yaml")
|
| 15 |
+
ap.add_argument("--imgsz", type=int, default=640)
|
| 16 |
+
ap.add_argument("--workers", type=int, default=4)
|
| 17 |
+
a = ap.parse_args()
|
| 18 |
+
for path in a.models:
|
| 19 |
+
r = YOLO(path, task="detect").val(data=a.data, imgsz=a.imgsz, batch=1, device=0,
|
| 20 |
+
workers=a.workers, plots=False, verbose=False)
|
| 21 |
+
print(f"RES {path}: mAP50={r.box.map50:.4f} mAP50-95={r.box.map:.4f} P={r.box.mp:.3f} R={r.box.mr:.3f} "
|
| 22 |
+
f"| {r.speed['preprocess']:.2f}+{r.speed['inference']:.2f}+{r.speed['postprocess']:.2f} ms/img",
|
| 23 |
+
flush=True)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if __name__ == "__main__":
|
| 27 |
+
main()
|
code/face_id.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Face identity helper: SCRFD landmarks + ArcFace (w600k_r50) embeddings via plain onnxruntime.
|
| 3 |
+
|
| 4 |
+
No `insightface` package needed (it would pull a CPU onnxruntime over onnxruntime-gpu); only the two ONNX files
|
| 5 |
+
from InsightFace's buffalo_l pack in models/. NOTE: those pretrained models are licensed for non-commercial
|
| 6 |
+
research use only.
|
| 7 |
+
|
| 8 |
+
Used by blur_video.py --keep to leave one person's face visible while blurring everyone else.
|
| 9 |
+
"""
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import onnxruntime as ort
|
| 15 |
+
|
| 16 |
+
MODELS = Path(__file__).resolve().parent / "models"
|
| 17 |
+
# ArcFace 112x112 alignment template (left eye, right eye, nose, left mouth, right mouth)
|
| 18 |
+
ARC_TEMPLATE = np.array([[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366],
|
| 19 |
+
[41.5493, 92.3655], [70.7299, 92.2041]], np.float32)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _session(path, device):
|
| 23 |
+
prov = ["CUDAExecutionProvider", "CPUExecutionProvider"] if device != "cpu" else ["CPUExecutionProvider"]
|
| 24 |
+
so = ort.SessionOptions()
|
| 25 |
+
so.log_severity_level = 3
|
| 26 |
+
return ort.InferenceSession(str(path), sess_options=so, providers=prov)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _nms(boxes, scores, thr=0.4):
|
| 30 |
+
idx = cv2.dnn.NMSBoxes([[float(b[0]), float(b[1]), float(b[2] - b[0]), float(b[3] - b[1])] for b in boxes],
|
| 31 |
+
[float(s) for s in scores], 0.0, thr)
|
| 32 |
+
return np.array(idx, dtype=int).reshape(-1)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class FaceID:
|
| 36 |
+
def __init__(self, device="cpu"):
|
| 37 |
+
self.det = _session(MODELS / "det_10g.onnx", device)
|
| 38 |
+
self.rec = _session(MODELS / "w600k_r50.onnx", device)
|
| 39 |
+
self.det_in = self.det.get_inputs()[0].name
|
| 40 |
+
self.rec_in = self.rec.get_inputs()[0].name
|
| 41 |
+
|
| 42 |
+
def detect(self, img, size=192, thr=0.4):
|
| 43 |
+
"""SCRFD on `img` letterboxed (top-left) into a size x size canvas.
|
| 44 |
+
Returns boxes (N,4) xyxy, scores (N,), landmarks (N,5,2), all in `img` pixel coordinates."""
|
| 45 |
+
h, w = img.shape[:2]
|
| 46 |
+
s = size / max(h, w)
|
| 47 |
+
nw, nh = max(1, int(round(w * s))), max(1, int(round(h * s)))
|
| 48 |
+
canvas = np.zeros((size, size, 3), np.uint8)
|
| 49 |
+
canvas[:nh, :nw] = cv2.resize(img, (nw, nh))
|
| 50 |
+
blob = cv2.dnn.blobFromImage(canvas, 1 / 128.0, (size, size), (127.5, 127.5, 127.5), swapRB=True)
|
| 51 |
+
outs = self.det.run(None, {self.det_in: blob})
|
| 52 |
+
B, S, K = [], [], []
|
| 53 |
+
for i, stride in enumerate((8, 16, 32)):
|
| 54 |
+
sc, bb, kp = outs[i][:, 0], outs[i + 3] * stride, outs[i + 6] * stride
|
| 55 |
+
g = size // stride
|
| 56 |
+
ys, xs = np.mgrid[:g, :g]
|
| 57 |
+
ctr = np.repeat(np.stack([xs, ys], -1).reshape(-1, 2) * stride, 2, axis=0).astype(np.float32)
|
| 58 |
+
m = sc >= thr
|
| 59 |
+
if not m.any():
|
| 60 |
+
continue
|
| 61 |
+
c, bb, kp = ctr[m], bb[m], kp[m]
|
| 62 |
+
B.append(np.stack([c[:, 0] - bb[:, 0], c[:, 1] - bb[:, 1], c[:, 0] + bb[:, 2], c[:, 1] + bb[:, 3]], 1))
|
| 63 |
+
K.append(np.stack([c[:, 0:1] + kp[:, 0::2], c[:, 1:2] + kp[:, 1::2]], -1))
|
| 64 |
+
S.append(sc[m])
|
| 65 |
+
if not B:
|
| 66 |
+
return np.zeros((0, 4)), np.zeros(0), np.zeros((0, 5, 2))
|
| 67 |
+
B, S, K = np.concatenate(B) / s, np.concatenate(S), np.concatenate(K) / s
|
| 68 |
+
keep = _nms(B, S)
|
| 69 |
+
return B[keep], S[keep], K[keep]
|
| 70 |
+
|
| 71 |
+
def embed_aligned(self, img, lmk):
|
| 72 |
+
M, _ = cv2.estimateAffinePartial2D(lmk.astype(np.float32), ARC_TEMPLATE, method=cv2.LMEDS)
|
| 73 |
+
if M is None:
|
| 74 |
+
return None
|
| 75 |
+
face = cv2.warpAffine(img, M, (112, 112), borderValue=0)
|
| 76 |
+
blob = cv2.dnn.blobFromImage(face, 1 / 127.5, (112, 112), (127.5, 127.5, 127.5), swapRB=True)
|
| 77 |
+
e = self.rec.run(None, {self.rec_in: blob})[0][0]
|
| 78 |
+
return e / (np.linalg.norm(e) + 1e-9)
|
| 79 |
+
|
| 80 |
+
def embed_box(self, frame, box, expand=0.5, min_size=40):
|
| 81 |
+
"""Embedding of the face inside `box` (xyxy) of a full frame, or None when it is too small / not found.
|
| 82 |
+
Crops the box expanded by `expand`, finds landmarks of the face nearest the box centre, aligns, embeds."""
|
| 83 |
+
H, W = frame.shape[:2]
|
| 84 |
+
x1, y1, x2, y2 = [float(v) for v in box[:4]]
|
| 85 |
+
bw, bh = x2 - x1, y2 - y1
|
| 86 |
+
if min(bw, bh) < min_size:
|
| 87 |
+
return None
|
| 88 |
+
cx1, cy1 = max(0, int(x1 - bw * expand)), max(0, int(y1 - bh * expand))
|
| 89 |
+
cx2, cy2 = min(W, int(x2 + bw * expand)), min(H, int(y2 + bh * expand))
|
| 90 |
+
crop = frame[cy1:cy2, cx1:cx2]
|
| 91 |
+
if crop.size == 0:
|
| 92 |
+
return None
|
| 93 |
+
B, S, K = self.detect(crop)
|
| 94 |
+
if len(B) == 0:
|
| 95 |
+
return None
|
| 96 |
+
tx, ty = (x1 + x2) / 2 - cx1, (y1 + y2) / 2 - cy1
|
| 97 |
+
d = [np.hypot((b[0] + b[2]) / 2 - tx, (b[1] + b[3]) / 2 - ty) / max(bw, 1) for b in B]
|
| 98 |
+
j = int(np.argmin(d))
|
| 99 |
+
if d[j] > 0.35: # the found face is not the one in `box`
|
| 100 |
+
return None
|
| 101 |
+
return self.embed_aligned(crop, K[j])
|
| 102 |
+
|
| 103 |
+
def reference_embeddings(self, paths):
|
| 104 |
+
"""One embedding per reference photo (largest face in each). Raises if a photo has no usable face."""
|
| 105 |
+
embs = []
|
| 106 |
+
for p in paths:
|
| 107 |
+
img = cv2.imread(str(p))
|
| 108 |
+
if img is None:
|
| 109 |
+
raise SystemExit(f"cannot read reference image {p}")
|
| 110 |
+
B, S, K = self.detect(img, size=640, thr=0.5)
|
| 111 |
+
if len(B) == 0:
|
| 112 |
+
raise SystemExit(f"no face found in reference image {p}")
|
| 113 |
+
if len(B) > 1:
|
| 114 |
+
print(f"warning: {len(B)} faces in {p}; using the largest")
|
| 115 |
+
j = int(np.argmax((B[:, 2] - B[:, 0]) * (B[:, 3] - B[:, 1])))
|
| 116 |
+
e = self.embed_aligned(img, K[j])
|
| 117 |
+
if e is None:
|
| 118 |
+
raise SystemExit(f"could not align face in {p}")
|
| 119 |
+
embs.append(e)
|
| 120 |
+
return np.stack(embs)
|
| 121 |
+
|
| 122 |
+
@staticmethod
|
| 123 |
+
def similarity(emb, refs):
|
| 124 |
+
"""Best cosine similarity of `emb` against the reference embeddings."""
|
| 125 |
+
return float((refs @ emb).max())
|
code/quantize_int8.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Post-training static INT8 quantization (QDQ, per-channel weights) of the face detector ONNX.
|
| 3 |
+
|
| 4 |
+
Usage: python quantize_int8.py export/face_yolo11n_fp32.onnx export/face_yolo11n_int8.onnx [--calib 300]
|
| 5 |
+
"""
|
| 6 |
+
import argparse
|
| 7 |
+
import random
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import cv2
|
| 11 |
+
import numpy as np
|
| 12 |
+
import onnx
|
| 13 |
+
from onnxruntime.quantization import CalibrationDataReader, CalibrationMethod, QuantFormat, QuantType, quantize_static
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def letterbox(img, size=640):
|
| 17 |
+
h, w = img.shape[:2]
|
| 18 |
+
s = min(size / h, size / w)
|
| 19 |
+
nh, nw = round(h * s), round(w * s)
|
| 20 |
+
img = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
|
| 21 |
+
canvas = np.full((size, size, 3), 114, np.uint8)
|
| 22 |
+
top, left = (size - nh) // 2, (size - nw) // 2
|
| 23 |
+
canvas[top:top + nh, left:left + nw] = img
|
| 24 |
+
return canvas
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Reader(CalibrationDataReader):
|
| 28 |
+
def __init__(self, files, input_name):
|
| 29 |
+
self.it = iter(files)
|
| 30 |
+
self.name = input_name
|
| 31 |
+
|
| 32 |
+
def get_next(self):
|
| 33 |
+
f = next(self.it, None)
|
| 34 |
+
if f is None:
|
| 35 |
+
return None
|
| 36 |
+
img = letterbox(cv2.imread(str(f)))[:, :, ::-1].transpose(2, 0, 1) # BGR->RGB, CHW
|
| 37 |
+
return {self.name: (img[None].astype(np.float32) / 255.0)}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
ap = argparse.ArgumentParser()
|
| 42 |
+
ap.add_argument("src")
|
| 43 |
+
ap.add_argument("dst")
|
| 44 |
+
ap.add_argument("--calib", type=int, default=300)
|
| 45 |
+
ap.add_argument("--images", default="dataset/images/train")
|
| 46 |
+
a = ap.parse_args()
|
| 47 |
+
|
| 48 |
+
random.seed(0)
|
| 49 |
+
files = sorted(Path(a.images).glob("*.jpg"))
|
| 50 |
+
random.shuffle(files)
|
| 51 |
+
files = files[:a.calib]
|
| 52 |
+
g = onnx.load(a.src).graph
|
| 53 |
+
inp = g.input[0].name
|
| 54 |
+
# The Detect head's decode (DFL box decoding, class Sigmoid, final Concats) mixes box values up to 640 with
|
| 55 |
+
# 0-1 class scores in one tensor; INT8 would give them one shared scale and crush the scores. Keep it float.
|
| 56 |
+
exclude = [n.name for n in g.node
|
| 57 |
+
if n.name.startswith("/model.23/") and not n.name.startswith(("/model.23/cv2.", "/model.23/cv3."))]
|
| 58 |
+
print(f"keeping {len(exclude)} decode nodes in float")
|
| 59 |
+
quantize_static(a.src, a.dst, Reader(files, inp), quant_format=QuantFormat.QDQ, per_channel=True,
|
| 60 |
+
weight_type=QuantType.QInt8, activation_type=QuantType.QUInt8,
|
| 61 |
+
calibrate_method=CalibrationMethod.MinMax, nodes_to_exclude=exclude)
|
| 62 |
+
# keep ultralytics metadata (class names, stride, imgsz) so the model loads like the original
|
| 63 |
+
src, q = onnx.load(a.src), onnx.load(a.dst)
|
| 64 |
+
del q.metadata_props[:]
|
| 65 |
+
q.metadata_props.extend(src.metadata_props)
|
| 66 |
+
onnx.save(q, a.dst)
|
| 67 |
+
print(f"RES wrote {a.dst}: {Path(a.dst).stat().st_size / 1e6:.1f} MB (from {Path(a.src).stat().st_size / 1e6:.1f} MB)")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
main()
|
code/recall_at_conf.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Face recall at a fixed confidence threshold on WIDER val (what fraction of GT faces get a box with IoU>=0.5)."""
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from ultralytics import YOLO
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def iou(a, b):
|
| 11 |
+
x1 = np.maximum(a[:, None, 0], b[None, :, 0]); y1 = np.maximum(a[:, None, 1], b[None, :, 1])
|
| 12 |
+
x2 = np.minimum(a[:, None, 2], b[None, :, 2]); y2 = np.minimum(a[:, None, 3], b[None, :, 3])
|
| 13 |
+
inter = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
|
| 14 |
+
aa = (a[:, 2] - a[:, 0]) * (a[:, 3] - a[:, 1]); ab = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
|
| 15 |
+
return inter / (aa[:, None] + ab[None, :] - inter + 1e-9)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
ap = argparse.ArgumentParser()
|
| 20 |
+
ap.add_argument("--model", default="export/face_yolo11n_fp16.onnx")
|
| 21 |
+
ap.add_argument("--conf", type=float, default=0.25)
|
| 22 |
+
ap.add_argument("--iou", type=float, default=0.5)
|
| 23 |
+
ap.add_argument("--val", default="dataset/images/val")
|
| 24 |
+
ap.add_argument("--device", default="0", help="0 for GPU, cpu for CPU")
|
| 25 |
+
ap.add_argument("--imgsz", type=int, default=640, help="inference size (.pt models only; ONNX exports are fixed)")
|
| 26 |
+
a = ap.parse_args()
|
| 27 |
+
|
| 28 |
+
model = YOLO(a.model, task="detect")
|
| 29 |
+
files = sorted(Path(a.val).glob("*.jpg"))
|
| 30 |
+
hits, sizes, n_pred, n_fp = [], [], 0, 0
|
| 31 |
+
for i in range(0, len(files), 1): # ONNX export is fixed batch=1
|
| 32 |
+
batch = files[i:i + 1]
|
| 33 |
+
for f, r in zip(batch, model.predict([str(x) for x in batch], conf=a.conf, imgsz=a.imgsz, device=a.device, verbose=False)):
|
| 34 |
+
h, w = r.orig_shape
|
| 35 |
+
gt = np.array([[float(v) for v in l.split()[1:]] for l in
|
| 36 |
+
Path(str(f).replace("images", "labels").replace(".jpg", ".txt")).read_text().split("\n") if l.strip()])
|
| 37 |
+
g = np.stack([(gt[:, 0] - gt[:, 2] / 2) * w, (gt[:, 1] - gt[:, 3] / 2) * h,
|
| 38 |
+
(gt[:, 0] + gt[:, 2] / 2) * w, (gt[:, 1] + gt[:, 3] / 2) * h], 1)
|
| 39 |
+
p = r.boxes.xyxy.cpu().numpy()
|
| 40 |
+
n_pred += len(p)
|
| 41 |
+
if len(p):
|
| 42 |
+
m = iou(g, p)
|
| 43 |
+
hits += list(m.max(1) >= a.iou)
|
| 44 |
+
n_fp += int((m.max(0) < a.iou).sum())
|
| 45 |
+
else:
|
| 46 |
+
hits += [False] * len(g)
|
| 47 |
+
sizes += list(gt[:, 3] * h) # face height in px at the image's stored (<=640) scale
|
| 48 |
+
hits, sizes = np.array(hits), np.array(sizes)
|
| 49 |
+
print(f"RES faces={len(hits)} overall recall@conf{a.conf}/IoU{a.iou} = {hits.mean():.1%} (false-positive boxes: {n_fp}/{n_pred})")
|
| 50 |
+
for lo, hi in [(0, 10), (10, 20), (20, 40), (40, 80), (80, 1e9)]:
|
| 51 |
+
m = (sizes >= lo) & (sizes < hi)
|
| 52 |
+
print(f"RES face height {lo:>3}-{int(min(hi, 999)):>3}px: {hits[m].mean():6.1%} (n={m.sum()})")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
code/train.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Fine-tune YOLO11n (COCO-pretrained) as a single-class face detector on converted WIDER FACE.
|
| 3 |
+
|
| 4 |
+
Adds video-like degradation (motion blur + JPEG compression) on top of ultralytics' default augmentation,
|
| 5 |
+
implemented with plain cv2 so no albumentations / second OpenCV build is needed.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python train.py # full run (40 epochs)
|
| 9 |
+
python train.py --fraction 0.02 --epochs 1 --name smoke # quick pipeline check
|
| 10 |
+
"""
|
| 11 |
+
import argparse
|
| 12 |
+
import random
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
from ultralytics import YOLO
|
| 18 |
+
from ultralytics.data import augment
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _motion_blur(img, max_k=15):
|
| 22 |
+
k = random.choice(range(3, max_k + 1, 2))
|
| 23 |
+
kernel = np.zeros((k, k), np.float32)
|
| 24 |
+
kernel[k // 2, :] = 1.0
|
| 25 |
+
rot = cv2.getRotationMatrix2D((k / 2 - 0.5, k / 2 - 0.5), random.uniform(0, 180), 1.0)
|
| 26 |
+
kernel = cv2.warpAffine(kernel, rot, (k, k))
|
| 27 |
+
s = kernel.sum()
|
| 28 |
+
return img if s == 0 else cv2.filter2D(img, -1, kernel / s)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _jpeg(img, qmin=20, qmax=90):
|
| 32 |
+
ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, random.randint(qmin, qmax)])
|
| 33 |
+
return cv2.imdecode(enc, cv2.IMREAD_COLOR) if ok else img
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def install_video_degradation(p_blur=0.3, p_jpeg=0.3):
|
| 37 |
+
"""Wrap ultralytics' Albumentations step (runs after mosaic/affine, train only) with cv2 degradations."""
|
| 38 |
+
orig = augment.Albumentations.__call__
|
| 39 |
+
|
| 40 |
+
def call(self, labels):
|
| 41 |
+
img = labels["img"]
|
| 42 |
+
if random.random() < p_blur:
|
| 43 |
+
img = _motion_blur(img)
|
| 44 |
+
if random.random() < p_jpeg:
|
| 45 |
+
img = _jpeg(img)
|
| 46 |
+
labels["img"] = img
|
| 47 |
+
return orig(self, labels)
|
| 48 |
+
|
| 49 |
+
augment.Albumentations.__call__ = call
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
ap = argparse.ArgumentParser()
|
| 54 |
+
ap.add_argument("--data", default="dataset/data.yaml")
|
| 55 |
+
ap.add_argument("--model", default="yolo11n.pt")
|
| 56 |
+
ap.add_argument("--epochs", type=int, default=40)
|
| 57 |
+
ap.add_argument("--batch", type=int, default=64)
|
| 58 |
+
ap.add_argument("--imgsz", type=int, default=640)
|
| 59 |
+
ap.add_argument("--workers", type=int, default=16)
|
| 60 |
+
ap.add_argument("--fraction", type=float, default=1.0, help="fraction of train set (smoke tests)")
|
| 61 |
+
ap.add_argument("--cache", default="disk", help="ram | disk | False")
|
| 62 |
+
ap.add_argument("--project", default=str(Path(__file__).resolve().parent / "runs"))
|
| 63 |
+
ap.add_argument("--name", default="face_yolo11n")
|
| 64 |
+
ap.add_argument("--no-degrade", action="store_true")
|
| 65 |
+
ap.add_argument("--resume", default=None,
|
| 66 |
+
help="path to a stopped run's last.pt; continues it and finishes at --epochs "
|
| 67 |
+
"(the LR schedule is recomputed for the new total)")
|
| 68 |
+
a = ap.parse_args()
|
| 69 |
+
|
| 70 |
+
if not a.no_degrade:
|
| 71 |
+
install_video_degradation()
|
| 72 |
+
|
| 73 |
+
if a.resume:
|
| 74 |
+
import torch
|
| 75 |
+
ck = torch.load(a.resume, map_location="cpu", weights_only=False)
|
| 76 |
+
ck["train_args"]["epochs"] = a.epochs
|
| 77 |
+
torch.save(ck, a.resume)
|
| 78 |
+
model = YOLO(a.resume)
|
| 79 |
+
model.train(resume=True)
|
| 80 |
+
else:
|
| 81 |
+
model = YOLO(a.model)
|
| 82 |
+
model.train(
|
| 83 |
+
data=a.data, epochs=a.epochs, imgsz=a.imgsz, batch=a.batch, workers=a.workers,
|
| 84 |
+
fraction=a.fraction, cache=False if a.cache == "False" else a.cache, amp=True,
|
| 85 |
+
close_mosaic=5, patience=0, project=a.project, name=a.name, exist_ok=True,
|
| 86 |
+
plots=False,
|
| 87 |
+
)
|
| 88 |
+
m = model.val(data=a.data, imgsz=a.imgsz, batch=a.batch)
|
| 89 |
+
print(f"RESULT mAP50={m.box.map50:.4f} mAP50-95={m.box.map:.4f} P={m.box.mp:.4f} R={m.box.mr:.4f}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
main()
|
yolo11n/face_yolo11n.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:98b9fedf995fdc7bf20c6bbccde5d1ca46b3fb908843207be67794395d6d6a28
|
| 3 |
+
size 5447834
|
yolo11n/face_yolo11n_fp16.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:958d99534290613fb7fb646467671a893b5ebc9941de385ceea039cc0612e827
|
| 3 |
+
size 5355828
|
yolo11n/face_yolo11n_fp32.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9506b1f2b497cf0d3c39eb756ecacef82965d6d01e8037f3c2a91f6cb1b22297
|
| 3 |
+
size 10604582
|
yolo11n/face_yolo11n_int8.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:48a6930873aee81ce40464b13de001aff90757e86f119d08cac642e9e4d80778
|
| 3 |
+
size 3258276
|
yolo11s/face_yolo11s.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:09cca2067809392d575e947b6be2a7b4bc09123b078c60f5f41155afdae833c9
|
| 3 |
+
size 19155034
|
yolo11s/face_yolo11s_fp16.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2be67ca882dd4e694a407a59b1c9762508e3756b7077b8f919799b2ed492e72
|
| 3 |
+
size 19017872
|
yolo11s/face_yolo11s_fp32.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1c4eac37a7a5efbe791225022799ac8980743bf0271204f43f782b327785c1eb
|
| 3 |
+
size 37928280
|