diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ec7d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/RUN.md b/RUN.md new file mode 100644 index 0000000..91b56f0 --- /dev/null +++ b/RUN.md @@ -0,0 +1,11 @@ +# Counter +## Pakai virtual env di /media/jetson/DATA/karung-sukawarna/venv + +alias chicken-counter='PYTHONPATH={fullpath git clone folder} /media/jetson/DATA/karung-sukawarna/venv/bin/python -m chicken_counter.cli' + +chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-06-10 --no-video --progress-bar + +# Dashboard +## Pakai virtual env di /media/jetson/DATA/karung-sukawarna/venv +source /media/jetson/DATA/karung-sukawarna/venv/bin/source +python dashboard.py --port 8080 diff --git a/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine new file mode 100755 index 0000000..b189515 Binary files /dev/null and b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine differ diff --git a/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx new file mode 100755 index 0000000..55acf3a Binary files /dev/null and b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx differ diff --git a/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt old mode 100644 new mode 100755 diff --git a/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt b/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt old mode 100644 new mode 100755 diff --git a/configs/cameras/example_camera.yaml b/configs/cameras/example_camera.yaml old mode 100644 new mode 100755 diff --git a/configs/cycle7_batch.yaml b/configs/cycle7_batch.yaml old mode 100644 new mode 100755 index 01aea8b..d50284f --- a/configs/cycle7_batch.yaml +++ b/configs/cycle7_batch.yaml @@ -1,5 +1,5 @@ batch: - root_dir: /home/nvidia-admin/VIDEOS/cycle7/kandang-atas + root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas camera_glob: "kandang_*_camera_{num}_*.mp4" output_subdir: output compress_max_mb: 200 @@ -8,7 +8,7 @@ batch: defaults: detection: - model_path: /home/nvidia-admin/LABS/try-weight-estimator/try-chicken-sukawarna/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt + model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine classes: [0] ignored_classes: [1, 2] conf: 0.35 @@ -23,7 +23,7 @@ defaults: buffer_below_px: 250 show_in_overlay: true tracker: - tracker_config_path: /home/nvidia-admin/LABS/try-weight-estimator/try-chicken-sukawarna/configs/trackers/botsort_chicken.yaml + tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml persist: true track_buffer: 75 gate: @@ -66,6 +66,10 @@ defaults: half: false overlay_buffer_reuse: true inference_stride: 2 + stream: + enabled: true + shm_dir: /dev/shm + interval_frames: 5 feedback: enabled: true every_n_frames: 3000 diff --git a/configs/trackers/botsort_chicken.yaml b/configs/trackers/botsort_chicken.yaml old mode 100644 new mode 100755 diff --git a/dashboard.py b/dashboard.py new file mode 100755 index 0000000..cdab747 --- /dev/null +++ b/dashboard.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Standalone live dashboard for chicken-counter pipeline. + +Serve from project root: + PYTHONPATH=src python3 dashboard.py [--port 8080] +""" + +from __future__ import annotations + +import argparse +import json +import os +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from socketserver import ThreadingMixIn +from urllib.parse import unquote, urlparse + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + +DEFAULT_SHM_DIR = "/dev/shm" +DEFAULT_PORT = 8080 + +DASHBOARD_HTML = r""" + + + + +Chicken Counter — Live Dashboard + + + +
+ +
+
+waiting for pipeline... + ago   + +
+
+live stream +
+
+
+
+ + +""" + + +class DashboardHandler(SimpleHTTPRequestHandler): + shm_dir = DEFAULT_SHM_DIR + poll_ms = 500 + + def log_message(self, format, *args): + pass + + def do_GET(self): + try: + self._handle_request() + except (BrokenPipeError, ConnectionResetError): + pass + + def _handle_request(self): + parsed = urlparse(self.path) + path = unquote(parsed.path) + + if path == "/" or path == "/index.html": + html = DASHBOARD_HTML.replace("%%POLL_MS%%", str(self.poll_ms)).replace("%%SHM_DIR%%", self.shm_dir) + self._send_html(html) + return + + if path == "/api/cameras": + cameras = self._discover_cameras() + self._send_json({"cameras": cameras}) + return + + if path.startswith("/shm/"): + rel = path[len("/shm/"):] + parts = rel.split("/", 1) + if len(parts) >= 1: + parts[0] = f"chicken_counter_{parts[0]}" + rel = "/".join(parts) + shm_path = Path(self.shm_dir) / rel + resolved = shm_path.resolve() + if not str(resolved).startswith(str(Path(self.shm_dir).resolve())): + self.send_error(403) + return + if not resolved.exists(): + self.send_error(404) + return + ct = "image/jpeg" if resolved.suffix in (".jpg", ".jpeg") else "application/json" + self.send_response(200) + self.send_header("Content-Type", ct) + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.end_headers() + self.wfile.write(resolved.read_bytes()) + return + + self.send_error(404) + + def _discover_cameras(self): + shm = Path(self.shm_dir) + cameras = [] + if shm.is_dir(): + for entry in sorted(shm.iterdir()): + if entry.is_dir() and entry.name.startswith("chicken_counter_"): + cam_id = entry.name[len("chicken_counter_"):] + cameras.append(cam_id) + return cameras + + def _send_html(self, html: str): + data = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_json(self, obj): + data = json.dumps(obj).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def main(): + parser = argparse.ArgumentParser(description="Chicken Counter live dashboard") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"HTTP port (default: {DEFAULT_PORT})") + parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR, help=f"Shared memory directory (default: {DEFAULT_SHM_DIR})") + parser.add_argument("--poll-ms", type=int, default=500, help="Image poll interval in ms (default: 500)") + args = parser.parse_args() + + DashboardHandler.shm_dir = args.shm_dir + DashboardHandler.poll_ms = args.poll_ms + + server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler) + print(f"[dashboard] serving at http://0.0.0.0:{args.port}") + print(f"[dashboard] shm_dir={args.shm_dir} poll={args.poll_ms}ms") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[dashboard] stopped") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml old mode 100644 new mode 100755 diff --git a/src/chicken_counter.egg-info/PKG-INFO b/src/chicken_counter.egg-info/PKG-INFO old mode 100644 new mode 100755 diff --git a/src/chicken_counter.egg-info/SOURCES.txt b/src/chicken_counter.egg-info/SOURCES.txt old mode 100644 new mode 100755 diff --git a/src/chicken_counter.egg-info/dependency_links.txt b/src/chicken_counter.egg-info/dependency_links.txt old mode 100644 new mode 100755 diff --git a/src/chicken_counter.egg-info/entry_points.txt b/src/chicken_counter.egg-info/entry_points.txt old mode 100644 new mode 100755 diff --git a/src/chicken_counter.egg-info/requires.txt b/src/chicken_counter.egg-info/requires.txt old mode 100644 new mode 100755 diff --git a/src/chicken_counter.egg-info/top_level.txt b/src/chicken_counter.egg-info/top_level.txt old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__init__.py b/src/chicken_counter/__init__.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/__init__.cpython-312.pyc b/src/chicken_counter/__pycache__/__init__.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/__init__.cpython-313.pyc b/src/chicken_counter/__pycache__/__init__.cpython-313.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc b/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc b/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/capture.cpython-312.pyc b/src/chicken_counter/__pycache__/capture.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/cli.cpython-312.pyc b/src/chicken_counter/__pycache__/cli.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/compress.cpython-312.pyc b/src/chicken_counter/__pycache__/compress.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/config.cpython-312.pyc b/src/chicken_counter/__pycache__/config.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/config.cpython-313.pyc b/src/chicken_counter/__pycache__/config.cpython-313.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/counting.cpython-312.pyc b/src/chicken_counter/__pycache__/counting.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/motion.cpython-312.pyc b/src/chicken_counter/__pycache__/motion.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/overlay.cpython-312.pyc b/src/chicken_counter/__pycache__/overlay.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc b/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/report.cpython-312.pyc b/src/chicken_counter/__pycache__/report.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/tracking.cpython-312.pyc b/src/chicken_counter/__pycache__/tracking.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/tracking.cpython-313.pyc b/src/chicken_counter/__pycache__/tracking.cpython-313.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/types.cpython-312.pyc b/src/chicken_counter/__pycache__/types.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/types.cpython-313.pyc b/src/chicken_counter/__pycache__/types.cpython-313.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc b/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/src/chicken_counter/batch_discovery.py b/src/chicken_counter/batch_discovery.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/batch_runner.py b/src/chicken_counter/batch_runner.py old mode 100644 new mode 100755 index 8453c0e..dac07f4 --- a/src/chicken_counter/batch_runner.py +++ b/src/chicken_counter/batch_runner.py @@ -14,7 +14,7 @@ from chicken_counter.tracking import DetectionTracker from chicken_counter.types import CameraBatchResult -def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: +def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose: bool = False, no_video: bool = False, show_progress: bool = False) -> 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 @@ -23,6 +23,8 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: print(f"[batch] starting daily run for {run_date}") print(f"[batch] input folder: {day_dir}") print(f"[batch] output folder: {output_dir}") + if no_video: + print("[batch] --no-video: skipping video output, overlay, and compression") discovery = discover_camera_videos(day_dir, settings) camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num) @@ -31,11 +33,12 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: camera_id for camera_id, _preset in camera_order if camera_id in discovery.found ) first_source = discovery.found[first_camera_id] + init_output_path = output_dir / f"{first_camera_id}_vis.mp4" if not no_video else None init_config = build_camera_config_from_batch( settings, first_camera_id, source=first_source, - output_path=output_dir / f"{first_camera_id}_vis.mp4", + output_path=init_output_path, checkpoint_dir=output_dir / "checkpoints" / first_camera_id, ) shared_tracker = DetectionTracker(init_config) @@ -58,7 +61,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: continue source_path = discovery.found[camera_id] - vis_path = output_dir / f"{camera_id}_vis.mp4" + vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None checkpoint_dir = output_dir / "checkpoints" / camera_id print(f"[batch] processing {camera_id} from {source_path.name}") @@ -69,7 +72,8 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: output_path=vis_path, checkpoint_dir=checkpoint_dir, ) - pipeline_result = run_pipeline(camera_config, tracker=shared_tracker) + camera_config.performance.verbose = verbose + pipeline_result = run_pipeline(camera_config, tracker=shared_tracker, show_progress=show_progress) camera_results.append( CameraBatchResult( camera_id=camera_id, @@ -82,6 +86,14 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: ) persist_batch_reports(run_date, camera_results, output_dir) + if no_video: + 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 + print("[batch] all cameras complete; starting compression") for item in camera_results: if item.skipped or item.pipeline is None: diff --git a/src/chicken_counter/capture.py b/src/chicken_counter/capture.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/cli.py b/src/chicken_counter/cli.py old mode 100644 new mode 100755 index 08c3911..9ce3fac --- a/src/chicken_counter/cli.py +++ b/src/chicken_counter/cli.py @@ -16,6 +16,8 @@ def build_parser() -> argparse.ArgumentParser: 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.") + run_parser.add_argument("--verbose", action="store_true", help="Enable debug-level logging.") + run_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.") 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.") @@ -23,6 +25,9 @@ def build_parser() -> argparse.ArgumentParser: "--date", help="Processing date folder in YYYY-MM-DD format. Defaults to today.", ) + batch_parser.add_argument("--verbose", action="store_true", help="Enable debug-level logging.") + batch_parser.add_argument("--no-video", action="store_true", help="Skip video output and compression for speed.") + batch_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.") parser.add_argument("--config", help=argparse.SUPPRESS) parser.add_argument("--camera-id", help=argparse.SUPPRESS) @@ -35,12 +40,13 @@ def main() -> None: if args.command == "batch": settings = load_batch_config(args.config) - run_daily_batch(settings, date=args.date) + run_daily_batch(settings, date=args.date, verbose=args.verbose, no_video=args.no_video, show_progress=args.progress_bar) return if args.command == "run": config = load_camera_config(args.config, args.camera_id) - result = run_pipeline(config) + config.performance.verbose = args.verbose + result = run_pipeline(config, show_progress=args.progress_bar) print( f"[done] camera={result.camera_id} total_entered={result.total_entered_count} " f"frames={result.frames_processed} reason={result.stopped_reason}" @@ -49,7 +55,8 @@ def main() -> None: if args.config: config = load_camera_config(args.config, args.camera_id) - result = run_pipeline(config) + config.performance.verbose = args.verbose + result = run_pipeline(config, show_progress=args.progress_bar) print( f"[done] camera={result.camera_id} total_entered={result.total_entered_count} " f"frames={result.frames_processed} reason={result.stopped_reason}" diff --git a/src/chicken_counter/compress.py b/src/chicken_counter/compress.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/config.py b/src/chicken_counter/config.py old mode 100644 new mode 100755 index 286a841..5d01be9 --- a/src/chicken_counter/config.py +++ b/src/chicken_counter/config.py @@ -168,6 +168,14 @@ class PerformanceConfig: half: bool = False overlay_buffer_reuse: bool = True inference_stride: int = 1 + verbose: bool = False + + +@dataclass +class StreamConfig: + enabled: bool = False + shm_dir: str = "/dev/shm" + interval_frames: int = 5 @dataclass @@ -193,6 +201,7 @@ class CameraConfig: performance: PerformanceConfig feedback: FeedbackConfig detection_zone: DetectionZoneConfig = field(default_factory=DetectionZoneConfig) + stream: StreamConfig = field(default_factory=StreamConfig) @dataclass @@ -269,6 +278,7 @@ def _build_camera_config(raw: dict[str, Any]) -> CameraConfig: performance=PerformanceConfig(**raw.get("performance", {})), feedback=FeedbackConfig(**raw.get("feedback", {})), detection_zone=DetectionZoneConfig(**raw.get("detection_zone", {})), + stream=StreamConfig(**raw.get("stream", {})), ) @@ -338,7 +348,7 @@ def build_camera_config_from_batch( camera_id: str, *, source: str | Path, - output_path: str | Path, + output_path: str | Path | None, checkpoint_dir: str | Path, ) -> CameraConfig: if camera_id not in settings.cameras: @@ -378,13 +388,13 @@ def build_camera_config_from_batch( raw["overlay"]["count_anchor"] = list(preset.count_anchor) raw.setdefault("display", {}) - raw["display"]["output_path"] = str(output_path) + raw["display"]["output_path"] = str(output_path) if output_path is not None else None 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"]["save_images"] = output_path is not None raw["feedback"]["image_output_dir"] = str(checkpoint_dir) raw["feedback"]["log_to_terminal"] = True diff --git a/src/chicken_counter/counting.py b/src/chicken_counter/counting.py old mode 100644 new mode 100755 index 5a7f530..0a170e4 --- a/src/chicken_counter/counting.py +++ b/src/chicken_counter/counting.py @@ -20,6 +20,8 @@ class CountingZone: track_buffer: int, min_box_area_px: int = 0, validate_while_inside: bool = True, + *, + verbose: bool = False, ) -> None: self.roi = roi self.gate = gate @@ -28,6 +30,7 @@ class CountingZone: 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.verbose = verbose self.inside_box_count = 0 self.total_entered_count = 0 self.histories: dict[int, deque[tuple[int, int]]] = defaultdict(lambda: deque(maxlen=trail_length)) @@ -86,6 +89,15 @@ class CountingZone: sequence_number=self.sequence_numbers_by_track_id[track.track_id], ) ) + if self.verbose: + x1, y1, x2, y2 = track.bbox_xyxy + bbox_area = max(0, x2 - x1) * max(0, y2 - y1) + overlap = self._bbox_overlap_ratio(track) + print( + f"[count] track={track.track_id} seq=#{self.total_entered_count} " + f"frame={frame_index} area={bbox_area} overlap={overlap:.2f} " + f"conf={track.confidence:.2f} centroid={track.centroid}" + ) self.inside_box_count = len(inside_ids) self.current_inside_ids = inside_ids diff --git a/src/chicken_counter/motion.py b/src/chicken_counter/motion.py old mode 100644 new mode 100755 index a4ee1a3..d97fe94 --- a/src/chicken_counter/motion.py +++ b/src/chicken_counter/motion.py @@ -10,12 +10,14 @@ from chicken_counter.types import MotionState, TrackObservation class BackwardMotionDetector: - def __init__(self, config: MotionConfig, roi: RoiConfig) -> None: + def __init__(self, config: MotionConfig, roi: RoiConfig, *, verbose: bool = False) -> None: self.config = config self.roi = roi self.previous_gray: np.ndarray | None = None self.state = MotionState() self._roi_bounds = self._compute_roi_bounds() + self.verbose = verbose + self._update_count = 0 def _compute_roi_bounds(self) -> tuple[int, int, int, int]: x_values = [point[0] for point in self.roi.points] @@ -94,6 +96,25 @@ class BackwardMotionDetector: self.state.backward_active = False if self.state.consecutive_reverse_frames >= self.config.debounce_frames: + was_active = self.state.backward_active self.state.backward_active = True + if self.verbose and not was_active: + print( + f"[motion #{self._update_count}] BACKWARD TRIGGERED! " + f"smoothed_speed={self.state.smoothed_speed:.1f} " + f"consecutive={self.state.consecutive_reverse_frames}" + ) + + if self.verbose: + self._update_count += 1 + features_found = len(valid_prev) if points is not None and self.previous_gray is not None else 0 + print( + f"[motion #{self._update_count}] " + f"features={features_found} " + f"median_speed={median_axis_speed:.1f} " + f"smoothed_speed={self.state.smoothed_speed:.1f} " + f"consecutive_rev={self.state.consecutive_reverse_frames} " + f"backward={self.state.backward_active}" + ) return self.state diff --git a/src/chicken_counter/overlay.py b/src/chicken_counter/overlay.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/pipeline.py b/src/chicken_counter/pipeline.py old mode 100644 new mode 100755 index 6633cfe..86ecb07 --- a/src/chicken_counter/pipeline.py +++ b/src/chicken_counter/pipeline.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +import shutil +import sys import time from dataclasses import dataclass from pathlib import Path @@ -19,6 +22,62 @@ from chicken_counter.types import FrameResult, PipelineResult, TrackObservation from chicken_counter.video_writer import make_video_writer +class _ProgressBar: + def __init__(self, total: int | None, width: int = 30) -> None: + self._total = total + self._width = width + self._last_render = 0.0 + self._last_line_len = 0 + self._checkpoint_msg = "" + + def _build_checkpoint_suffix(self) -> str: + if not self._checkpoint_msg: + return "" + msg = self._checkpoint_msg + self._checkpoint_msg = "" + return f" [{msg}]" + + def render(self, frame_index: int, elapsed: float, fps: float, inside: int, total_entered: int, backward: bool) -> None: + now = time.monotonic() + if now - self._last_render < 0.2 and frame_index > 1 and not self._checkpoint_msg: + return + self._last_render = now + + elapsed_str = _format_duration(elapsed) + checkpoint_suffix = self._build_checkpoint_suffix() + if self._total: + pct = min(100, frame_index * 100 // self._total) + filled = self._width * pct // 100 + bar = "[" + "=" * filled + ">" + " " * (self._width - filled) + "]" + eta_seconds = (self._total - frame_index) / fps if fps > 0 else 0.0 + eta_str = _format_duration(eta_seconds) + status = "backward" if backward else "running" + line = ( + f"\r{bar} {pct:3d}% {frame_index}/{self._total} " + f"{elapsed_str} eta={eta_str} {fps:.1f}fps " + f"count={inside}/{total_entered} {status}{checkpoint_suffix}" + ) + else: + status = "backward" if backward else "running" + line = ( + f"\rframe={frame_index} {elapsed_str} {fps:.1f}fps " + f"count={inside}/{total_entered} {status}{checkpoint_suffix}" + ) + + pad = max(0, self._last_line_len - len(line)) + self._last_line_len = len(line) + sys.stderr.write(line + " " * pad) + sys.stderr.flush() + + def emit(self, message: str) -> None: + self._checkpoint_msg = message + self._last_render = 0.0 + + def finish(self) -> None: + sys.stderr.write("\n") + sys.stderr.flush() + + @dataclass class PipelineArtifacts: capture: cv2.VideoCapture @@ -48,8 +107,9 @@ def build_pipeline( track_buffer=config.tracker.track_buffer, min_box_area_px=config.detection.min_box_area_px, validate_while_inside=config.detection.validate_while_inside, + verbose=config.performance.verbose, ) - motion_detector = BackwardMotionDetector(config.motion, config.roi) + motion_detector = BackwardMotionDetector(config.motion, config.roi, verbose=config.performance.verbose) width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) @@ -80,6 +140,12 @@ def build_pipeline( codec_preference=config.display.codec_preference, ) + if config.stream.enabled: + cam_dir = Path(config.stream.shm_dir) / f"chicken_counter_{config.camera_id}" + if cam_dir.exists(): + shutil.rmtree(str(cam_dir)) + print(f"[stream] cleaned {cam_dir}") + return PipelineArtifacts( capture=capture, tracker=tracker, @@ -97,6 +163,8 @@ def build_pipeline( def run_pipeline( config: CameraConfig, tracker: DetectionTracker | None = None, + *, + show_progress: bool = False, ) -> PipelineResult: if tracker is not None: tracker.config = config @@ -114,26 +182,51 @@ def run_pipeline( last_tracks: list[TrackObservation] = [] stopped_reason = "eof" user_quit = False - + verbose = config.performance.verbose + cumulative_timings: dict[str, float] = {"read": 0.0, "infer": 0.0, "motion": 0.0, "count": 0.0, "overlay": 0.0, "write": 0.0} + timed_frames = 0 + verbose_interval = max(1, inference_stride * 30) + progress = _ProgressBar(artifacts.total_source_frames) if show_progress else None + try: while True: + if verbose: + t0 = time.monotonic() + ok, frame = artifacts.capture.read() if not ok: break frame_index += 1 + + if verbose: + t_read = time.monotonic() + if frame_index % inference_stride == 0 or not last_tracks: last_tracks = artifacts.tracker.infer( frame, crop_rect=artifacts.detection_zone_rect, ) + + if verbose and frame_index % inference_stride == 0: + t_infer = time.monotonic() + tracks = last_tracks motion_state = artifacts.motion_detector.update(frame, tracks, frame_index) + + if verbose: + t_motion = time.monotonic() + count_events = artifacts.counting_zone.update( tracks, frame_index, counting_paused=motion_state.backward_active, ) + + if verbose: + t_count = time.monotonic() + + needs_overlay = config.display.show_window or artifacts.writer is not None or config.stream.enabled annotated = draw_overlay( frame, config, @@ -142,7 +235,10 @@ def run_pipeline( motion_state, frame_index=frame_index, buffer=artifacts.overlay_buffer, - ) + ) if needs_overlay else frame + + if verbose: + t_overlay = time.monotonic() result = FrameResult( frame_index=frame_index, @@ -152,12 +248,58 @@ def run_pipeline( motion_state=motion_state, count_events=count_events, ) - _consume_result(config, artifacts, annotated, result) + _consume_result(config, artifacts, annotated, result, progress) last_annotated = annotated + if config.stream.enabled and frame_index % max(1, config.stream.interval_frames) == 0: + _write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result) + + if verbose: + t_write = time.monotonic() + if frame_index % inference_stride == 0: + cumulative_timings["read"] += (t_read - t0) * 1000 + cumulative_timings["infer"] += (t_infer - t_read) * 1000 + cumulative_timings["motion"] += (t_motion - t_infer) * 1000 + cumulative_timings["count"] += (t_count - t_motion) * 1000 + cumulative_timings["overlay"] += (t_overlay - t_count) * 1000 + cumulative_timings["write"] += (t_write - t_overlay) * 1000 + timed_frames += 1 + + if frame_index % verbose_interval == 0 and timed_frames > 0: + n = timed_frames + print( + f"[debug ~{verbose_interval}f avg ms] " + f"read={cumulative_timings['read']/n:.1f} " + f"infer={cumulative_timings['infer']/n:.1f} " + f"motion={cumulative_timings['motion']/n:.1f} " + f"count={cumulative_timings['count']/n:.1f} " + f"overlay={cumulative_timings['overlay']/n:.1f} " + f"write={cumulative_timings['write']/n:.1f} " + f"tracks={len(tracks)} " + f"inside={artifacts.counting_zone.inside_box_count} " + f"total={artifacts.counting_zone.total_entered_count} " + f"motion_speed={motion_state.smoothed_speed:.1f} " + f"backward={motion_state.backward_active}" + ) + cumulative_timings = {k: 0.0 for k in cumulative_timings} + timed_frames = 0 + + if progress is not None: + elapsed = time.monotonic() - artifacts.run_start_time + fps = frame_index / elapsed if elapsed > 0 else 0.0 + progress.render( + frame_index, elapsed, fps, + artifacts.counting_zone.inside_box_count, + artifacts.counting_zone.total_entered_count, + motion_state.backward_active, + ) + if motion_state.backward_active: stopped_reason = "backward" - print(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline") + if progress is not None: + progress.emit(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline") + else: + 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: @@ -183,6 +325,9 @@ def run_pipeline( if user_quit: stopped_reason = "user_quit" + if progress is not None: + progress.finish() + return PipelineResult( camera_id=config.camera_id, total_entered_count=artifacts.counting_zone.total_entered_count, @@ -199,6 +344,7 @@ def _consume_result( artifacts: PipelineArtifacts, annotated, result: FrameResult, + progress: _ProgressBar | None = None, ) -> None: if config.display.show_window: cv2.imshow(config.display.window_name, annotated) @@ -206,13 +352,42 @@ def _consume_result( 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 config.performance.verbose: + msg = ( + 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 progress is not None: + progress.emit(msg) + else: + print(msg) if _should_emit_feedback(config, result.frame_index): - _emit_periodic_feedback(config, artifacts, annotated, result) + _emit_periodic_feedback(config, artifacts, annotated, result, progress) + + +def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult) -> None: + cam_dir = Path(shm_dir) / f"chicken_counter_{camera_id}" + cam_dir.mkdir(parents=True, exist_ok=True) + + jpg_path = cam_dir / "frame.jpg" + tmp_path = cam_dir / ".frame_tmp.jpg" + cv2.imwrite(str(tmp_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 75]) + tmp_path.replace(jpg_path) + + stats = { + "frame_index": result.frame_index, + "inside_box_count": result.inside_box_count, + "total_entered_count": result.total_entered_count, + "track_count": len(result.tracks), + "backward_active": result.motion_state.backward_active, + "smoothed_speed": round(result.motion_state.smoothed_speed, 1), + "count_events": len(result.count_events), + } + stats_path = cam_dir / "stats.json" + stats_tmp = cam_dir / ".stats_tmp.json" + stats_tmp.write_text(json.dumps(stats), encoding="utf-8") + stats_tmp.replace(stats_path) def _should_emit_feedback(config: CameraConfig, frame_index: int) -> bool: @@ -238,15 +413,16 @@ def _emit_periodic_feedback( artifacts: PipelineArtifacts, annotated, result: FrameResult, + progress: _ProgressBar | None = None, ) -> 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}" + progress_text = f"frame={result.frame_index}" if artifacts.total_source_frames: - progress = f"frame={result.frame_index}/{artifacts.total_source_frames}" + progress_text = f"frame={result.frame_index}/{artifacts.total_source_frames}" eta_text = "" if artifacts.total_source_frames and fps > 0: @@ -254,13 +430,17 @@ def _emit_periodic_feedback( eta_seconds = remaining_frames / fps eta_text = f" eta={_format_duration(eta_seconds)}" - print( - f"[checkpoint] {progress} elapsed={_format_duration(elapsed)} " + msg = ( + f"[checkpoint] {progress_text} 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 progress is not None: + progress.emit(msg) + else: + print(f"\r\033[K{msg}") if config.feedback.save_images: output_dir = Path(config.feedback.image_output_dir) diff --git a/src/chicken_counter/report.py b/src/chicken_counter/report.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/tracking.py b/src/chicken_counter/tracking.py old mode 100644 new mode 100755 index ad1d057..db55a7f --- a/src/chicken_counter/tracking.py +++ b/src/chicken_counter/tracking.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from pathlib import Path import numpy as np @@ -18,6 +19,8 @@ class DetectionTracker: 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)) + self.verbose = config.performance.verbose + self._infer_count = 0 print( f"[model] loaded {self.model_kind} from {model_path} " f"(imgsz={config.detection.imgsz}, device={config.detection.device})" @@ -57,9 +60,18 @@ class DetectionTracker: if self.model_kind != "engine" and self.config.performance.half: track_kwargs["half"] = True + if self.verbose: + t_start = time.monotonic() + results = self.model.track(**track_kwargs) + if self.verbose: + t_track = time.monotonic() + self._infer_count += 1 + if not results: + if self.verbose: + print(f"[tracker #{self._infer_count}] no detections (infer={t_track - t_start:.1f}ms)") return [] result = results[0] @@ -115,6 +127,17 @@ class DetectionTracker: ) ) + if self.verbose: + unique_ids = sorted(set(t.track_id for t in tracks)) + confs = [t.confidence for t in tracks] if tracks else [0] + print( + f"[tracker #{self._infer_count}] " + f"det={len(tracks)} unique={len(unique_ids)} " + f"conf=[{min(confs):.2f}..{max(confs):.2f}] " + f"ids={unique_ids[:10]}{'+' if len(unique_ids) > 10 else ''} " + f"infer={t_track - t_start:.1f}ms" + ) + return tracks @staticmethod diff --git a/src/chicken_counter/types.py b/src/chicken_counter/types.py old mode 100644 new mode 100755 diff --git a/src/chicken_counter/video_writer.py b/src/chicken_counter/video_writer.py old mode 100644 new mode 100755 diff --git a/tests/__pycache__/test_tracking.cpython-312.pyc b/tests/__pycache__/test_tracking.cpython-312.pyc old mode 100644 new mode 100755 diff --git a/tests/test_tracking.py b/tests/test_tracking.py old mode 100644 new mode 100755