fix: clip pad ±3s + OSD-clock alignment at cut points (single-frame OCR)
ci / smoke (push) Canceled after 0s

This commit is contained in:
andrew committed 2026-09-28 16:33:49 +07:00
1 parent 110d557dbf
commit 14f5ed4aac
7 files changed
+314 -12

No files matched your search

+2
View File
@@ -31,6 +31,8 @@ RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
# --- Batch clip download (motionEye; dashboard /api/batch-clip only) ---
MOTIONEYE_URL=http://192.168.192.253:8765 # motionEye base URL for batch clip download; empty disables
MOTIONEYE_CAMERA_ID=2 # motionEye camera id (1 = default camera)
MOTIONEYE_CLIP_PAD=3 # seconds trimmed before/after the batch window (float)
MOTIONEYE_OSD_ALIGN=1 # 1 = OCR-correct OSD clock drift at cut points, 0 = filename-based offsets only
# --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
# Dashboard switches persist there (atomic write, comments preserved) and apply
+2
View File
@@ -32,6 +32,8 @@ version tags are cut in this repo — POC stage, `main` is the release line).
- History `Klip` button → office-only `GET /api/batch-clip/<date>/<batch_number>`
trims+concatenates motionEye recordings to the batch window (`src/clip.py`),
download `b<n>-<date>-<HH-MM-SS>.mp4`.
- `MOTIONEYE_CLIP_PAD` (default ±3 s window padding) + OSD-clock alignment via
single-frame OCR at each cut point (`MOTIONEYE_OSD_ALIGN`, default on).
### Changed
- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`,
+19 -1
View File
@@ -1575,10 +1575,25 @@ def api_batch_clip(date, batch_number):
except ValueError:
camera_id = 2
try:
pad_seconds = float((os.getenv("MOTIONEYE_CLIP_PAD") or "").strip())
except (TypeError, ValueError):
pad_seconds = 3.0
_align_raw = os.getenv("MOTIONEYE_OSD_ALIGN")
align_osd = (
True if _align_raw is None
else _align_raw.strip().lower() not in ("0", "false", "no", "")
)
if not str(batch_number).isdigit():
return jsonify({"success": False, "error": f"batch_number tidak valid: {batch_number}"}), 400
batch_number = int(batch_number)
print(
f"[CLIP] batch={date}/{batch_number} pad={pad_seconds} align={align_osd}",
flush=True,
)
try:
conn = get_db()
cur = conn.cursor()
@@ -1614,7 +1629,10 @@ def api_batch_clip(date, batch_number):
return jsonify({"success": False, "error": f"Modul klip belum tersedia: {e}"}), 503
try:
clip_batch(motion_url, camera_id, str(start_iso), str(end_iso), tmp_path)
clip_batch(
motion_url, camera_id, str(start_iso), str(end_iso), tmp_path,
pad_seconds=pad_seconds, align_osd=align_osd,
)
except ClipError as ce:
_unlink_quiet(tmp_path)
msg = str(ce)
+2
View File
@@ -80,6 +80,8 @@ when set.
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
| `MOTIONEYE_URL` | `""` | motionEye base URL for batch clip download (empty disables the endpoint) |
| `MOTIONEYE_CAMERA_ID` | `2` | motionEye camera id used for batch clip download |
| `MOTIONEYE_CLIP_PAD` | `3` | Seconds padded before/after the batch window when trimming the clip (float) |
| `MOTIONEYE_OSD_ALIGN` | `1` | `1` = OCR-correct burned-in OSD clock drift at cut points, `0` = filename-based offsets only |
| `MODEL_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
+13
View File
@@ -103,6 +103,19 @@ under the hood) and returns the file as an attachment.
- URL keys (`.env`): `MOTIONEYE_URL` (base URL; empty → 404
`motionEye belum dikonfigurasi (MOTIONEYE_URL)`), `MOTIONEYE_CAMERA_ID`
(default `2`).
- `MOTIONEYE_CLIP_PAD` — float seconds padded before/after the batch window
at both cut points (default `3` → ±3 s; empty/invalid falls back to `3.0`).
- `MOTIONEYE_OSD_ALIGN` — OSD-clock alignment, default **on** (unset = on;
`0` / `false` / `no` / empty = off). Why: the camera's burned-in OSD clock
and the recording clock drift — up to ~+22 s inside a long clip — because
of dropped frames, so filename-based offsets cut at the wrong media time.
When on, `clip_batch` OCRs **1 frame** at each cut point (~2–6 s extra per
clip, result cached per clip+second) and corrects the cut times. `0` →
filename-based offsets only. If OCR fails, it silently falls back to
filename-based offsets; both cases log one
`[CLIP] batch=... pad=... align=...` line at request start (plus any
`[CLIP]` alignment detail lines) — check with `journalctl -u
karung-counter-dashboard`.
- motionEye HTTP API used: `GET /movie/<id>/list/` (recording segments in the
window) and `GET /movie/<id>/download<path>` (segment bytes).
- Requires `ffmpeg` / `ffprobe` on PATH — both already installed on the Jetson.
+121 -9
View File
@@ -12,6 +12,12 @@ import urllib.request
from datetime import datetime, timedelta
_PATH_RE = re.compile(r"(\d{4}-\d{2}-\d{2})/(\d{2})-(\d{2})-(\d{2})\.mp4$")
# OSD clock burned into pixels: "2026-09-26 08:52:47" (OCR sometimes glues date+time)
_OSD_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\s*(\d{1,2}:\d{2}:\d{2})")
_OSD_CACHE = {} # (url, round(media_offset s)) -> datetime
_OCR = None # lazy rapidocr singleton
_OCR_FAILED = False
_FRAME_W, _FRAME_H = 960, 540
_CANDIDATE_BACK = timedelta(minutes=90)
_OVERLAP_MIN = 0.5
_NO_CLIP_MSG = (
@@ -130,6 +136,91 @@ def _fmt(seconds: float) -> str:
return "{:.3f}".format(seconds)
def osd_time_at(url: str, media_offset: float, timeout: int = 45):
"""Extract ONE frame at media_offset, OCR the burned-in OSD clock.
Returns naive datetime or None (never raises). Lazy rapidocr singleton.
"""
global _OCR, _OCR_FAILED
if _OCR_FAILED:
return None
try:
proc = subprocess.run(
[
"ffmpeg", "-v", "error",
"-ss", _fmt(media_offset),
"-i", url,
"-frames:v", "1",
"-s", "{}x{}".format(_FRAME_W, _FRAME_H),
"-f", "rawvideo", "-pix_fmt", "bgr24", "-",
],
capture_output=True, timeout=timeout,
)
need = _FRAME_W * _FRAME_H * 3
if proc.returncode != 0 or len(proc.stdout) < need:
return None
import numpy as np # CI-safe: numpy is a test dep
frame = np.frombuffer(proc.stdout[:need], np.uint8).reshape(
_FRAME_H, _FRAME_W, 3
)
if _OCR is None:
try:
from rapidocr_onnxruntime import RapidOCR # type: ignore
except ImportError:
_OCR_FAILED = True
print("[CLIP] rapidocr_onnxruntime missing — OSD align off "
"(filename-based offsets).", flush=True)
return None
_OCR = RapidOCR()
result, _ = _OCR(frame)
text = " ".join(str(item[1]) for item in (result or []))
match = _OSD_RE.search(text)
if not match:
return None
return datetime.strptime(
"{} {}".format(match.group(1), match.group(2)), "%Y-%m-%d %H:%M:%S"
)
except Exception:
return None
def _align_offset(url: str, cur: float, desired: datetime,
duration: float, label: str) -> float:
"""Correct one cut offset so the OSD at that frame reads `desired`.
Max 2 probes (frame+OCR each); OCR failure keeps the filename-based
offset. Media time drifts vs wall clock inside a clip (dropped frames),
so the correction is measured AT the cut point, not assumed constant.
"""
original = cur
corrected = False
last_osd = None
for _ in range(2):
key = (url, int(round(cur)))
osd = _OSD_CACHE.get(key)
if osd is None:
osd = osd_time_at(url, cur)
if osd is not None:
_OSD_CACHE[key] = osd
if osd is None:
break
last_osd = osd
delta = (osd - desired).total_seconds()
if abs(delta) <= 1.0:
break
if corrected and osd.date() != desired.date():
break # suspicious day jump — keep the one correction already made
new = min(max(cur - delta, 0.0), duration)
if new == cur:
break
cur = new
corrected = True
print("[CLIP] align {}: {:.1f} -> {:.1f} (osd={}, desired={})".format(
label, original, cur, last_osd, desired), flush=True)
return cur
def _piece_argv(clip_url: str, off0: float, off1: float, piece: str) -> list:
return [
"ffmpeg", "-y",
@@ -166,11 +257,23 @@ def _run_ffmpeg(args: list, timeout: int):
def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
out_path: str, timeout: int = 180) -> None:
"""List → cover → trim pieces → concat into out_path. Raises ClipError only."""
out_path: str, timeout: int = 180,
pad_seconds: float = 3.0, align_osd: bool = True) -> None:
"""List → cover → trim pieces → concat into out_path. Raises ClipError only.
pad_seconds widens the target window (trim a little before/after the
batch). align_osd OCR-corrects the burned-in OSD clock at the cut
points (media-vs-wall drift; ~1-2 s per probe, cached).
"""
try:
start = datetime.fromisoformat(start_iso)
end = datetime.fromisoformat(end_iso)
try:
pad = float(pad_seconds)
except (TypeError, ValueError):
pad = 3.0
window_start = start - timedelta(seconds=pad)
window_end = end + timedelta(seconds=pad)
out_path = os.path.abspath(out_path)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
tmpdir = None
@@ -180,20 +283,29 @@ def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
def probe(path):
return probe_duration(_download_url(motion_url, camera_id, path))
clips = find_clips(movies, start, end, probe)
clips = find_clips(movies, window_start, window_end, probe)
if not clips:
raise ClipError(_NO_CLIP_MSG)
tmpdir = tempfile.mkdtemp(dir=os.path.dirname(out_path))
pieces = []
for index, clip in enumerate(clips):
piece = os.path.join(tmpdir, "piece_{:03d}.mp4".format(index))
clip_url = _download_url(motion_url, camera_id, clip["path"])
off0, off1 = clip["off0"], clip["off1"]
if align_osd:
orig0, orig1 = off0, off1
if index == 0:
off0 = _align_offset(clip_url, off0, window_start,
clip["duration"], "start")
if index == len(clips) - 1:
off1 = _align_offset(clip_url, off1, window_end,
clip["duration"], "end")
if off1 - off0 < 1.0:
off0, off1 = orig0, orig1
print("[CLIP] align rejected (span < 1s); "
"filename-based offsets kept.", flush=True)
_run_ffmpeg(
_piece_argv(
_download_url(motion_url, camera_id, clip["path"]),
clip["off0"],
clip["off1"],
piece,
),
_piece_argv(clip_url, off0, off1, piece),
timeout,
)
pieces.append(piece)
+155 -2
View File
@@ -201,7 +201,8 @@ def test_clip_batch_single_piece_argv(monkeypatch, tmp_path):
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
out = tmp_path / "sub" / "batch.mp4"
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50", str(out))
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50", str(out),
pad_seconds=0, align_osd=False)
assert len(calls) == 1
args, timeout = calls[0]
assert timeout == 180
@@ -229,7 +230,8 @@ def test_clip_batch_two_pieces_trim_and_concat(monkeypatch, tmp_path):
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
out = tmp_path / "batch.mp4"
clip.clip_batch(BASE, 1, START_ISO, END_ISO, str(out), timeout=60)
clip.clip_batch(BASE, 1, START_ISO, END_ISO, str(out), timeout=60,
pad_seconds=0, align_osd=False)
assert len(calls) == 3
(piece_a, timeout_a), (piece_b, _), (concat_args, timeout_b) = calls
assert timeout_a == timeout_b == 60
@@ -273,3 +275,154 @@ def test_clip_batch_bad_iso_raises_cliperror(tmp_path):
with pytest.raises(ClipError):
clip.clip_batch(BASE, 1, "bukan-tanggal", "2026-09-26T10:00:00",
str(tmp_path / "out.mp4"))
# ------------------------------------------------------- pad + OSD alignment
@pytest.fixture(autouse=True)
def _clear_osd_cache():
clip._OSD_CACHE.clear()
yield
clip._OSD_CACHE.clear()
def _one_movie(monkeypatch, start_iso, duration):
movies = [{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")}]
patch_batch_env(monkeypatch, movies, duration=duration)
def test_pad_extends_selection_window(monkeypatch, tmp_path):
# clip 09:00:00 + 5 s ends exactly at batch start → pad 0 finds nothing
movies = [{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")}]
patch_batch_env(monkeypatch, movies, duration=5.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
with pytest.raises(ClipError, match="Tidak ada rekaman"):
clip.clip_batch(BASE, 1, "2026-09-26T09:00:05", "2026-09-26T09:00:10",
str(tmp_path / "a.mp4"), pad_seconds=0, align_osd=False)
clip.clip_batch(BASE, 1, "2026-09-26T09:00:05", "2026-09-26T09:00:10",
str(tmp_path / "b.mp4"), pad_seconds=3, align_osd=False)
args = calls[0][0]
assert args[args.index("-ss") + 1] == "2.000" # window_start 09:00:02
assert args[args.index("-to") + 1] == "5.000" # clip ends 09:00:05
def test_osd_alignment_corrects_start_offset(monkeypatch, tmp_path):
_one_movie(monkeypatch, None, 600.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
# batch 09:01:10→09:02:00 (pad 0): filename off0=70, off1=120.
# OSD at media 70 reads desired+14 → shift to 56; at 56 reads desired.
reads = {
70: dt("2026-09-26 09:01:24"),
56: dt("2026-09-26 09:01:10"),
120: dt("2026-09-26 09:02:00"),
}
def fake_osd(url, media_offset, timeout=45):
return reads.get(int(round(media_offset)))
monkeypatch.setattr(clip, "osd_time_at", fake_osd)
out = tmp_path / "out.mp4"
clip.clip_batch(BASE, 1, "2026-09-26T09:01:10", "2026-09-26T09:02:00",
str(out), pad_seconds=0, align_osd=True)
args = calls[0][0]
assert args[args.index("-ss") + 1] == "56.000"
assert args[args.index("-to") + 1] == "120.000"
def test_align_disabled_never_probes_osd(monkeypatch, tmp_path):
_one_movie(monkeypatch, None, 600.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
def boom(*a, **k):
raise AssertionError("osd_time_at must not be called")
monkeypatch.setattr(clip, "osd_time_at", boom)
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=False)
args = calls[0][0]
assert args[args.index("-ss") + 1] == "10.000"
def test_osd_failure_keeps_filename_offsets(monkeypatch, tmp_path):
_one_movie(monkeypatch, None, 600.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
monkeypatch.setattr(clip, "osd_time_at", lambda *a, **k: None)
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
args = calls[0][0]
assert args[args.index("-ss") + 1] == "10.000"
assert args[args.index("-to") + 1] == "50.000"
def test_osd_alignment_probe_iteration_cap(monkeypatch, tmp_path):
_one_movie(monkeypatch, None, 600.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
probes = []
def always_off(url, media_offset, timeout=45):
probes.append(int(round(media_offset)))
return dt("2026-09-26 09:01:24") # always +14 vs desired 09:01:10
monkeypatch.setattr(clip, "osd_time_at", always_off)
clip.clip_batch(BASE, 1, "2026-09-26T09:01:10", "2026-09-26T09:02:00",
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
# start align: at most 2 probes; end align: 2 more (same cap)
assert len(probes) <= 4
start_probes = [p for p in probes if p in (70, 56, 42)]
assert len(start_probes) <= 2
args = calls[0][0]
# after two corrections 70 -> 56 -> 42
assert args[args.index("-ss") + 1] == "42.000"
def test_osd_date_mismatch_corrects_once_then_stops(monkeypatch, tmp_path):
# batch spans New Year's Eve; OSD read lands on the wrong calendar day
movies = [{"path": "/2026-12-31/23-00-00.mp4",
"start": dt("2026-12-31 23:00:00")}]
monkeypatch.setattr(clip, "list_movies", lambda *a, **k: list(movies))
monkeypatch.setattr(clip, "probe_duration", lambda url, timeout=30: 7200.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
probes = []
def wrong_day(url, media_offset, timeout=45):
off = int(round(media_offset))
probes.append(off)
if off == 1924:
return dt("2027-01-01 00:32:04") # +1 h vs desired Dec31 23:32:04
if off == 0:
return dt("2027-01-01 00:31:50") # still Jan 1 after correction
return None # end-offset probes do nothing
monkeypatch.setattr(clip, "osd_time_at", wrong_day)
clip.clip_batch(BASE, 1, "2026-12-31T23:32:04", "2026-12-31T23:35:00",
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
# start align: probe at 1924 → corrects (clamped to 0), probe at 0 has
# wrong date again → rejected by date check (2 probes max).
assert probes[:2] == [1924, 0]
args = calls[0][0]
assert args[args.index("-ss") + 1] == "0.000"
assert args[args.index("-to") + 1] == "2100.000" # end offset untouched
def test_alignment_span_collapse_falls_back(monkeypatch, tmp_path):
_one_movie(monkeypatch, None, 600.0)
calls, captured = [], {}
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
def collapse(url, media_offset, timeout=45):
# readings that would push off1 before off0
return dt("2026-09-26 09:02:59") # end desired 09:00:50 → huge shift
monkeypatch.setattr(clip, "osd_time_at", collapse)
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
args = calls[0][0]
# fallback keeps filename-based offsets
assert args[args.index("-ss") + 1] == "10.000"
assert args[args.index("-to") + 1] == "50.000"