Merge pull request 'agent: temporal video dedup — drop near-duplicate frames before the GPU' (#175) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
This commit was merged in pull request #175.
This commit is contained in:
@@ -17,7 +17,7 @@ from .worker import Worker
|
||||
# Bump on every agent change. The page embeds this and /status reports it; the UI
|
||||
# warns to reload when they differ — so a stale browser-cached page can't be
|
||||
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
|
||||
VERSION = "2026-07-01.2 · crop dedup before embed"
|
||||
VERSION = "2026-07-01.3 · video frame dedup"
|
||||
|
||||
logbuf.install()
|
||||
cfg = Config.from_env()
|
||||
|
||||
@@ -38,6 +38,9 @@ class Config:
|
||||
max_regions: int # hard cap on total regions per JOB (submit-size backstop)
|
||||
dedupe_iou: float # crops overlapping >= this (same kind) are near-dupes,
|
||||
# dropped before the embed; >=1.0 disables it
|
||||
frame_dedupe_distance: int # video frames whose dHash differs by < this many
|
||||
# bits are near-duplicates, dropped before detect;
|
||||
# higher keeps more frames, 0 disables
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
@@ -65,4 +68,5 @@ class Config:
|
||||
max_figures=int(os.environ.get("MAX_FIGURES", "8")),
|
||||
max_regions=int(os.environ.get("MAX_REGIONS", "128")),
|
||||
dedupe_iou=float(os.environ.get("DEDUPE_IOU", "0.85")),
|
||||
frame_dedupe_distance=int(os.environ.get("FRAME_DEDUPE_DISTANCE", "8")),
|
||||
)
|
||||
|
||||
@@ -25,6 +25,47 @@ def is_video(mime: str) -> bool:
|
||||
return bool(mime) and (mime.startswith("video/") or mime in {"image/gif"})
|
||||
|
||||
|
||||
def _dhash(img: Image.Image, size: int = 8) -> int:
|
||||
"""Difference hash: compare adjacent pixels of a (size+1 × size) grayscale
|
||||
thumbnail → a `size*size`-bit fingerprint. Cheap (64 comparisons on a 72-px
|
||||
thumbnail) and robust to scaling/compression noise — near-identical frames
|
||||
hash within a few bits, a real scene change moves many."""
|
||||
small = img.convert("L").resize((size + 1, size))
|
||||
px = list(small.getdata())
|
||||
bits = 0
|
||||
for row in range(size):
|
||||
base = row * (size + 1)
|
||||
for col in range(size):
|
||||
bits = (bits << 1) | int(px[base + col] > px[base + col + 1])
|
||||
return bits
|
||||
|
||||
|
||||
def dedupe_frames(
|
||||
frames: list[tuple[float, Image.Image]], min_distance: int
|
||||
) -> list[tuple[float, Image.Image]]:
|
||||
"""Drop visually near-duplicate frames. A near-static video sampled into many
|
||||
frames re-runs the WHOLE detect→CCIP→SigLIP chain on ~identical frames — the
|
||||
dominant video load. Greedy perceptual-hash dedup: keep a frame only if its
|
||||
dHash differs from every already-kept frame by >= min_distance bits (Hamming),
|
||||
so a static run collapses to one frame while genuinely distinct scenes all
|
||||
survive. Order + timestamps preserved. CPU-only (64-bit int XORs), so it runs
|
||||
in the decode stage and spares the GPU the skipped frames entirely.
|
||||
|
||||
min_distance is the coarseness dial: higher keeps more frames (safer for brief
|
||||
localized changes an 8×8 hash can miss), 0 disables. The first frame is always
|
||||
kept (nothing to compare against)."""
|
||||
if min_distance <= 0 or len(frames) <= 1:
|
||||
return frames
|
||||
kept: list[tuple[float, Image.Image]] = []
|
||||
hashes: list[int] = []
|
||||
for t, frame in frames:
|
||||
h = _dhash(frame)
|
||||
if all(bin(h ^ k).count("1") >= min_distance for k in hashes):
|
||||
hashes.append(h)
|
||||
kept.append((t, frame))
|
||||
return kept
|
||||
|
||||
|
||||
def to_rgb(img: Image.Image) -> Image.Image:
|
||||
"""RGB, flattening any transparency onto white first. A naive convert('RGB')
|
||||
on a palette-with-transparency image (common for character PNGs on a clear
|
||||
|
||||
@@ -467,6 +467,15 @@ class Worker:
|
||||
data, job.get("frame_interval_seconds", 4.0),
|
||||
job.get("max_frames", 64),
|
||||
) or [(None, media.load_image(data))]
|
||||
# Temporal dedup: a near-static video sampled into many frames re-runs
|
||||
# the whole detect+embed chain on ~identical frames. Drop near-dup
|
||||
# frames HERE (decode stage, CPU) so the GPU never sees them.
|
||||
if len(frames) > 1 and self.cfg.frame_dedupe_distance > 0:
|
||||
kept = media.dedupe_frames(frames, self.cfg.frame_dedupe_distance)
|
||||
if len(kept) < len(frames):
|
||||
log.info("job %s: video frames %d→%d (near-dup dedup)",
|
||||
job.get("job_id"), len(frames), len(kept))
|
||||
frames = kept
|
||||
else:
|
||||
frames = [(None, media.load_image(data))]
|
||||
self._record("decode", time.monotonic() - _t)
|
||||
|
||||
Reference in New Issue
Block a user