forked from zakaria/chicken-counting-sukawarna-det
intial commit
This commit is contained in:
commit
a54a070ca9
49 files changed
+2960
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Chicken counting pipeline package for Jetson single-camera and daily batch runs."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user