File size: 12,769 Bytes
d176ecd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
277
278
279
280
281
282
283
#!/usr/bin/env python3
"""Detect, track and pixelate (or black out) faces in a video. Audio is preserved via ffmpeg.

Detection runs every N frames; between detections ByteTrack's Kalman filter carries the boxes forward.
Tuned for recall (privacy): low confidence threshold, box padding, raw detections are blurred immediately
(without waiting for track confirmation) and recently lost tracks are held for a few frames.

--keep: leave ONE person's face visible and blur everybody else. Give one or more reference photos of that
person. Fail-safe by design: a face stays blurred unless its track was confirmed as the target by several
consecutive face-recognition matches; a clear non-match, a track box jump, or going too long without a
successful re-check puts the blur back.

Examples:
  python blur_video.py in.mp4 out.mp4 --model export/face_yolo11n_fp16.onnx -n 1
  python blur_video.py in.mp4 out.mp4 --mode black
  python blur_video.py in.mp4 out.mp4 -n 1 --keep me1.jpg me2.jpg
"""
import argparse
import queue
import shutil
import subprocess
import sys
import threading
import time
from types import SimpleNamespace

import cv2
import numpy as np
from ultralytics import YOLO
from ultralytics.trackers.byte_tracker import BYTETracker


def pad_box(x1, y1, x2, y2, pad, w, h):
    bw, bh = x2 - x1, y2 - y1
    x1, x2 = x1 - bw * pad, x2 + bw * pad
    y1, y2 = y1 - bh * pad, y2 + bh * pad
    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)))


def iou(a, b):
    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
    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 pixelate(frame, box, blocks):
    """Downscale the region so its shorter side has `blocks` cells, then nearest-neighbour upscale."""
    x1, y1, x2, y2 = box
    if x2 - x1 < 2 or y2 - y1 < 2:
        return
    roi = frame[y1:y2, x1:x2]
    h, w = roi.shape[:2]
    cell = max(1.0, min(w, h) / blocks)
    small = cv2.resize(roi, (max(1, round(w / cell)), max(1, round(h / cell))), interpolation=cv2.INTER_AREA)
    frame[y1:y2, x1:x2] = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)


def blackout(frame, box):
    x1, y1, x2, y2 = box
    frame[y1:y2, x1:x2] = 0


def open_ffmpeg_writer(src, dst, w, h, fps, crf):
    cmd = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{w}x{h}", "-r", f"{fps}", "-i", "-",
        "-i", src,
        "-map", "0:v:0", "-map", "1:a?",
        "-c:v", "libx264", "-preset", "fast", "-crf", str(crf), "-pix_fmt", "yuv420p",
        "-c:a", "copy", "-shortest", "-movflags", "+faststart", dst,
    ]
    return subprocess.Popen(cmd, stdin=subprocess.PIPE)


class KeepTracker:
    """Decides which ByteTrack ids are the person to keep visible. Everything not confirmed stays blurred."""

    def __init__(self, fid, refs, thr, min_size, hits_needed, recheck, expire, budget):
        self.fid, self.refs, self.thr, self.min_size = fid, refs, thr, min_size
        self.hits_needed, self.recheck, self.expire, self.budget = hits_needed, recheck, expire, budget
        self.state = {}  # tid -> dict(hits, kept, last_ok, last_try, box)
        self.seen, self.ever_kept = set(), set()

    def is_kept(self, tid, idx):
        s = self.state.get(tid)
        if s is None or not s["kept"]:
            return False
        if idx - s["last_ok"] > self.expire:  # could not re-confirm for too long -> blur again
            s["kept"], s["hits"] = False, 0
            return False
        return True

    def update(self, frame, idx, tracked):
        """tracked: list of (xyxy, tid) from a detection frame (clean, un-blurred frame)."""
        todo = []
        for box, tid in tracked:
            self.seen.add(tid)
            s = self.state.setdefault(tid, dict(hits=0, kept=False, last_ok=-10**9, last_try=-10**9, box=box))
            if s["kept"] and iou(s["box"], box) < 0.3:  # box jumped: possible ID switch -> distrust
                s["kept"], s["hits"] = False, 0
            s["box"] = box
            if not self.is_kept(tid, idx):
                todo.append((0, box, tid))  # undecided first
            elif idx - s["last_try"] >= self.recheck:
                todo.append((1, box, tid))
        todo.sort(key=lambda t: (t[0], self.state[t[2]]["last_try"]))
        for _, box, tid in todo[:self.budget]:
            s = self.state[tid]
            s["last_try"] = idx
            e = self.fid.embed_box(frame, box, min_size=self.min_size)
            if e is None:
                continue  # inconclusive: keep old state (a kept track lapses via `expire`)
            if self.fid.similarity(e, self.refs) >= self.thr:
                s["hits"] += 1
                s["last_ok"] = idx
                if s["hits"] >= self.hits_needed:
                    s["kept"] = True
                    self.ever_kept.add(tid)
            else:  # clear non-match: blur immediately
                s["hits"], s["kept"] = 0, False


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("input")
    ap.add_argument("output")
    ap.add_argument("--model", default="runs/face_yolo11n/weights/best.pt", help=".pt or .onnx face detector")
    ap.add_argument("-n", "--every", type=int, default=3, help="run detector every N frames (default 3)")
    ap.add_argument("--conf", type=float, default=0.25, help="detection confidence threshold (default 0.25)")
    ap.add_argument("--pad", type=float, default=0.15, help="pad each box by this fraction per side (default 0.15)")
    ap.add_argument("--mode", choices=["pixelate", "black"], default="pixelate",
                    help="pixelate faces (default) or fill the padded box with solid black")
    ap.add_argument("--blocks", type=int, default=8,
                    help="pixel blocks across a face's shorter side; LOWER = stronger pixelation (default 8)")
    ap.add_argument("--hold", type=int, default=8, help="keep blurring a lost track's prediction this many frames")
    ap.add_argument("--imgsz", type=int, default=640)
    ap.add_argument("--device", default=None, help="e.g. 0 or cpu (default: auto)")
    ap.add_argument("--crf", type=int, default=18, help="x264 quality (lower = better)")
    k = ap.add_argument_group("keep one person visible (face recognition)")
    k.add_argument("--keep", nargs="+", metavar="REF.jpg",
                   help="reference photo(s) of the person to leave unblurred; everyone else is blurred")
    k.add_argument("--keep-threshold", type=float, default=0.45,
                   help="cosine similarity needed to count as the same person; HIGHER = stricter (default 0.45)")
    k.add_argument("--keep-hits", type=int, default=2, help="consecutive matches before a track is unblurred")
    k.add_argument("--keep-min-size", type=int, default=40,
                   help="faces smaller than this many px are never recognised, so they stay blurred (default 40)")
    k.add_argument("--keep-recheck", type=int, default=8, help="re-verify a kept track every this many frames")
    k.add_argument("--keep-expire", type=int, default=24,
                   help="blur a kept track again if it could not be re-confirmed for this many frames")
    k.add_argument("--keep-budget", type=int, default=4, help="max recognitions per frame (speed cap)")
    a = ap.parse_args()

    if shutil.which("ffmpeg") is None:
        sys.exit("ffmpeg not found on PATH")
    if a.every < 1 or a.blocks < 1:
        sys.exit("--every and --blocks must be >= 1")

    keeper = None
    if a.keep:
        from face_id import FaceID
        fid = FaceID("cpu" if a.device == "cpu" else "0")
        refs = fid.reference_embeddings(a.keep)
        print(f"keeping the person in {len(refs)} reference photo(s); threshold {a.keep_threshold}")
        keeper = KeepTracker(fid, refs, a.keep_threshold, a.keep_min_size, a.keep_hits,
                             a.keep_recheck, a.keep_expire, a.keep_budget)

    cap = cv2.VideoCapture(a.input)
    if not cap.isOpened():
        sys.exit(f"cannot open {a.input}")
    fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
    ok, frame = cap.read()
    if not ok:
        sys.exit("no frames in input")
    H, W = frame.shape[:2]

    model = YOLO(a.model, task="detect")
    tracker = BYTETracker(SimpleNamespace(
        track_high_thresh=a.conf, track_low_thresh=0.1, new_track_thresh=a.conf,
        track_buffer=max(30, 4 * a.every), match_thresh=0.8, fuse_score=True))
    writer = open_ffmpeg_writer(a.input, a.output, W, H, fps, a.crf)

    # Decode and encode run in their own threads so they overlap with detection (frame order is preserved:
    # one reader, one writer, FIFO queues). cv2/ffmpeg pipe I/O release the GIL.
    rq, wq, write_failed = queue.Queue(maxsize=48), queue.Queue(maxsize=48), []
    first_frame = frame

    def reader():
        rq.put(first_frame)
        while True:
            okr, f = cap.read()
            if not okr:
                rq.put(None)
                return
            rq.put(f)

    def writer_loop():
        try:
            while True:
                f = wq.get()
                if f is None:
                    return
                writer.stdin.write(f.tobytes())
        except (BrokenPipeError, OSError):
            write_failed.append(True)
            while wq.get() is not None:  # drain so the main thread never blocks on a full queue
                pass

    threading.Thread(target=reader, daemon=True).start()
    wthread = threading.Thread(target=writer_loop)
    wthread.start()

    t0 = time.perf_counter()
    idx = n_det = 0
    while True:
        frame = rq.get()
        if frame is None:
            break
        items = []  # (xyxy, track_id or None)
        if idx % a.every == 0:
            r = model.predict(frame, conf=a.conf, imgsz=a.imgsz, device=a.device, verbose=False)[0]
            det = r.boxes.cpu().numpy()
            n_det += 1
            tracked = tracker.update(det, frame)
            items += [(b[:4], int(b[4])) for b in tracked]  # confirmed tracks
            items += [(b, None) for b in det.xyxy]  # raw detections: don't wait for track confirmation
            items += [(t.xyxy, t.track_id) for t in tracker.tracked_stracks if not t.is_activated]
            items += [(t.xyxy, t.track_id) for t in tracker.lost_stracks if tracker.frame_id - t.end_frame <= a.hold]
            if keeper:  # recognise on the clean frame, before anything is drawn
                keeper.update(frame, idx, [(b[:4], int(b[4])) for b in tracked])
        else:
            # no detector this frame: advance Kalman state of every live track and use the prediction
            tracker.frame_id += 1
            pool = tracker.tracked_stracks + tracker.lost_stracks
            if pool:
                tracker.multi_predict(pool)
            items += [(t.xyxy, t.track_id) for t in tracker.tracked_stracks]
            items += [(t.xyxy, t.track_id) for t in tracker.lost_stracks if tracker.frame_id - t.end_frame <= a.hold]

        if keeper:
            kept_boxes = [np.asarray(b, dtype=float)[:4] for b, tid in items
                          if tid is not None and keeper.is_kept(tid, idx)]
            items = [(b, tid) for b, tid in items
                     if not (tid is not None and keeper.is_kept(tid, idx))
                     and not (tid is None and any(iou(np.asarray(b, dtype=float)[:4], kb) >= 0.5 for kb in kept_boxes))]

        for b, _ in items:
            box = pad_box(*np.asarray(b, dtype=float)[:4], a.pad, W, H)
            if a.mode == "black":
                blackout(frame, box)
            else:
                pixelate(frame, box, a.blocks)
        wq.put(frame)

        idx += 1
        if idx % 100 == 0:
            el = time.perf_counter() - t0
            print(f"\r{idx}/{total or '?'} frames  {idx / el:.1f} FPS", end="", flush=True)

    wq.put(None)
    wthread.join()
    cap.release()
    try:
        writer.stdin.close()
    except (BrokenPipeError, OSError):
        pass
    rc = writer.wait()
    if write_failed:
        rc = rc or 1
    el = time.perf_counter() - t0
    print(f"\rdone: {idx} frames in {el:.1f}s = {idx / el:.1f} FPS processing "
          f"(detector ran on {n_det} frames, every {a.every}) -> {a.output}")
    if keeper:
        print(f"keep: {len(keeper.ever_kept)} of {len(keeper.seen)} tracks were recognised as the target and left visible")
    if rc != 0:
        sys.exit(f"ffmpeg exited with code {rc}")


if __name__ == "__main__":
    main()