Merge pull request 'Optimize python biar bisa jalan di Jetson Orin Nano' (#1) from dsutanto/chicken-counting-sukawarna-det:main into main

Reviewed-on: zakaria/chicken-counting-sukawarna-det#1
This commit is contained in:
zakaria committed 2026-07-21 16:25:25 +07:00
commit 69751c9c1d
54 files changed
+789 -28

No files matched your search

+220
View File
@@ -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
Regular → Executable
View File
File mode changed.
+11
View File
@@ -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
Binary file not shown.
Binary file not shown.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
Regular → Executable
+7 -3
View File
@@ -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
View File
File mode changed.
Executable
+261
View File
@@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Chicken Counter — Live Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden}
#app{display:flex;height:100vh}
#sidebar{width:260px;background:#16161e;padding:16px;overflow-y:auto;flex-shrink:0}
#sidebar h1{font-size:18px;color:#80dc5a;margin-bottom:16px}
#sidebar .stat{margin-bottom:12px}
#sidebar .stat label{display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px}
#sidebar .stat .value{font-size:22px;font-weight:700;color:#e0e0e0}
#sidebar .stat .value.warn{color:#ff9f43}
#sidebar .stat .value.good{color:#80dc5a}
#cam-list{list-style:none;margin-top:16px}
#cam-list li{padding:8px 10px;margin:2px 0;border-radius:6px;cursor:pointer;font-size:13px;transition:background .2s}
#cam-list li:hover{background:#222}
#cam-list li.active{background:#1a3a2a;color:#80dc5a;font-weight:700}
#cam-list li .cam-badge{float:right;font-size:10px;padding:1px 6px;border-radius:8px;background:#222;color:#888}
#cam-list li.active .cam-badge{background:#2a5a3a;color:#80dc5a}
#main{flex:1;display:flex;flex-direction:column}
#frame-container{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative}
#frame-img{max-width:100%;max-height:100%;object-fit:contain}
#no-frame{color:#555;font-size:18px}
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:#16161e;font-size:12px}
#top-bar .refresh{color:#888}
#top-bar .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
#top-bar .status-dot.online{background:#80dc5a;box-shadow:0 0 6px #80dc5a}
#top-bar .status-dot.offline{background:#555}
.refresh-btn{padding:4px 12px;border-radius:4px;background:#222;border:1px solid #444;color:#ccc;cursor:pointer;font-size:11px}
.refresh-btn:hover{background:#333}
</style>
</head>
<body>
<div id="app">
<div id="sidebar">
<h1>&#x1f414; Chicken Counter</h1>
<div class="stat"><label>Total Entered</label><div class="value good" id="stat-total">--</div></div>
<div class="stat"><label>Inside Box</label><div class="value" id="stat-inside">--</div></div>
<div class="stat"><label>Tracks</label><div class="value" id="stat-tracks">--</div></div>
<div class="stat"><label>Frame</label><div class="value" id="stat-frame">--</div></div>
<div class="stat"><label>Motion Speed</label><div class="value" id="stat-speed">--</div></div>
<div class="stat"><label>Status</label><div class="value" id="stat-status">--</div></div>
<ul id="cam-list"></ul>
</div>
<div id="main">
<div id="top-bar">
<span><span class="status-dot" id="status-dot"></span><span id="status-text">waiting for pipeline...</span></span>
<span><span class="refresh" id="refresh-counter"></span> ago &nbsp;
<button class="refresh-btn" onclick="load()">&#x21bb; Refresh</button></span>
</div>
<div id="frame-container">
<img id="frame-img" alt="live stream">
<div id="no-frame"></div>
</div>
</div>
</div>
<script>
var POLL_MS = %%POLL_MS%%;
var SHM = "%%SHM_DIR%%";
var cameras = [];
var activeCam = null;
var lastUpdate = 0;
var img = document.getElementById("frame-img");
var noFrame = document.getElementById("no-frame");
function loadCameras() {{
fetch("/api/cameras").then(r => r.json()).then(data => {{
cameras = data.cameras || [];
renderCamList();
if (cameras.length && !activeCam) selectCam(cameras[0]);
if (!cameras.length) {{ noFrame.textContent = "No cameras found in " + SHM; img.style.display = "none"; }}
}});
}}
function renderCamList() {{
var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) {{
return '<li class="' + (c === activeCam ? "active" : "") + '" onclick="selectCam(\'' + c + '\')">' +
c + '<span class="cam-badge">&#x25b6;</span></li>';
}}).join("");
}}
function selectCam(id) {{
activeCam = id;
renderCamList();
load();
}}
function load() {{
if (!activeCam) return;
var t = Date.now();
img.src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(function(r) {{
if (!r.ok) {{ setOffline(); return; }}
return r.json();
}}).then(function(s) {{
if (!s) return;
lastUpdate = Date.now();
document.getElementById("stat-total").textContent = s.total_entered_count;
document.getElementById("stat-inside").textContent = s.inside_box_count;
document.getElementById("stat-tracks").textContent = s.track_count;
document.getElementById("stat-frame").textContent = s.frame_index;
document.getElementById("stat-speed").textContent = s.smoothed_speed;
document.getElementById("stat-status").textContent = s.backward_active ? "BACKWARD STOP" : "RUNNING";
var el = document.getElementById("stat-status");
el.className = "value" + (s.backward_active ? " warn" : " good");
document.getElementById("status-dot").className = "status-dot online";
document.getElementById("status-text").textContent = activeCam + " \u2022 frame " + s.frame_index;
}});
}}
function setOffline() {{
document.getElementById("status-dot").className = "status-dot offline";
document.getElementById("status-text").textContent = activeCam + " \u2022 offline";
}}
function updateRefresh() {{
var ago = Math.round((Date.now() - lastUpdate) / 1000);
document.getElementById("refresh-counter").textContent = ago + "s";
}}
img.onerror = function() {{ img.style.display = "none"; noFrame.style.display = "block"; noFrame.textContent = "Waiting for frame..."; }};
img.onload = function() {{ img.style.display = "block"; noFrame.style.display = "none"; }};
setInterval(function() {{ load(); }}, POLL_MS);
setInterval(loadCameras, 3000);
setInterval(updateRefresh, 1000);
loadCameras();
</script>
</body>
</html>"""
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()
Regular → Executable
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
Regular → Executable
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
+16 -4
View File
@@ -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:
Regular → Executable
View File
File mode changed.
Regular → Executable
+10 -3
View File
@@ -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}"
Regular → Executable
View File
File mode changed.
Regular → Executable
+13 -3
View File
@@ -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
Regular → Executable
+12
View File
@@ -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
Regular → Executable
+22 -1
View File
@@ -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
Regular → Executable
View File
File mode changed.
Regular → Executable
+194 -14
View File
@@ -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)
Regular → Executable
View File
File mode changed.
Regular → Executable
+23
View File
@@ -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
Regular → Executable
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
Regular → Executable
View File
File mode changed.