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
+
+
+
+
+
+
🐔 Chicken Counter
+
--
+
--
+
--
+
--
+
--
+
--
+
+
+
+
+waiting for pipeline...
+ ago
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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