intial commit

This commit is contained in:
zakariasaputra committed 2026-07-17 14:58:29 +07:00
commit a54a070ca9
49 files changed
+2960

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Chicken counting pipeline package for Jetson single-camera and daily batch runs."""
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
"""Locate daily input videos for each camera in a dated Cycle7 folder."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from chicken_counter.config import BatchSettings
@dataclass
class CameraDiscoveryResult:
found: dict[str, Path] = field(default_factory=dict)
skipped: dict[str, str] = field(default_factory=dict)
def discover_camera_videos(day_dir: Path, settings: BatchSettings) -> CameraDiscoveryResult:
if not day_dir.is_dir():
raise FileNotFoundError(f"Daily input folder does not exist: {day_dir}")
result = CameraDiscoveryResult()
total_configured = len(settings.cameras)
for camera_id, preset in sorted(
settings.cameras.items(),
key=lambda item: item[1].camera_num,
):
pattern = settings.batch.camera_glob.format(num=preset.camera_num)
matches = sorted(day_dir.glob(pattern))
if not matches:
result.skipped[camera_id] = "video_not_found"
continue
if len(matches) > 1:
result.skipped[camera_id] = "multiple_matches"
continue
result.found[camera_id] = matches[0]
if not result.found:
skipped_summary = ", ".join(
f"{camera_id} ({reason})" for camera_id, reason in result.skipped.items()
)
raise FileNotFoundError(
f"No camera videos found in {day_dir}. Skipped: {skipped_summary}"
)
found_count = len(result.found)
if result.skipped:
skipped_summary = ", ".join(
f"{camera_id} ({reason})" for camera_id, reason in result.skipped.items()
)
print(
f"[batch] discovered {found_count}/{total_configured} cameras; "
f"skipped: {skipped_summary}"
)
else:
print(f"[batch] discovered {found_count}/{total_configured} cameras")
return result
+111
View File
@@ -0,0 +1,111 @@
"""Run CC1–CC4 sequentially, then compress videos and write the JSON report."""
from __future__ import annotations
from datetime import date as date_type
from pathlib import Path
from chicken_counter.batch_discovery import discover_camera_videos
from chicken_counter.compress import compress_video_to_target
from chicken_counter.config import BatchSettings, build_camera_config_from_batch
from chicken_counter.pipeline import run_pipeline
from chicken_counter.report import build_batch_report, persist_batch_reports
from chicken_counter.tracking import DetectionTracker
from chicken_counter.types import CameraBatchResult
def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path:
run_date = date or date_type.today().isoformat()
day_dir = Path(settings.batch.root_dir) / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
print(f"[batch] starting daily run for {run_date}")
print(f"[batch] input folder: {day_dir}")
print(f"[batch] output folder: {output_dir}")
discovery = discover_camera_videos(day_dir, settings)
camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num)
first_camera_id = next(
camera_id for camera_id, _preset in camera_order if camera_id in discovery.found
)
first_source = discovery.found[first_camera_id]
init_config = build_camera_config_from_batch(
settings,
first_camera_id,
source=first_source,
output_path=output_dir / f"{first_camera_id}_vis.mp4",
checkpoint_dir=output_dir / "checkpoints" / first_camera_id,
)
shared_tracker = DetectionTracker(init_config)
camera_results: list[CameraBatchResult] = []
report_path = output_dir / f"counts_{run_date}.json"
for camera_id, _preset in camera_order:
if camera_id in discovery.skipped:
skip_reason = discovery.skipped[camera_id]
print(f"[batch] skipping {camera_id}: {skip_reason}")
camera_results.append(
CameraBatchResult(
camera_id=camera_id,
skipped=True,
skip_reason=skip_reason,
)
)
persist_batch_reports(run_date, camera_results, output_dir)
continue
source_path = discovery.found[camera_id]
vis_path = output_dir / f"{camera_id}_vis.mp4"
checkpoint_dir = output_dir / "checkpoints" / camera_id
print(f"[batch] processing {camera_id} from {source_path.name}")
camera_config = build_camera_config_from_batch(
settings,
camera_id,
source=source_path,
output_path=vis_path,
checkpoint_dir=checkpoint_dir,
)
pipeline_result = run_pipeline(camera_config, tracker=shared_tracker)
camera_results.append(
CameraBatchResult(
camera_id=camera_id,
pipeline=pipeline_result,
)
)
print(
f"[batch] finished {camera_id}: total_entered={pipeline_result.total_entered_count} "
f"frames={pipeline_result.frames_processed} reason={pipeline_result.stopped_reason}"
)
persist_batch_reports(run_date, camera_results, output_dir)
print("[batch] all cameras complete; starting compression")
for item in camera_results:
if item.skipped or item.pipeline is None:
continue
vis_path = item.pipeline.vis_video_path
if not vis_path:
continue
compressed_path = output_dir / f"{item.camera_id}_compressed.mp4"
size_mb = compress_video_to_target(
vis_path,
compressed_path,
max_mb=settings.batch.compress_max_mb,
)
item.compressed_video_path = str(compressed_path)
item.compressed_size_mb = size_mb
if settings.batch.delete_intermediate:
Path(vis_path).unlink(missing_ok=True)
persist_batch_reports(run_date, camera_results, output_dir)
report = build_batch_report(run_date, camera_results, output_dir=output_dir)
print(
f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
f"report={report_path}"
)
return report_path
+12
View File
@@ -0,0 +1,12 @@
"""Open video files or camera streams for the counting pipeline."""
from __future__ import annotations
import cv2
def open_capture(source: str | int) -> cv2.VideoCapture:
capture = cv2.VideoCapture(source)
if not capture.isOpened():
raise RuntimeError(f"Unable to open video source: {source}")
return capture
+63
View File
@@ -0,0 +1,63 @@
"""Command-line entrypoint for single-camera runs and daily batch processing."""
from __future__ import annotations
import argparse
from chicken_counter.batch_runner import run_daily_batch
from chicken_counter.config import load_batch_config, load_camera_config
from chicken_counter.pipeline import run_pipeline
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the chicken counting pipeline.")
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser("run", help="Run a single camera pipeline.")
run_parser.add_argument("--config", required=True, help="Path to camera config YAML/JSON.")
run_parser.add_argument("--camera-id", help="Camera ID when using a multi-camera config file.")
batch_parser = subparsers.add_parser("batch", help="Run the daily Cycle7 multi-camera batch.")
batch_parser.add_argument("--config", required=True, help="Path to batch config YAML/JSON.")
batch_parser.add_argument(
"--date",
help="Processing date folder in YYYY-MM-DD format. Defaults to today.",
)
parser.add_argument("--config", help=argparse.SUPPRESS)
parser.add_argument("--camera-id", help=argparse.SUPPRESS)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.command == "batch":
settings = load_batch_config(args.config)
run_daily_batch(settings, date=args.date)
return
if args.command == "run":
config = load_camera_config(args.config, args.camera_id)
result = run_pipeline(config)
print(
f"[done] camera={result.camera_id} total_entered={result.total_entered_count} "
f"frames={result.frames_processed} reason={result.stopped_reason}"
)
return
if args.config:
config = load_camera_config(args.config, args.camera_id)
result = run_pipeline(config)
print(
f"[done] camera={result.camera_id} total_entered={result.total_entered_count} "
f"frames={result.frames_processed} reason={result.stopped_reason}"
)
return
parser.print_help()
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""Compress annotated videos with ffmpeg to stay under a target file size."""
from __future__ import annotations
import subprocess
from pathlib import Path
import cv2
def _video_duration_seconds(path: Path) -> float:
capture = cv2.VideoCapture(str(path))
if not capture.isOpened():
raise RuntimeError(f"Unable to open video for duration probe: {path}")
frame_count = capture.get(cv2.CAP_PROP_FRAME_COUNT)
fps = capture.get(cv2.CAP_PROP_FPS)
capture.release()
if fps and fps > 0 and frame_count and frame_count > 0:
return float(frame_count / fps)
raise RuntimeError(f"Unable to determine duration for video: {path}")
def _file_size_mb(path: Path) -> float:
return path.stat().st_size / (1024 * 1024)
def _run_ffmpeg(command: list[str]) -> None:
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
stderr = result.stderr.strip() or result.stdout.strip()
raise RuntimeError(f"ffmpeg failed: {stderr}")
def compress_video_to_target(
input_path: str | Path,
output_path: str | Path,
*,
max_mb: int = 200,
max_attempts: int = 3,
) -> float:
input_file = Path(input_path)
output_file = Path(output_path)
if not input_file.is_file():
raise FileNotFoundError(f"Input video not found: {input_file}")
output_file.parent.mkdir(parents=True, exist_ok=True)
duration = _video_duration_seconds(input_file)
if duration <= 0:
raise RuntimeError(f"Invalid video duration for {input_file}")
target_kbps = int((max_mb * 8192) / duration * 0.92)
target_kbps = max(300, target_kbps)
for attempt in range(max_attempts):
attempt_kbps = max(300, int(target_kbps * (0.85**attempt)))
if output_file.exists():
output_file.unlink()
codec_attempts = [
["-c:v", "h264_nvmpi", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"],
["-c:v", "libx264", "-preset", "fast", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"],
]
last_error: Exception | None = None
for codec_args in codec_attempts:
command = [
"ffmpeg",
"-y",
"-i",
str(input_file),
*codec_args,
"-c:a",
"copy",
str(output_file),
]
try:
_run_ffmpeg(command)
break
except RuntimeError as exc:
last_error = exc
if output_file.exists():
output_file.unlink()
else:
if last_error is not None:
raise last_error
raise RuntimeError(f"Unable to compress video: {input_file}")
size_mb = _file_size_mb(output_file)
print(
f"[compress] {output_file.name}: {size_mb:.1f} MB "
f"(attempt {attempt + 1}, target {attempt_kbps} kbps)"
)
if size_mb <= max_mb:
return size_mb
final_size = _file_size_mb(output_file)
if final_size > max_mb:
raise RuntimeError(
f"Compressed video still exceeds {max_mb} MB: {output_file} ({final_size:.1f} MB)"
)
return final_size
+391
View File
@@ -0,0 +1,391 @@
"""Load YAML/JSON settings for single-camera runs and daily batch jobs."""
from __future__ import annotations
import copy
from dataclasses import dataclass, field
import json
from pathlib import Path
from typing import Any
import yaml
Point = tuple[int, int]
@dataclass
class DetectionConfig:
model_path: str
classes: list[int] = field(default_factory=lambda: [0])
ignored_classes: list[int] = field(default_factory=lambda: [1, 2])
conf: float = 0.35
iou: float = 0.55
imgsz: int = 640
device: str | int | None = None
min_box_area_px: int = 0
validate_while_inside: bool = True
@dataclass
class DetectionZoneConfig:
enabled: bool = False
buffer_above_px: int = 250
buffer_below_px: int = 250
show_in_overlay: bool = False
def compute_rect(
self,
roi: RoiConfig,
frame_width: int,
frame_height: int,
) -> tuple[int, int, int, int]:
x_values = [point[0] for point in roi.points]
y_values = [point[1] for point in roi.points]
x1 = max(0, min(x_values))
x2 = min(frame_width, max(x_values))
y1 = max(0, min(y_values) - self.buffer_above_px)
y2 = min(frame_height, max(y_values) + self.buffer_below_px)
return x1, y1, x2, y2
@dataclass
class TrackerConfig:
tracker_config_path: str
persist: bool = True
track_buffer: int = 75
@dataclass
class RoiConfig:
points: list[Point]
inset_left_px: int = 0
inset_right_px: int = 0
inset_top_px: int = 0
inset_bottom_px: int = 0
min_overlap_ratio: float = 0.0
@property
def is_polygon(self) -> bool:
return len(self.points) > 2
def bounding_rect(self) -> tuple[int, int, int, int]:
x_values = [point[0] for point in self.points]
y_values = [point[1] for point in self.points]
return min(x_values), min(y_values), max(x_values), max(y_values)
def counting_polygon(self) -> list[Point]:
x_min, y_min, x_max, y_max = self.bounding_rect()
x_min += self.inset_left_px
x_max -= self.inset_right_px
y_min += self.inset_top_px
y_max -= self.inset_bottom_px
min_width = 20
min_height = 20
if x_max - x_min < min_width:
center_x = (x_min + x_max) // 2
half = min_width // 2
x_min = center_x - half
x_max = center_x + half
if y_max - y_min < min_height:
center_y = (y_min + y_max) // 2
half = min_height // 2
y_min = center_y - half
y_max = center_y + half
return [
(x_min, y_min),
(x_max, y_min),
(x_max, y_max),
(x_min, y_max),
]
def counting_rect(self) -> tuple[int, int, int, int]:
polygon = self.counting_polygon()
x_values = [point[0] for point in polygon]
y_values = [point[1] for point in polygon]
return min(x_values), min(y_values), max(x_values), max(y_values)
@dataclass
class GateConfig:
mode: str = "two_line"
lines_y: list[int] = field(default_factory=lambda: [320, 600])
direction: str = "bottom_to_up"
@dataclass
class MotionConfig:
enabled: bool = True
axis: str = "vertical"
forward_sign: float = 1.0
ema_alpha: float = 0.2
reverse_enter_threshold: float = -1.5
reverse_exit_threshold: float = -0.5
debounce_frames: int = 12
min_features: int = 60
max_corners: int = 300
quality_level: float = 0.01
min_distance: int = 8
block_radius: int = 6
stride_frames: int = 1
flow_scale: float = 1.0
Color = tuple[int, int, int]
@dataclass
class OverlayConfig:
show_boxes: bool = True
show_track_trails: bool = True
trail_length: int = 20
show_center_marker: bool = True
show_track_ring: bool = False
count_anchor: Point = (900, 120)
inside_box_only: bool = True
pending_blink: bool = True
pending_colors: list[Color] = field(
default_factory=lambda: [(255, 255, 0), (0, 255, 255)]
)
@dataclass
class DisplayConfig:
window_name: str = "Chicken Counter"
show_window: bool = True
output_path: str | None = None
write_fps: float | None = None
max_frames: int | None = None
encoder: str = "auto"
output_bitrate_kbps: int = 4000
codec_preference: list[str] = field(default_factory=lambda: ["avc1", "mp4v", "H264"])
@dataclass
class PerformanceConfig:
half: bool = False
overlay_buffer_reuse: bool = True
inference_stride: int = 1
@dataclass
class FeedbackConfig:
enabled: bool = False
every_n_frames: int = 300
save_images: bool = True
image_output_dir: str = "output/checkpoints"
log_to_terminal: bool = True
@dataclass
class CameraConfig:
camera_id: str
source: str | int
detection: DetectionConfig
tracker: TrackerConfig
roi: RoiConfig
gate: GateConfig
motion: MotionConfig
overlay: OverlayConfig
display: DisplayConfig
performance: PerformanceConfig
feedback: FeedbackConfig
detection_zone: DetectionZoneConfig = field(default_factory=DetectionZoneConfig)
@dataclass
class BatchConfig:
root_dir: str
camera_glob: str = "kandang_*_camera_{num}_*.mp4"
output_subdir: str = "output"
compress_max_mb: int = 200
delete_intermediate: bool = False
checkpoint_every_n_frames: int = 3000
@dataclass
class CameraPreset:
camera_id: str
camera_num: int
roi: RoiConfig
count_anchor: Point | None = None
gate: GateConfig | None = None
motion: MotionConfig | None = None
@dataclass
class BatchSettings:
batch: BatchConfig
defaults: dict[str, Any]
cameras: dict[str, CameraPreset]
def _load_data(path: Path) -> dict[str, Any]:
if path.suffix.lower() == ".json":
return json.loads(path.read_text(encoding="utf-8"))
return yaml.safe_load(path.read_text(encoding="utf-8"))
def _point_list(raw_points: list[list[int]] | list[tuple[int, int]]) -> list[Point]:
return [tuple(map(int, point)) for point in raw_points]
def _build_overlay_config(overlay_raw: dict[str, Any]) -> OverlayConfig:
overlay_kwargs = {
**overlay_raw,
"count_anchor": tuple(overlay_raw["count_anchor"]),
}
if "pending_colors" in overlay_raw:
overlay_kwargs["pending_colors"] = [
tuple(map(int, color)) for color in overlay_raw["pending_colors"]
]
return OverlayConfig(**overlay_kwargs)
def _build_roi_config(roi_raw: dict[str, Any]) -> RoiConfig:
return RoiConfig(
points=_point_list(roi_raw["points"]),
inset_left_px=int(roi_raw.get("inset_left_px", 0)),
inset_right_px=int(roi_raw.get("inset_right_px", 0)),
inset_top_px=int(roi_raw.get("inset_top_px", 0)),
inset_bottom_px=int(roi_raw.get("inset_bottom_px", 0)),
min_overlap_ratio=float(roi_raw.get("min_overlap_ratio", 0.0)),
)
def _build_camera_config(raw: dict[str, Any]) -> CameraConfig:
return CameraConfig(
camera_id=raw["camera_id"],
source=raw["source"],
detection=DetectionConfig(**raw["detection"]),
tracker=TrackerConfig(**raw["tracker"]),
roi=_build_roi_config(raw["roi"]),
gate=GateConfig(**raw["gate"]),
motion=MotionConfig(**raw["motion"]),
overlay=_build_overlay_config(raw["overlay"]),
display=DisplayConfig(**raw["display"]),
performance=PerformanceConfig(**raw.get("performance", {})),
feedback=FeedbackConfig(**raw.get("feedback", {})),
detection_zone=DetectionZoneConfig(**raw.get("detection_zone", {})),
)
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
merged = copy.deepcopy(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
def load_camera_config(path: str | Path, camera_id: str | None = None) -> CameraConfig:
config_path = Path(path)
raw = _load_data(config_path)
if "batch" in raw:
raise ValueError(
"This is a batch config file. Use 'chicken-counter batch --config ...' instead."
)
if "cameras" in raw and "defaults" not in raw:
if not camera_id:
raise ValueError("camera_id is required when config contains multiple cameras")
raw = raw["cameras"][camera_id]
return _build_camera_config(raw)
def load_batch_config(path: str | Path) -> BatchSettings:
config_path = Path(path)
raw = _load_data(config_path)
if "batch" not in raw:
raise ValueError("Batch config must contain a top-level 'batch' section")
batch = BatchConfig(**raw["batch"])
defaults = raw.get("defaults", {})
cameras: dict[str, CameraPreset] = {}
for camera_id, camera_raw in raw.get("cameras", {}).items():
roi_points = _point_list(camera_raw["roi"]["points"])
count_anchor = None
if "count_anchor" in camera_raw:
count_anchor = tuple(camera_raw["count_anchor"])
elif "overlay" in camera_raw and "count_anchor" in camera_raw["overlay"]:
count_anchor = tuple(camera_raw["overlay"]["count_anchor"])
gate = GateConfig(**camera_raw["gate"]) if "gate" in camera_raw else None
motion = MotionConfig(**camera_raw["motion"]) if "motion" in camera_raw else None
cameras[camera_id] = CameraPreset(
camera_id=camera_id,
camera_num=int(camera_raw["camera_num"]),
roi=RoiConfig(points=roi_points),
count_anchor=count_anchor,
gate=gate,
motion=motion,
)
return BatchSettings(batch=batch, defaults=defaults, cameras=cameras)
def build_camera_config_from_batch(
settings: BatchSettings,
camera_id: str,
*,
source: str | Path,
output_path: str | Path,
checkpoint_dir: str | Path,
) -> CameraConfig:
if camera_id not in settings.cameras:
raise KeyError(f"Unknown camera_id in batch config: {camera_id}")
preset = settings.cameras[camera_id]
raw = _deep_merge(settings.defaults, {"camera_id": camera_id, "source": str(source)})
raw.setdefault("roi", {})
raw["roi"]["points"] = [list(point) for point in preset.roi.points]
if preset.gate is not None:
raw["gate"] = {
"mode": preset.gate.mode,
"lines_y": preset.gate.lines_y,
"direction": preset.gate.direction,
}
if preset.motion is not None:
raw["motion"] = {
"enabled": preset.motion.enabled,
"axis": preset.motion.axis,
"forward_sign": preset.motion.forward_sign,
"ema_alpha": preset.motion.ema_alpha,
"reverse_enter_threshold": preset.motion.reverse_enter_threshold,
"reverse_exit_threshold": preset.motion.reverse_exit_threshold,
"debounce_frames": preset.motion.debounce_frames,
"min_features": preset.motion.min_features,
"max_corners": preset.motion.max_corners,
"quality_level": preset.motion.quality_level,
"min_distance": preset.motion.min_distance,
"block_radius": preset.motion.block_radius,
"stride_frames": preset.motion.stride_frames,
"flow_scale": preset.motion.flow_scale,
}
if preset.count_anchor is not None:
raw.setdefault("overlay", {})
raw["overlay"]["count_anchor"] = list(preset.count_anchor)
raw.setdefault("display", {})
raw["display"]["output_path"] = str(output_path)
raw["display"]["show_window"] = False
raw.setdefault("feedback", {})
raw["feedback"]["enabled"] = True
raw["feedback"]["every_n_frames"] = settings.batch.checkpoint_every_n_frames
raw["feedback"]["save_images"] = True
raw["feedback"]["image_output_dir"] = str(checkpoint_dir)
raw["feedback"]["log_to_terminal"] = True
return _build_camera_config(raw)
+151
View File
@@ -0,0 +1,151 @@
"""Count chickens entering the ROI and assign visible sequence IDs."""
from __future__ import annotations
from collections import defaultdict, deque
import cv2
import numpy as np
from chicken_counter.config import GateConfig, RoiConfig
from chicken_counter.types import CountEvent, TrackObservation
class CountingZone:
def __init__(
self,
roi: RoiConfig,
gate: GateConfig,
trail_length: int,
track_buffer: int,
min_box_area_px: int = 0,
validate_while_inside: bool = True,
) -> None:
self.roi = roi
self.gate = gate
self.trail_length = trail_length
self.track_buffer = track_buffer
self.min_box_area_px = min_box_area_px
self.min_overlap_ratio = roi.min_overlap_ratio
self.validate_while_inside = validate_while_inside
self.inside_box_count = 0
self.total_entered_count = 0
self.histories: dict[int, deque[tuple[int, int]]] = defaultdict(lambda: deque(maxlen=trail_length))
self.last_seen_frame: dict[int, int] = {}
self.counted_ids: set[int] = set()
self.prev_inside_ids: set[int] = set()
self.current_inside_ids: set[int] = set()
self.sequence_numbers_by_track_id: dict[int, int] = {}
self.latest_validated_track_id: int | None = None
self._counting_polygon = np.array(roi.counting_polygon(), dtype=np.int32)
self._counting_rect = roi.counting_rect()
def update(
self,
tracks: list[TrackObservation],
frame_index: int,
*,
counting_paused: bool = False,
) -> list[CountEvent]:
events: list[CountEvent] = []
active_ids = set()
inside_ids = set()
for track in tracks:
active_ids.add(track.track_id)
self.last_seen_frame[track.track_id] = frame_index
self.histories[track.track_id].append(track.centroid)
if self._inside_roi(track.centroid):
inside_ids.add(track.track_id)
if counting_paused:
continue
if track.track_id not in inside_ids or track.track_id in self.counted_ids:
continue
should_validate = False
if self.validate_while_inside:
should_validate = self._meets_validation_thresholds(track)
else:
just_entered_box = (
track.track_id in inside_ids and track.track_id not in self.prev_inside_ids
)
should_validate = just_entered_box and self._meets_validation_thresholds(track)
if should_validate:
self.counted_ids.add(track.track_id)
self.total_entered_count += 1
self.sequence_numbers_by_track_id[track.track_id] = self.total_entered_count
self.latest_validated_track_id = track.track_id
events.append(
CountEvent(
track_id=track.track_id,
frame_index=frame_index,
total_entered_after_event=self.total_entered_count,
sequence_number=self.sequence_numbers_by_track_id[track.track_id],
)
)
self.inside_box_count = len(inside_ids)
self.current_inside_ids = inside_ids
self.prev_inside_ids = inside_ids
self._purge_stale(frame_index, active_ids)
return events
def trail_for(self, track_id: int) -> list[tuple[int, int]]:
return list(self.histories.get(track_id, ()))
def sequence_number_for(self, track_id: int) -> int | None:
return self.sequence_numbers_by_track_id.get(track_id)
def is_inside(self, track_id: int) -> bool:
return track_id in self.current_inside_ids
def is_validated(self, track_id: int) -> bool:
return track_id in self.counted_ids
def _inside_roi(self, point: tuple[int, int]) -> bool:
return cv2.pointPolygonTest(self._counting_polygon, point, False) > 0
def _meets_size_threshold(self, track: TrackObservation) -> bool:
x1, y1, x2, y2 = track.bbox_xyxy
area = max(0, x2 - x1) * max(0, y2 - y1)
return area >= self.min_box_area_px
def _bbox_overlap_ratio(self, track: TrackObservation) -> float:
x1, y1, x2, y2 = track.bbox_xyxy
bbox_area = max(0, x2 - x1) * max(0, y2 - y1)
if bbox_area <= 0:
return 0.0
rx1, ry1, rx2, ry2 = self._counting_rect
ix1 = max(x1, rx1)
iy1 = max(y1, ry1)
ix2 = min(x2, rx2)
iy2 = min(y2, ry2)
if ix2 <= ix1 or iy2 <= iy1:
return 0.0
intersection_area = (ix2 - ix1) * (iy2 - iy1)
return intersection_area / bbox_area
def _meets_overlap_threshold(self, track: TrackObservation) -> bool:
if self.min_overlap_ratio <= 0:
return True
return self._bbox_overlap_ratio(track) >= self.min_overlap_ratio
def _meets_validation_thresholds(self, track: TrackObservation) -> bool:
return self._meets_size_threshold(track) and self._meets_overlap_threshold(track)
def _purge_stale(self, frame_index: int, active_ids: set[int]) -> None:
stale_ids = [
track_id
for track_id, last_seen in self.last_seen_frame.items()
if track_id not in active_ids and frame_index - last_seen > self.track_buffer
]
for track_id in stale_ids:
self.last_seen_frame.pop(track_id, None)
self.histories.pop(track_id, None)
self.prev_inside_ids.discard(track_id)
self.current_inside_ids.discard(track_id)
+99
View File
@@ -0,0 +1,99 @@
"""Detect backward cart motion using sparse optical flow on the background."""
from __future__ import annotations
import cv2
import numpy as np
from chicken_counter.config import MotionConfig, RoiConfig
from chicken_counter.types import MotionState, TrackObservation
class BackwardMotionDetector:
def __init__(self, config: MotionConfig, roi: RoiConfig) -> None:
self.config = config
self.roi = roi
self.previous_gray: np.ndarray | None = None
self.state = MotionState()
self._roi_bounds = self._compute_roi_bounds()
def _compute_roi_bounds(self) -> tuple[int, int, int, int]:
x_values = [point[0] for point in self.roi.points]
y_values = [point[1] for point in self.roi.points]
return min(x_values), min(y_values), max(x_values), max(y_values)
def update(
self,
frame: np.ndarray,
tracks: list[TrackObservation],
frame_index: int,
) -> MotionState:
if not self.config.enabled:
return self.state
stride = max(1, self.config.stride_frames)
if frame_index % stride != 0:
return self.state
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
x_min, y_min, x_max, y_max = self._roi_bounds
gray = gray[y_min:y_max, x_min:x_max]
scale = self.config.flow_scale
if scale < 1.0:
target_width = max(1, int(gray.shape[1] * scale))
target_height = max(1, int(gray.shape[0] * scale))
gray = cv2.resize(gray, (target_width, target_height), interpolation=cv2.INTER_AREA)
else:
scale = 1.0
mask = np.full(gray.shape, 255, dtype=np.uint8)
for track in tracks:
x1, y1, x2, y2 = track.bbox_xyxy
r = self.config.block_radius
local_x1 = int((max(0, x1 - r) - x_min) * scale)
local_y1 = int((max(0, y1 - r) - y_min) * scale)
local_x2 = int((min(x_max, x2 + r) - x_min) * scale)
local_y2 = int((min(y_max, y2 + r) - y_min) * scale)
if local_x2 <= local_x1 or local_y2 <= local_y1:
continue
cv2.rectangle(mask, (local_x1, local_y1), (local_x2, local_y2), 0, -1)
points = cv2.goodFeaturesToTrack(
gray,
maxCorners=self.config.max_corners,
qualityLevel=self.config.quality_level,
minDistance=self.config.min_distance,
mask=mask,
)
if self.previous_gray is None or points is None or len(points) < self.config.min_features:
self.previous_gray = gray
return self.state
next_points, status, _ = cv2.calcOpticalFlowPyrLK(self.previous_gray, gray, points, None)
self.previous_gray = gray
if next_points is None or status is None:
return self.state
valid_prev = points[status.flatten() == 1]
valid_next = next_points[status.flatten() == 1]
if len(valid_prev) < self.config.min_features:
return self.state
flow = valid_next - valid_prev
axis_values = flow[:, 0, 1] if self.config.axis == "vertical" else flow[:, 0, 0]
median_axis_speed = float(np.median(axis_values)) * self.config.forward_sign
alpha = self.config.ema_alpha
self.state.smoothed_speed = alpha * median_axis_speed + (1.0 - alpha) * self.state.smoothed_speed
if self.state.smoothed_speed <= self.config.reverse_enter_threshold:
self.state.consecutive_reverse_frames += 1
elif self.state.smoothed_speed > self.config.reverse_exit_threshold:
self.state.consecutive_reverse_frames = 0
self.state.backward_active = False
if self.state.consecutive_reverse_frames >= self.config.debounce_frames:
self.state.backward_active = True
return self.state
+189
View File
@@ -0,0 +1,189 @@
"""Render annotated frames with boxes, counting guides, and totals."""
from __future__ import annotations
from collections.abc import Iterable
import cv2
import numpy as np
from chicken_counter.config import CameraConfig
from chicken_counter.counting import CountingZone
from chicken_counter.types import MotionState, TrackObservation
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
CYAN = (255, 255, 0)
RED = (0, 0, 255)
YELLOW = (0, 255, 255)
ORANGE = (0, 165, 255)
BLUE = (255, 120, 0)
LIME = (80, 220, 80)
GRAY = (160, 160, 160)
def draw_overlay(
frame: np.ndarray,
config: CameraConfig,
counting_zone: CountingZone,
tracks: Iterable[TrackObservation],
motion_state: MotionState,
frame_index: int = 0,
buffer: np.ndarray | None = None,
) -> np.ndarray:
if buffer is not None:
np.copyto(buffer, frame)
annotated = buffer
else:
annotated = frame.copy()
if config.detection_zone.enabled and config.detection_zone.show_in_overlay:
_draw_detection_zone(annotated, config)
_draw_roi_and_gates(annotated, config)
blink_on = (frame_index // 8) % 2 == 0
pending_colors = config.overlay.pending_colors or [CYAN, YELLOW]
for track in tracks:
inside_box = counting_zone.is_inside(track.track_id)
if config.overlay.inside_box_only and not inside_box:
continue
validated = counting_zone.is_validated(track.track_id)
x1, y1, x2, y2 = track.bbox_xyxy
cx, cy = track.centroid
sequence_number = counting_zone.sequence_number_for(track.track_id)
if config.overlay.show_boxes:
if validated:
box_color = ORANGE
elif config.overlay.pending_blink:
box_color = pending_colors[0 if blink_on else 1 % len(pending_colors)]
else:
box_color = pending_colors[0]
cv2.rectangle(annotated, (x1, y1), (x2, y2), box_color, 2)
if validated and sequence_number is not None:
_draw_outlined_text(
annotated,
str(sequence_number),
(x1, max(24, y1 - 8)),
font_scale=0.8,
fill_color=LIME,
outline_color=BLACK,
thickness=2,
outline_thickness=4,
)
if config.overlay.show_center_marker:
marker_color = ORANGE if validated else (pending_colors[0 if blink_on else 1 % len(pending_colors)])
cv2.circle(annotated, (cx, cy), 4, marker_color, -1)
if config.overlay.show_track_ring:
radius = max(20, int(max(x2 - x1, y2 - y1) * 0.6))
cv2.circle(annotated, (cx, cy), radius, WHITE, 1)
if config.overlay.show_track_trails:
trail = counting_zone.trail_for(track.track_id)
_draw_trail(annotated, trail)
count_x, count_y = config.overlay.count_anchor
_draw_outlined_text(
annotated,
f"TOTAL ENTERED: {counting_zone.total_entered_count}",
(count_x, count_y),
font_scale=1.35,
fill_color=BLUE,
outline_color=BLACK,
thickness=4,
outline_thickness=6,
)
motion_label = "BACKWARD STOP" if motion_state.backward_active else "FORWARD"
motion_color = RED if motion_state.backward_active else YELLOW
cv2.putText(
annotated,
motion_label,
(count_x, count_y + 42),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
motion_color,
2,
cv2.LINE_AA,
)
return annotated
def _draw_outlined_text(
frame: np.ndarray,
text: str,
origin: tuple[int, int],
*,
font_scale: float,
fill_color: tuple[int, int, int],
outline_color: tuple[int, int, int],
thickness: int,
outline_thickness: int,
) -> None:
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(
frame,
text,
origin,
font,
font_scale,
outline_color,
outline_thickness,
cv2.LINE_AA,
)
cv2.putText(
frame,
text,
origin,
font,
font_scale,
fill_color,
thickness,
cv2.LINE_AA,
)
def _draw_roi_and_gates(frame: np.ndarray, config: CameraConfig) -> None:
points = np.array(config.roi.counting_polygon(), dtype=np.int32)
cv2.polylines(frame, [points], isClosed=True, color=BLUE, thickness=3)
def _draw_detection_zone(frame: np.ndarray, config: CameraConfig) -> None:
height, width = frame.shape[:2]
x1, y1, x2, y2 = config.detection_zone.compute_rect(config.roi, width, height)
_draw_dashed_rectangle(frame, (x1, y1), (x2, y2), GRAY, thickness=1)
def _draw_dashed_rectangle(
frame: np.ndarray,
pt1: tuple[int, int],
pt2: tuple[int, int],
color: tuple[int, int, int],
*,
thickness: int = 1,
dash_length: int = 12,
) -> None:
x1, y1 = pt1
x2, y2 = pt2
for x_start in range(x1, x2, dash_length * 2):
cv2.line(frame, (x_start, y1), (min(x_start + dash_length, x2), y1), color, thickness)
for x_start in range(x1, x2, dash_length * 2):
cv2.line(frame, (x_start, y2), (min(x_start + dash_length, x2), y2), color, thickness)
for y_start in range(y1, y2, dash_length * 2):
cv2.line(frame, (x1, y_start), (x1, min(y_start + dash_length, y2)), color, thickness)
for y_start in range(y1, y2, dash_length * 2):
cv2.line(frame, (x2, y_start), (x2, min(y_start + dash_length, y2)), color, thickness)
def _draw_trail(frame: np.ndarray, trail: list[tuple[int, int]]) -> None:
if len(trail) < 2:
return
for start, end in zip(trail[:-1], trail[1:]):
cv2.line(frame, start, end, YELLOW, 2)
+269
View File
@@ -0,0 +1,269 @@
"""Run the main per-frame counting loop and connect all pipeline stages."""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
from chicken_counter.capture import open_capture
from chicken_counter.config import CameraConfig
from chicken_counter.counting import CountingZone
from chicken_counter.motion import BackwardMotionDetector
from chicken_counter.overlay import draw_overlay
from chicken_counter.tracking import DetectionTracker
from chicken_counter.types import FrameResult, PipelineResult, TrackObservation
from chicken_counter.video_writer import make_video_writer
@dataclass
class PipelineArtifacts:
capture: cv2.VideoCapture
tracker: DetectionTracker
counting_zone: CountingZone
motion_detector: BackwardMotionDetector
writer: cv2.VideoWriter | None
overlay_buffer: np.ndarray | None
run_start_time: float
total_source_frames: int | None
owns_tracker: bool
detection_zone_rect: tuple[int, int, int, int] | None = None
def build_pipeline(
config: CameraConfig,
tracker: DetectionTracker | None = None,
) -> PipelineArtifacts:
capture = open_capture(config.source)
owns_tracker = tracker is None
if tracker is None:
tracker = DetectionTracker(config)
counting_zone = CountingZone(
roi=config.roi,
gate=config.gate,
trail_length=config.overlay.trail_length,
track_buffer=config.tracker.track_buffer,
min_box_area_px=config.detection.min_box_area_px,
validate_while_inside=config.detection.validate_while_inside,
)
motion_detector = BackwardMotionDetector(config.motion, config.roi)
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
total_source_frames = frame_count if frame_count > 0 else None
detection_zone_rect = None
if config.detection_zone.enabled and width > 0 and height > 0:
detection_zone_rect = config.detection_zone.compute_rect(config.roi, width, height)
print(
f"[detection_zone] enabled crop=({detection_zone_rect[0]}, {detection_zone_rect[1]})"
f"-({detection_zone_rect[2]}, {detection_zone_rect[3]})"
)
overlay_buffer = None
if config.performance.overlay_buffer_reuse and width > 0 and height > 0:
overlay_buffer = np.empty((height, width, 3), dtype=np.uint8)
writer = None
if config.display.output_path:
fps = config.display.write_fps or capture.get(cv2.CAP_PROP_FPS) or 30.0
writer = make_video_writer(
config.display.output_path,
(width, height),
fps,
encoder=config.display.encoder,
output_bitrate_kbps=config.display.output_bitrate_kbps,
codec_preference=config.display.codec_preference,
)
return PipelineArtifacts(
capture=capture,
tracker=tracker,
counting_zone=counting_zone,
motion_detector=motion_detector,
writer=writer,
overlay_buffer=overlay_buffer,
run_start_time=time.monotonic(),
total_source_frames=total_source_frames,
owns_tracker=owns_tracker,
detection_zone_rect=detection_zone_rect,
)
def run_pipeline(
config: CameraConfig,
tracker: DetectionTracker | None = None,
) -> PipelineResult:
if tracker is not None:
tracker.config = config
tracker.reset_tracking()
artifacts = build_pipeline(config, tracker=tracker)
inference_stride = max(1, config.performance.inference_stride)
print(
f"[perf] inference_stride={inference_stride} "
f"motion.stride_frames={max(1, config.motion.stride_frames)} "
f"motion.flow_scale={config.motion.flow_scale}"
)
frame_index = 0
last_annotated = None
last_tracks: list[TrackObservation] = []
stopped_reason = "eof"
user_quit = False
try:
while True:
ok, frame = artifacts.capture.read()
if not ok:
break
frame_index += 1
if frame_index % inference_stride == 0 or not last_tracks:
last_tracks = artifacts.tracker.infer(
frame,
crop_rect=artifacts.detection_zone_rect,
)
tracks = last_tracks
motion_state = artifacts.motion_detector.update(frame, tracks, frame_index)
count_events = artifacts.counting_zone.update(
tracks,
frame_index,
counting_paused=motion_state.backward_active,
)
annotated = draw_overlay(
frame,
config,
artifacts.counting_zone,
tracks,
motion_state,
frame_index=frame_index,
buffer=artifacts.overlay_buffer,
)
result = FrameResult(
frame_index=frame_index,
tracks=tracks,
inside_box_count=artifacts.counting_zone.inside_box_count,
total_entered_count=artifacts.counting_zone.total_entered_count,
motion_state=motion_state,
count_events=count_events,
)
_consume_result(config, artifacts, annotated, result)
last_annotated = annotated
if motion_state.backward_active:
stopped_reason = "backward"
print(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline")
break
if config.display.max_frames and frame_index >= config.display.max_frames:
stopped_reason = "max_frames"
break
if config.display.show_window and cv2.waitKey(1) & 0xFF == ord("q"):
stopped_reason = "user_quit"
user_quit = True
break
if artifacts.writer is not None and last_annotated is not None:
freeze_frame_count = int((config.display.write_fps or 30.0) * 2)
for _ in range(max(1, freeze_frame_count)):
artifacts.writer.write(last_annotated)
finally:
artifacts.capture.release()
if artifacts.writer is not None:
artifacts.writer.release()
if config.display.show_window:
cv2.destroyAllWindows()
elapsed_seconds = time.monotonic() - artifacts.run_start_time
if user_quit:
stopped_reason = "user_quit"
return PipelineResult(
camera_id=config.camera_id,
total_entered_count=artifacts.counting_zone.total_entered_count,
frames_processed=frame_index,
stopped_reason=stopped_reason,
vis_video_path=config.display.output_path,
source_video=str(config.source),
elapsed_seconds=elapsed_seconds,
)
def _consume_result(
config: CameraConfig,
artifacts: PipelineArtifacts,
annotated,
result: FrameResult,
) -> None:
if config.display.show_window:
cv2.imshow(config.display.window_name, annotated)
if artifacts.writer is not None:
artifacts.writer.write(annotated)
for event in result.count_events:
print(
f"[frame {event.frame_index}] counted track={event.track_id} "
f"inside_box={result.inside_box_count} total_entered={event.total_entered_after_event}"
)
if _should_emit_feedback(config, result.frame_index):
_emit_periodic_feedback(config, artifacts, annotated, result)
def _should_emit_feedback(config: CameraConfig, frame_index: int) -> bool:
if not config.feedback.enabled:
return False
if config.feedback.every_n_frames <= 0:
return False
return frame_index % config.feedback.every_n_frames == 0
def _format_duration(seconds: float) -> str:
if seconds < 60:
return f"{seconds:.0f}s"
minutes, secs = divmod(int(seconds), 60)
if minutes < 60:
return f"{minutes}m{secs:02d}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h{minutes:02d}m"
def _emit_periodic_feedback(
config: CameraConfig,
artifacts: PipelineArtifacts,
annotated,
result: FrameResult,
) -> None:
if config.feedback.log_to_terminal:
elapsed = time.monotonic() - artifacts.run_start_time
fps = result.frame_index / elapsed if elapsed > 0 else 0.0
status = "backward_stop" if result.motion_state.backward_active else "running"
progress = f"frame={result.frame_index}"
if artifacts.total_source_frames:
progress = f"frame={result.frame_index}/{artifacts.total_source_frames}"
eta_text = ""
if artifacts.total_source_frames and fps > 0:
remaining_frames = max(0, artifacts.total_source_frames - result.frame_index)
eta_seconds = remaining_frames / fps
eta_text = f" eta={_format_duration(eta_seconds)}"
print(
f"[checkpoint] {progress} elapsed={_format_duration(elapsed)} "
f"fps={fps:.1f} inside_box={result.inside_box_count} "
f"total_entered={result.total_entered_count} "
f"backward_active={result.motion_state.backward_active} "
f"status={status}{eta_text}"
)
if config.feedback.save_images:
output_dir = Path(config.feedback.image_output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
image_path = output_dir / f"frame_{result.frame_index:06d}.jpg"
cv2.imwrite(str(image_path), annotated)
+117
View File
@@ -0,0 +1,117 @@
"""Build and write the per-camera count JSON including the 4-camera total."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from chicken_counter.types import BatchReport, CameraBatchResult
def build_camera_report_entry(
item: CameraBatchResult,
*,
output_dir: Path | None = None,
) -> dict:
if item.skipped:
return {
"skipped": True,
"skip_reason": item.skip_reason,
"total_entered": 0,
}
pipeline = item.pipeline
if pipeline is None:
return {}
source_name = Path(pipeline.source_video).name
return {
"total_entered": pipeline.total_entered_count,
"source_video": source_name,
"vis_video": _relative_output_path(pipeline.vis_video_path, output_dir),
"compressed_video": _relative_output_path(item.compressed_video_path, output_dir),
"compressed_size_mb": item.compressed_size_mb,
"frames_processed": pipeline.frames_processed,
"stopped_reason": pipeline.stopped_reason,
"elapsed_seconds": round(pipeline.elapsed_seconds, 1),
}
def build_batch_report(
date: str,
results: list[CameraBatchResult],
*,
output_dir: Path | None = None,
) -> BatchReport:
cameras: dict[str, dict] = {}
total_sum = 0
for item in results:
camera_entry = build_camera_report_entry(item, output_dir=output_dir)
if not camera_entry:
continue
cameras[item.camera_id] = camera_entry
if not item.skipped:
total_sum += camera_entry["total_entered"]
return BatchReport(
date=date,
generated_at=datetime.now(timezone.utc).isoformat(),
cameras=cameras,
total_entered_sum=total_sum,
)
def write_batch_report(report: BatchReport, output_path: str | Path) -> Path:
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report.__dict__, indent=2), encoding="utf-8")
print(f"[report] wrote {path}")
return path
def write_camera_report(
date: str,
item: CameraBatchResult,
output_dir: str | Path,
) -> Path:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
camera_entry = build_camera_report_entry(item, output_dir=output_path)
payload = {
"date": date,
"camera_id": item.camera_id,
"generated_at": datetime.now(timezone.utc).isoformat(),
**camera_entry,
}
path = output_path / f"{item.camera_id}_counts_{date}.json"
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"[report] wrote {path}")
return path
def persist_batch_reports(
date: str,
results: list[CameraBatchResult],
output_dir: str | Path,
) -> Path:
output_path = Path(output_dir)
latest = results[-1]
write_camera_report(date, latest, output_path)
aggregate_path = output_path / f"counts_{date}.json"
report = build_batch_report(date, results, output_dir=output_path)
write_batch_report(report, aggregate_path)
return aggregate_path
def _relative_output_path(path: str | Path | None, base_dir: Path | None = None) -> str | None:
if path is None:
return None
resolved = Path(path)
if base_dir is not None:
try:
return resolved.relative_to(base_dir).as_posix()
except ValueError:
pass
return resolved.name
+127
View File
@@ -0,0 +1,127 @@
"""Run YOLO detection and BoT-SORT tracking on each frame."""
from __future__ import annotations
from pathlib import Path
import numpy as np
from ultralytics import YOLO
from chicken_counter.config import CameraConfig
from chicken_counter.types import TrackObservation
class DetectionTracker:
def __init__(self, config: CameraConfig) -> None:
self.config = config
model_path = Path(config.detection.model_path)
self.model_kind = model_path.suffix.lower().lstrip(".") or "unknown"
self.model = YOLO(config.detection.model_path)
self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
print(
f"[model] loaded {self.model_kind} from {model_path} "
f"(imgsz={config.detection.imgsz}, device={config.detection.device})"
)
if self.model_kind == "engine":
print("[model] TensorRT engine active; runtime half flag is ignored")
def reset_tracking(self) -> None:
if hasattr(self.model, "predictor"):
self.model.predictor = None
def infer(
self,
frame: np.ndarray,
*,
crop_rect: tuple[int, int, int, int] | None = None,
) -> list[TrackObservation]:
offset_x = 0
offset_y = 0
source = frame
if crop_rect is not None:
x1, y1, x2, y2 = crop_rect
source = frame[y1:y2, x1:x2]
offset_x, offset_y = x1, y1
track_kwargs: dict = {
"source": source,
"persist": self.config.tracker.persist,
"tracker": self.tracker_config_path,
"conf": self.config.detection.conf,
"iou": self.config.detection.iou,
"classes": self.config.detection.classes,
"imgsz": self.config.detection.imgsz,
"verbose": False,
"device": self.config.detection.device,
}
if self.model_kind != "engine" and self.config.performance.half:
track_kwargs["half"] = True
results = self.model.track(**track_kwargs)
if not results:
return []
result = results[0]
boxes = result.boxes
if boxes is None or boxes.id is None:
return []
ids = boxes.id.int().cpu().numpy()
classes = boxes.cls.int().cpu().numpy()
confidences = boxes.conf.cpu().numpy()
xyxy = boxes.xyxy.int().cpu().numpy()
mask_polygons = None
if result.masks is not None and result.masks.xy is not None:
mask_polygons = result.masks.xy
if len(mask_polygons) != len(boxes):
raise RuntimeError(
f"Ultralytics box/mask count mismatch: {len(boxes)} boxes, "
f"{len(mask_polygons)} masks"
)
tracks: list[TrackObservation] = []
for index in range(len(boxes)):
track_id = int(ids[index])
class_id = int(classes[index])
confidence = float(confidences[index])
bbox = xyxy[index]
x1 = int(bbox[0]) + offset_x
y1 = int(bbox[1]) + offset_y
x2 = int(bbox[2]) + offset_x
y2 = int(bbox[3]) + offset_y
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
continue
polygon = None
if mask_polygons is not None:
poly = np.asarray(mask_polygons[index], dtype=np.float64).copy()
if poly.ndim == 2 and poly.shape[0] >= 3:
poly[:, 0] += offset_x
poly[:, 1] += offset_y
polygon = poly
tracks.append(
TrackObservation(
track_id=track_id,
class_id=class_id,
confidence=confidence,
bbox_xyxy=(x1, y1, x2, y2),
centroid=centroid,
mask_polygon_xy=polygon,
)
)
return tracks
@staticmethod
def _centroid_in_rect(
centroid: tuple[int, int],
rect: tuple[int, int, int, int],
) -> bool:
x1, y1, x2, y2 = rect
cx, cy = centroid
return x1 <= cx <= x2 and y1 <= cy <= y2
+72
View File
@@ -0,0 +1,72 @@
"""Shared dataclasses for tracks, frame results, and batch reports."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TrackObservation:
track_id: int
class_id: int
confidence: float
bbox_xyxy: tuple[int, int, int, int]
centroid: tuple[int, int]
# Full-frame polygon (N, 2) float/int array; None for detect-only models.
mask_polygon_xy: Any | None = None
@dataclass
class CountEvent:
track_id: int
frame_index: int
total_entered_after_event: int
sequence_number: int
@dataclass
class MotionState:
smoothed_speed: float = 0.0
consecutive_reverse_frames: int = 0
backward_active: bool = False
@dataclass
class FrameResult:
frame_index: int
tracks: list[TrackObservation] = field(default_factory=list)
inside_box_count: int = 0
total_entered_count: int = 0
latest_validated_track_id: int | None = None
motion_state: MotionState = field(default_factory=MotionState)
count_events: list[CountEvent] = field(default_factory=list)
@dataclass
class PipelineResult:
camera_id: str
total_entered_count: int
frames_processed: int
stopped_reason: str
vis_video_path: str | None
source_video: str
elapsed_seconds: float
@dataclass
class CameraBatchResult:
camera_id: str
pipeline: PipelineResult | None = None
skipped: bool = False
skip_reason: str | None = None
compressed_video_path: str | None = None
compressed_size_mb: float | None = None
@dataclass
class BatchReport:
date: str
generated_at: str
cameras: dict[str, dict]
total_entered_sum: int
+81
View File
@@ -0,0 +1,81 @@
"""Write annotated output videos via GStreamer or OpenCV codecs."""
from __future__ import annotations
from pathlib import Path
import cv2
def make_video_writer(
path: str,
frame_size: tuple[int, int],
fps: float,
*,
encoder: str = "auto",
output_bitrate_kbps: int = 4000,
codec_preference: list[str] | None = None,
) -> cv2.VideoWriter:
Path(path).parent.mkdir(parents=True, exist_ok=True)
width, height = frame_size
bitrate_bps = max(1, output_bitrate_kbps) * 1000
codecs = codec_preference or ["avc1", "mp4v", "H264"]
if encoder in ("auto", "gstreamer"):
writer = _try_gstreamer_writer(path, width, height, fps, bitrate_bps)
if writer is not None:
print(f"[video] opened GStreamer hardware encoder (bitrate={output_bitrate_kbps} kbps)")
return writer
if encoder == "gstreamer":
raise RuntimeError(
f"GStreamer video writer failed for path: {path}. "
"Ensure OpenCV was built with GStreamer and Jetson encoder plugins are available."
)
writer = _try_opencv_writer(path, frame_size, fps, codecs)
if writer is not None:
print(f"[video] opened OpenCV encoder (codecs tried: {codecs})")
return writer
raise RuntimeError(
"Unable to open video writer for path: "
f"{path}. Tried encoder={encoder}, codecs={codecs}"
)
def _try_gstreamer_writer(
path: str,
width: int,
height: int,
fps: float,
bitrate_bps: int,
) -> cv2.VideoWriter | None:
fps_int = max(1, int(round(fps)))
pipeline = (
f"appsrc ! video/x-raw, format=BGR ! "
f"video/x-raw,width={width},height={height},framerate={fps_int}/1 ! "
f"videoconvert ! nvvidconv ! "
f"video/x-raw(memory:NVMM),format=NV12 ! "
f"nvv4l2h264enc bitrate={bitrate_bps} insert-sps-pps=true ! "
f"h264parse ! mp4mux ! filesink location={path}"
)
writer = cv2.VideoWriter(pipeline, cv2.CAP_GSTREAMER, 0, fps, (width, height), True)
if writer.isOpened():
return writer
writer.release()
return None
def _try_opencv_writer(
path: str,
frame_size: tuple[int, int],
fps: float,
codec_candidates: list[str],
) -> cv2.VideoWriter | None:
for codec in codec_candidates:
fourcc = cv2.VideoWriter_fourcc(*codec)
writer = cv2.VideoWriter(path, fourcc, fps, frame_size)
if writer.isOpened():
return writer
writer.release()
return None