The 2024 video set in all_video_info_merged.xlsx covers 63 (date, machine)
sessions — 129 video instances — that have no auto-detectable targets, so
ROI placement requires manual reference-point selection. This commit adds
the three-stage pipeline that lets a user click for an hour, then walk
away while the tracker grinds overnight:
1. build_video_inventory.py — scan /mnt/ethoscope_data/videos/ and join
against the xlsx, producing data/metadata/video_inventory.csv
2. pick_targets.py — interactive matplotlib/Tk picker. User clicks
TOP/CORNER/LEFT (the L-shape ethoscope expects); after the third
click the 6 ROI rectangles are drawn on top of the frame so geometry
can be verified before saving. Also supports marking a video
'unusable' (FOV wrong) so it's permanently skipped, frame stepping
by ±1s/±5%/midpoint, point editing in --redo mode, and a crosshair
cursor that survives matplotlib's per-motion cursor reset.
3. track_videos.py — headless batch tracker. Reads the JSON sidecars,
builds 6 ROIs from the HD-mating-arena geometry, runs MultiFlyTracker
against the merged.mp4 via MovieVirtualCamera, writes SQLite DBs to
data/tracked/. Idempotent (skips done DBs), parallel via --jobs,
subclasses MovieVirtualCamera so frames stay BGR (MultiFlyTracker
calls cvtColor(BGR2GRAY) without checking channel count).
Plus auto_detect_targets.py (fallback that runs ethoscope's auto-detector
in case any videos do have visible target dots), monitor_tracking.py
(progress + ETA from data/tracked/ ground truth, --watch for live view),
and tracking_geometry.py (single source of truth for the affine math
shared by picker and tracker).
requirements-tracking.txt pins the extra deps (opencv-python, openpyxl,
gitpython, netifaces, mysql-connector-python) — these are only needed
for the tracking pipeline, not the existing analysis notebooks.
Verified end-to-end on one of the user-picked videos: ~4000 rows/ROI in
a 120s slice, fly bounding boxes in the expected 800-2000 px² band.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
218 lines
7.7 KiB
Python
218 lines
7.7 KiB
Python
"""Headless offline tracker.
|
|
|
|
Reads target JSONs produced by `pick_targets.py`, builds the 6 ROIs of the
|
|
HD mating arena from the L-shape reference points, runs ethoscope's
|
|
`MultiFlyTracker` against the merged.mp4 file via `MovieVirtualCamera`, and
|
|
writes a SQLite DB to `data/tracked/<video_basename>_tracking.db`.
|
|
|
|
Idempotent: skips videos whose tracking DB already exists (unless --redo).
|
|
|
|
Usage:
|
|
python track_videos.py # process all videos with target JSON
|
|
python track_videos.py --redo # re-track even if DB exists
|
|
python track_videos.py --jobs 4 # run up to 4 videos in parallel
|
|
python track_videos.py --max-duration 1800 # cap each video at 30 min (sec)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import traceback
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
# Import ethoscope from the local source tree (no pip install).
|
|
ETHOSCOPE_SRC = Path("/home/gg/Code/ethoscope_project/ethoscope/src/ethoscope")
|
|
sys.path.insert(0, str(ETHOSCOPE_SRC))
|
|
|
|
from config import TARGETS_DIR, TRACKING_OUTPUT_DIR # noqa: E402
|
|
from tracking_geometry import HD_FG_DATA, compute_roi_polygons # noqa: E402
|
|
|
|
|
|
def build_rois_from_targets(reference_points):
|
|
"""Wrap the shared geometry into ethoscope `ROI` objects."""
|
|
from ethoscope.core.roi import ROI
|
|
|
|
polys = compute_roi_polygons(reference_points)
|
|
return [ROI(poly.reshape((1, 4, 2)), idx=i + 1) for i, poly in enumerate(polys)]
|
|
|
|
|
|
def track_one(json_path: Path, output_dir: Path, max_duration: float | None,
|
|
redo: bool) -> tuple[str, str]:
|
|
"""Track a single video. Returns (status, message). Run in subprocess.
|
|
|
|
Statuses: "ok", "skip", "error".
|
|
"""
|
|
# Re-import inside subprocess so each worker has its own ethoscope state.
|
|
import sys as _sys
|
|
_sys.path.insert(0, str(ETHOSCOPE_SRC))
|
|
import cv2
|
|
from ethoscope.core.monitor import Monitor
|
|
from ethoscope.hardware.input.cameras import MovieVirtualCamera
|
|
from ethoscope.io.sqlite import SQLiteResultWriter
|
|
from ethoscope.trackers.multi_fly_tracker import MultiFlyTracker
|
|
|
|
class BGRMovieCamera(MovieVirtualCamera):
|
|
"""MovieVirtualCamera variant that keeps BGR frames.
|
|
|
|
MultiFlyTracker calls cv2.cvtColor(img, COLOR_BGR2GRAY) without checking
|
|
whether img is already grayscale, so we must feed it 3-channel input.
|
|
"""
|
|
def _next_image(self):
|
|
ret, frame = self.capture.read()
|
|
if not ret or frame is None:
|
|
return None
|
|
return frame # BGR, untouched
|
|
|
|
payload = json.loads(json_path.read_text())
|
|
if payload.get("unusable"):
|
|
reason = payload.get("reason") or "no reason given"
|
|
return "skip", f"marked unusable: {reason}"
|
|
video_path = Path(payload["video_path"])
|
|
if not video_path.exists():
|
|
return "error", f"video missing: {video_path}"
|
|
|
|
out_db = output_dir / f"{video_path.stem}_tracking.db"
|
|
if out_db.exists() and not redo:
|
|
return "skip", f"DB exists: {out_db.name}"
|
|
if out_db.exists():
|
|
out_db.unlink()
|
|
|
|
rois = build_rois_from_targets(payload["reference_points"])
|
|
|
|
cam_kwargs = {"use_wall_clock": False}
|
|
if max_duration is not None:
|
|
cam_kwargs["max_duration"] = max_duration
|
|
cam = BGRMovieCamera(str(video_path), **cam_kwargs)
|
|
|
|
metadata = {
|
|
"machine_id": payload.get("machine_uuid", "unknown"),
|
|
"machine_name": payload.get("machine_name", "unknown"),
|
|
"date_time": int(payload.get("session_epoch", 0)),
|
|
"frame_width": cam.width,
|
|
"frame_height": cam.height,
|
|
"version": "offline-tracker-1",
|
|
"experimental_info": "{}",
|
|
"selected_options": json.dumps({
|
|
"tracker": "MultiFlyTracker",
|
|
"template": "HD_Mating_Arena_6_ROIS",
|
|
"fg_data": HD_FG_DATA,
|
|
"maxN": 2,
|
|
}),
|
|
"hardware_info": "{}",
|
|
"reference_points": str([list(map(int, p)) for p in payload["reference_points"]]),
|
|
"backup_filename": out_db.name,
|
|
"result_writer_type": "SQLite3",
|
|
"sqlite_source_path": str(out_db),
|
|
}
|
|
|
|
tracker_data = {
|
|
"maxN": 2,
|
|
"visualise": False,
|
|
"fg_data": HD_FG_DATA,
|
|
"adaptive_threshold": True,
|
|
"min_fg_threshold": 10,
|
|
"max_fg_threshold": 50,
|
|
}
|
|
|
|
db_credentials = {"name": str(out_db)}
|
|
rw = SQLiteResultWriter(
|
|
db_credentials, rois, metadata=metadata,
|
|
make_dam_like_table=False, take_frame_shots=False, erase_old_db=True,
|
|
)
|
|
|
|
monit = Monitor(
|
|
cam, MultiFlyTracker, rois,
|
|
reference_points=payload["reference_points"],
|
|
data=tracker_data,
|
|
)
|
|
|
|
try:
|
|
with rw as result_writer:
|
|
monit.run(result_writer=result_writer, drawer=None, verbose=False)
|
|
except Exception:
|
|
return "error", traceback.format_exc(limit=5)
|
|
finally:
|
|
try:
|
|
cam._close()
|
|
except Exception:
|
|
pass
|
|
|
|
if not out_db.exists():
|
|
return "error", "tracking finished but DB was not created"
|
|
return "ok", str(out_db)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--redo", action="store_true", help="re-track even if DB exists")
|
|
parser.add_argument("--jobs", type=int, default=1, help="parallel workers")
|
|
parser.add_argument(
|
|
"--max-duration", type=float, default=None,
|
|
help="cap each video at this many seconds (default: full video)",
|
|
)
|
|
parser.add_argument("--limit", type=int, default=None, help="process only first N")
|
|
parser.add_argument("--video", type=str, default=None,
|
|
help="track a single video (mp4 path); requires its target JSON")
|
|
args = parser.parse_args()
|
|
|
|
TRACKING_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
if args.video:
|
|
stem = Path(args.video).stem
|
|
json_path = TARGETS_DIR / f"{stem}.json"
|
|
if not json_path.exists():
|
|
sys.exit(f"No target JSON for {args.video}: expected {json_path}")
|
|
jsons = [json_path]
|
|
else:
|
|
jsons = sorted(TARGETS_DIR.glob("*.json"))
|
|
|
|
if args.limit:
|
|
jsons = jsons[: args.limit]
|
|
|
|
if not jsons:
|
|
print("No target JSONs found. Run pick_targets.py first.")
|
|
return
|
|
|
|
print(f"Tracking {len(jsons)} videos (jobs={args.jobs}, redo={args.redo}).")
|
|
n_ok = n_skip = n_err = 0
|
|
|
|
if args.jobs <= 1:
|
|
for jp in jsons:
|
|
print(f" → {jp.name}", flush=True)
|
|
status, msg = track_one(jp, TRACKING_OUTPUT_DIR, args.max_duration, args.redo)
|
|
print(f" {status}: {msg.splitlines()[-1] if msg else ''}", flush=True)
|
|
n_ok += status == "ok"
|
|
n_skip += status == "skip"
|
|
n_err += status == "error"
|
|
else:
|
|
with ProcessPoolExecutor(max_workers=args.jobs) as ex:
|
|
futs = {
|
|
ex.submit(track_one, jp, TRACKING_OUTPUT_DIR, args.max_duration, args.redo): jp
|
|
for jp in jsons
|
|
}
|
|
for fut in as_completed(futs):
|
|
jp = futs[fut]
|
|
try:
|
|
status, msg = fut.result()
|
|
except Exception as e:
|
|
status, msg = "error", f"future raised: {e}"
|
|
print(f" {jp.name}: {status} — {msg.splitlines()[-1] if msg else ''}",
|
|
flush=True)
|
|
n_ok += status == "ok"
|
|
n_skip += status == "skip"
|
|
n_err += status == "error"
|
|
|
|
print(f"\nDone. ok={n_ok} skipped={n_skip} errors={n_err}")
|
|
sys.exit(0 if n_err == 0 else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
main()
|