diff --git a/.env.example b/.env.example index 4a0db62..4100c3f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ae636eb..30e3ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` trims+concatenates motionEye recordings to the batch window (`src/clip.py`), download `b--.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`, diff --git a/counter_dashboard.py b/counter_dashboard.py index a472b27..c37108b 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -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) diff --git a/docs/configuration.md b/docs/configuration.md index 67ad43c..c38d662 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/docs/deployment.md b/docs/deployment.md index d8a2094..7f01cc9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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//list/` (recording segments in the window) and `GET /movie//download` (segment bytes). - Requires `ffmpeg` / `ffprobe` on PATH — both already installed on the Jetson. diff --git a/src/clip.py b/src/clip.py index d6b8c15..ef26e22 100644 --- a/src/clip.py +++ b/src/clip.py @@ -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) diff --git a/tests/test_clip.py b/tests/test_clip.py index 4df71a1..6329601 100644 --- a/tests/test_clip.py +++ b/tests/test_clip.py @@ -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"