commit fe220504fdbbb193755ff960920cae68bcf0614c Author: dsutanto Date: Wed Jul 29 14:04:26 2026 +0700 Recount Dashboard diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f58f38d --- /dev/null +++ b/.gitignore @@ -0,0 +1,227 @@ +# 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 + +# csv and db +*.csv +*.db +batch_crossings.csv +bytetrack_counter.db + diff --git a/.oc/session.json b/.oc/session.json new file mode 100644 index 0000000..b785756 --- /dev/null +++ b/.oc/session.json @@ -0,0 +1,5487 @@ +{ + "info": { + "id": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "slug": "tidy-meadow", + "projectID": "45e61044497a123d750492db8a5bb735bc6664ea", + "directory": "/opt/bytetrack-counter", + "path": "", + "title": "Add version printing to compiled binary", + "agent": "build", + "model": { + "id": "deepseek-v4-pro", + "providerID": "deepseek" + }, + "version": "1.17.11", + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + }, + "cost": 0.10242953700000004, + "tokens": { + "input": 186493, + "output": 8386, + "reasoning": 6093, + "cache": { + "read": 2402304, + "write": 0 + } + }, + "time": { + "created": 1783308022494, + "updated": 1785285181158 + } + }, + "messages": [ + { + "info": { + "role": "user", + "time": { + "created": 1783308022565 + }, + "agent": "build", + "model": { + "providerID": "deepseek", + "modelID": "deepseek-v4-pro" + }, + "summary": { + "diffs": [] + }, + "id": "msg_f3570f325001LLIc39xgDMbjmi", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "type": "text", + "text": "add feature to print version of compiled binary", + "id": "prt_f3570f328001cniFC5kj9CExRl", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_f3570f325001LLIc39xgDMbjmi" + } + ] + }, + { + "info": { + "parentID": "msg_f3570f325001LLIc39xgDMbjmi", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1783308022603, + "completed": 1783308053394 + }, + "error": { + "name": "MessageAbortedError", + "data": { + "message": "Aborted" + } + }, + "id": "msg_f3570f34b001YbHLZypL941I6t", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "8dd1d5b751ba93c645ddb4a0e14c3d75f17a6197", + "type": "step-start", + "id": "prt_f3570f728001vJVIia8AVAav6d", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_f3570f34b001YbHLZypL941I6t" + }, + { + "type": "reasoning", + "text": "The user wants to add a feature to print the version of a compiled binary. Let me explore the codebase to understand the project structure, find where the main entry point is, and understand how the binary is built.", + "time": { + "start": 1783308024338, + "end": 1783308025454 + }, + "id": "prt_f3570fa12001ob70CgaXuV5ByH", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_f3570f34b001YbHLZypL941I6t" + }, + { + "type": "tool", + "tool": "task", + "callID": "call_00_uxwAWf2fzO77kQGQlsAz6862", + "state": { + "title": "Explore codebase structure", + "metadata": { + "parentSessionId": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "sessionId": "ses_0ca8ef7acffe5gGEvQ17HVlkkJ", + "model": { + "modelID": "deepseek-v4-pro", + "providerID": "deepseek" + }, + "interrupted": true + }, + "status": "error", + "input": { + "description": "Explore codebase structure", + "prompt": "Explore the codebase at /opt/bytetrack-counter to understand:\n1. What kind of project this is (language, build system, framework)\n2. Where the main entry point is\n3. How the binary is compiled/built\n4. Whether there's already any version or build info mechanism\n5. Look at build configuration files (Cargo.toml, CMakeLists.txt, Makefile, package.json, go.mod, etc.)\n\nReturn: project type, language, build system, main entry point file(s), build configuration content, and any existing version info mechanisms.", + "subagent_type": "explore" + }, + "time": { + "start": 1783308027995, + "end": 1783308053390 + }, + "error": "Tool execution aborted" + }, + "id": "prt_f3570fe77001DQAlRdM0zmtZUv", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_f3570f34b001YbHLZypL941I6t" + }, + { + "type": "patch", + "hash": "8dd1d5b751ba93c645ddb4a0e14c3d75f17a6197", + "files": [ + "/opt/bytetrack-counter/bytetrack_counter.db" + ], + "id": "prt_f35716a860013zXLkb7kaQHKQs", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_f3570f34b001YbHLZypL941I6t" + } + ] + }, + { + "info": { + "role": "user", + "time": { + "created": 1785284228706 + }, + "agent": "build", + "model": { + "providerID": "deepseek", + "modelID": "deepseek-v4-pro" + }, + "summary": { + "diffs": [ + { + "file": "AGENTS.md", + "patch": "Index: AGENTS.md\n===================================================================\n--- AGENTS.md\t\n+++ AGENTS.md\t\n@@ -0,0 +1,51 @@\n+# AGENTS.md — bytetrack-counter\n+\n+## Architecture\n+\n+- **Edge AI counter**: RTSP camera → YOLO RKNN (NPU) → tracking → line-crossing → SQLite + JSON state → Flask dashboard.\n+- **3 counter scripts**, only 1 deployed:\n+ - `counter_live.py` — Jetson TensorRT variant (CUDA, NOT used on RK3588).\n+ - `counter_live_rknn.py` — RK3588 with simple IoU tracker.\n+ - **`counter_live_rknn_bytetrack.py`** — RK3588 with ByteTrack. **This is what systemd runs.**\n+- `batch_store.py` — shared SQLite persistence + batch state machine.\n+- `counter_dashboard.py` — Flask dashboard on port 5000, same DB.\n+\n+## No build / test / lint\n+\n+There is no build system, no test framework, no linter config, no typechecker. \n+Do not try to run `pytest`, `ruff`, `mypy`, etc. — they don't exist here.\n+\n+## How to run\n+\n+```bash\n+# Copy env (required, .env is gitignored)\n+cp config.env.example .env\n+\n+# Venv (must use system-site-packages for RKNN toolkit)\n+python3 -m venv --system-site-packages venv\n+source venv/bin/pip install -r requirements.txt\n+\n+# Run counter (RK3588 only — needs rknn-toolkit-lite2 & RKNN model)\n+PYTHONNOUSERSITE=1 venv/bin/python counter_live_rknn_bytetrack.py\n+\n+# Run dashboard\n+PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py\n+```\n+\n+## Key environment & install quirks\n+\n+- **`PYTHONNOUSERSITE=1`** is mandatory when running from the venv — without it, system/user packages leak in.\n+- **`.env` is gitignored** — always copy from `config.env.example` first.\n+- **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility.\n+- **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`.\n+- Service user is **`root`**, not `jetson` (despite README saying otherwise).\n+- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`.\n+- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.\n+\n+## Code conventions\n+\n+- All config lives in `.env` (dotenv), read via `os.getenv()` at module top-level in each script.\n+- The 3 counter scripts duplicate ~80% of each other (drawing helpers, batch loop, etc). Changes to logic may need replication across variants.\n+- `batch_store.py` has its own threading (cutoff watcher, batch timeout timer) — thread safety is via a single `state_lock`.\n+- The dashboard re-creates DB tables on startup (`_ensure_db()`) independently from `batch_store.py`.\n+- No formal version tracking exists anywhere in this codebase.\n", + "additions": 51, + "deletions": 0, + "status": "added" + } + ] + }, + "id": "msg_fab3b7662001asy7eTmLMm4mEq", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "type": "text", + "text": "Create or update `AGENTS.md` for this repository.\n\nThe goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: \"Would an agent likely miss this without help?\" If not, leave it out.\n\nUser-provided focus or constraints (honor these):\n\n\n## How to investigate\n\nRead the highest-value sources first:\n- `README*`, root manifests, workspace config, lockfiles\n- build, test, lint, formatter, typecheck, and codegen config\n- CI workflows and pre-commit / task runner config\n- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)\n- repo-local OpenCode config such as `opencode.json`\n\nIf architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.\n\nPrefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.\n\n## What to extract\n\nLook for the highest-signal facts for an agent working in this repo:\n- exact developer commands, especially non-obvious ones\n- how to run a single test, a single package, or a focused verification step\n- required command order when it matters, such as `lint -> typecheck -> test`\n- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints\n- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow\n- repo-specific style or workflow conventions that differ from defaults\n- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites\n- important constraints from existing instruction files worth preserving\n\nGood `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.\n\n## Questions\n\nOnly ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.\n\nGood questions:\n- undocumented team conventions\n- branch / PR / release expectations\n- missing setup or test prerequisites that are known but not written down\n\nDo not ask about anything the repo already makes clear.\n\n## Writing rules\n\nInclude only high-signal, repo-specific guidance such as:\n- exact commands and shortcuts the agent would otherwise guess wrong\n- architecture notes that are not obvious from filenames\n- conventions that differ from language or framework defaults\n- setup requirements, environment quirks, and operational gotchas\n- references to existing instruction sources that matter\n\nExclude:\n- generic software advice\n- long tutorials or exhaustive file trees\n- obvious language conventions\n- speculative claims or anything you could not verify\n- content better stored in another file referenced via `opencode.json` `instructions`\n\nWhen in doubt, omit.\n\nPrefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.\n\nIf `AGENTS.md` already exists at `/opt/bytetrack-counter`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.", + "id": "prt_fab3b7667001OXhdyCDbKwqx4c", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b7662001asy7eTmLMm4mEq" + } + ] + }, + { + "info": { + "parentID": "msg_fab3b7662001asy7eTmLMm4mEq", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.00354438, + "tokens": { + "total": 9821, + "input": 7670, + "output": 212, + "reasoning": 19, + "cache": { + "write": 0, + "read": 1920 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785284228748, + "completed": 1785284234332 + }, + "finish": "tool-calls", + "id": "msg_fab3b768b0016CKiVP10E6vAsf", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-start", + "id": "prt_fab3b8129001XaHaEJfKlPR5qP", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + }, + { + "type": "reasoning", + "text": "Let me explore the codebase to understand the project and then create/update AGENTS.md.", + "time": { + "start": 1785284232274, + "end": 1785284232833 + }, + "id": "prt_fab3b8452001stzG28Qh2k3CUZ", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_00_jK4A2K3Ongbsd2KIA1vj5882", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter" + }, + "output": "/opt/bytetrack-counter\ndirectory\n\n__pycache__/\n.env\n.git/\n.gitignore\nbatch_crossings.csv\nbatch_store.py\nbytetrack_counter.db\nbytetrack-counter-dashboard.service\nbytetrack-counter.service\nconfig.env.example\ncounter_dashboard.py\ncounter_live_rknn_bytetrack.py\ncounter_live_rknn.py\ncounter_live.py\nDEPLOY.md\nenv/\ninstall-services.sh\nREADME.md\nrequirements.txt\nsetup-venv.sh\ntemplates/\nuninstall-services.sh\nvenv/\n\n(23 entries)\n", + "metadata": { + "preview": "__pycache__/\n.env\n.git/\n.gitignore\nbatch_crossings.csv\nbatch_store.py\nbytetrack_counter.db\nbytetrack-counter-dashboard.service\nbytetrack-counter.service\nconfig.env.example\ncounter_dashboard.py\ncounter_live_rknn_bytetrack.py\ncounter_live_rknn.py\ncounter_live.py\nDEPLOY.md\nenv/\ninstall-services.sh\nREADME.md\nrequirements.txt\nsetup-venv.sh", + "truncated": false, + "loaded": [], + "display": { + "type": "directory", + "path": "/opt/bytetrack-counter", + "entries": [ + "__pycache__/", + ".env", + ".git/", + ".gitignore", + "batch_crossings.csv", + "batch_store.py", + "bytetrack_counter.db", + "bytetrack-counter-dashboard.service", + "bytetrack-counter.service", + "config.env.example", + "counter_dashboard.py", + "counter_live_rknn_bytetrack.py", + "counter_live_rknn.py", + "counter_live.py", + "DEPLOY.md", + "env/", + "install-services.sh", + "README.md", + "requirements.txt", + "setup-venv.sh", + "templates/", + "uninstall-services.sh", + "venv/" + ], + "offset": 1, + "totalEntries": 23, + "truncated": false + } + }, + "title": "", + "time": { + "start": 1785284232983, + "end": 1785284233008 + } + }, + "id": "prt_fab3b8689001qukGnyRb4iq1Wi", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + }, + { + "type": "tool", + "tool": "glob", + "callID": "call_01_KmjrUVbZ4X4d86sYSS0Z3134", + "state": { + "status": "completed", + "input": { + "pattern": "**/AGENTS.md", + "path": "/opt/bytetrack-counter" + }, + "output": "No files found", + "metadata": { + "count": 0, + "truncated": false + }, + "title": "", + "time": { + "start": 1785284233315, + "end": 1785284233359 + } + }, + "id": "prt_fab3b875f001giby1E7bbNhX8W", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + }, + { + "type": "tool", + "tool": "glob", + "callID": "call_02_RlzVEwcdJxWQh4j5pGt87999", + "state": { + "status": "completed", + "input": { + "pattern": "**/CLAUDE.md", + "path": "/opt/bytetrack-counter" + }, + "output": "No files found", + "metadata": { + "count": 0, + "truncated": false + }, + "title": "", + "time": { + "start": 1785284233696, + "end": 1785284233757 + } + }, + "id": "prt_fab3b88c0001bu28nlQR21fNPW", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + }, + { + "type": "tool", + "tool": "glob", + "callID": "call_03_iaTsWXV0yjAFZjEUANHm3432", + "state": { + "status": "completed", + "input": { + "pattern": "**/opencode.json", + "path": "/opt/bytetrack-counter" + }, + "output": "No files found", + "metadata": { + "count": 0, + "truncated": false + }, + "title": "", + "time": { + "start": 1785284234073, + "end": 1785284234118 + } + }, + "id": "prt_fab3b8a3f0018UdBO66GWB5QPd", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + }, + { + "reason": "tool-calls", + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-finish", + "tokens": { + "total": 9821, + "input": 7670, + "output": 212, + "reasoning": 19, + "cache": { + "write": 0, + "read": 1920 + } + }, + "cost": 0.00354438, + "id": "prt_fab3b8c01001oRRWNmyUZU2kVd", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b768b0016CKiVP10E6vAsf" + } + ] + }, + { + "info": { + "parentID": "msg_fab3b7662001asy7eTmLMm4mEq", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.000391964, + "tokens": { + "total": 10274, + "input": 272, + "output": 248, + "reasoning": 26, + "cache": { + "write": 0, + "read": 9728 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785284234339, + "completed": 1785284237633 + }, + "finish": "tool-calls", + "id": "msg_fab3b8c630019oax7kHIohIWBa", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-start", + "id": "prt_fab3b8e95001CCejmuIy60hMBE", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "reasoning", + "text": "No existing AGENTS.md, CLAUDE.md, or opencode.json. Let me read the key files to understand the project.", + "time": { + "start": 1785284235363, + "end": 1785284235828 + }, + "id": "prt_fab3b9063001lXlgeRKKMkQ345", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_00_Q5AQBhUEOQySx1cZSzWo0273", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/README.md" + }, + "output": "/opt/bytetrack-counter/README.md\nfile\n\n1: # Jetson Edge Counter (Production)\n2: \n3: RTSP + YOLO TensorRT line-crossing counter for edge Jetson. Replaces MQTT `frigate-counter` on site.\n4: \n5: ## Architecture\n6: \n7: - **Input:** Direct LAN camera RTSP (low latency)\n8: - **Inference:** YOLO11n `.engine` (TensorRT) on Jetson GPU\n9: - **Logic:** Line crossing (`ayam` count, `talenan` closes batch) via `batch_store.py`\n10: - **Output:** `jetson_counter.db` + `current_batch.json`\n11: - **Dashboard:** Flask on port **5000**\n12: \n13: Batch lifecycle: talenan closes batch → idle until next ayam line cross (count starts at 1).\n14: \n15: ## Deploy\n16: \n17: See **[DEPLOY.md](DEPLOY.md)**.\n18: \n19: | Item | Default |\n20: |------|---------|\n21: | Install path | `/opt/jetson-counter` |\n22: | Venv | `/opt/jetson-counter/venv` |\n23: | DB | `/opt/jetson-counter/jetson_counter.db` |\n24: | Dashboard | `http://:5000` |\n25: | Cutoff | `20:00` |\n26: \n27: ## Commands\n28: \n29: | Command | Purpose |\n30: |---------|---------|\n31: | `sudo systemctl status jetson-counter` | Counter running? |\n32: | `sudo journalctl -u jetson-counter -f` | Live logs |\n33: | `sudo systemctl restart jetson-counter` | After `.env` change |\n34: | `sudo ./uninstall-services.sh` | Remove services |\n35: \n36: ## Key env vars\n37: \n38: | Variable | Purpose |\n39: |----------|---------|\n40: | `SOURCE` | Direct camera RTSP URL |\n41: | `MODEL_PATH` | `.engine` file path |\n42: | `IMGSZ` / `HALF` | Must match engine export |\n43: | `CROSS_DIRECTION` | `rtl` (default), `ltr`, or `both` |\n44: | `LINE_X` / `LINE_X_FRAC` | Counting line position |\n45: \n46: ## Files\n47: \n48: | File | Purpose |\n49: |------|---------|\n50: | `counter_live.py` | RTSP + YOLO + line crossing |\n51: | `batch_store.py` | SQLite persistence |\n52: | `counter_dashboard.py` | Flask UI |\n53: | `config.env.example` | Env template |\n54: | `jetson-counter.service` | Counter systemd unit |\n55: | `install-services.sh` | Install + disable legacy MQTT counter |\n56: \n57: ## Dev stack\n58: \n59: Lab / comparison: [`jetson-counter-dev/`](../jetson-counter-dev/) (port 8081, separate DB).\n\n(End of file - total 59 lines)\n", + "metadata": { + "preview": "# Jetson Edge Counter (Production)\n\nRTSP + YOLO TensorRT line-crossing counter for edge Jetson. Replaces MQTT `frigate-counter` on site.\n\n## Architecture\n\n- **Input:** Direct LAN camera RTSP (low latency)\n- **Inference:** YOLO11n `.engine` (TensorRT) on Jetson GPU\n- **Logic:** Line crossing (`ayam` count, `talenan` closes batch) via `batch_store.py`\n- **Output:** `jetson_counter.db` + `current_batch.json`\n- **Dashboard:** Flask on port **5000**\n\nBatch lifecycle: talenan closes batch → idle until next ayam line cross (count starts at 1).\n\n## Deploy\n\nSee **[DEPLOY.md](DEPLOY.md)**.\n\n| Item | Default |\n|------|---------|", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/README.md", + "text": "# Jetson Edge Counter (Production)\n\nRTSP + YOLO TensorRT line-crossing counter for edge Jetson. Replaces MQTT `frigate-counter` on site.\n\n## Architecture\n\n- **Input:** Direct LAN camera RTSP (low latency)\n- **Inference:** YOLO11n `.engine` (TensorRT) on Jetson GPU\n- **Logic:** Line crossing (`ayam` count, `talenan` closes batch) via `batch_store.py`\n- **Output:** `jetson_counter.db` + `current_batch.json`\n- **Dashboard:** Flask on port **5000**\n\nBatch lifecycle: talenan closes batch → idle until next ayam line cross (count starts at 1).\n\n## Deploy\n\nSee **[DEPLOY.md](DEPLOY.md)**.\n\n| Item | Default |\n|------|---------|\n| Install path | `/opt/jetson-counter` |\n| Venv | `/opt/jetson-counter/venv` |\n| DB | `/opt/jetson-counter/jetson_counter.db` |\n| Dashboard | `http://:5000` |\n| Cutoff | `20:00` |\n\n## Commands\n\n| Command | Purpose |\n|---------|---------|\n| `sudo systemctl status jetson-counter` | Counter running? |\n| `sudo journalctl -u jetson-counter -f` | Live logs |\n| `sudo systemctl restart jetson-counter` | After `.env` change |\n| `sudo ./uninstall-services.sh` | Remove services |\n\n## Key env vars\n\n| Variable | Purpose |\n|----------|---------|\n| `SOURCE` | Direct camera RTSP URL |\n| `MODEL_PATH` | `.engine` file path |\n| `IMGSZ` / `HALF` | Must match engine export |\n| `CROSS_DIRECTION` | `rtl` (default), `ltr`, or `both` |\n| `LINE_X` / `LINE_X_FRAC` | Counting line position |\n\n## Files\n\n| File | Purpose |\n|------|---------|\n| `counter_live.py` | RTSP + YOLO + line crossing |\n| `batch_store.py` | SQLite persistence |\n| `counter_dashboard.py` | Flask UI |\n| `config.env.example` | Env template |\n| `jetson-counter.service` | Counter systemd unit |\n| `install-services.sh` | Install + disable legacy MQTT counter |\n\n## Dev stack\n\nLab / comparison: [`jetson-counter-dev/`](../jetson-counter-dev/) (port 8081, separate DB).", + "lineStart": 1, + "lineEnd": 59, + "totalLines": 59, + "truncated": false + } + }, + "title": "README.md", + "time": { + "start": 1785284235996, + "end": 1785284236128 + } + }, + "id": "prt_fab3b9238001gw2IVOq3OmFKCP", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_01_FixcLZLgw20u0fGSN3BT6049", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/requirements.txt" + }, + "output": "/opt/bytetrack-counter/requirements.txt\nfile\n\n1: numpy<2\n2: rknn-toolkit-lite2\n3: opencv-python\n4: flask\n5: python-dotenv\n6: openpyxl\n\n(End of file - total 6 lines)\n", + "metadata": { + "preview": "numpy<2\nrknn-toolkit-lite2\nopencv-python\nflask\npython-dotenv\nopenpyxl", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/requirements.txt", + "text": "numpy<2\nrknn-toolkit-lite2\nopencv-python\nflask\npython-dotenv\nopenpyxl", + "lineStart": 1, + "lineEnd": 6, + "totalLines": 6, + "truncated": false + } + }, + "title": "requirements.txt", + "time": { + "start": 1785284236303, + "end": 1785284236315 + } + }, + "id": "prt_fab3b9341001kG6xCTKFRZrEq0", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_02_EA0ldEx8QS3NNn0tFRBT8644", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/DEPLOY.md" + }, + "output": "/opt/bytetrack-counter/DEPLOY.md\nfile\n\n1: # Edge Jetson Deploy\n2: \n3: Production counter: **direct LAN RTSP** + **YOLO11n TensorRT** + SQLite batch store. \n4: Replaces MQTT `frigate-counter` on the edge Jetson.\n5: \n6: ## Quick install\n7: \n8: ```bash\n9: # 1. Copy this folder to Jetson\n10: sudo mkdir -p /opt/jetson-counter\n11: sudo cp -r jetson-counter/* /opt/jetson-counter/\n12: sudo chown -R jetson:jetson /opt/jetson-counter\n13: \n14: # 2. Configure\n15: cd /opt/jetson-counter\n16: cp config.env.example .env\n17: nano .env # SOURCE, MODEL_PATH, CAMERA_NAME, etc.\n18: sed -i 's/\\r$//' .env\n19: \n20: # 3. Venv + services\n21: chmod +x setup-venv.sh install-services.sh\n22: sudo ./setup-venv.sh\n23: sudo ./install-services.sh\n24: ```\n25: \n26: Dashboard: `http://:5000`\n27: \n28: ---\n29: \n30: ## YOLO11n TensorRT engine (one-time)\n31: \n32: On the Jetson (must match `IMGSZ` / `HALF` in `.env`):\n33: \n34: ```bash\n35: source /opt/jetson-counter/venv/bin/activate\n36: export PYTHONNOUSERSITE=1\n37: \n38: yolo export model=/media/jetson/DATA/yolo11n.pt format=engine half=True imgsz=416 device=0\n39: ```\n40: \n41: Verify classes:\n42: \n43: ```bash\n44: PYTHONNOUSERSITE=1 python -c \"\n45: from ultralytics import YOLO\n46: m = YOLO('/media/jetson/DATA/yolo11n.engine')\n47: print(m.names)\n48: \"\n49: ```\n50: \n51: Expect `ayam` and `talenan`.\n52: \n53: ---\n54: \n55: ## Direct camera RTSP\n56: \n57: Set in `.env`:\n58: \n59: ```env\n60: SOURCE=rtsp://user:pass@192.168.x.x:554/stream1\n61: ```\n62: \n63: Test before install:\n64: \n65: ```bash\n66: ffplay -rtsp_transport tcp -t 5 \"$SOURCE\"\n67: nc -zv 554\n68: ```\n69: \n70: ---\n71: \n72: ## Cutover from MQTT frigate-counter\n73: \n74: `install-services.sh` automatically:\n75: \n76: 1. Disables `frigate-counter` and `frigate-counter-dashboard`\n77: 2. Enables `jetson-counter` + `jetson-counter-dashboard`\n78: \n79: Archive old DB (optional):\n80: \n81: ```bash\n82: sudo cp /opt/frigate-counter/frigate_counter.db ~/frigate_counter.db.backup\n83: ```\n84: \n85: ---\n86: \n87: ## Validation checklist\n88: \n89: ```bash\n90: sudo systemctl is-active jetson-counter jetson-counter-dashboard\n91: PYTHONNOUSERSITE=1 /opt/jetson-counter/venv/bin/python -c \"import torch; print('cuda', torch.cuda.is_available())\"\n92: sudo journalctl -u jetson-counter -n 20 --no-pager\n93: ```\n94: \n95: Good signs:\n96: \n97: - `Stream ready!`\n98: - `Loaded engine size: ... MiB`\n99: - `Frame 100 | Batch ...`\n100: \n101: ---\n102: \n103: ## Logs & restart\n104: \n105: ```bash\n106: sudo journalctl -u jetson-counter -f\n107: sudo systemctl restart jetson-counter # after .env change\n108: ```\n109: \n110: ---\n111: \n112: ## JetPack 6.0 torch wheel\n113: \n114: If `setup-venv.sh` fails on torch URL, list wheels:\n115: \n116: ```bash\n117: curl -s https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/ | grep cp310\n118: ```\n119: \n120: Set `TORCH_WHEEL_URL=...` when running `setup-venv.sh`.\n121: \n122: See also [jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md](../jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md) for torchvision and RTSP issues.\n\n(End of file - total 122 lines)\n", + "metadata": { + "preview": "# Edge Jetson Deploy\n\nProduction counter: **direct LAN RTSP** + **YOLO11n TensorRT** + SQLite batch store. \nReplaces MQTT `frigate-counter` on the edge Jetson.\n\n## Quick install\n\n```bash\n# 1. Copy this folder to Jetson\nsudo mkdir -p /opt/jetson-counter\nsudo cp -r jetson-counter/* /opt/jetson-counter/\nsudo chown -R jetson:jetson /opt/jetson-counter\n\n# 2. Configure\ncd /opt/jetson-counter\ncp config.env.example .env\nnano .env # SOURCE, MODEL_PATH, CAMERA_NAME, etc.\nsed -i 's/\\r$//' .env\n\n# 3. Venv + services", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/DEPLOY.md", + "text": "# Edge Jetson Deploy\n\nProduction counter: **direct LAN RTSP** + **YOLO11n TensorRT** + SQLite batch store. \nReplaces MQTT `frigate-counter` on the edge Jetson.\n\n## Quick install\n\n```bash\n# 1. Copy this folder to Jetson\nsudo mkdir -p /opt/jetson-counter\nsudo cp -r jetson-counter/* /opt/jetson-counter/\nsudo chown -R jetson:jetson /opt/jetson-counter\n\n# 2. Configure\ncd /opt/jetson-counter\ncp config.env.example .env\nnano .env # SOURCE, MODEL_PATH, CAMERA_NAME, etc.\nsed -i 's/\\r$//' .env\n\n# 3. Venv + services\nchmod +x setup-venv.sh install-services.sh\nsudo ./setup-venv.sh\nsudo ./install-services.sh\n```\n\nDashboard: `http://:5000`\n\n---\n\n## YOLO11n TensorRT engine (one-time)\n\nOn the Jetson (must match `IMGSZ` / `HALF` in `.env`):\n\n```bash\nsource /opt/jetson-counter/venv/bin/activate\nexport PYTHONNOUSERSITE=1\n\nyolo export model=/media/jetson/DATA/yolo11n.pt format=engine half=True imgsz=416 device=0\n```\n\nVerify classes:\n\n```bash\nPYTHONNOUSERSITE=1 python -c \"\nfrom ultralytics import YOLO\nm = YOLO('/media/jetson/DATA/yolo11n.engine')\nprint(m.names)\n\"\n```\n\nExpect `ayam` and `talenan`.\n\n---\n\n## Direct camera RTSP\n\nSet in `.env`:\n\n```env\nSOURCE=rtsp://user:pass@192.168.x.x:554/stream1\n```\n\nTest before install:\n\n```bash\nffplay -rtsp_transport tcp -t 5 \"$SOURCE\"\nnc -zv 554\n```\n\n---\n\n## Cutover from MQTT frigate-counter\n\n`install-services.sh` automatically:\n\n1. Disables `frigate-counter` and `frigate-counter-dashboard`\n2. Enables `jetson-counter` + `jetson-counter-dashboard`\n\nArchive old DB (optional):\n\n```bash\nsudo cp /opt/frigate-counter/frigate_counter.db ~/frigate_counter.db.backup\n```\n\n---\n\n## Validation checklist\n\n```bash\nsudo systemctl is-active jetson-counter jetson-counter-dashboard\nPYTHONNOUSERSITE=1 /opt/jetson-counter/venv/bin/python -c \"import torch; print('cuda', torch.cuda.is_available())\"\nsudo journalctl -u jetson-counter -n 20 --no-pager\n```\n\nGood signs:\n\n- `Stream ready!`\n- `Loaded engine size: ... MiB`\n- `Frame 100 | Batch ...`\n\n---\n\n## Logs & restart\n\n```bash\nsudo journalctl -u jetson-counter -f\nsudo systemctl restart jetson-counter # after .env change\n```\n\n---\n\n## JetPack 6.0 torch wheel\n\nIf `setup-venv.sh` fails on torch URL, list wheels:\n\n```bash\ncurl -s https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/ | grep cp310\n```\n\nSet `TORCH_WHEEL_URL=...` when running `setup-venv.sh`.\n\nSee also [jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md](../jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md) for torchvision and RTSP issues.", + "lineStart": 1, + "lineEnd": 122, + "totalLines": 122, + "truncated": false + } + }, + "title": "DEPLOY.md", + "time": { + "start": 1785284236566, + "end": 1785284236618 + } + }, + "id": "prt_fab3b9421001hFUMAR8utSHwcF", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_03_XcAXNMUkJ5Y7aLBm0Igk4940", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/.gitignore" + }, + "output": "/opt/bytetrack-counter/.gitignore\nfile\n\n1: # Byte-compiled / optimized / DLL files\n2: __pycache__/\n3: *.py[codz]\n4: *$py.class\n5: \n6: # C extensions\n7: *.so\n8: \n9: # Distribution / packaging\n10: .Python\n11: build/\n12: develop-eggs/\n13: dist/\n14: downloads/\n15: eggs/\n16: .eggs/\n17: lib/\n18: lib64/\n19: parts/\n20: sdist/\n21: var/\n22: wheels/\n23: share/python-wheels/\n24: *.egg-info/\n25: .installed.cfg\n26: *.egg\n27: MANIFEST\n28: \n29: # PyInstaller\n30: # Usually these files are written by a python script from a template\n31: # before PyInstaller builds the exe, so as to inject date/other infos into it.\n32: *.manifest\n33: *.spec\n34: \n35: # Installer logs\n36: pip-log.txt\n37: pip-delete-this-directory.txt\n38: \n39: # Unit test / coverage reports\n40: htmlcov/\n41: .tox/\n42: .nox/\n43: .coverage\n44: .coverage.*\n45: .cache\n46: nosetests.xml\n47: coverage.xml\n48: *.cover\n49: *.py.cover\n50: *.lcov\n51: .hypothesis/\n52: .pytest_cache/\n53: cover/\n54: \n55: # Translations\n56: *.mo\n57: *.pot\n58: \n59: # Django stuff:\n60: *.log\n61: local_settings.py\n62: db.sqlite3\n63: db.sqlite3-journal\n64: \n65: # Flask stuff:\n66: instance/\n67: .webassets-cache\n68: \n69: # Scrapy stuff:\n70: .scrapy\n71: \n72: # Sphinx documentation\n73: docs/_build/\n74: \n75: # PyBuilder\n76: .pybuilder/\n77: target/\n78: \n79: # Jupyter Notebook\n80: .ipynb_checkpoints\n81: \n82: # IPython\n83: profile_default/\n84: ipython_config.py\n85: \n86: # pyenv\n87: # For a library or package, you might want to ignore these files since the code is\n88: # intended to run in multiple environments; otherwise, check them in:\n89: # .python-version\n90: \n91: # pipenv\n92: # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n93: # However, in case of collaboration, if having platform-specific dependencies or dependencies\n94: # having no cross-platform support, pipenv may install dependencies that don't work, or not\n95: # install all needed dependencies.\n96: # Pipfile.lock\n97: \n98: # UV\n99: # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.\n100: # This is especially recommended for binary packages to ensure reproducibility, and is more\n101: # commonly ignored for libraries.\n102: # uv.lock\n103: \n104: # poetry\n105: # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n106: # This is especially recommended for binary packages to ensure reproducibility, and is more\n107: # commonly ignored for libraries.\n108: # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n109: # poetry.lock\n110: # poetry.toml\n111: \n112: # pdm\n113: # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n114: # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.\n115: # https://pdm-project.org/en/latest/usage/project/#working-with-version-control\n116: # pdm.lock\n117: # pdm.toml\n118: .pdm-python\n119: .pdm-build/\n120: \n121: # pixi\n122: # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.\n123: # pixi.lock\n124: # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one\n125: # in the .venv directory. It is recommended not to include this directory in version control.\n126: .pixi/*\n127: !.pixi/config.toml\n128: \n129: # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n130: __pypackages__/\n131: \n132: # Celery stuff\n133: celerybeat-schedule*\n134: celerybeat.pid\n135: \n136: # Redis\n137: *.rdb\n138: *.aof\n139: *.pid\n140: \n141: # RabbitMQ\n142: mnesia/\n143: rabbitmq/\n144: rabbitmq-data/\n145: \n146: # ActiveMQ\n147: activemq-data/\n148: \n149: # SageMath parsed files\n150: *.sage.py\n151: \n152: # Environments\n153: .env\n154: .envrc\n155: .venv\n156: env/\n157: venv/\n158: ENV/\n159: env.bak/\n160: venv.bak/\n161: \n162: # Spyder project settings\n163: .spyderproject\n164: .spyproject\n165: \n166: # Rope project settings\n167: .ropeproject\n168: \n169: # mkdocs documentation\n170: /site\n171: \n172: # mypy\n173: .mypy_cache/\n174: .dmypy.json\n175: dmypy.json\n176: \n177: # Pyre type checker\n178: .pyre/\n179: \n180: # pytype static type analyzer\n181: .pytype/\n182: \n183: # Cython debug symbols\n184: cython_debug/\n185: \n186: # PyCharm\n187: # JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n188: # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n189: # and can be added to the global gitignore or merged into this file. For a more nuclear\n190: # option (not recommended) you can uncomment the following to ignore the entire idea folder.\n191: # .idea/\n192: \n193: # Abstra\n194: # Abstra is an AI-powered process automation framework.\n195: # Ignore directories containing user credentials, local state, and settings.\n196: # Learn more at https://abstra.io/docs\n197: .abstra/\n198: \n199: # Visual Studio Code\n200: # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore \n201: # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore\n202: # and can be added to the global gitignore or merged into this file. However, if you prefer, \n203: # you could uncomment the following to ignore the entire vscode folder\n204: # .vscode/\n205: # Temporary file for partial code execution\n206: tempCodeRunnerFile.py\n207: \n208: # Ruff stuff:\n209: .ruff_cache/\n210: \n211: # PyPI configuration file\n212: .pypirc\n213: \n214: # Marimo\n215: marimo/_static/\n216: marimo/_lsp/\n217: __marimo__/\n218: \n219: # Streamlit\n220: .streamlit/secrets.toml\n\n(End of file - total 220 lines)\n", + "metadata": { + "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[codz]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/.gitignore", + "text": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[codz]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n# Usually these files are written by a python script from a template\n# before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py.cover\n*.lcov\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n# For a library or package, you might want to ignore these files since the code is\n# intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n# However, in case of collaboration, if having platform-specific dependencies or dependencies\n# having no cross-platform support, pipenv may install dependencies that don't work, or not\n# install all needed dependencies.\n# Pipfile.lock\n\n# UV\n# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.\n# This is especially recommended for binary packages to ensure reproducibility, and is more\n# commonly ignored for libraries.\n# uv.lock\n\n# poetry\n# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n# This is especially recommended for binary packages to ensure reproducibility, and is more\n# commonly ignored for libraries.\n# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n# poetry.lock\n# poetry.toml\n\n# pdm\n# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.\n# https://pdm-project.org/en/latest/usage/project/#working-with-version-control\n# pdm.lock\n# pdm.toml\n.pdm-python\n.pdm-build/\n\n# pixi\n# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.\n# pixi.lock\n# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one\n# in the .venv directory. It is recommended not to include this directory in version control.\n.pixi/*\n!.pixi/config.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule*\ncelerybeat.pid\n\n# Redis\n*.rdb\n*.aof\n*.pid\n\n# RabbitMQ\nmnesia/\nrabbitmq/\nrabbitmq-data/\n\n# ActiveMQ\nactivemq-data/\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.envrc\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n# JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n# and can be added to the global gitignore or merged into this file. For a more nuclear\n# option (not recommended) you can uncomment the following to ignore the entire idea folder.\n# .idea/\n\n# Abstra\n# Abstra is an AI-powered process automation framework.\n# Ignore directories containing user credentials, local state, and settings.\n# Learn more at https://abstra.io/docs\n.abstra/\n\n# Visual Studio Code\n# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore \n# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore\n# and can be added to the global gitignore or merged into this file. However, if you prefer, \n# you could uncomment the following to ignore the entire vscode folder\n# .vscode/\n# Temporary file for partial code execution\ntempCodeRunnerFile.py\n\n# Ruff stuff:\n.ruff_cache/\n\n# PyPI configuration file\n.pypirc\n\n# Marimo\nmarimo/_static/\nmarimo/_lsp/\n__marimo__/\n\n# Streamlit\n.streamlit/secrets.toml", + "lineStart": 1, + "lineEnd": 220, + "totalLines": 220, + "truncated": false + } + }, + "title": ".gitignore", + "time": { + "start": 1785284236844, + "end": 1785284236858 + } + }, + "id": "prt_fab3b956c001n37Ae0D8qA1FIm", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_04_8kLRyFQlLLrl8gWMDuZu8872", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/setup-venv.sh" + }, + "output": "/opt/bytetrack-counter/setup-venv.sh\nfile\n\n1: #!/usr/bin/env bash\n2: # One-time venv for edge Jetson counter — NVIDIA torch required (not PyPI).\n3: set -euo pipefail\n4: \n5: INSTALL_DIR=\"${INSTALL_DIR:-/opt/jetson-counter}\"\n6: VENV_DIR=\"${VENV_DIR:-/opt/jetson-counter/venv}\"\n7: SERVICE_USER=\"${SERVICE_USER:-jetson}\"\n8: TORCH_WHEEL_URL=\"${TORCH_WHEEL_URL:-https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/torch-2.4.0a0+3bcc3cddb5.nv24.07.16234504-cp310-cp310-linux_aarch64.whl}\"\n9: \n10: if [[ \"$(id -u)\" -ne 0 ]]; then\n11: echo \"Run as root: sudo ./setup-venv.sh\"\n12: exit 1\n13: fi\n14: \n15: apt-get install -y libopenblas-base libopenmpi-dev libomp-dev 2>/dev/null || true\n16: \n17: mkdir -p \"${INSTALL_DIR}\"\n18: if [[ ! -x \"${VENV_DIR}/bin/python\" ]]; then\n19: python3 -m venv --system-site-packages \"${VENV_DIR}\"\n20: fi\n21: chown -R \"${SERVICE_USER}:${SERVICE_USER}\" \"${INSTALL_DIR}\"\n22: \n23: sudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install --upgrade pip\n24: sudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install \"numpy<2\"\n25: \n26: if ! sudo -u \"${SERVICE_USER}\" PYTHONNOUSERSITE=1 \"${VENV_DIR}/bin/python\" -c \"import torch; assert torch.cuda.is_available()\" 2>/dev/null; then\n27: echo \"Installing NVIDIA Jetson torch wheel...\"\n28: sudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install --no-cache-dir \"${TORCH_WHEEL_URL}\"\n29: fi\n30: \n31: sudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install ultralytics flask opencv-python\n32: \n33: sudo -u \"${SERVICE_USER}\" PYTHONNOUSERSITE=1 \"${VENV_DIR}/bin/python\" -c \"\n34: import torch\n35: from ultralytics import YOLO\n36: import cv2\n37: import flask\n38: print('venv ok | torch', torch.__version__, '| cuda', torch.cuda.is_available())\n39: \"\n40: \n41: echo \"\"\n42: echo \"If torchvision import fails for ultralytics, copy/build torchvision into venv.\"\n43: echo \"Next: cp config.env.example .env && nano .env && sudo ./install-services.sh\"\n\n(End of file - total 43 lines)\n", + "metadata": { + "preview": "#!/usr/bin/env bash\n# One-time venv for edge Jetson counter — NVIDIA torch required (not PyPI).\nset -euo pipefail\n\nINSTALL_DIR=\"${INSTALL_DIR:-/opt/jetson-counter}\"\nVENV_DIR=\"${VENV_DIR:-/opt/jetson-counter/venv}\"\nSERVICE_USER=\"${SERVICE_USER:-jetson}\"\nTORCH_WHEEL_URL=\"${TORCH_WHEEL_URL:-https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/torch-2.4.0a0+3bcc3cddb5.nv24.07.16234504-cp310-cp310-linux_aarch64.whl}\"\n\nif [[ \"$(id -u)\" -ne 0 ]]; then\n echo \"Run as root: sudo ./setup-venv.sh\"\n exit 1\nfi\n\napt-get install -y libopenblas-base libopenmpi-dev libomp-dev 2>/dev/null || true\n\nmkdir -p \"${INSTALL_DIR}\"\nif [[ ! -x \"${VENV_DIR}/bin/python\" ]]; then\n python3 -m venv --system-site-packages \"${VENV_DIR}\"\nfi", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/setup-venv.sh", + "text": "#!/usr/bin/env bash\n# One-time venv for edge Jetson counter — NVIDIA torch required (not PyPI).\nset -euo pipefail\n\nINSTALL_DIR=\"${INSTALL_DIR:-/opt/jetson-counter}\"\nVENV_DIR=\"${VENV_DIR:-/opt/jetson-counter/venv}\"\nSERVICE_USER=\"${SERVICE_USER:-jetson}\"\nTORCH_WHEEL_URL=\"${TORCH_WHEEL_URL:-https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/torch-2.4.0a0+3bcc3cddb5.nv24.07.16234504-cp310-cp310-linux_aarch64.whl}\"\n\nif [[ \"$(id -u)\" -ne 0 ]]; then\n echo \"Run as root: sudo ./setup-venv.sh\"\n exit 1\nfi\n\napt-get install -y libopenblas-base libopenmpi-dev libomp-dev 2>/dev/null || true\n\nmkdir -p \"${INSTALL_DIR}\"\nif [[ ! -x \"${VENV_DIR}/bin/python\" ]]; then\n python3 -m venv --system-site-packages \"${VENV_DIR}\"\nfi\nchown -R \"${SERVICE_USER}:${SERVICE_USER}\" \"${INSTALL_DIR}\"\n\nsudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install --upgrade pip\nsudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install \"numpy<2\"\n\nif ! sudo -u \"${SERVICE_USER}\" PYTHONNOUSERSITE=1 \"${VENV_DIR}/bin/python\" -c \"import torch; assert torch.cuda.is_available()\" 2>/dev/null; then\n echo \"Installing NVIDIA Jetson torch wheel...\"\n sudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install --no-cache-dir \"${TORCH_WHEEL_URL}\"\nfi\n\nsudo -u \"${SERVICE_USER}\" \"${VENV_DIR}/bin/pip\" install ultralytics flask opencv-python\n\nsudo -u \"${SERVICE_USER}\" PYTHONNOUSERSITE=1 \"${VENV_DIR}/bin/python\" -c \"\nimport torch\nfrom ultralytics import YOLO\nimport cv2\nimport flask\nprint('venv ok | torch', torch.__version__, '| cuda', torch.cuda.is_available())\n\"\n\necho \"\"\necho \"If torchvision import fails for ultralytics, copy/build torchvision into venv.\"\necho \"Next: cp config.env.example .env && nano .env && sudo ./install-services.sh\"", + "lineStart": 1, + "lineEnd": 43, + "totalLines": 43, + "truncated": false + } + }, + "title": "setup-venv.sh", + "time": { + "start": 1785284237140, + "end": 1785284237252 + } + }, + "id": "prt_fab3b965e001ivLihBr50XThW6", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_05_tsusidwd3qAQiCqmM8XU8836", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/install-services.sh" + }, + "output": "/opt/bytetrack-counter/install-services.sh\nfile\n\n1: #!/usr/bin/env bash\n2: # Install edge Jetson counter + dashboard; disable legacy MQTT frigate-counter.\n3: # Run on the Jetson: sudo ./install-services.sh\n4: set -euo pipefail\n5: \n6: INSTALL_DIR=\"${INSTALL_DIR:-/opt/jetson-counter}\"\n7: VENV_DIR=\"${VENV_DIR:-/opt/jetson-counter/venv}\"\n8: SERVICE_USER=\"${SERVICE_USER:-jetson}\"\n9: \n10: if [[ \"$(id -u)\" -ne 0 ]]; then\n11: echo \"Run as root: sudo ./install-services.sh\"\n12: exit 1\n13: fi\n14: \n15: if [[ ! -f \"${INSTALL_DIR}/.env\" ]]; then\n16: echo \"Missing ${INSTALL_DIR}/.env\"\n17: echo \" cp ${INSTALL_DIR}/config.env.example ${INSTALL_DIR}/.env && nano ${INSTALL_DIR}/.env\"\n18: exit 1\n19: fi\n20: \n21: if [[ ! -x \"${VENV_DIR}/bin/python\" ]]; then\n22: echo \"Missing venv: ${VENV_DIR}/bin/python\"\n23: echo \" sudo ./setup-venv.sh\"\n24: exit 1\n25: fi\n26: \n27: sed -i 's/\\r$//' \"${INSTALL_DIR}/.env\" 2>/dev/null || true\n28: \n29: mkdir -p \"${INSTALL_DIR}/.ultralytics\" \"${INSTALL_DIR}/.torch\"\n30: chown -R \"${SERVICE_USER}:${SERVICE_USER}\" \"${INSTALL_DIR}\"\n31: \n32: # Disable legacy MQTT counter (replace mode)\n33: for legacy in frigate-counter frigate-counter-dashboard; do\n34: if systemctl is-enabled \"${legacy}\" &>/dev/null; then\n35: systemctl disable --now \"${legacy}\" || true\n36: echo \"Disabled legacy ${legacy}\"\n37: fi\n38: done\n39: \n40: for unit in jetson-counter jetson-counter-dashboard; do\n41: sed -e \"s|/opt/jetson-counter|${INSTALL_DIR}|g\" \\\n42: -e \"s|User=jetson|User=${SERVICE_USER}|g\" \\\n43: -e \"s|Group=jetson|Group=${SERVICE_USER}|g\" \\\n44: \"${INSTALL_DIR}/${unit}.service\" > \"/etc/systemd/system/${unit}.service\"\n45: echo \"Installed /etc/systemd/system/${unit}.service\"\n46: done\n47: \n48: chown -R \"${SERVICE_USER}:${SERVICE_USER}\" \"${INSTALL_DIR}\"\n49: \n50: PYTHONNOUSERSITE=1 \"${VENV_DIR}/bin/python\" -c \"\n51: from ultralytics import YOLO\n52: import torch\n53: print('import ok | cuda', torch.cuda.is_available())\n54: \" || {\n55: echo \"Import check failed — fix venv before starting services.\"\n56: exit 1\n57: }\n58: \n59: systemctl daemon-reload\n60: systemctl reset-failed jetson-counter jetson-counter-dashboard 2>/dev/null || true\n61: systemctl enable jetson-counter jetson-counter-dashboard\n62: systemctl restart jetson-counter jetson-counter-dashboard\n63: \n64: echo \"\"\n65: systemctl --no-pager status jetson-counter jetson-counter-dashboard || true\n66: echo \"\"\n67: echo \"Logs: sudo journalctl -u jetson-counter -f\"\n68: echo \"Dashboard: http://$(hostname -I | awk '{print $1}'):5000\"\n\n(End of file - total 68 lines)\n", + "metadata": { + "preview": "#!/usr/bin/env bash\n# Install edge Jetson counter + dashboard; disable legacy MQTT frigate-counter.\n# Run on the Jetson: sudo ./install-services.sh\nset -euo pipefail\n\nINSTALL_DIR=\"${INSTALL_DIR:-/opt/jetson-counter}\"\nVENV_DIR=\"${VENV_DIR:-/opt/jetson-counter/venv}\"\nSERVICE_USER=\"${SERVICE_USER:-jetson}\"\n\nif [[ \"$(id -u)\" -ne 0 ]]; then\n echo \"Run as root: sudo ./install-services.sh\"\n exit 1\nfi\n\nif [[ ! -f \"${INSTALL_DIR}/.env\" ]]; then\n echo \"Missing ${INSTALL_DIR}/.env\"\n echo \" cp ${INSTALL_DIR}/config.env.example ${INSTALL_DIR}/.env && nano ${INSTALL_DIR}/.env\"\n exit 1\nfi\n", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/install-services.sh", + "text": "#!/usr/bin/env bash\n# Install edge Jetson counter + dashboard; disable legacy MQTT frigate-counter.\n# Run on the Jetson: sudo ./install-services.sh\nset -euo pipefail\n\nINSTALL_DIR=\"${INSTALL_DIR:-/opt/jetson-counter}\"\nVENV_DIR=\"${VENV_DIR:-/opt/jetson-counter/venv}\"\nSERVICE_USER=\"${SERVICE_USER:-jetson}\"\n\nif [[ \"$(id -u)\" -ne 0 ]]; then\n echo \"Run as root: sudo ./install-services.sh\"\n exit 1\nfi\n\nif [[ ! -f \"${INSTALL_DIR}/.env\" ]]; then\n echo \"Missing ${INSTALL_DIR}/.env\"\n echo \" cp ${INSTALL_DIR}/config.env.example ${INSTALL_DIR}/.env && nano ${INSTALL_DIR}/.env\"\n exit 1\nfi\n\nif [[ ! -x \"${VENV_DIR}/bin/python\" ]]; then\n echo \"Missing venv: ${VENV_DIR}/bin/python\"\n echo \" sudo ./setup-venv.sh\"\n exit 1\nfi\n\nsed -i 's/\\r$//' \"${INSTALL_DIR}/.env\" 2>/dev/null || true\n\nmkdir -p \"${INSTALL_DIR}/.ultralytics\" \"${INSTALL_DIR}/.torch\"\nchown -R \"${SERVICE_USER}:${SERVICE_USER}\" \"${INSTALL_DIR}\"\n\n# Disable legacy MQTT counter (replace mode)\nfor legacy in frigate-counter frigate-counter-dashboard; do\n if systemctl is-enabled \"${legacy}\" &>/dev/null; then\n systemctl disable --now \"${legacy}\" || true\n echo \"Disabled legacy ${legacy}\"\n fi\ndone\n\nfor unit in jetson-counter jetson-counter-dashboard; do\n sed -e \"s|/opt/jetson-counter|${INSTALL_DIR}|g\" \\\n -e \"s|User=jetson|User=${SERVICE_USER}|g\" \\\n -e \"s|Group=jetson|Group=${SERVICE_USER}|g\" \\\n \"${INSTALL_DIR}/${unit}.service\" > \"/etc/systemd/system/${unit}.service\"\n echo \"Installed /etc/systemd/system/${unit}.service\"\ndone\n\nchown -R \"${SERVICE_USER}:${SERVICE_USER}\" \"${INSTALL_DIR}\"\n\nPYTHONNOUSERSITE=1 \"${VENV_DIR}/bin/python\" -c \"\nfrom ultralytics import YOLO\nimport torch\nprint('import ok | cuda', torch.cuda.is_available())\n\" || {\n echo \"Import check failed — fix venv before starting services.\"\n exit 1\n}\n\nsystemctl daemon-reload\nsystemctl reset-failed jetson-counter jetson-counter-dashboard 2>/dev/null || true\nsystemctl enable jetson-counter jetson-counter-dashboard\nsystemctl restart jetson-counter jetson-counter-dashboard\n\necho \"\"\nsystemctl --no-pager status jetson-counter jetson-counter-dashboard || true\necho \"\"\necho \"Logs: sudo journalctl -u jetson-counter -f\"\necho \"Dashboard: http://$(hostname -I | awk '{print $1}'):5000\"", + "lineStart": 1, + "lineEnd": 68, + "totalLines": 68, + "truncated": false + } + }, + "title": "install-services.sh", + "time": { + "start": 1785284237384, + "end": 1785284237428 + } + }, + "id": "prt_fab3b9781001ZEY72RPwnM7F0w", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + }, + { + "reason": "tool-calls", + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-finish", + "tokens": { + "total": 10274, + "input": 272, + "output": 248, + "reasoning": 26, + "cache": { + "write": 0, + "read": 9728 + } + }, + "cost": 0.000391964, + "id": "prt_fab3b98f4001tSAip2xPIP13qH", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b8c630019oax7kHIohIWBa" + } + ] + }, + { + "info": { + "parentID": "msg_fab3b7662001asy7eTmLMm4mEq", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.00258274, + "tokens": { + "total": 15862, + "input": 5392, + "output": 214, + "reasoning": 16, + "cache": { + "write": 0, + "read": 10240 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785284237640, + "completed": 1785284240946 + }, + "finish": "tool-calls", + "id": "msg_fab3b9948001NqWLx9PXClyyv8", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-start", + "id": "prt_fab3b9be5001YEAxOxjPaxN0rA", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "type": "reasoning", + "text": "Now let me look at the main source files to understand the codebase better.", + "time": { + "start": 1785284238684, + "end": 1785284239121 + }, + "id": "prt_fab3b9d5c0015PbtmRrSWhyK6Q", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_00_z5R05gvWfSAWc2BWe9nR5485", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/counter_live.py" + }, + "output": "/opt/bytetrack-counter/counter_live.py\nfile\n\n1: \"\"\"\n2: Edge production live counter — RTSP + YOLO TensorRT + line crossing.\n3: Replaces MQTT frigate-counter on Jetson with local LAN camera inference.\n4: \"\"\"\n5: from ultralytics import YOLO\n6: import cv2\n7: import csv\n8: import numpy as np\n9: import os\n10: import signal\n11: import time\n12: from datetime import datetime\n13: from pathlib import Path\n14: \n15: from dotenv import load_dotenv\n16: load_dotenv()\n17: \n18: from batch_store import BatchStore\n19: \n20: # --- config (override via env / .env) ---\n21: OUTPUT_DIR = os.getenv('OUTPUT_DIR', '/opt/jetson-counter')\n22: DB_PATH = os.getenv('DB_PATH', f'{OUTPUT_DIR}/jetson_counter.db')\n23: STATE_FILE = os.getenv('STATE_FILE', f'{OUTPUT_DIR}/current_batch.json')\n24: SOURCE = os.getenv('SOURCE', 'rtsp://user:pass@192.168.0.100:554/stream1')\n25: MODEL_PATH = os.getenv('MODEL_PATH', '/media/jetson/DATA/yolo11n.engine')\n26: CAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1')\n27: OBJECT_LABEL = os.getenv('OBJECT_LABEL', 'ayam-potong')\n28: CLASS_AYAM = os.getenv('CLASS_AYAM', 'ayam')\n29: CLASS_TALENAN = os.getenv('CLASS_TALENAN', 'talenan')\n30: \n31: LINE_X = int(os.getenv('LINE_X')) if os.getenv('LINE_X') else None\n32: LINE_X_FRAC = float(os.getenv('LINE_X_FRAC', '0.5'))\n33: CROSS_DIRECTION = os.getenv('CROSS_DIRECTION', 'rtl').lower()\n34: \n35: IMGSZ = int(os.getenv('IMGSZ', '416'))\n36: HALF = os.getenv('HALF', 'true').lower() == 'true'\n37: CONF = float(os.getenv('CONF', '0.3'))\n38: DEVICE = int(os.getenv('DEVICE', '0'))\n39: TRACKER = os.getenv('TRACKER', 'bytetrack.yaml')\n40: \n41: DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00')\n42: BATCH_TIMEOUT_SECONDS = float(os.getenv('BATCH_TIMEOUT_SECONDS', '300'))\n43: IGNORE_BATCH_LABEL_TIMEOUT = float(os.getenv('IGNORE_BATCH_LABEL_TIMEOUT_SECONDS', '30'))\n44: MIN_OBJECT_PER_BATCH = int(os.getenv('MIN_OBJECT_PER_BATCH', '60'))\n45: MIN_DURATION_PER_BATCH = int(os.getenv('MIN_DURATION_PER_BATCH', '60'))\n46: \n47: EXPORT_CSV = os.getenv('EXPORT_CSV', 'true').lower() == 'true'\n48: CROSS_CSV = os.getenv('CROSS_CSV', f'{OUTPUT_DIR}/batch_crossings.csv')\n49: \n50: WARMUP_FRAMES = int(os.getenv('WARMUP_FRAMES', '30'))\n51: RECONNECT_DELAY_SEC = int(os.getenv('RECONNECT_DELAY_SEC', '3'))\n52: MAX_RECONNECT_ATTEMPTS = int(os.getenv('MAX_RECONNECT_ATTEMPTS', '0'))\n53: FLUSH_EVERY_N_FRAMES = int(os.getenv('FLUSH_EVERY_N_FRAMES', '100'))\n54: TRACKED_PRUNE_SEC = int(os.getenv('TRACKED_PRUNE_SEC', '300'))\n55: RECORD_VIDEO = os.getenv('RECORD_VIDEO', 'false').lower() == 'true'\n56: VIDEO_SEGMENT_SEC = int(os.getenv('VIDEO_SEGMENT_SEC', '3600'))\n57: OUTPUT_FPS = int(os.getenv('OUTPUT_FPS', '15'))\n58: \n59: LIVE_STREAM_ENABLED = os.getenv('LIVE_STREAM_ENABLED', 'false').lower() == 'true'\n60: LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', '/dev/shm/jetson-counter/live_frame.jpg')\n61: LIVE_STREAM_QUALITY = int(os.getenv('LIVE_STREAM_QUALITY', '75'))\n62: LIVE_STREAM_EVERY_N = int(os.getenv('LIVE_STREAM_EVERY_N', '2'))\n63: \n64: RTSP_FFMPEG_OPTIONS = os.getenv(\n65: 'OPENCV_FFMPEG_CAPTURE_OPTIONS',\n66: 'rtsp_transport;tcp|fflags;nobuffer|flags;low_delay',\n67: )\n68: \n69: IS_LIVE = SOURCE.lower().startswith(('rtsp://', 'http://'))\n70: \n71: CROSS_FLASH_FRAMES = 12\n72: POPUP_LIFETIME = 20\n73: LINE_PULSE_FRAMES = 12\n74: COUNT_PULSE_FRAMES = 15\n75: BATCH_PULSE_FRAMES = 20\n76: \n77: SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)]\n78: SK_COLORS = [\n79: (0, 255, 255), (0, 255, 255), (255, 0, 255), (255, 0, 255),\n80: (0, 255, 0), (255, 255, 0), (0, 0, 255), (200, 200, 0),\n81: ]\n82: \n83: C_PANEL = (28, 24, 18)\n84: C_BORDER = (90, 85, 75)\n85: C_ACCENT = (255, 200, 60)\n86: C_GREEN = (80, 220, 100)\n87: C_TEXT = (235, 235, 235)\n88: C_MUTED = (150, 150, 150)\n89: C_AYAM_BOX = (0, 165, 255)\n90: C_TALENAN_BOX = (220, 120, 60)\n91: C_LINE_CORE = (180, 220, 255)\n92: C_LINE_GLOW = (100, 160, 220)\n93: \n94: shutdown_requested = False\n95: \n96: \n97: def request_shutdown(signum, frame):\n98: global shutdown_requested\n99: shutdown_requested = True\n100: print('\\nShutdown requested — finishing current frame...')\n101: \n102: \n103: signal.signal(signal.SIGINT, request_shutdown)\n104: signal.signal(signal.SIGTERM, request_shutdown)\n105: \n106: \n107: def resolve_class_ids(names):\n108: name_to_id = {v: k for k, v in names.items()}\n109: missing = [n for n in (CLASS_AYAM, CLASS_TALENAN) if n not in name_to_id]\n110: if missing:\n111: raise ValueError(f'Model missing classes {missing}. Available: {list(names.values())}')\n112: return name_to_id[CLASS_AYAM], name_to_id[CLASS_TALENAN]\n113: \n114: \n115: def box_cx(box):\n116: return (int(box[0]) + int(box[2])) // 2\n117: \n118: \n119: def resolve_line_x(frame_width):\n120: if LINE_X is not None:\n121: return LINE_X\n122: if LINE_X_FRAC != 0.5:\n123: return int(frame_width * LINE_X_FRAC)\n124: return frame_width // 2\n125: \n126: \n127: def crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION):\n128: if direction == 'ltr':\n129: return prev_cx < line_x <= cx\n130: if direction == 'both':\n131: return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx)\n132: return prev_cx > line_x >= cx\n133: \n134: \n135: def now_str():\n136: return datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n137: \n138: \n139: def open_capture(source):\n140: if source.lower().startswith(('rtsp://', 'http://')):\n141: os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = RTSP_FFMPEG_OPTIONS\n142: cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG)\n143: cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)\n144: return cap\n145: \n146: \n147: def warmup_stream(cap, n=WARMUP_FRAMES):\n148: print('Warming up stream...')\n149: for _ in range(n):\n150: cap.read()\n151: print('Stream ready!')\n152: \n153: \n154: def open_video_writer(path, w, h, fps):\n155: return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*'avc1'), fps, (w, h))\n156: \n157: \n158: class CsvLogger:\n159: def __init__(self, path, header):\n160: Path(path).parent.mkdir(parents=True, exist_ok=True)\n161: new_file = not Path(path).exists() or Path(path).stat().st_size == 0\n162: self.file = open(path, 'a', newline='', buffering=1)\n163: self.writer = csv.writer(self.file)\n164: if new_file:\n165: self.writer.writerow(header)\n166: self.file.flush()\n167: \n168: def write_row(self, row):\n169: self.writer.writerow(row)\n170: self.file.flush()\n171: \n172: def close(self):\n173: self.file.close()\n174: \n175: \n176: class VideoSegmentWriter:\n177: def __init__(self, output_dir, w, h, fps, segment_sec):\n178: self.output_dir = Path(output_dir)\n179: self.output_dir.mkdir(parents=True, exist_ok=True)\n180: self.w, self.h, self.fps = w, h, fps\n181: self.segment_sec = segment_sec\n182: self.segment_start = time.monotonic()\n183: self.writer = None\n184: self._open_next()\n185: \n186: def _segment_path(self):\n187: ts = datetime.now().strftime('%Y%m%d_%H%M%S')\n188: return str(self.output_dir / f'live_{ts}.mp4')\n189: \n190: def _open_next(self):\n191: if self.writer is not None:\n192: self.writer.release()\n193: path = self._segment_path()\n194: self.writer = open_video_writer(path, self.w, self.h, self.fps)\n195: self.segment_start = time.monotonic()\n196: print(f'Recording segment: {path}')\n197: \n198: def write(self, frame):\n199: if time.monotonic() - self.segment_start >= self.segment_sec:\n200: self._open_next()\n201: self.writer.write(frame)\n202: \n203: def release(self):\n204: if self.writer is not None:\n205: self.writer.release()\n206: \n207: \n208: def prune_stale_tracks(tracked, now_mono):\n209: stale = [tid for tid, (_, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC]\n210: for tid in stale:\n211: del tracked[tid]\n212: \n213: \n214: def overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65):\n215: x1, y1 = max(0, x1), max(0, y1)\n216: x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2)\n217: if x2 <= x1 or y2 <= y1:\n218: return\n219: roi = img[y1:y2, x1:x2]\n220: patch = np.full_like(roi, color, dtype=np.uint8)\n221: cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi)\n222: \n223: \n224: def draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4):\n225: font = cv2.FONT_HERSHEY_SIMPLEX\n226: (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1)\n227: x1, y1 = x, y - th - pad_y\n228: x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y\n229: cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1)\n230: cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1)\n231: cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA)\n232: \n233: \n234: def draw_elegant_counting_line(img, line_x, h, pulse_remaining=0):\n235: strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1)\n236: glow_alpha = 0.12 + 0.18 * strength\n237: for offset in (14, 9, 5):\n238: color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW)\n239: cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA)\n240: cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA)\n241: dash_len, gap = 18, 12\n242: y = 0\n243: while y < h:\n244: y_end = min(y + dash_len, h)\n245: cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA)\n246: y += dash_len + gap\n247: cv2.putText(img, 'COUNT LINE', (line_x - 46, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.42, C_LINE_CORE, 1, cv2.LINE_AA)\n248: \n249: \n250: def draw_hero_count(img, line_x, h, count, pulse_remaining=0):\n251: text = str(count)\n252: font = cv2.FONT_HERSHEY_SIMPLEX\n253: boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1))\n254: font_scale, thickness = 1.6 + boost, 3\n255: (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness)\n256: pad = 14\n257: tx, ty = line_x - tw // 2, h // 2 + th // 2\n258: overlay_rect(img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad // 2, C_PANEL, alpha=0.78)\n259: cv2.rectangle(img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad // 2), C_LINE_CORE, 2)\n260: cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA)\n261: \n262: \n263: def draw_hud(img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate, camera_id, clock):\n264: bar_h = 52\n265: overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72)\n266: cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1)\n267: cv2.putText(img, 'BATCH', (16, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n268: batch_label = str(batch_num) if batch_num else '—'\n269: cv2.putText(img, batch_label, (16, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_ACCENT, 2, cv2.LINE_AA)\n270: cv2.putText(img, 'COUNT', (100, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n271: cv2.putText(img, str(batch_count), (100, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_GREEN, 2, cv2.LINE_AA)\n272: cv2.putText(img, 'TOTAL', (190, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n273: cv2.putText(img, str(total_ayam), (190, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA)\n274: cv2.putText(img, 'UPTIME', (280, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n275: cv2.putText(img, f'{elapsed_sec / 3600:.1f}h', (280, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA)\n276: cv2.putText(img, 'RATE', (380, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n277: cv2.putText(img, f'{rate:.1f}/min', (380, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_ACCENT, 1, cv2.LINE_AA)\n278: cv2.putText(img, clock, (w - 180, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.55, C_TEXT, 1, cv2.LINE_AA)\n279: cv2.putText(img, f'CAM {camera_id}', (w - 180, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n280: \n281: \n282: def draw_footer(img, w, h, frame_idx, live_tag):\n283: bar_h = 28\n284: overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55)\n285: cv2.putText(img, f'{live_tag} | Frame {frame_idx}', (12, h - 9), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n286: \n287: \n288: def draw_skeleton_bold(img, kpts):\n289: for (a, b), color in zip(SKELETON, SK_COLORS):\n290: if a < len(kpts) and b < len(kpts):\n291: xa, ya = int(kpts[a][0]), int(kpts[a][1])\n292: xb, yb = int(kpts[b][0]), int(kpts[b][1])\n293: if xa > 0 and ya > 0 and xb > 0 and yb > 0:\n294: cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA)\n295: for kp in kpts:\n296: x, y = int(kp[0]), int(kp[1])\n297: if x > 0 and y > 0:\n298: cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA)\n299: cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA)\n300: \n301: \n302: def draw_popups(img, popups, frame_idx):\n303: alive = []\n304: for pop in popups:\n305: age = frame_idx - pop['born']\n306: if age > POPUP_LIFETIME:\n307: continue\n308: alive.append(pop)\n309: fade = 1.0 - age / POPUP_LIFETIME\n310: y = pop['y'] - int(age * 1.8)\n311: color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade))\n312: cv2.putText(img, pop['text'], (pop['x'], y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, cv2.LINE_AA)\n313: return alive\n314: \n315: \n316: def draw_batch_banner(img, w, batch_num, pulse_remaining):\n317: if pulse_remaining <= 0:\n318: return\n319: text = f'NEW BATCH {batch_num}'\n320: font = cv2.FONT_HERSHEY_SIMPLEX\n321: (tw, th), _ = cv2.getTextSize(text, font, 0.8, 2)\n322: x1, y1 = w // 2 - tw // 2 - 16, 62\n323: x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20\n324: overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7)\n325: cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2)\n326: cv2.putText(img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA)\n327: \n328: \n329: def connect_stream(source, warmup=WARMUP_FRAMES):\n330: attempts = 0\n331: while not shutdown_requested:\n332: cap = open_capture(source)\n333: if not cap.isOpened():\n334: attempts += 1\n335: if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS:\n336: raise RuntimeError(f'Cannot open source after {attempts} attempts: {source}')\n337: print(f'Cannot open source, retry in {RECONNECT_DELAY_SEC}s...')\n338: time.sleep(RECONNECT_DELAY_SEC)\n339: continue\n340: if warmup > 0 and source.lower().startswith(('rtsp://', 'http://')):\n341: warmup_stream(cap, warmup)\n342: w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))\n343: h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))\n344: fps = cap.get(cv2.CAP_PROP_FPS)\n345: if not fps or fps <= 1:\n346: fps = OUTPUT_FPS\n347: return cap, w, h, fps\n348: return None, 0, 0, OUTPUT_FPS\n349: \n350: \n351: def run():\n352: global shutdown_requested\n353: \n354: store = BatchStore(\n355: db_path=DB_PATH,\n356: state_file=STATE_FILE,\n357: camera_name=CAMERA_NAME,\n358: object_label=OBJECT_LABEL,\n359: cutoff_time=DAILY_CUTOFF_TIME,\n360: batch_timeout=BATCH_TIMEOUT_SECONDS,\n361: ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT,\n362: min_object_per_batch=MIN_OBJECT_PER_BATCH,\n363: min_duration_per_batch=MIN_DURATION_PER_BATCH,\n364: logger=lambda msg: print(f'[{now_str()}] {msg}'),\n365: )\n366: store.start_cutoff_watcher()\n367: \n368: cross_logger = None\n369: if EXPORT_CSV:\n370: cross_logger = CsvLogger(CROSS_CSV, ['batch', 'frame', 'timestamp', 'chicken_id'])\n371: \n372: model = YOLO(MODEL_PATH)\n373: ayam_cls, talenan_cls = resolve_class_ids(model.names)\n374: \n375: ayam_tracked = {}\n376: talenan_tracked = {}\n377: ayam_line_crossed = set()\n378: talenan_line_crossed = set()\n379: \n380: ayam_cross_flash = {}\n381: talenan_cross_flash = {}\n382: line_pulse = count_pulse = batch_pulse = 0\n383: popups = []\n384: \n385: session_start = time.time()\n386: frame_idx = 0\n387: video_writer = None\n388: \n389: cap, w, h, fps = connect_stream(SOURCE)\n390: if cap is None:\n391: store.shutdown()\n392: return\n393: \n394: line_x = resolve_line_x(w)\n395: print(f'Jetson counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}')\n396: print(f'Model: {MODEL_PATH} | imgsz={IMGSZ} half={HALF}')\n397: print(f'DB: {DB_PATH}')\n398: print(f'State: {STATE_FILE}')\n399: \n400: if RECORD_VIDEO:\n401: video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC)\n402: \n403: reconnect_count = 0\n404: \n405: while not shutdown_requested:\n406: ret, frame = cap.read()\n407: if not ret:\n408: if not IS_LIVE:\n409: break\n410: reconnect_count += 1\n411: print(f'Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s...')\n412: cap.release()\n413: time.sleep(RECONNECT_DELAY_SEC)\n414: cap, w, h, fps = connect_stream(SOURCE)\n415: if cap is None:\n416: break\n417: line_x = resolve_line_x(w)\n418: continue\n419: \n420: now = time.time()\n421: elapsed = now - session_start\n422: mono = time.monotonic()\n423: ayam_crossed_frame = batch_closed_frame = batch_started_frame = False\n424: \n425: results = model.track(\n426: frame,\n427: device=DEVICE,\n428: persist=True,\n429: conf=CONF,\n430: imgsz=IMGSZ,\n431: half=HALF,\n432: tracker=TRACKER,\n433: verbose=False,\n434: )\n435: r = results[0]\n436: \n437: if r.boxes.id is not None:\n438: ids = r.boxes.id.int().tolist()\n439: boxes = r.boxes.xyxy.tolist()\n440: clss = r.boxes.cls.int().tolist()\n441: kpts_all = r.keypoints.xy.tolist() if r.keypoints else []\n442: \n443: talenan_items, ayam_items = [], []\n444: for i, (track_id, box, cls_id) in enumerate(zip(ids, boxes, clss)):\n445: cx = box_cx(box)\n446: x1, y1, x2, y2 = [int(v) for v in box]\n447: kpts = kpts_all[i] if i < len(kpts_all) else None\n448: item = (track_id, cx, x1, y1, x2, y2, kpts)\n449: if cls_id == talenan_cls:\n450: talenan_items.append(item)\n451: elif cls_id == ayam_cls:\n452: ayam_items.append(item)\n453: \n454: for track_id, cx, x1, y1, x2, y2, _ in talenan_items:\n455: if track_id in talenan_tracked:\n456: prev_cx, _ = talenan_tracked[track_id]\n457: if crossed_line(prev_cx, cx, line_x) and track_id not in talenan_line_crossed:\n458: talenan_line_crossed.add(track_id)\n459: if store.record_talenan_crossing(track_id):\n460: batch_closed_frame = True\n461: talenan_cross_flash[track_id] = CROSS_FLASH_FRAMES\n462: popups.append({'x': cx - 20, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': 'BATCH CLOSED'})\n463: talenan_tracked[track_id] = (cx, mono)\n464: \n465: for track_id, cx, x1, y1, x2, y2, kpts in ayam_items:\n466: if track_id in ayam_tracked:\n467: prev_cx, _ = ayam_tracked[track_id]\n468: if crossed_line(prev_cx, cx, line_x) and track_id not in ayam_line_crossed:\n469: ayam_line_crossed.add(track_id)\n470: _, started_new = store.record_ayam_crossing(track_id)\n471: if cross_logger:\n472: cross_logger.write_row([\n473: store.current_batch_number, frame_idx,\n474: datetime.now().isoformat(), track_id,\n475: ])\n476: ayam_crossed_frame = True\n477: if started_new:\n478: batch_started_frame = True\n479: ayam_cross_flash[track_id] = CROSS_FLASH_FRAMES\n480: popups.append({'x': cx - 12, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': '+1'})\n481: ayam_tracked[track_id] = (cx, mono)\n482: \n483: for track_id, cx, x1, y1, x2, y2, _ in talenan_items:\n484: flash = talenan_cross_flash.get(track_id, 0)\n485: color = C_GREEN if flash > 0 else C_TALENAN_BOX\n486: cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n487: draw_pill(frame, f'TALENAN {track_id}', x1, y1 - 4, color)\n488: \n489: for track_id, cx, x1, y1, x2, y2, kpts in ayam_items:\n490: flash = ayam_cross_flash.get(track_id, 0)\n491: color = C_GREEN if flash > 0 else C_AYAM_BOX\n492: cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n493: draw_pill(frame, f'ID {track_id}', x1, y1 - 4, color)\n494: if kpts is not None:\n495: draw_skeleton_bold(frame, kpts)\n496: \n497: if ayam_crossed_frame:\n498: line_pulse = LINE_PULSE_FRAMES\n499: count_pulse = COUNT_PULSE_FRAMES\n500: if batch_closed_frame:\n501: line_pulse = LINE_PULSE_FRAMES\n502: if batch_started_frame:\n503: batch_pulse = BATCH_PULSE_FRAMES\n504: \n505: batch_num = store.current_batch_number or 0\n506: batch_count = store.current_batch_count\n507: display_total = store.display_total()\n508: rate = (display_total / elapsed * 60) if elapsed > 0 else 0.0\n509: \n510: draw_elegant_counting_line(frame, line_x, h, line_pulse)\n511: draw_hero_count(frame, line_x, h, batch_count, count_pulse)\n512: draw_hud(frame, w, batch_num, batch_count, display_total, elapsed, rate, CAMERA_NAME, now_str())\n513: draw_batch_banner(frame, w, batch_num, batch_pulse)\n514: draw_footer(frame, w, h, frame_idx, 'LIVE' if IS_LIVE else 'FILE')\n515: popups = draw_popups(frame, popups, frame_idx)\n516: \n517: for flash_store in (ayam_cross_flash, talenan_cross_flash):\n518: for tid in list(flash_store):\n519: flash_store[tid] -= 1\n520: if flash_store[tid] <= 0:\n521: del flash_store[tid]\n522: line_pulse = max(0, line_pulse - 1)\n523: count_pulse = max(0, count_pulse - 1)\n524: batch_pulse = max(0, batch_pulse - 1)\n525: \n526: if video_writer is not None:\n527: video_writer.write(frame)\n528: \n529: if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:\n530: try:\n531: Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)\n532: _, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY])\n533: with open(LIVE_STREAM_FRAME_PATH, 'wb') as f:\n534: f.write(jpeg.tobytes())\n535: except Exception:\n536: pass\n537: \n538: frame_idx += 1\n539: if frame_idx % FLUSH_EVERY_N_FRAMES == 0:\n540: print(\n541: f'[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} '\n542: f'| Total: {display_total} | Uptime {elapsed / 3600:.2f}h'\n543: )\n544: prune_stale_tracks(ayam_tracked, mono)\n545: prune_stale_tracks(talenan_tracked, mono)\n546: \n547: cap.release()\n548: if video_writer is not None:\n549: video_writer.release()\n550: if cross_logger:\n551: cross_logger.close()\n552: store.shutdown()\n553: \n554: print('\\n=== Batch Summary (SQLite) ===')\n555: print(f'Database: {DB_PATH}')\n556: \n557: \n558: if __name__ == '__main__':\n559: run()\n\n(End of file - total 559 lines)\n", + "metadata": { + "preview": "\"\"\"\nEdge production live counter — RTSP + YOLO TensorRT + line crossing.\nReplaces MQTT frigate-counter on Jetson with local LAN camera inference.\n\"\"\"\nfrom ultralytics import YOLO\nimport cv2\nimport csv\nimport numpy as np\nimport os\nimport signal\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\nload_dotenv()\n\nfrom batch_store import BatchStore\n\n# --- config (override via env / .env) ---", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/counter_live.py", + "text": "\"\"\"\nEdge production live counter — RTSP + YOLO TensorRT + line crossing.\nReplaces MQTT frigate-counter on Jetson with local LAN camera inference.\n\"\"\"\nfrom ultralytics import YOLO\nimport cv2\nimport csv\nimport numpy as np\nimport os\nimport signal\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\nload_dotenv()\n\nfrom batch_store import BatchStore\n\n# --- config (override via env / .env) ---\nOUTPUT_DIR = os.getenv('OUTPUT_DIR', '/opt/jetson-counter')\nDB_PATH = os.getenv('DB_PATH', f'{OUTPUT_DIR}/jetson_counter.db')\nSTATE_FILE = os.getenv('STATE_FILE', f'{OUTPUT_DIR}/current_batch.json')\nSOURCE = os.getenv('SOURCE', 'rtsp://user:pass@192.168.0.100:554/stream1')\nMODEL_PATH = os.getenv('MODEL_PATH', '/media/jetson/DATA/yolo11n.engine')\nCAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1')\nOBJECT_LABEL = os.getenv('OBJECT_LABEL', 'ayam-potong')\nCLASS_AYAM = os.getenv('CLASS_AYAM', 'ayam')\nCLASS_TALENAN = os.getenv('CLASS_TALENAN', 'talenan')\n\nLINE_X = int(os.getenv('LINE_X')) if os.getenv('LINE_X') else None\nLINE_X_FRAC = float(os.getenv('LINE_X_FRAC', '0.5'))\nCROSS_DIRECTION = os.getenv('CROSS_DIRECTION', 'rtl').lower()\n\nIMGSZ = int(os.getenv('IMGSZ', '416'))\nHALF = os.getenv('HALF', 'true').lower() == 'true'\nCONF = float(os.getenv('CONF', '0.3'))\nDEVICE = int(os.getenv('DEVICE', '0'))\nTRACKER = os.getenv('TRACKER', 'bytetrack.yaml')\n\nDAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00')\nBATCH_TIMEOUT_SECONDS = float(os.getenv('BATCH_TIMEOUT_SECONDS', '300'))\nIGNORE_BATCH_LABEL_TIMEOUT = float(os.getenv('IGNORE_BATCH_LABEL_TIMEOUT_SECONDS', '30'))\nMIN_OBJECT_PER_BATCH = int(os.getenv('MIN_OBJECT_PER_BATCH', '60'))\nMIN_DURATION_PER_BATCH = int(os.getenv('MIN_DURATION_PER_BATCH', '60'))\n\nEXPORT_CSV = os.getenv('EXPORT_CSV', 'true').lower() == 'true'\nCROSS_CSV = os.getenv('CROSS_CSV', f'{OUTPUT_DIR}/batch_crossings.csv')\n\nWARMUP_FRAMES = int(os.getenv('WARMUP_FRAMES', '30'))\nRECONNECT_DELAY_SEC = int(os.getenv('RECONNECT_DELAY_SEC', '3'))\nMAX_RECONNECT_ATTEMPTS = int(os.getenv('MAX_RECONNECT_ATTEMPTS', '0'))\nFLUSH_EVERY_N_FRAMES = int(os.getenv('FLUSH_EVERY_N_FRAMES', '100'))\nTRACKED_PRUNE_SEC = int(os.getenv('TRACKED_PRUNE_SEC', '300'))\nRECORD_VIDEO = os.getenv('RECORD_VIDEO', 'false').lower() == 'true'\nVIDEO_SEGMENT_SEC = int(os.getenv('VIDEO_SEGMENT_SEC', '3600'))\nOUTPUT_FPS = int(os.getenv('OUTPUT_FPS', '15'))\n\nLIVE_STREAM_ENABLED = os.getenv('LIVE_STREAM_ENABLED', 'false').lower() == 'true'\nLIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', '/dev/shm/jetson-counter/live_frame.jpg')\nLIVE_STREAM_QUALITY = int(os.getenv('LIVE_STREAM_QUALITY', '75'))\nLIVE_STREAM_EVERY_N = int(os.getenv('LIVE_STREAM_EVERY_N', '2'))\n\nRTSP_FFMPEG_OPTIONS = os.getenv(\n 'OPENCV_FFMPEG_CAPTURE_OPTIONS',\n 'rtsp_transport;tcp|fflags;nobuffer|flags;low_delay',\n)\n\nIS_LIVE = SOURCE.lower().startswith(('rtsp://', 'http://'))\n\nCROSS_FLASH_FRAMES = 12\nPOPUP_LIFETIME = 20\nLINE_PULSE_FRAMES = 12\nCOUNT_PULSE_FRAMES = 15\nBATCH_PULSE_FRAMES = 20\n\nSKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)]\nSK_COLORS = [\n (0, 255, 255), (0, 255, 255), (255, 0, 255), (255, 0, 255),\n (0, 255, 0), (255, 255, 0), (0, 0, 255), (200, 200, 0),\n]\n\nC_PANEL = (28, 24, 18)\nC_BORDER = (90, 85, 75)\nC_ACCENT = (255, 200, 60)\nC_GREEN = (80, 220, 100)\nC_TEXT = (235, 235, 235)\nC_MUTED = (150, 150, 150)\nC_AYAM_BOX = (0, 165, 255)\nC_TALENAN_BOX = (220, 120, 60)\nC_LINE_CORE = (180, 220, 255)\nC_LINE_GLOW = (100, 160, 220)\n\nshutdown_requested = False\n\n\ndef request_shutdown(signum, frame):\n global shutdown_requested\n shutdown_requested = True\n print('\\nShutdown requested — finishing current frame...')\n\n\nsignal.signal(signal.SIGINT, request_shutdown)\nsignal.signal(signal.SIGTERM, request_shutdown)\n\n\ndef resolve_class_ids(names):\n name_to_id = {v: k for k, v in names.items()}\n missing = [n for n in (CLASS_AYAM, CLASS_TALENAN) if n not in name_to_id]\n if missing:\n raise ValueError(f'Model missing classes {missing}. Available: {list(names.values())}')\n return name_to_id[CLASS_AYAM], name_to_id[CLASS_TALENAN]\n\n\ndef box_cx(box):\n return (int(box[0]) + int(box[2])) // 2\n\n\ndef resolve_line_x(frame_width):\n if LINE_X is not None:\n return LINE_X\n if LINE_X_FRAC != 0.5:\n return int(frame_width * LINE_X_FRAC)\n return frame_width // 2\n\n\ndef crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION):\n if direction == 'ltr':\n return prev_cx < line_x <= cx\n if direction == 'both':\n return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx)\n return prev_cx > line_x >= cx\n\n\ndef now_str():\n return datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n\n\ndef open_capture(source):\n if source.lower().startswith(('rtsp://', 'http://')):\n os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = RTSP_FFMPEG_OPTIONS\n cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG)\n cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)\n return cap\n\n\ndef warmup_stream(cap, n=WARMUP_FRAMES):\n print('Warming up stream...')\n for _ in range(n):\n cap.read()\n print('Stream ready!')\n\n\ndef open_video_writer(path, w, h, fps):\n return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*'avc1'), fps, (w, h))\n\n\nclass CsvLogger:\n def __init__(self, path, header):\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n new_file = not Path(path).exists() or Path(path).stat().st_size == 0\n self.file = open(path, 'a', newline='', buffering=1)\n self.writer = csv.writer(self.file)\n if new_file:\n self.writer.writerow(header)\n self.file.flush()\n\n def write_row(self, row):\n self.writer.writerow(row)\n self.file.flush()\n\n def close(self):\n self.file.close()\n\n\nclass VideoSegmentWriter:\n def __init__(self, output_dir, w, h, fps, segment_sec):\n self.output_dir = Path(output_dir)\n self.output_dir.mkdir(parents=True, exist_ok=True)\n self.w, self.h, self.fps = w, h, fps\n self.segment_sec = segment_sec\n self.segment_start = time.monotonic()\n self.writer = None\n self._open_next()\n\n def _segment_path(self):\n ts = datetime.now().strftime('%Y%m%d_%H%M%S')\n return str(self.output_dir / f'live_{ts}.mp4')\n\n def _open_next(self):\n if self.writer is not None:\n self.writer.release()\n path = self._segment_path()\n self.writer = open_video_writer(path, self.w, self.h, self.fps)\n self.segment_start = time.monotonic()\n print(f'Recording segment: {path}')\n\n def write(self, frame):\n if time.monotonic() - self.segment_start >= self.segment_sec:\n self._open_next()\n self.writer.write(frame)\n\n def release(self):\n if self.writer is not None:\n self.writer.release()\n\n\ndef prune_stale_tracks(tracked, now_mono):\n stale = [tid for tid, (_, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC]\n for tid in stale:\n del tracked[tid]\n\n\ndef overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65):\n x1, y1 = max(0, x1), max(0, y1)\n x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2)\n if x2 <= x1 or y2 <= y1:\n return\n roi = img[y1:y2, x1:x2]\n patch = np.full_like(roi, color, dtype=np.uint8)\n cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi)\n\n\ndef draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4):\n font = cv2.FONT_HERSHEY_SIMPLEX\n (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1)\n x1, y1 = x, y - th - pad_y\n x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y\n cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1)\n cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1)\n cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA)\n\n\ndef draw_elegant_counting_line(img, line_x, h, pulse_remaining=0):\n strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1)\n glow_alpha = 0.12 + 0.18 * strength\n for offset in (14, 9, 5):\n color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW)\n cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA)\n cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA)\n dash_len, gap = 18, 12\n y = 0\n while y < h:\n y_end = min(y + dash_len, h)\n cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA)\n y += dash_len + gap\n cv2.putText(img, 'COUNT LINE', (line_x - 46, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.42, C_LINE_CORE, 1, cv2.LINE_AA)\n\n\ndef draw_hero_count(img, line_x, h, count, pulse_remaining=0):\n text = str(count)\n font = cv2.FONT_HERSHEY_SIMPLEX\n boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1))\n font_scale, thickness = 1.6 + boost, 3\n (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness)\n pad = 14\n tx, ty = line_x - tw // 2, h // 2 + th // 2\n overlay_rect(img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad // 2, C_PANEL, alpha=0.78)\n cv2.rectangle(img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad // 2), C_LINE_CORE, 2)\n cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA)\n\n\ndef draw_hud(img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate, camera_id, clock):\n bar_h = 52\n overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72)\n cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1)\n cv2.putText(img, 'BATCH', (16, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n batch_label = str(batch_num) if batch_num else '—'\n cv2.putText(img, batch_label, (16, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_ACCENT, 2, cv2.LINE_AA)\n cv2.putText(img, 'COUNT', (100, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n cv2.putText(img, str(batch_count), (100, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_GREEN, 2, cv2.LINE_AA)\n cv2.putText(img, 'TOTAL', (190, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n cv2.putText(img, str(total_ayam), (190, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA)\n cv2.putText(img, 'UPTIME', (280, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n cv2.putText(img, f'{elapsed_sec / 3600:.1f}h', (280, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA)\n cv2.putText(img, 'RATE', (380, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n cv2.putText(img, f'{rate:.1f}/min', (380, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_ACCENT, 1, cv2.LINE_AA)\n cv2.putText(img, clock, (w - 180, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.55, C_TEXT, 1, cv2.LINE_AA)\n cv2.putText(img, f'CAM {camera_id}', (w - 180, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n\n\ndef draw_footer(img, w, h, frame_idx, live_tag):\n bar_h = 28\n overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55)\n cv2.putText(img, f'{live_tag} | Frame {frame_idx}', (12, h - 9), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)\n\n\ndef draw_skeleton_bold(img, kpts):\n for (a, b), color in zip(SKELETON, SK_COLORS):\n if a < len(kpts) and b < len(kpts):\n xa, ya = int(kpts[a][0]), int(kpts[a][1])\n xb, yb = int(kpts[b][0]), int(kpts[b][1])\n if xa > 0 and ya > 0 and xb > 0 and yb > 0:\n cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA)\n for kp in kpts:\n x, y = int(kp[0]), int(kp[1])\n if x > 0 and y > 0:\n cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA)\n cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA)\n\n\ndef draw_popups(img, popups, frame_idx):\n alive = []\n for pop in popups:\n age = frame_idx - pop['born']\n if age > POPUP_LIFETIME:\n continue\n alive.append(pop)\n fade = 1.0 - age / POPUP_LIFETIME\n y = pop['y'] - int(age * 1.8)\n color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade))\n cv2.putText(img, pop['text'], (pop['x'], y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, cv2.LINE_AA)\n return alive\n\n\ndef draw_batch_banner(img, w, batch_num, pulse_remaining):\n if pulse_remaining <= 0:\n return\n text = f'NEW BATCH {batch_num}'\n font = cv2.FONT_HERSHEY_SIMPLEX\n (tw, th), _ = cv2.getTextSize(text, font, 0.8, 2)\n x1, y1 = w // 2 - tw // 2 - 16, 62\n x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20\n overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7)\n cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2)\n cv2.putText(img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA)\n\n\ndef connect_stream(source, warmup=WARMUP_FRAMES):\n attempts = 0\n while not shutdown_requested:\n cap = open_capture(source)\n if not cap.isOpened():\n attempts += 1\n if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS:\n raise RuntimeError(f'Cannot open source after {attempts} attempts: {source}')\n print(f'Cannot open source, retry in {RECONNECT_DELAY_SEC}s...')\n time.sleep(RECONNECT_DELAY_SEC)\n continue\n if warmup > 0 and source.lower().startswith(('rtsp://', 'http://')):\n warmup_stream(cap, warmup)\n w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))\n h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))\n fps = cap.get(cv2.CAP_PROP_FPS)\n if not fps or fps <= 1:\n fps = OUTPUT_FPS\n return cap, w, h, fps\n return None, 0, 0, OUTPUT_FPS\n\n\ndef run():\n global shutdown_requested\n\n store = BatchStore(\n db_path=DB_PATH,\n state_file=STATE_FILE,\n camera_name=CAMERA_NAME,\n object_label=OBJECT_LABEL,\n cutoff_time=DAILY_CUTOFF_TIME,\n batch_timeout=BATCH_TIMEOUT_SECONDS,\n ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT,\n min_object_per_batch=MIN_OBJECT_PER_BATCH,\n min_duration_per_batch=MIN_DURATION_PER_BATCH,\n logger=lambda msg: print(f'[{now_str()}] {msg}'),\n )\n store.start_cutoff_watcher()\n\n cross_logger = None\n if EXPORT_CSV:\n cross_logger = CsvLogger(CROSS_CSV, ['batch', 'frame', 'timestamp', 'chicken_id'])\n\n model = YOLO(MODEL_PATH)\n ayam_cls, talenan_cls = resolve_class_ids(model.names)\n\n ayam_tracked = {}\n talenan_tracked = {}\n ayam_line_crossed = set()\n talenan_line_crossed = set()\n\n ayam_cross_flash = {}\n talenan_cross_flash = {}\n line_pulse = count_pulse = batch_pulse = 0\n popups = []\n\n session_start = time.time()\n frame_idx = 0\n video_writer = None\n\n cap, w, h, fps = connect_stream(SOURCE)\n if cap is None:\n store.shutdown()\n return\n\n line_x = resolve_line_x(w)\n print(f'Jetson counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}')\n print(f'Model: {MODEL_PATH} | imgsz={IMGSZ} half={HALF}')\n print(f'DB: {DB_PATH}')\n print(f'State: {STATE_FILE}')\n\n if RECORD_VIDEO:\n video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC)\n\n reconnect_count = 0\n\n while not shutdown_requested:\n ret, frame = cap.read()\n if not ret:\n if not IS_LIVE:\n break\n reconnect_count += 1\n print(f'Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s...')\n cap.release()\n time.sleep(RECONNECT_DELAY_SEC)\n cap, w, h, fps = connect_stream(SOURCE)\n if cap is None:\n break\n line_x = resolve_line_x(w)\n continue\n\n now = time.time()\n elapsed = now - session_start\n mono = time.monotonic()\n ayam_crossed_frame = batch_closed_frame = batch_started_frame = False\n\n results = model.track(\n frame,\n device=DEVICE,\n persist=True,\n conf=CONF,\n imgsz=IMGSZ,\n half=HALF,\n tracker=TRACKER,\n verbose=False,\n )\n r = results[0]\n\n if r.boxes.id is not None:\n ids = r.boxes.id.int().tolist()\n boxes = r.boxes.xyxy.tolist()\n clss = r.boxes.cls.int().tolist()\n kpts_all = r.keypoints.xy.tolist() if r.keypoints else []\n\n talenan_items, ayam_items = [], []\n for i, (track_id, box, cls_id) in enumerate(zip(ids, boxes, clss)):\n cx = box_cx(box)\n x1, y1, x2, y2 = [int(v) for v in box]\n kpts = kpts_all[i] if i < len(kpts_all) else None\n item = (track_id, cx, x1, y1, x2, y2, kpts)\n if cls_id == talenan_cls:\n talenan_items.append(item)\n elif cls_id == ayam_cls:\n ayam_items.append(item)\n\n for track_id, cx, x1, y1, x2, y2, _ in talenan_items:\n if track_id in talenan_tracked:\n prev_cx, _ = talenan_tracked[track_id]\n if crossed_line(prev_cx, cx, line_x) and track_id not in talenan_line_crossed:\n talenan_line_crossed.add(track_id)\n if store.record_talenan_crossing(track_id):\n batch_closed_frame = True\n talenan_cross_flash[track_id] = CROSS_FLASH_FRAMES\n popups.append({'x': cx - 20, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': 'BATCH CLOSED'})\n talenan_tracked[track_id] = (cx, mono)\n\n for track_id, cx, x1, y1, x2, y2, kpts in ayam_items:\n if track_id in ayam_tracked:\n prev_cx, _ = ayam_tracked[track_id]\n if crossed_line(prev_cx, cx, line_x) and track_id not in ayam_line_crossed:\n ayam_line_crossed.add(track_id)\n _, started_new = store.record_ayam_crossing(track_id)\n if cross_logger:\n cross_logger.write_row([\n store.current_batch_number, frame_idx,\n datetime.now().isoformat(), track_id,\n ])\n ayam_crossed_frame = True\n if started_new:\n batch_started_frame = True\n ayam_cross_flash[track_id] = CROSS_FLASH_FRAMES\n popups.append({'x': cx - 12, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': '+1'})\n ayam_tracked[track_id] = (cx, mono)\n\n for track_id, cx, x1, y1, x2, y2, _ in talenan_items:\n flash = talenan_cross_flash.get(track_id, 0)\n color = C_GREEN if flash > 0 else C_TALENAN_BOX\n cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n draw_pill(frame, f'TALENAN {track_id}', x1, y1 - 4, color)\n\n for track_id, cx, x1, y1, x2, y2, kpts in ayam_items:\n flash = ayam_cross_flash.get(track_id, 0)\n color = C_GREEN if flash > 0 else C_AYAM_BOX\n cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n draw_pill(frame, f'ID {track_id}', x1, y1 - 4, color)\n if kpts is not None:\n draw_skeleton_bold(frame, kpts)\n\n if ayam_crossed_frame:\n line_pulse = LINE_PULSE_FRAMES\n count_pulse = COUNT_PULSE_FRAMES\n if batch_closed_frame:\n line_pulse = LINE_PULSE_FRAMES\n if batch_started_frame:\n batch_pulse = BATCH_PULSE_FRAMES\n\n batch_num = store.current_batch_number or 0\n batch_count = store.current_batch_count\n display_total = store.display_total()\n rate = (display_total / elapsed * 60) if elapsed > 0 else 0.0\n\n draw_elegant_counting_line(frame, line_x, h, line_pulse)\n draw_hero_count(frame, line_x, h, batch_count, count_pulse)\n draw_hud(frame, w, batch_num, batch_count, display_total, elapsed, rate, CAMERA_NAME, now_str())\n draw_batch_banner(frame, w, batch_num, batch_pulse)\n draw_footer(frame, w, h, frame_idx, 'LIVE' if IS_LIVE else 'FILE')\n popups = draw_popups(frame, popups, frame_idx)\n\n for flash_store in (ayam_cross_flash, talenan_cross_flash):\n for tid in list(flash_store):\n flash_store[tid] -= 1\n if flash_store[tid] <= 0:\n del flash_store[tid]\n line_pulse = max(0, line_pulse - 1)\n count_pulse = max(0, count_pulse - 1)\n batch_pulse = max(0, batch_pulse - 1)\n\n if video_writer is not None:\n video_writer.write(frame)\n\n if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:\n try:\n Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)\n _, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY])\n with open(LIVE_STREAM_FRAME_PATH, 'wb') as f:\n f.write(jpeg.tobytes())\n except Exception:\n pass\n\n frame_idx += 1\n if frame_idx % FLUSH_EVERY_N_FRAMES == 0:\n print(\n f'[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} '\n f'| Total: {display_total} | Uptime {elapsed / 3600:.2f}h'\n )\n prune_stale_tracks(ayam_tracked, mono)\n prune_stale_tracks(talenan_tracked, mono)\n\n cap.release()\n if video_writer is not None:\n video_writer.release()\n if cross_logger:\n cross_logger.close()\n store.shutdown()\n\n print('\\n=== Batch Summary (SQLite) ===')\n print(f'Database: {DB_PATH}')\n\n\nif __name__ == '__main__':\n run()", + "lineStart": 1, + "lineEnd": 559, + "totalLines": 559, + "truncated": false + } + }, + "title": "counter_live.py", + "time": { + "start": 1785284239384, + "end": 1785284239564 + } + }, + "id": "prt_fab3b9f17001mQ2o24A7ATDGTQ", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_01_EH0xKXBAcnACHOs2YfUx7328", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/batch_store.py" + }, + "output": "/opt/bytetrack-counter/batch_store.py\nfile\n\n1: \"\"\"\n2: Production batch persistence for edge Jetson counter.\n3: Mirrors frigate-counter SQLite schema + current_batch.json contract.\n4: \"\"\"\n5: import json\n6: import sqlite3\n7: import threading\n8: import time\n9: from datetime import datetime, timedelta\n10: from pathlib import Path\n11: \n12: \n13: class BatchStore:\n14: def __init__(\n15: self,\n16: db_path,\n17: state_file,\n18: camera_name,\n19: object_label='ayam-potong',\n20: cutoff_time='20:00',\n21: batch_timeout=300.0,\n22: ignore_batch_label_timeout=30.0,\n23: min_object_per_batch=60,\n24: min_duration_per_batch=60,\n25: carry_ids=50,\n26: logger=print,\n27: ):\n28: self.db_path = db_path\n29: self.state_file = Path(state_file)\n30: self.camera_name = camera_name\n31: self.object_label = object_label\n32: self.cutoff_time_str = cutoff_time\n33: datetime.strptime(cutoff_time, '%H:%M')\n34: \n35: self.batch_timeout = float(batch_timeout)\n36: self.ignore_batch_label_timeout = float(ignore_batch_label_timeout)\n37: self.min_object_per_batch = int(min_object_per_batch)\n38: self.min_duration_per_batch = int(min_duration_per_batch)\n39: self.carry_ids = int(carry_ids)\n40: self.log = logger\n41: \n42: self.state_lock = threading.Lock()\n43: self.batch_timer = None\n44: self.ignore_batch_label = False\n45: self.ignore_batch_label_timer = None\n46: self.previous_state = None\n47: self.shutdown_event = threading.Event()\n48: \n49: Path(db_path).parent.mkdir(parents=True, exist_ok=True)\n50: self.state_file.parent.mkdir(parents=True, exist_ok=True)\n51: \n52: self.db = sqlite3.connect(db_path, check_same_thread=False)\n53: self._init_db()\n54: self.current_state = self._load_state()\n55: self.previous_state = self.current_state\n56: \n57: def _init_db(self):\n58: cur = self.db.cursor()\n59: cur.execute(\n60: \"\"\"\n61: CREATE TABLE IF NOT EXISTS batches (\n62: id INTEGER PRIMARY KEY AUTOINCREMENT,\n63: counting_date TEXT NOT NULL,\n64: batch_number INTEGER NOT NULL,\n65: camera_name TEXT NOT NULL,\n66: object_label TEXT NOT NULL,\n67: count INTEGER NOT NULL,\n68: start_time TEXT NOT NULL,\n69: end_time TEXT NOT NULL,\n70: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n71: UNIQUE(counting_date, batch_number, camera_name, object_label)\n72: )\n73: \"\"\"\n74: )\n75: cur.execute(\n76: \"\"\"\n77: CREATE TABLE IF NOT EXISTS daily_summaries (\n78: id INTEGER PRIMARY KEY AUTOINCREMENT,\n79: counting_date TEXT NOT NULL,\n80: camera_name TEXT NOT NULL,\n81: object_label TEXT NOT NULL,\n82: total_count INTEGER NOT NULL DEFAULT 0,\n83: total_batches INTEGER NOT NULL DEFAULT 0,\n84: updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n85: UNIQUE(counting_date, camera_name, object_label)\n86: )\n87: \"\"\"\n88: )\n89: self.db.commit()\n90: \n91: def get_counting_date(self, dt=None):\n92: if dt is None:\n93: dt = datetime.now()\n94: cutoff = datetime.strptime(self.cutoff_time_str, '%H:%M').time()\n95: if dt.time() < cutoff:\n96: return dt.date().isoformat()\n97: return (dt.date() + timedelta(days=1)).isoformat()\n98: \n99: def _load_state(self):\n100: if not self.state_file.exists():\n101: return None\n102: try:\n103: with open(self.state_file, 'r', encoding='utf-8') as f:\n104: state = json.load(f)\n105: current_date = self.get_counting_date()\n106: if state.get('counting_date') != current_date:\n107: self.log(\n108: f\"State file belongs to previous counting day ({state.get('counting_date')}). \"\n109: 'Finalizing before fresh start.'\n110: )\n111: self._insert_batch(\n112: state['counting_date'],\n113: state['batch_number'],\n114: state['count'],\n115: state['start_time'],\n116: datetime.now().isoformat(),\n117: )\n118: self.state_file.unlink(missing_ok=True)\n119: return None\n120: self.log(\n121: f\"Resumed batch #{state['batch_number']} from {state['start_time']} \"\n122: f\"with count={state['count']}\"\n123: )\n124: self._reset_batch_timer()\n125: return state\n126: except Exception as exc:\n127: self.log(f'Failed to load state file: {exc}')\n128: return None\n129: \n130: def save_state(self):\n131: if self.current_state is None:\n132: self.state_file.unlink(missing_ok=True)\n133: return\n134: with open(self.state_file, 'w', encoding='utf-8') as f:\n135: json.dump(self.current_state, f, indent=2, ensure_ascii=False)\n136: \n137: def get_next_batch_number(self, counting_date):\n138: cur = self.db.cursor()\n139: cur.execute(\n140: \"\"\"\n141: SELECT COALESCE(MAX(batch_number), 0)\n142: FROM batches\n143: WHERE counting_date = ? AND camera_name = ? AND object_label = ?\n144: \"\"\",\n145: (counting_date, self.camera_name, self.object_label),\n146: )\n147: return cur.fetchone()[0] + 1\n148: \n149: def start_new_batch(self, counting_date):\n150: batch_number = self.get_next_batch_number(counting_date)\n151: now = datetime.now().isoformat()\n152: counted_ids = []\n153: if self.previous_state is not None:\n154: try:\n155: counted_ids = self.previous_state['counted_event_ids'][-self.carry_ids:]\n156: except (KeyError, TypeError):\n157: counted_ids = []\n158: self.current_state = {\n159: 'counting_date': counting_date,\n160: 'batch_number': batch_number,\n161: 'count': 0,\n162: 'start_time': now,\n163: 'last_detection_time': now,\n164: 'counted_event_ids': counted_ids,\n165: }\n166: self.save_state()\n167: self.log(f'Started batch #{batch_number} for {counting_date} ({self.object_label})')\n168: \n169: def _reset_batch_timer(self):\n170: if self.batch_timer:\n171: self.batch_timer.cancel()\n172: self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout)\n173: self.batch_timer.daemon = True\n174: self.batch_timer.start()\n175: \n176: def _on_batch_timeout(self):\n177: self.log(f'Batch inactivity timeout ({self.batch_timeout}s) reached')\n178: self.end_batch(closed_by='timeout')\n179: \n180: def _ignore_batch_label(self):\n181: if not self.ignore_batch_label_timer:\n182: self.ignore_batch_label = True\n183: self.ignore_batch_label_timer = threading.Timer(\n184: self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout\n185: )\n186: self.ignore_batch_label_timer.daemon = True\n187: self.ignore_batch_label_timer.start()\n188: self.log(\n189: f'Ignore batch label for {self.ignore_batch_label_timeout}s'\n190: )\n191: \n192: def _on_ignore_batch_label_timeout(self):\n193: self.ignore_batch_label_timer = None\n194: self.ignore_batch_label = False\n195: self.log('Ignore batch label cooldown finished')\n196: \n197: def record_ayam_crossing(self, track_id):\n198: \"\"\"Line-cross equivalent of production ayam-potong MQTT event.\"\"\"\n199: with self.state_lock:\n200: counting_date = self.get_counting_date()\n201: started_new = False\n202: if self.current_state is None:\n203: self.start_new_batch(counting_date)\n204: started_new = True\n205: elif self.current_state['counting_date'] != counting_date:\n206: self._end_batch_locked(closed_by='cutoff')\n207: self.start_new_batch(counting_date)\n208: started_new = True\n209: \n210: event_key = str(track_id)\n211: if event_key not in self.current_state['counted_event_ids']:\n212: self.current_state['count'] += 1\n213: self.current_state['counted_event_ids'].append(event_key)\n214: self.log(\n215: f'Counted ayam (track {track_id}) | batch #{self.current_state[\"batch_number\"]} '\n216: f'total: {self.current_state[\"count\"]}'\n217: )\n218: \n219: self.current_state['last_detection_time'] = datetime.now().isoformat()\n220: self.save_state()\n221: self._reset_batch_timer()\n222: return self.current_state['count'], started_new\n223: \n224: def record_talenan_crossing(self, track_id):\n225: \"\"\"Line-cross equivalent of production telenan MQTT batch close.\"\"\"\n226: if self.ignore_batch_label:\n227: return False\n228: with self.state_lock:\n229: self._ignore_batch_label()\n230: self._end_batch_locked(closed_by='talenan')\n231: self.log(f'Batch closed by talenan (track {track_id})')\n232: if self.batch_timer:\n233: self.batch_timer.cancel()\n234: self.batch_timer = None\n235: return True\n236: \n237: def end_batch(self, closed_by='manual'):\n238: with self.state_lock:\n239: self._end_batch_locked(closed_by=closed_by)\n240: \n241: def _end_batch_locked(self, closed_by='manual'):\n242: if self.current_state is None:\n243: return False\n244: \n245: self.previous_state = self.current_state\n246: state = self.current_state\n247: \n248: start_time_obj = datetime.fromisoformat(state['start_time'])\n249: end_time_obj = datetime.now()\n250: duration_seconds = (end_time_obj - start_time_obj).total_seconds()\n251: \n252: if (state['count'] < self.min_object_per_batch\n253: or duration_seconds < self.min_duration_per_batch):\n254: self.current_state = None\n255: self.save_state()\n256: if self.batch_timer:\n257: self.batch_timer.cancel()\n258: self.batch_timer = None\n259: self.log(\n260: f'Batch #{state[\"batch_number\"]} discarded '\n261: f'(count={state[\"count\"]}, duration={duration_seconds:.0f}s)'\n262: )\n263: return False\n264: \n265: end_time = end_time_obj.isoformat()\n266: try:\n267: self._insert_batch(\n268: state['counting_date'],\n269: state['batch_number'],\n270: state['count'],\n271: state['start_time'],\n272: end_time,\n273: )\n274: cps = state['count'] / duration_seconds if duration_seconds > 0 else 0\n275: self.log(\n276: f'Batch #{state[\"batch_number\"]} ended | count={state[\"count\"]} | '\n277: f'duration={duration_seconds:.0f}s | cps={cps:.3f} | closed_by={closed_by}'\n278: )\n279: except Exception as exc:\n280: self.log(f'Failed to persist batch: {exc}')\n281: return False\n282: \n283: self.current_state = None\n284: self.save_state()\n285: if self.batch_timer:\n286: self.batch_timer.cancel()\n287: self.batch_timer = None\n288: return True\n289: \n290: def _insert_batch(self, counting_date, batch_number, count, start_time, end_time):\n291: cur = self.db.cursor()\n292: cur.execute(\n293: \"\"\"\n294: INSERT INTO batches\n295: (counting_date, batch_number, camera_name, object_label, count, start_time, end_time)\n296: VALUES (?, ?, ?, ?, ?, ?, ?)\n297: \"\"\",\n298: (counting_date, batch_number, self.camera_name, self.object_label, count, start_time, end_time),\n299: )\n300: cur.execute(\n301: \"\"\"\n302: INSERT INTO daily_summaries\n303: (counting_date, camera_name, object_label, total_count, total_batches)\n304: VALUES (?, ?, ?, ?, 1)\n305: ON CONFLICT(counting_date, camera_name, object_label)\n306: DO UPDATE SET\n307: total_count = total_count + excluded.total_count,\n308: total_batches = total_batches + excluded.total_batches,\n309: updated_at = CURRENT_TIMESTAMP\n310: \"\"\",\n311: (counting_date, self.camera_name, self.object_label, count),\n312: )\n313: self.db.commit()\n314: \n315: cur.execute(\n316: \"\"\"\n317: SELECT total_count, total_batches\n318: FROM daily_summaries\n319: WHERE counting_date = ? AND camera_name = ? AND object_label = ?\n320: \"\"\",\n321: (counting_date, self.camera_name, self.object_label),\n322: )\n323: row = cur.fetchone()\n324: if row:\n325: self.log(\n326: f'Daily totals for {counting_date}: {row[0]} objects across {row[1]} batch(es)'\n327: )\n328: \n329: def cutoff_watcher_loop(self):\n330: while not self.shutdown_event.is_set():\n331: time.sleep(60)\n332: with self.state_lock:\n333: if self.current_state is None:\n334: continue\n335: if self.current_state['counting_date'] != self.get_counting_date():\n336: self.log('Daily cutoff reached – finalizing batch')\n337: self._end_batch_locked(closed_by='cutoff')\n338: \n339: def start_cutoff_watcher(self):\n340: t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True)\n341: t.start()\n342: return t\n343: \n344: @property\n345: def current_batch_number(self):\n346: if self.current_state is None:\n347: return 0\n348: return self.current_state['batch_number']\n349: \n350: @property\n351: def current_batch_count(self):\n352: if self.current_state is None:\n353: return 0\n354: return self.current_state['count']\n355: \n356: def get_closed_total_for_day(self, counting_date=None):\n357: if counting_date is None:\n358: counting_date = self.get_counting_date()\n359: cur = self.db.cursor()\n360: cur.execute(\n361: \"\"\"\n362: SELECT COALESCE(total_count, 0)\n363: FROM daily_summaries\n364: WHERE counting_date = ? AND camera_name = ? AND object_label = ?\n365: \"\"\",\n366: (counting_date, self.camera_name, self.object_label),\n367: )\n368: row = cur.fetchone()\n369: return row[0] if row else 0\n370: \n371: def display_total(self):\n372: return self.get_closed_total_for_day() + self.current_batch_count\n373: \n374: def shutdown(self):\n375: self.shutdown_event.set()\n376: self.end_batch(closed_by='shutdown')\n377: if self.batch_timer:\n378: self.batch_timer.cancel()\n379: self.db.close()\n\n(End of file - total 379 lines)\n", + "metadata": { + "preview": "\"\"\"\nProduction batch persistence for edge Jetson counter.\nMirrors frigate-counter SQLite schema + current_batch.json contract.\n\"\"\"\nimport json\nimport sqlite3\nimport threading\nimport time\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\n\nclass BatchStore:\n def __init__(\n self,\n db_path,\n state_file,\n camera_name,\n object_label='ayam-potong',\n cutoff_time='20:00',", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/batch_store.py", + "text": "\"\"\"\nProduction batch persistence for edge Jetson counter.\nMirrors frigate-counter SQLite schema + current_batch.json contract.\n\"\"\"\nimport json\nimport sqlite3\nimport threading\nimport time\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\n\nclass BatchStore:\n def __init__(\n self,\n db_path,\n state_file,\n camera_name,\n object_label='ayam-potong',\n cutoff_time='20:00',\n batch_timeout=300.0,\n ignore_batch_label_timeout=30.0,\n min_object_per_batch=60,\n min_duration_per_batch=60,\n carry_ids=50,\n logger=print,\n ):\n self.db_path = db_path\n self.state_file = Path(state_file)\n self.camera_name = camera_name\n self.object_label = object_label\n self.cutoff_time_str = cutoff_time\n datetime.strptime(cutoff_time, '%H:%M')\n\n self.batch_timeout = float(batch_timeout)\n self.ignore_batch_label_timeout = float(ignore_batch_label_timeout)\n self.min_object_per_batch = int(min_object_per_batch)\n self.min_duration_per_batch = int(min_duration_per_batch)\n self.carry_ids = int(carry_ids)\n self.log = logger\n\n self.state_lock = threading.Lock()\n self.batch_timer = None\n self.ignore_batch_label = False\n self.ignore_batch_label_timer = None\n self.previous_state = None\n self.shutdown_event = threading.Event()\n\n Path(db_path).parent.mkdir(parents=True, exist_ok=True)\n self.state_file.parent.mkdir(parents=True, exist_ok=True)\n\n self.db = sqlite3.connect(db_path, check_same_thread=False)\n self._init_db()\n self.current_state = self._load_state()\n self.previous_state = self.current_state\n\n def _init_db(self):\n cur = self.db.cursor()\n cur.execute(\n \"\"\"\n CREATE TABLE IF NOT EXISTS batches (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n counting_date TEXT NOT NULL,\n batch_number INTEGER NOT NULL,\n camera_name TEXT NOT NULL,\n object_label TEXT NOT NULL,\n count INTEGER NOT NULL,\n start_time TEXT NOT NULL,\n end_time TEXT NOT NULL,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(counting_date, batch_number, camera_name, object_label)\n )\n \"\"\"\n )\n cur.execute(\n \"\"\"\n CREATE TABLE IF NOT EXISTS daily_summaries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n counting_date TEXT NOT NULL,\n camera_name TEXT NOT NULL,\n object_label TEXT NOT NULL,\n total_count INTEGER NOT NULL DEFAULT 0,\n total_batches INTEGER NOT NULL DEFAULT 0,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(counting_date, camera_name, object_label)\n )\n \"\"\"\n )\n self.db.commit()\n\n def get_counting_date(self, dt=None):\n if dt is None:\n dt = datetime.now()\n cutoff = datetime.strptime(self.cutoff_time_str, '%H:%M').time()\n if dt.time() < cutoff:\n return dt.date().isoformat()\n return (dt.date() + timedelta(days=1)).isoformat()\n\n def _load_state(self):\n if not self.state_file.exists():\n return None\n try:\n with open(self.state_file, 'r', encoding='utf-8') as f:\n state = json.load(f)\n current_date = self.get_counting_date()\n if state.get('counting_date') != current_date:\n self.log(\n f\"State file belongs to previous counting day ({state.get('counting_date')}). \"\n 'Finalizing before fresh start.'\n )\n self._insert_batch(\n state['counting_date'],\n state['batch_number'],\n state['count'],\n state['start_time'],\n datetime.now().isoformat(),\n )\n self.state_file.unlink(missing_ok=True)\n return None\n self.log(\n f\"Resumed batch #{state['batch_number']} from {state['start_time']} \"\n f\"with count={state['count']}\"\n )\n self._reset_batch_timer()\n return state\n except Exception as exc:\n self.log(f'Failed to load state file: {exc}')\n return None\n\n def save_state(self):\n if self.current_state is None:\n self.state_file.unlink(missing_ok=True)\n return\n with open(self.state_file, 'w', encoding='utf-8') as f:\n json.dump(self.current_state, f, indent=2, ensure_ascii=False)\n\n def get_next_batch_number(self, counting_date):\n cur = self.db.cursor()\n cur.execute(\n \"\"\"\n SELECT COALESCE(MAX(batch_number), 0)\n FROM batches\n WHERE counting_date = ? AND camera_name = ? AND object_label = ?\n \"\"\",\n (counting_date, self.camera_name, self.object_label),\n )\n return cur.fetchone()[0] + 1\n\n def start_new_batch(self, counting_date):\n batch_number = self.get_next_batch_number(counting_date)\n now = datetime.now().isoformat()\n counted_ids = []\n if self.previous_state is not None:\n try:\n counted_ids = self.previous_state['counted_event_ids'][-self.carry_ids:]\n except (KeyError, TypeError):\n counted_ids = []\n self.current_state = {\n 'counting_date': counting_date,\n 'batch_number': batch_number,\n 'count': 0,\n 'start_time': now,\n 'last_detection_time': now,\n 'counted_event_ids': counted_ids,\n }\n self.save_state()\n self.log(f'Started batch #{batch_number} for {counting_date} ({self.object_label})')\n\n def _reset_batch_timer(self):\n if self.batch_timer:\n self.batch_timer.cancel()\n self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout)\n self.batch_timer.daemon = True\n self.batch_timer.start()\n\n def _on_batch_timeout(self):\n self.log(f'Batch inactivity timeout ({self.batch_timeout}s) reached')\n self.end_batch(closed_by='timeout')\n\n def _ignore_batch_label(self):\n if not self.ignore_batch_label_timer:\n self.ignore_batch_label = True\n self.ignore_batch_label_timer = threading.Timer(\n self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout\n )\n self.ignore_batch_label_timer.daemon = True\n self.ignore_batch_label_timer.start()\n self.log(\n f'Ignore batch label for {self.ignore_batch_label_timeout}s'\n )\n\n def _on_ignore_batch_label_timeout(self):\n self.ignore_batch_label_timer = None\n self.ignore_batch_label = False\n self.log('Ignore batch label cooldown finished')\n\n def record_ayam_crossing(self, track_id):\n \"\"\"Line-cross equivalent of production ayam-potong MQTT event.\"\"\"\n with self.state_lock:\n counting_date = self.get_counting_date()\n started_new = False\n if self.current_state is None:\n self.start_new_batch(counting_date)\n started_new = True\n elif self.current_state['counting_date'] != counting_date:\n self._end_batch_locked(closed_by='cutoff')\n self.start_new_batch(counting_date)\n started_new = True\n\n event_key = str(track_id)\n if event_key not in self.current_state['counted_event_ids']:\n self.current_state['count'] += 1\n self.current_state['counted_event_ids'].append(event_key)\n self.log(\n f'Counted ayam (track {track_id}) | batch #{self.current_state[\"batch_number\"]} '\n f'total: {self.current_state[\"count\"]}'\n )\n\n self.current_state['last_detection_time'] = datetime.now().isoformat()\n self.save_state()\n self._reset_batch_timer()\n return self.current_state['count'], started_new\n\n def record_talenan_crossing(self, track_id):\n \"\"\"Line-cross equivalent of production telenan MQTT batch close.\"\"\"\n if self.ignore_batch_label:\n return False\n with self.state_lock:\n self._ignore_batch_label()\n self._end_batch_locked(closed_by='talenan')\n self.log(f'Batch closed by talenan (track {track_id})')\n if self.batch_timer:\n self.batch_timer.cancel()\n self.batch_timer = None\n return True\n\n def end_batch(self, closed_by='manual'):\n with self.state_lock:\n self._end_batch_locked(closed_by=closed_by)\n\n def _end_batch_locked(self, closed_by='manual'):\n if self.current_state is None:\n return False\n\n self.previous_state = self.current_state\n state = self.current_state\n\n start_time_obj = datetime.fromisoformat(state['start_time'])\n end_time_obj = datetime.now()\n duration_seconds = (end_time_obj - start_time_obj).total_seconds()\n\n if (state['count'] < self.min_object_per_batch\n or duration_seconds < self.min_duration_per_batch):\n self.current_state = None\n self.save_state()\n if self.batch_timer:\n self.batch_timer.cancel()\n self.batch_timer = None\n self.log(\n f'Batch #{state[\"batch_number\"]} discarded '\n f'(count={state[\"count\"]}, duration={duration_seconds:.0f}s)'\n )\n return False\n\n end_time = end_time_obj.isoformat()\n try:\n self._insert_batch(\n state['counting_date'],\n state['batch_number'],\n state['count'],\n state['start_time'],\n end_time,\n )\n cps = state['count'] / duration_seconds if duration_seconds > 0 else 0\n self.log(\n f'Batch #{state[\"batch_number\"]} ended | count={state[\"count\"]} | '\n f'duration={duration_seconds:.0f}s | cps={cps:.3f} | closed_by={closed_by}'\n )\n except Exception as exc:\n self.log(f'Failed to persist batch: {exc}')\n return False\n\n self.current_state = None\n self.save_state()\n if self.batch_timer:\n self.batch_timer.cancel()\n self.batch_timer = None\n return True\n\n def _insert_batch(self, counting_date, batch_number, count, start_time, end_time):\n cur = self.db.cursor()\n cur.execute(\n \"\"\"\n INSERT INTO batches\n (counting_date, batch_number, camera_name, object_label, count, start_time, end_time)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n \"\"\",\n (counting_date, batch_number, self.camera_name, self.object_label, count, start_time, end_time),\n )\n cur.execute(\n \"\"\"\n INSERT INTO daily_summaries\n (counting_date, camera_name, object_label, total_count, total_batches)\n VALUES (?, ?, ?, ?, 1)\n ON CONFLICT(counting_date, camera_name, object_label)\n DO UPDATE SET\n total_count = total_count + excluded.total_count,\n total_batches = total_batches + excluded.total_batches,\n updated_at = CURRENT_TIMESTAMP\n \"\"\",\n (counting_date, self.camera_name, self.object_label, count),\n )\n self.db.commit()\n\n cur.execute(\n \"\"\"\n SELECT total_count, total_batches\n FROM daily_summaries\n WHERE counting_date = ? AND camera_name = ? AND object_label = ?\n \"\"\",\n (counting_date, self.camera_name, self.object_label),\n )\n row = cur.fetchone()\n if row:\n self.log(\n f'Daily totals for {counting_date}: {row[0]} objects across {row[1]} batch(es)'\n )\n\n def cutoff_watcher_loop(self):\n while not self.shutdown_event.is_set():\n time.sleep(60)\n with self.state_lock:\n if self.current_state is None:\n continue\n if self.current_state['counting_date'] != self.get_counting_date():\n self.log('Daily cutoff reached – finalizing batch')\n self._end_batch_locked(closed_by='cutoff')\n\n def start_cutoff_watcher(self):\n t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True)\n t.start()\n return t\n\n @property\n def current_batch_number(self):\n if self.current_state is None:\n return 0\n return self.current_state['batch_number']\n\n @property\n def current_batch_count(self):\n if self.current_state is None:\n return 0\n return self.current_state['count']\n\n def get_closed_total_for_day(self, counting_date=None):\n if counting_date is None:\n counting_date = self.get_counting_date()\n cur = self.db.cursor()\n cur.execute(\n \"\"\"\n SELECT COALESCE(total_count, 0)\n FROM daily_summaries\n WHERE counting_date = ? AND camera_name = ? AND object_label = ?\n \"\"\",\n (counting_date, self.camera_name, self.object_label),\n )\n row = cur.fetchone()\n return row[0] if row else 0\n\n def display_total(self):\n return self.get_closed_total_for_day() + self.current_batch_count\n\n def shutdown(self):\n self.shutdown_event.set()\n self.end_batch(closed_by='shutdown')\n if self.batch_timer:\n self.batch_timer.cancel()\n self.db.close()", + "lineStart": 1, + "lineEnd": 379, + "totalLines": 379, + "truncated": false + } + }, + "title": "batch_store.py", + "time": { + "start": 1785284239603, + "end": 1785284239654 + } + }, + "id": "prt_fab3ba023001b11UUWdv72InOW", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_02_GngfrJznz8i7yllztxzQ2349", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/counter_dashboard.py" + }, + "output": "/opt/bytetrack-counter/counter_dashboard.py\nfile\n\n1: #!/usr/bin/env python3\n2: \"\"\"\n3: Edge Jetson production counter dashboard.\n4: Reads jetson_counter.db + current_batch.json from jetson-counter stack.\n5: Default port 5000 (replaces frigate-counter dashboard role).\n6: \"\"\"\n7: \n8: import json\n9: import os\n10: import sqlite3\n11: import time\n12: from io import BytesIO\n13: from datetime import datetime, timedelta\n14: \n15: from openpyxl import Workbook\n16: from openpyxl.styles import Font, Alignment, PatternFill, Border, Side\n17: \n18: from flask import Flask, render_template, jsonify, request, Response\n19: from werkzeug.serving import WSGIRequestHandler\n20: \n21: from dotenv import load_dotenv\n22: load_dotenv()\n23: \n24: \n25: app = Flask(__name__, template_folder=\"templates\")\n26: app.config[\"SECRET_KEY\"] = os.getenv(\"SECRET_KEY\", \"change-me-in-production\")\n27: \n28: _DEFAULT_DIR = \"/opt/jetson-counter\"\n29: DB_PATH = os.getenv(\"DB_PATH\", f\"{_DEFAULT_DIR}/jetson_counter.db\")\n30: CURRENT_BATCH_PATH = os.getenv(\"STATE_FILE\", os.getenv(\"CURRENT_BATCH_PATH\", f\"{_DEFAULT_DIR}/current_batch.json\"))\n31: CUTOFF_TIME = os.getenv(\"CUTOFF_TIME\", os.getenv(\"DAILY_CUTOFF_TIME\", \"20:00\"))\n32: \n33: LIVE_STREAM_FRAME_PATH = os.getenv(\"LIVE_STREAM_FRAME_PATH\", \"/dev/shm/jetson-counter/live_frame.jpg\")\n34: \n35: SITE_NAME = os.getenv(\"SITE_NAME\", \"LIVE\")\n36: \n37: DASHBOARD_PORT = int(os.getenv(\"DASHBOARD_PORT\", \"5000\"))\n38: DASHBOARD_HOST = os.getenv(\"DASHBOARD_HOST\", \"0.0.0.0\")\n39: FLASK_DEBUG = os.getenv(\"FLASK_DEBUG\", \"false\").lower() == \"true\"\n40: \n41: @app.route(\"/api/live-video\")\n42: def api_live_video():\n43: if not os.path.isfile(LIVE_STREAM_FRAME_PATH):\n44: return jsonify({\"success\": False, \"error\": \"Live stream frame not available yet\"}), 503\n45: \n46: def generate():\n47: consecutive_fails = 0\n48: MAX_FAILS = 30\n49: while True:\n50: try:\n51: with open(LIVE_STREAM_FRAME_PATH, \"rb\") as f:\n52: jpeg = f.read()\n53: consecutive_fails = 0\n54: yield (b\"--frame\\r\\n\"\n55: b\"Content-Type: image/jpeg\\r\\n\\r\\n\" + jpeg + b\"\\r\\n\")\n56: except FileNotFoundError:\n57: consecutive_fails += 1\n58: if consecutive_fails >= MAX_FAILS:\n59: return\n60: time.sleep(1.0)\n61: continue\n62: except Exception:\n63: consecutive_fails += 1\n64: if consecutive_fails >= MAX_FAILS:\n65: return\n66: time.sleep(0.5)\n67: continue\n68: time.sleep(0.05)\n69: return Response(generate(), mimetype=\"multipart/x-mixed-replace; boundary=frame\")\n70: \n71: \n72: def _ensure_db():\n73: conn = sqlite3.connect(DB_PATH)\n74: cur = conn.cursor()\n75: cur.execute(\n76: \"\"\"\n77: CREATE TABLE IF NOT EXISTS batches (\n78: id INTEGER PRIMARY KEY AUTOINCREMENT,\n79: counting_date TEXT NOT NULL,\n80: batch_number INTEGER NOT NULL,\n81: camera_name TEXT NOT NULL,\n82: object_label TEXT NOT NULL,\n83: count INTEGER NOT NULL,\n84: start_time TEXT NOT NULL,\n85: end_time TEXT NOT NULL,\n86: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n87: UNIQUE(counting_date, batch_number, camera_name, object_label)\n88: )\n89: \"\"\"\n90: )\n91: cur.execute(\n92: \"\"\"\n93: CREATE TABLE IF NOT EXISTS daily_summaries (\n94: id INTEGER PRIMARY KEY AUTOINCREMENT,\n95: counting_date TEXT NOT NULL,\n96: camera_name TEXT NOT NULL,\n97: object_label TEXT NOT NULL,\n98: total_count INTEGER NOT NULL DEFAULT 0,\n99: total_batches INTEGER NOT NULL DEFAULT 0,\n100: updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n101: UNIQUE(counting_date, camera_name, object_label)\n102: )\n103: \"\"\"\n104: )\n105: conn.commit()\n106: conn.close()\n107: \n108: _ensure_db()\n109: \n110: \n111: def get_db():\n112: conn = sqlite3.connect(DB_PATH)\n113: conn.row_factory = sqlite3.Row\n114: return conn\n115: \n116: \n117: def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):\n118: if dt is None:\n119: dt = datetime.now()\n120: cutoff = datetime.strptime(cutoff_str, \"%H:%M\").time()\n121: if dt.time() < cutoff:\n122: return dt.date().isoformat()\n123: return (dt.date() + timedelta(days=1)).isoformat()\n124: \n125: \n126: @app.route(\"/\")\n127: def index():\n128: return render_template(\"dashboard.html\", site_name=SITE_NAME)\n129: \n130: \n131: @app.route(\"/api/current-batch\")\n132: def api_current_batch():\n133: try:\n134: with open(CURRENT_BATCH_PATH, \"r\") as f:\n135: data = json.load(f)\n136: return jsonify(\n137: {\n138: \"success\": True,\n139: \"counting_date\": data.get(\"counting_date\"),\n140: \"batch_number\": data.get(\"batch_number\"),\n141: \"count\": data.get(\"count\", 0),\n142: \"start_time\": data.get(\"start_time\"),\n143: \"last_detection_time\": data.get(\"last_detection_time\"),\n144: }\n145: )\n146: except FileNotFoundError:\n147: return jsonify(\n148: {\n149: \"success\": False,\n150: \"error\": \"No active batch\",\n151: \"count\": 0,\n152: \"batch_number\": None,\n153: \"counting_date\": None,\n154: }\n155: ), 200\n156: except Exception as e:\n157: return jsonify(\n158: {\n159: \"success\": False,\n160: \"error\": str(e),\n161: \"count\": 0,\n162: \"batch_number\": None,\n163: \"counting_date\": None,\n164: }\n165: ), 500\n166: \n167: \n168: @app.route(\"/api/previous-batch\")\n169: def api_previous_batch():\n170: try:\n171: conn = get_db()\n172: cur = conn.cursor()\n173: cur.execute(\n174: \"\"\"\n175: SELECT counting_date, batch_number, count, start_time, end_time,\n176: ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n177: FROM batches\n178: ORDER BY end_time DESC\n179: LIMIT 1\n180: \"\"\"\n181: )\n182: row = cur.fetchone()\n183: conn.close()\n184: if row:\n185: return jsonify(\n186: {\n187: \"success\": True,\n188: \"date\": row[\"counting_date\"],\n189: \"batch_number\": row[\"batch_number\"],\n190: \"count\": row[\"count\"],\n191: \"start_time\": row[\"start_time\"],\n192: \"end_time\": row[\"end_time\"],\n193: \"duration_minutes\": row[\"duration_minutes\"],\n194: }\n195: )\n196: return jsonify({\"success\": False, \"error\": \"No previous batch\"}), 200\n197: except sqlite3.OperationalError as e:\n198: return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\"}), 200\n199: except Exception as e:\n200: return jsonify({\"success\": False, \"error\": str(e)}), 500\n201: \n202: \n203: @app.route(\"/api/summary\")\n204: def api_summary():\n205: try:\n206: conn = get_db()\n207: cur = conn.cursor()\n208: today = get_counting_date()\n209: cur.execute(\n210: \"\"\"\n211: SELECT COALESCE(total_count, 0) as total_count,\n212: COALESCE(total_batches, 0) as total_batches\n213: FROM daily_summaries\n214: WHERE counting_date = ?\n215: \"\"\",\n216: (today,),\n217: )\n218: today_row = cur.fetchone()\n219: yesterday = (datetime.strptime(today, \"%Y-%m-%d\").date() - timedelta(days=1)).isoformat()\n220: cur.execute(\n221: \"\"\"\n222: SELECT COALESCE(total_count, 0) as total_count,\n223: COALESCE(total_batches, 0) as total_batches\n224: FROM daily_summaries\n225: WHERE counting_date = ?\n226: \"\"\",\n227: (yesterday,),\n228: )\n229: yesterday_row = cur.fetchone()\n230: cur.execute(\n231: \"\"\"\n232: SELECT COALESCE(SUM(total_count), 0) as grand_total,\n233: COALESCE(SUM(total_batches), 0) as grand_batches,\n234: COUNT(DISTINCT counting_date) as total_days\n235: FROM daily_summaries\n236: \"\"\"\n237: )\n238: all_time = cur.fetchone()\n239: cur.execute(\"SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries\")\n240: avg = cur.fetchone()\n241: cur.execute(\n242: \"\"\"\n243: SELECT counting_date, total_count\n244: FROM daily_summaries\n245: ORDER BY total_count DESC\n246: LIMIT 1\n247: \"\"\"\n248: )\n249: best = cur.fetchone()\n250: conn.close()\n251: return jsonify(\n252: {\n253: \"today\": {\n254: \"date\": today,\n255: \"total_count\": today_row[\"total_count\"] if today_row else 0,\n256: \"total_batches\": today_row[\"total_batches\"] if today_row else 0,\n257: },\n258: \"yesterday\": {\n259: \"date\": yesterday,\n260: \"total_count\": yesterday_row[\"total_count\"] if yesterday_row else 0,\n261: \"total_batches\": yesterday_row[\"total_batches\"] if yesterday_row else 0,\n262: },\n263: \"all_time\": {\n264: \"grand_total\": all_time[\"grand_total\"],\n265: \"grand_batches\": all_time[\"grand_batches\"],\n266: \"total_days\": all_time[\"total_days\"],\n267: },\n268: \"average_per_day\": avg[\"avg_per_day\"] or 0,\n269: \"best_day\": {\n270: \"date\": best[\"counting_date\"] if best else None,\n271: \"count\": best[\"total_count\"] if best else 0,\n272: },\n273: }\n274: )\n275: except sqlite3.OperationalError as e:\n276: return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\", \"today\": {\"date\": datetime.now().date().isoformat(), \"total_count\": 0, \"total_batches\": 0}, \"yesterday\": {\"date\": \"\", \"total_count\": 0, \"total_batches\": 0}, \"all_time\": {\"grand_total\": 0, \"grand_batches\": 0, \"total_days\": 0}, \"average_per_day\": 0, \"best_day\": {\"date\": None, \"count\": 0}}), 200\n277: except Exception as e:\n278: return jsonify({\"success\": False, \"error\": str(e)}), 500\n279: \n280: \n281: @app.route(\"/api/daily-data\")\n282: def api_daily_data():\n283: try:\n284: days = request.args.get(\"days\", 30, type=int)\n285: date_from = (datetime.now() - timedelta(days=days)).date().isoformat()\n286: conn = get_db()\n287: cur = conn.cursor()\n288: cur.execute(\n289: \"\"\"\n290: SELECT counting_date, total_count, total_batches,\n291: ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch\n292: FROM daily_summaries\n293: WHERE counting_date >= ?\n294: ORDER BY counting_date ASC\n295: \"\"\",\n296: (date_from,),\n297: )\n298: daily_data = [\n299: {\n300: \"date\": row[\"counting_date\"],\n301: \"total_count\": row[\"total_count\"],\n302: \"total_batches\": row[\"total_batches\"],\n303: \"avg_per_batch\": row[\"avg_per_batch\"] or 0,\n304: }\n305: for row in cur.fetchall()\n306: ]\n307: conn.close()\n308: return jsonify(daily_data)\n309: except sqlite3.OperationalError as e:\n310: return jsonify([]), 200\n311: except Exception as e:\n312: return jsonify({\"success\": False, \"error\": str(e)}), 500\n313: \n314: \n315: @app.route(\"/api/day-detail/\")\n316: def api_day_detail(date):\n317: try:\n318: conn = get_db()\n319: cur = conn.cursor()\n320: cur.execute(\n321: \"\"\"\n322: SELECT batch_number, count, start_time, end_time,\n323: ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n324: FROM batches\n325: WHERE counting_date = ?\n326: ORDER BY batch_number ASC\n327: \"\"\",\n328: (date,),\n329: )\n330: batches = []\n331: total_duration = 0\n332: for row in cur.fetchall():\n333: duration = row[\"duration_minutes\"] or 0\n334: total_duration += duration\n335: batches.append(\n336: {\n337: \"batch_number\": row[\"batch_number\"],\n338: \"count\": row[\"count\"],\n339: \"start_time\": row[\"start_time\"],\n340: \"end_time\": row[\"end_time\"],\n341: \"duration_minutes\": duration,\n342: }\n343: )\n344: cur.execute(\n345: \"\"\"\n346: SELECT total_count, total_batches\n347: FROM daily_summaries\n348: WHERE counting_date = ?\n349: \"\"\",\n350: (date,),\n351: )\n352: summary = cur.fetchone()\n353: conn.close()\n354: return jsonify(\n355: {\n356: \"date\": date,\n357: \"total_count\": summary[\"total_count\"] if summary else 0,\n358: \"total_batches\": summary[\"total_batches\"] if summary else 0,\n359: \"total_duration_minutes\": round(total_duration, 1),\n360: \"avg_duration_minutes\": round(total_duration / len(batches), 1) if batches else 0,\n361: \"batches\": batches,\n362: }\n363: )\n364: except sqlite3.OperationalError as e:\n365: return jsonify({\"date\": date, \"total_count\": 0, \"total_batches\": 0, \"total_duration_minutes\": 0, \"avg_duration_minutes\": 0, \"batches\": [], \"error\": f\"Database unavailable: {e}\"}), 200\n366: except Exception as e:\n367: return jsonify({\"success\": False, \"error\": str(e)}), 500\n368: \n369: \n370: @app.route(\"/api/recent-batches\")\n371: def api_recent_batches():\n372: try:\n373: limit = request.args.get(\"limit\", 10, type=int)\n374: conn = get_db()\n375: cur = conn.cursor()\n376: cur.execute(\n377: \"\"\"\n378: SELECT counting_date, batch_number, count, start_time, end_time,\n379: ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n380: FROM batches\n381: ORDER BY end_time DESC\n382: LIMIT ?\n383: \"\"\",\n384: (limit,),\n385: )\n386: batches = [\n387: {\n388: \"date\": row[\"counting_date\"],\n389: \"batch_number\": row[\"batch_number\"],\n390: \"count\": row[\"count\"],\n391: \"start_time\": row[\"start_time\"],\n392: \"end_time\": row[\"end_time\"],\n393: \"duration_minutes\": row[\"duration_minutes\"] or 0,\n394: }\n395: for row in cur.fetchall()\n396: ]\n397: conn.close()\n398: return jsonify(batches)\n399: except sqlite3.OperationalError as e:\n400: return jsonify([]), 200\n401: except Exception as e:\n402: return jsonify({\"success\": False, \"error\": str(e)}), 500\n403: \n404: \n405: @app.route(\"/api/available-dates\")\n406: def api_available_dates():\n407: try:\n408: conn = get_db()\n409: cur = conn.cursor()\n410: cur.execute(\n411: \"\"\"\n412: SELECT counting_date, total_count, total_batches\n413: FROM daily_summaries\n414: ORDER BY counting_date DESC\n415: \"\"\"\n416: )\n417: dates = [\n418: {\n419: \"date\": row[\"counting_date\"],\n420: \"total_count\": row[\"total_count\"],\n421: \"total_batches\": row[\"total_batches\"],\n422: }\n423: for row in cur.fetchall()\n424: ]\n425: conn.close()\n426: return jsonify(dates)\n427: except sqlite3.OperationalError as e:\n428: return jsonify([]), 200\n429: except Exception as e:\n430: return jsonify({\"success\": False, \"error\": str(e)}), 500\n431: \n432: \n433: def _excel_response(wb, filename):\n434: output = BytesIO()\n435: wb.save(output)\n436: output.seek(0)\n437: return Response(\n438: output.getvalue(),\n439: mimetype=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n440: headers={\"Content-Disposition\": f\"attachment; filename={filename}\"},\n441: )\n442: \n443: \n444: def _style_header(ws, cols):\n445: header_font = Font(bold=True, color=\"FFFFFF\", size=11)\n446: header_fill = PatternFill(start_color=\"2F5496\", end_color=\"2F5496\", fill_type=\"solid\")\n447: thin_border = Border(\n448: left=Side(style=\"thin\"), right=Side(style=\"thin\"),\n449: top=Side(style=\"thin\"), bottom=Side(style=\"thin\"),\n450: )\n451: for col_idx, (col_letter, text) in enumerate(cols, 1):\n452: cell = ws.cell(row=1, column=col_idx, value=text)\n453: cell.font = header_font\n454: cell.fill = header_fill\n455: cell.alignment = Alignment(horizontal=\"center\")\n456: cell.border = thin_border\n457: ws.freeze_panes = \"A2\"\n458: \n459: \n460: def _auto_width(ws):\n461: for col in ws.columns:\n462: max_len = 0\n463: col_letter = col[0].column_letter\n464: for cell in col:\n465: if cell.value is not None:\n466: max_len = max(max_len, len(str(cell.value)))\n467: ws.column_dimensions[col_letter].width = max_len + 4\n468: \n469: \n470: @app.route(\"/api/export-daily-csv\")\n471: def export_daily_xlsx():\n472: try:\n473: days = request.args.get(\"days\", 30, type=int)\n474: date_from = (datetime.now() - timedelta(days=days)).date().isoformat()\n475: conn = get_db()\n476: cur = conn.cursor()\n477: cur.execute(\n478: \"\"\"\n479: SELECT counting_date, batch_number, count, start_time, end_time,\n480: ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n481: FROM batches\n482: WHERE counting_date >= ?\n483: ORDER BY counting_date ASC, batch_number ASC\n484: \"\"\",\n485: (date_from,),\n486: )\n487: rows = cur.fetchall()\n488: conn.close()\n489: except sqlite3.OperationalError as e:\n490: return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\"}), 503\n491: except Exception as e:\n492: return jsonify({\"success\": False, \"error\": str(e)}), 500\n493: \n494: wb = Workbook()\n495: ws = wb.active\n496: ws.title = \"Batch Details\"\n497: _style_header(ws, [(\"A\", \"Date\"), (\"B\", \"Batch #\"), (\"C\", \"Count\"), (\"D\", \"Start Time\"), (\"E\", \"End Time\"), (\"F\", \"Duration (min)\")])\n498: \n499: for r_idx, row in enumerate(rows, 2):\n500: ws.cell(row=r_idx, column=1, value=row[\"counting_date\"])\n501: ws.cell(row=r_idx, column=2, value=row[\"batch_number\"])\n502: ws.cell(row=r_idx, column=3, value=row[\"count\"])\n503: ws.cell(row=r_idx, column=4, value=row[\"start_time\"])\n504: ws.cell(row=r_idx, column=5, value=row[\"end_time\"])\n505: ws.cell(row=r_idx, column=6, value=row[\"duration_minutes\"] or 0)\n506: \n507: _auto_width(ws)\n508: filename = f\"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx\"\n509: return _excel_response(wb, filename)\n510: \n511: \n512: @app.route(\"/api/export-day-csv/\")\n513: def export_day_xlsx(date):\n514: try:\n515: conn = get_db()\n516: cur = conn.cursor()\n517: cur.execute(\n518: \"\"\"\n519: SELECT batch_number, count, start_time, end_time,\n520: ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n521: FROM batches\n522: WHERE counting_date = ?\n523: ORDER BY batch_number ASC\n524: \"\"\",\n525: (date,),\n526: )\n527: rows = cur.fetchall()\n528: conn.close()\n529: except sqlite3.OperationalError as e:\n530: return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\"}), 503\n531: except Exception as e:\n532: return jsonify({\"success\": False, \"error\": str(e)}), 500\n533: \n534: wb = Workbook()\n535: ws = wb.active\n536: ws.title = f\"Day {date}\"\n537: _style_header(ws, [(\"A\", \"Batch Number\"), (\"B\", \"Count\"), (\"C\", \"Start Time\"), (\"D\", \"End Time\"), (\"E\", \"Duration (min)\")])\n538: \n539: for r_idx, row in enumerate(rows, 2):\n540: ws.cell(row=r_idx, column=1, value=row[\"batch_number\"])\n541: ws.cell(row=r_idx, column=2, value=row[\"count\"])\n542: ws.cell(row=r_idx, column=3, value=row[\"start_time\"])\n543: ws.cell(row=r_idx, column=4, value=row[\"end_time\"])\n544: ws.cell(row=r_idx, column=5, value=row[\"duration_minutes\"] or 0)\n545: \n546: _auto_width(ws)\n547: filename = f\"{SITE_NAME}_day_detail_{date}.xlsx\"\n548: return _excel_response(wb, filename)\n549: \n550: \n551: if __name__ == \"__main__\":\n552: WSGIRequestHandler.protocol_version = \"HTTP/1.1\"\n553: print(f\"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}\")\n554: print(f\"DB: {DB_PATH}\")\n555: print(f\"State: {CURRENT_BATCH_PATH}\")\n556: app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)\n\n(End of file - total 556 lines)\n", + "metadata": { + "preview": "#!/usr/bin/env python3\n\"\"\"\nEdge Jetson production counter dashboard.\nReads jetson_counter.db + current_batch.json from jetson-counter stack.\nDefault port 5000 (replaces frigate-counter dashboard role).\n\"\"\"\n\nimport json\nimport os\nimport sqlite3\nimport time\nfrom io import BytesIO\nfrom datetime import datetime, timedelta\n\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font, Alignment, PatternFill, Border, Side\n\nfrom flask import Flask, render_template, jsonify, request, Response\nfrom werkzeug.serving import WSGIRequestHandler\n", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/counter_dashboard.py", + "text": "#!/usr/bin/env python3\n\"\"\"\nEdge Jetson production counter dashboard.\nReads jetson_counter.db + current_batch.json from jetson-counter stack.\nDefault port 5000 (replaces frigate-counter dashboard role).\n\"\"\"\n\nimport json\nimport os\nimport sqlite3\nimport time\nfrom io import BytesIO\nfrom datetime import datetime, timedelta\n\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font, Alignment, PatternFill, Border, Side\n\nfrom flask import Flask, render_template, jsonify, request, Response\nfrom werkzeug.serving import WSGIRequestHandler\n\nfrom dotenv import load_dotenv\nload_dotenv()\n\n\napp = Flask(__name__, template_folder=\"templates\")\napp.config[\"SECRET_KEY\"] = os.getenv(\"SECRET_KEY\", \"change-me-in-production\")\n\n_DEFAULT_DIR = \"/opt/jetson-counter\"\nDB_PATH = os.getenv(\"DB_PATH\", f\"{_DEFAULT_DIR}/jetson_counter.db\")\nCURRENT_BATCH_PATH = os.getenv(\"STATE_FILE\", os.getenv(\"CURRENT_BATCH_PATH\", f\"{_DEFAULT_DIR}/current_batch.json\"))\nCUTOFF_TIME = os.getenv(\"CUTOFF_TIME\", os.getenv(\"DAILY_CUTOFF_TIME\", \"20:00\"))\n\nLIVE_STREAM_FRAME_PATH = os.getenv(\"LIVE_STREAM_FRAME_PATH\", \"/dev/shm/jetson-counter/live_frame.jpg\")\n\nSITE_NAME = os.getenv(\"SITE_NAME\", \"LIVE\")\n\nDASHBOARD_PORT = int(os.getenv(\"DASHBOARD_PORT\", \"5000\"))\nDASHBOARD_HOST = os.getenv(\"DASHBOARD_HOST\", \"0.0.0.0\")\nFLASK_DEBUG = os.getenv(\"FLASK_DEBUG\", \"false\").lower() == \"true\"\n\n@app.route(\"/api/live-video\")\ndef api_live_video():\n if not os.path.isfile(LIVE_STREAM_FRAME_PATH):\n return jsonify({\"success\": False, \"error\": \"Live stream frame not available yet\"}), 503\n\n def generate():\n consecutive_fails = 0\n MAX_FAILS = 30\n while True:\n try:\n with open(LIVE_STREAM_FRAME_PATH, \"rb\") as f:\n jpeg = f.read()\n consecutive_fails = 0\n yield (b\"--frame\\r\\n\"\n b\"Content-Type: image/jpeg\\r\\n\\r\\n\" + jpeg + b\"\\r\\n\")\n except FileNotFoundError:\n consecutive_fails += 1\n if consecutive_fails >= MAX_FAILS:\n return\n time.sleep(1.0)\n continue\n except Exception:\n consecutive_fails += 1\n if consecutive_fails >= MAX_FAILS:\n return\n time.sleep(0.5)\n continue\n time.sleep(0.05)\n return Response(generate(), mimetype=\"multipart/x-mixed-replace; boundary=frame\")\n\n\ndef _ensure_db():\n conn = sqlite3.connect(DB_PATH)\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n CREATE TABLE IF NOT EXISTS batches (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n counting_date TEXT NOT NULL,\n batch_number INTEGER NOT NULL,\n camera_name TEXT NOT NULL,\n object_label TEXT NOT NULL,\n count INTEGER NOT NULL,\n start_time TEXT NOT NULL,\n end_time TEXT NOT NULL,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(counting_date, batch_number, camera_name, object_label)\n )\n \"\"\"\n )\n cur.execute(\n \"\"\"\n CREATE TABLE IF NOT EXISTS daily_summaries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n counting_date TEXT NOT NULL,\n camera_name TEXT NOT NULL,\n object_label TEXT NOT NULL,\n total_count INTEGER NOT NULL DEFAULT 0,\n total_batches INTEGER NOT NULL DEFAULT 0,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(counting_date, camera_name, object_label)\n )\n \"\"\"\n )\n conn.commit()\n conn.close()\n\n_ensure_db()\n\n\ndef get_db():\n conn = sqlite3.connect(DB_PATH)\n conn.row_factory = sqlite3.Row\n return conn\n\n\ndef get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):\n if dt is None:\n dt = datetime.now()\n cutoff = datetime.strptime(cutoff_str, \"%H:%M\").time()\n if dt.time() < cutoff:\n return dt.date().isoformat()\n return (dt.date() + timedelta(days=1)).isoformat()\n\n\n@app.route(\"/\")\ndef index():\n return render_template(\"dashboard.html\", site_name=SITE_NAME)\n\n\n@app.route(\"/api/current-batch\")\ndef api_current_batch():\n try:\n with open(CURRENT_BATCH_PATH, \"r\") as f:\n data = json.load(f)\n return jsonify(\n {\n \"success\": True,\n \"counting_date\": data.get(\"counting_date\"),\n \"batch_number\": data.get(\"batch_number\"),\n \"count\": data.get(\"count\", 0),\n \"start_time\": data.get(\"start_time\"),\n \"last_detection_time\": data.get(\"last_detection_time\"),\n }\n )\n except FileNotFoundError:\n return jsonify(\n {\n \"success\": False,\n \"error\": \"No active batch\",\n \"count\": 0,\n \"batch_number\": None,\n \"counting_date\": None,\n }\n ), 200\n except Exception as e:\n return jsonify(\n {\n \"success\": False,\n \"error\": str(e),\n \"count\": 0,\n \"batch_number\": None,\n \"counting_date\": None,\n }\n ), 500\n\n\n@app.route(\"/api/previous-batch\")\ndef api_previous_batch():\n try:\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT counting_date, batch_number, count, start_time, end_time,\n ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n FROM batches\n ORDER BY end_time DESC\n LIMIT 1\n \"\"\"\n )\n row = cur.fetchone()\n conn.close()\n if row:\n return jsonify(\n {\n \"success\": True,\n \"date\": row[\"counting_date\"],\n \"batch_number\": row[\"batch_number\"],\n \"count\": row[\"count\"],\n \"start_time\": row[\"start_time\"],\n \"end_time\": row[\"end_time\"],\n \"duration_minutes\": row[\"duration_minutes\"],\n }\n )\n return jsonify({\"success\": False, \"error\": \"No previous batch\"}), 200\n except sqlite3.OperationalError as e:\n return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\"}), 200\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@app.route(\"/api/summary\")\ndef api_summary():\n try:\n conn = get_db()\n cur = conn.cursor()\n today = get_counting_date()\n cur.execute(\n \"\"\"\n SELECT COALESCE(total_count, 0) as total_count,\n COALESCE(total_batches, 0) as total_batches\n FROM daily_summaries\n WHERE counting_date = ?\n \"\"\",\n (today,),\n )\n today_row = cur.fetchone()\n yesterday = (datetime.strptime(today, \"%Y-%m-%d\").date() - timedelta(days=1)).isoformat()\n cur.execute(\n \"\"\"\n SELECT COALESCE(total_count, 0) as total_count,\n COALESCE(total_batches, 0) as total_batches\n FROM daily_summaries\n WHERE counting_date = ?\n \"\"\",\n (yesterday,),\n )\n yesterday_row = cur.fetchone()\n cur.execute(\n \"\"\"\n SELECT COALESCE(SUM(total_count), 0) as grand_total,\n COALESCE(SUM(total_batches), 0) as grand_batches,\n COUNT(DISTINCT counting_date) as total_days\n FROM daily_summaries\n \"\"\"\n )\n all_time = cur.fetchone()\n cur.execute(\"SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries\")\n avg = cur.fetchone()\n cur.execute(\n \"\"\"\n SELECT counting_date, total_count\n FROM daily_summaries\n ORDER BY total_count DESC\n LIMIT 1\n \"\"\"\n )\n best = cur.fetchone()\n conn.close()\n return jsonify(\n {\n \"today\": {\n \"date\": today,\n \"total_count\": today_row[\"total_count\"] if today_row else 0,\n \"total_batches\": today_row[\"total_batches\"] if today_row else 0,\n },\n \"yesterday\": {\n \"date\": yesterday,\n \"total_count\": yesterday_row[\"total_count\"] if yesterday_row else 0,\n \"total_batches\": yesterday_row[\"total_batches\"] if yesterday_row else 0,\n },\n \"all_time\": {\n \"grand_total\": all_time[\"grand_total\"],\n \"grand_batches\": all_time[\"grand_batches\"],\n \"total_days\": all_time[\"total_days\"],\n },\n \"average_per_day\": avg[\"avg_per_day\"] or 0,\n \"best_day\": {\n \"date\": best[\"counting_date\"] if best else None,\n \"count\": best[\"total_count\"] if best else 0,\n },\n }\n )\n except sqlite3.OperationalError as e:\n return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\", \"today\": {\"date\": datetime.now().date().isoformat(), \"total_count\": 0, \"total_batches\": 0}, \"yesterday\": {\"date\": \"\", \"total_count\": 0, \"total_batches\": 0}, \"all_time\": {\"grand_total\": 0, \"grand_batches\": 0, \"total_days\": 0}, \"average_per_day\": 0, \"best_day\": {\"date\": None, \"count\": 0}}), 200\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@app.route(\"/api/daily-data\")\ndef api_daily_data():\n try:\n days = request.args.get(\"days\", 30, type=int)\n date_from = (datetime.now() - timedelta(days=days)).date().isoformat()\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT counting_date, total_count, total_batches,\n ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch\n FROM daily_summaries\n WHERE counting_date >= ?\n ORDER BY counting_date ASC\n \"\"\",\n (date_from,),\n )\n daily_data = [\n {\n \"date\": row[\"counting_date\"],\n \"total_count\": row[\"total_count\"],\n \"total_batches\": row[\"total_batches\"],\n \"avg_per_batch\": row[\"avg_per_batch\"] or 0,\n }\n for row in cur.fetchall()\n ]\n conn.close()\n return jsonify(daily_data)\n except sqlite3.OperationalError as e:\n return jsonify([]), 200\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@app.route(\"/api/day-detail/\")\ndef api_day_detail(date):\n try:\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT batch_number, count, start_time, end_time,\n ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n FROM batches\n WHERE counting_date = ?\n ORDER BY batch_number ASC\n \"\"\",\n (date,),\n )\n batches = []\n total_duration = 0\n for row in cur.fetchall():\n duration = row[\"duration_minutes\"] or 0\n total_duration += duration\n batches.append(\n {\n \"batch_number\": row[\"batch_number\"],\n \"count\": row[\"count\"],\n \"start_time\": row[\"start_time\"],\n \"end_time\": row[\"end_time\"],\n \"duration_minutes\": duration,\n }\n )\n cur.execute(\n \"\"\"\n SELECT total_count, total_batches\n FROM daily_summaries\n WHERE counting_date = ?\n \"\"\",\n (date,),\n )\n summary = cur.fetchone()\n conn.close()\n return jsonify(\n {\n \"date\": date,\n \"total_count\": summary[\"total_count\"] if summary else 0,\n \"total_batches\": summary[\"total_batches\"] if summary else 0,\n \"total_duration_minutes\": round(total_duration, 1),\n \"avg_duration_minutes\": round(total_duration / len(batches), 1) if batches else 0,\n \"batches\": batches,\n }\n )\n except sqlite3.OperationalError as e:\n return jsonify({\"date\": date, \"total_count\": 0, \"total_batches\": 0, \"total_duration_minutes\": 0, \"avg_duration_minutes\": 0, \"batches\": [], \"error\": f\"Database unavailable: {e}\"}), 200\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@app.route(\"/api/recent-batches\")\ndef api_recent_batches():\n try:\n limit = request.args.get(\"limit\", 10, type=int)\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT counting_date, batch_number, count, start_time, end_time,\n ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n FROM batches\n ORDER BY end_time DESC\n LIMIT ?\n \"\"\",\n (limit,),\n )\n batches = [\n {\n \"date\": row[\"counting_date\"],\n \"batch_number\": row[\"batch_number\"],\n \"count\": row[\"count\"],\n \"start_time\": row[\"start_time\"],\n \"end_time\": row[\"end_time\"],\n \"duration_minutes\": row[\"duration_minutes\"] or 0,\n }\n for row in cur.fetchall()\n ]\n conn.close()\n return jsonify(batches)\n except sqlite3.OperationalError as e:\n return jsonify([]), 200\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@app.route(\"/api/available-dates\")\ndef api_available_dates():\n try:\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT counting_date, total_count, total_batches\n FROM daily_summaries\n ORDER BY counting_date DESC\n \"\"\"\n )\n dates = [\n {\n \"date\": row[\"counting_date\"],\n \"total_count\": row[\"total_count\"],\n \"total_batches\": row[\"total_batches\"],\n }\n for row in cur.fetchall()\n ]\n conn.close()\n return jsonify(dates)\n except sqlite3.OperationalError as e:\n return jsonify([]), 200\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\ndef _excel_response(wb, filename):\n output = BytesIO()\n wb.save(output)\n output.seek(0)\n return Response(\n output.getvalue(),\n mimetype=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n headers={\"Content-Disposition\": f\"attachment; filename={filename}\"},\n )\n\n\ndef _style_header(ws, cols):\n header_font = Font(bold=True, color=\"FFFFFF\", size=11)\n header_fill = PatternFill(start_color=\"2F5496\", end_color=\"2F5496\", fill_type=\"solid\")\n thin_border = Border(\n left=Side(style=\"thin\"), right=Side(style=\"thin\"),\n top=Side(style=\"thin\"), bottom=Side(style=\"thin\"),\n )\n for col_idx, (col_letter, text) in enumerate(cols, 1):\n cell = ws.cell(row=1, column=col_idx, value=text)\n cell.font = header_font\n cell.fill = header_fill\n cell.alignment = Alignment(horizontal=\"center\")\n cell.border = thin_border\n ws.freeze_panes = \"A2\"\n\n\ndef _auto_width(ws):\n for col in ws.columns:\n max_len = 0\n col_letter = col[0].column_letter\n for cell in col:\n if cell.value is not None:\n max_len = max(max_len, len(str(cell.value)))\n ws.column_dimensions[col_letter].width = max_len + 4\n\n\n@app.route(\"/api/export-daily-csv\")\ndef export_daily_xlsx():\n try:\n days = request.args.get(\"days\", 30, type=int)\n date_from = (datetime.now() - timedelta(days=days)).date().isoformat()\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT counting_date, batch_number, count, start_time, end_time,\n ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n FROM batches\n WHERE counting_date >= ?\n ORDER BY counting_date ASC, batch_number ASC\n \"\"\",\n (date_from,),\n )\n rows = cur.fetchall()\n conn.close()\n except sqlite3.OperationalError as e:\n return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\"}), 503\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n wb = Workbook()\n ws = wb.active\n ws.title = \"Batch Details\"\n _style_header(ws, [(\"A\", \"Date\"), (\"B\", \"Batch #\"), (\"C\", \"Count\"), (\"D\", \"Start Time\"), (\"E\", \"End Time\"), (\"F\", \"Duration (min)\")])\n\n for r_idx, row in enumerate(rows, 2):\n ws.cell(row=r_idx, column=1, value=row[\"counting_date\"])\n ws.cell(row=r_idx, column=2, value=row[\"batch_number\"])\n ws.cell(row=r_idx, column=3, value=row[\"count\"])\n ws.cell(row=r_idx, column=4, value=row[\"start_time\"])\n ws.cell(row=r_idx, column=5, value=row[\"end_time\"])\n ws.cell(row=r_idx, column=6, value=row[\"duration_minutes\"] or 0)\n\n _auto_width(ws)\n filename = f\"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx\"\n return _excel_response(wb, filename)\n\n\n@app.route(\"/api/export-day-csv/\")\ndef export_day_xlsx(date):\n try:\n conn = get_db()\n cur = conn.cursor()\n cur.execute(\n \"\"\"\n SELECT batch_number, count, start_time, end_time,\n ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes\n FROM batches\n WHERE counting_date = ?\n ORDER BY batch_number ASC\n \"\"\",\n (date,),\n )\n rows = cur.fetchall()\n conn.close()\n except sqlite3.OperationalError as e:\n return jsonify({\"success\": False, \"error\": f\"Database unavailable: {e}\"}), 503\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n wb = Workbook()\n ws = wb.active\n ws.title = f\"Day {date}\"\n _style_header(ws, [(\"A\", \"Batch Number\"), (\"B\", \"Count\"), (\"C\", \"Start Time\"), (\"D\", \"End Time\"), (\"E\", \"Duration (min)\")])\n\n for r_idx, row in enumerate(rows, 2):\n ws.cell(row=r_idx, column=1, value=row[\"batch_number\"])\n ws.cell(row=r_idx, column=2, value=row[\"count\"])\n ws.cell(row=r_idx, column=3, value=row[\"start_time\"])\n ws.cell(row=r_idx, column=4, value=row[\"end_time\"])\n ws.cell(row=r_idx, column=5, value=row[\"duration_minutes\"] or 0)\n\n _auto_width(ws)\n filename = f\"{SITE_NAME}_day_detail_{date}.xlsx\"\n return _excel_response(wb, filename)\n\n\nif __name__ == \"__main__\":\n WSGIRequestHandler.protocol_version = \"HTTP/1.1\"\n print(f\"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}\")\n print(f\"DB: {DB_PATH}\")\n print(f\"State: {CURRENT_BATCH_PATH}\")\n app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)", + "lineStart": 1, + "lineEnd": 556, + "totalLines": 556, + "truncated": false + } + }, + "title": "counter_dashboard.py", + "time": { + "start": 1785284239884, + "end": 1785284239971 + } + }, + "id": "prt_fab3ba13f001b784GzzBuK3pIL", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_03_FLmzGWGb68M2KNx7MD5C8536", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/config.env.example" + }, + "output": "/opt/bytetrack-counter/config.env.example\nfile\n\n1: # =============================================================================\n2: # Edge RK3588 production counter + dashboard\n3: # Shared config for: counter_live_rknn_bytetrack.py + counter_dashboard.py\n4: # Copy to .env on device: cp config.env.example .env && nano .env\n5: # =============================================================================\n6: \n7: # --- Core paths ---\n8: # Root output directory (logs, DB, video, CSV)\n9: OUTPUT_DIR=/opt/bytetrack-counter\n10: # SQLite database path for batch entries & crossing logs\n11: DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db\n12: # JSON file persisting the current active batch state\n13: STATE_FILE=/tmp/bytetrack_current_batch.json\n14: \n15: # --- Input source ---\n16: # RTSP / HTTP live stream, or a local video file path\n17: SOURCE=rtsp://user:pass@192.168.0.100:554/stream1\n18: # FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)\n19: OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay\n20: \n21: # --- RKNN model ---\n22: # Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn)\n23: MODEL_PATH=/opt/models/yolo9t.rknn\n24: # Input image size for the model (square, e.g. 320 → 320×320)\n25: IMGSZ=320\n26: # Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant\n27: HALF=false\n28: # NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three\n29: CORE_MASK=7\n30: # Compute device index (reserved; not used at runtime)\n31: DEVICE=0\n32: \n33: # --- YOLO decoder ---\n34: # Number of object classes the model outputs (e.g. 2 = ayam + talenan)\n35: NUM_CLASSES=2\n36: # Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits\n37: SCORE_SIGMOID=false\n38: \n39: # --- Detection ---\n40: # Confidence threshold – detections below this are discarded before NMS\n41: CONF=0.3\n42: \n43: # --- ByteTrack tracking ---\n44: # General for ayam, index 0\n45: # Detections with score ≥ this get priority matching in the first association stage\n46: TRACK_HIGH_THRESH_0=0.5\n47: # Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage\n48: TRACK_LOW_THRESH_0=0.1\n49: # IoU threshold for the first-stage association (0–1). Higher = stricter overlap required\n50: TRACK_MATCH_THRESH_0=0.8\n51: # Frames a track survives without a match before being permanently removed\n52: TRACK_BUFFER_0=30\n53: # Minimum consecutive (or total) hits needed before a track is considered confirmed\n54: TRACK_MIN_HITS_0=3\n55: \n56: # For talenan, index 1\n57: # Detections with score ≥ this get priority matching in the first association stage\n58: TRACK_HIGH_THRESH_1=0.5\n59: # Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage\n60: TRACK_LOW_THRESH_1=0.1\n61: # IoU threshold for the first-stage association (0–1). Higher = stricter overlap required\n62: TRACK_MATCH_THRESH_1=0.6\n63: # Frames a track survives without a match before being permanently removed\n64: TRACK_BUFFER_1=30\n65: # Minimum consecutive (or total) hits needed before a track is considered confirmed\n66: TRACK_MIN_HITS_1=3\n67: \n68: # --- Display ---\n69: # Site name shown on the dashboard header (top-right)\n70: SITE_NAME=ZenAi\n71: \n72: # --- Object class names ---\n73: # Camera / location identifier shown in HUD and stored in DB\n74: CAMERA_NAME=ZenAi\n75: # Label used for batch grouping in the database\n76: OBJECT_LABEL=ayam-potong\n77: # Class name for the counted object (must match NUM_CLASSES order, index 0)\n78: CLASS_AYAM=ayam\n79: # Class name for the batch-closing trigger object (must match NUM_CLASSES order, index 1)\n80: CLASS_TALENAN=talenan\n81: \n82: # --- Line crossing ---\n83: # Direction for counting: rtl (right-to-left, default) | ltr (left-to-right) | both\n84: CROSS_DIRECTION=rtl\n85: # Fixed x-coordinate for the counting line (overrides LINE_X_FRAC if set)\n86: LINE_X=\n87: # Fraction of frame width where the counting line is drawn (default 0.5 = centre)\n88: LINE_X_FRAC=0.5\n89: \n90: # --- Batch management ---\n91: # Daily cutoff time (HH:MM) – a new day's batch numbering starts after this time.\n92: # CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn_bytetrack.py.\n93: DAILY_CUTOFF_TIME=20:00\n94: CUTOFF_TIME=20:00\n95: # Reset frame counter and ByteTrack track-ID counter back to 0 when the daily cutoff is reached (true/false, default: true)\n96: RESET_COUNTERS_AT_CUTOFF=true\n97: # Seconds of inactivity after which the current batch is auto-closed\n98: BATCH_TIMEOUT_SECONDS=300\n99: # Seconds a newly-opened batch ignores the talenan label before accepting a close trigger\n100: IGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30\n101: # Minimum number of objects required for a batch to be saved as valid\n102: MIN_OBJECT_PER_BATCH=60\n103: # Minimum duration in seconds a batch must be open to be saved as valid\n104: MIN_DURATION_PER_BATCH=60\n105: \n106: # --- CSV export ---\n107: # Write per-crossing events to a CSV file (true/false)\n108: EXPORT_CSV=true\n109: # Path where the crossing CSV is written\n110: CROSS_CSV=/opt/batch-counter/batch_crossings.csv\n111: \n112: # --- Rate / performance ---\n113: # Enable motion detection pre-filter: skip inference on frames with no movement\n114: # (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are\n115: # skipped, saving NPU/CPU load.\n116: MOTION_DETECTION_ENABLED=false\n117: # Mean absolute pixel difference threshold (0–255) to consider a frame as having\n118: # motion. Lower = more sensitive. Default 5.0.\n119: MOTION_THRESHOLD=5.0\n120: # Sliding window in seconds for computing the crossing rate (objects/minute)\n121: RATE_WINDOW_SEC=60\n122: # Number of frames to discard at startup to let the stream buffer stabilise\n123: WARMUP_FRAMES=30\n124: # Delay in seconds between stream reconnection attempts\n125: RECONNECT_DELAY_SEC=3\n126: # Maximum reconnection attempts (0 = infinite)\n127: MAX_RECONNECT_ATTEMPTS=0\n128: # Print status log every N processed frames\n129: FLUSH_EVERY_N_FRAMES=100\n130: # Seconds after which a tracked but unseen object is pruned from the active set\n131: TRACKED_PRUNE_SEC=300\n132: \n133: # --- Video recording ---\n134: # Save annotated frames to segmented MP4 files (true/false)\n135: RECORD_VIDEO=false\n136: # Duration in seconds of each video segment file\n137: VIDEO_SEGMENT_SEC=3600\n138: # Output video FPS (fallback if source FPS is unknown or ≤ 1)\n139: OUTPUT_FPS=15\n140: \n141: # --- Live stream snapshot ---\n142: # Periodically write the latest annotated frame as JPEG for an external web server\n143: LIVE_STREAM_ENABLED=false\n144: # Path to the shared-memory snapshot file (served by nginx / lighttpd)\n145: LIVE_STREAM_FRAME_PATH=/dev/shm/byetrack-counter/live_frame.jpg\n146: # JPEG quality (1–100)\n147: LIVE_STREAM_QUALITY=75\n148: # Write the snapshot every N frames (lower = more frequent updates)\n149: LIVE_STREAM_EVERY_N=2\n150: \n151: # --- Dashboard (counter_dashboard.py) ---\n152: # Flask secret key for session/cookie signing — change in production!\n153: SECRET_KEY=change-me-in-production\n154: # Bind address for the Flask web server\n155: DASHBOARD_HOST=0.0.0.0\n156: # Listen port for the dashboard web UI\n157: DASHBOARD_PORT=5000\n158: # Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production\n159: FLASK_DEBUG=false\n160: # Fallback name for the active-batch JSON state file used by the dashboard\n161: CURRENT_BATCH_PATH=/tmp/bytetrack_current_batch.json\n\n(End of file - total 161 lines)\n", + "metadata": { + "preview": "# =============================================================================\n# Edge RK3588 production counter + dashboard\n# Shared config for: counter_live_rknn_bytetrack.py + counter_dashboard.py\n# Copy to .env on device: cp config.env.example .env && nano .env\n# =============================================================================\n\n# --- Core paths ---\n# Root output directory (logs, DB, video, CSV)\nOUTPUT_DIR=/opt/bytetrack-counter\n# SQLite database path for batch entries & crossing logs\nDB_PATH=/opt/bytetrack-counter/bytetrack_counter.db\n# JSON file persisting the current active batch state\nSTATE_FILE=/tmp/bytetrack_current_batch.json\n\n# --- Input source ---\n# RTSP / HTTP live stream, or a local video file path\nSOURCE=rtsp://user:pass@192.168.0.100:554/stream1\n# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)\nOPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay\n", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/config.env.example", + "text": "# =============================================================================\n# Edge RK3588 production counter + dashboard\n# Shared config for: counter_live_rknn_bytetrack.py + counter_dashboard.py\n# Copy to .env on device: cp config.env.example .env && nano .env\n# =============================================================================\n\n# --- Core paths ---\n# Root output directory (logs, DB, video, CSV)\nOUTPUT_DIR=/opt/bytetrack-counter\n# SQLite database path for batch entries & crossing logs\nDB_PATH=/opt/bytetrack-counter/bytetrack_counter.db\n# JSON file persisting the current active batch state\nSTATE_FILE=/tmp/bytetrack_current_batch.json\n\n# --- Input source ---\n# RTSP / HTTP live stream, or a local video file path\nSOURCE=rtsp://user:pass@192.168.0.100:554/stream1\n# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)\nOPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay\n\n# --- RKNN model ---\n# Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn)\nMODEL_PATH=/opt/models/yolo9t.rknn\n# Input image size for the model (square, e.g. 320 → 320×320)\nIMGSZ=320\n# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant\nHALF=false\n# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three\nCORE_MASK=7\n# Compute device index (reserved; not used at runtime)\nDEVICE=0\n\n# --- YOLO decoder ---\n# Number of object classes the model outputs (e.g. 2 = ayam + talenan)\nNUM_CLASSES=2\n# Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits\nSCORE_SIGMOID=false\n\n# --- Detection ---\n# Confidence threshold – detections below this are discarded before NMS\nCONF=0.3\n\n# --- ByteTrack tracking ---\n# General for ayam, index 0\n# Detections with score ≥ this get priority matching in the first association stage\nTRACK_HIGH_THRESH_0=0.5\n# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage\nTRACK_LOW_THRESH_0=0.1\n# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required\nTRACK_MATCH_THRESH_0=0.8\n# Frames a track survives without a match before being permanently removed\nTRACK_BUFFER_0=30\n# Minimum consecutive (or total) hits needed before a track is considered confirmed\nTRACK_MIN_HITS_0=3\n\n# For talenan, index 1\n# Detections with score ≥ this get priority matching in the first association stage\nTRACK_HIGH_THRESH_1=0.5\n# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage\nTRACK_LOW_THRESH_1=0.1\n# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required\nTRACK_MATCH_THRESH_1=0.6\n# Frames a track survives without a match before being permanently removed\nTRACK_BUFFER_1=30\n# Minimum consecutive (or total) hits needed before a track is considered confirmed\nTRACK_MIN_HITS_1=3\n\n# --- Display ---\n# Site name shown on the dashboard header (top-right)\nSITE_NAME=ZenAi\n\n# --- Object class names ---\n# Camera / location identifier shown in HUD and stored in DB\nCAMERA_NAME=ZenAi\n# Label used for batch grouping in the database\nOBJECT_LABEL=ayam-potong\n# Class name for the counted object (must match NUM_CLASSES order, index 0)\nCLASS_AYAM=ayam\n# Class name for the batch-closing trigger object (must match NUM_CLASSES order, index 1)\nCLASS_TALENAN=talenan\n\n# --- Line crossing ---\n# Direction for counting: rtl (right-to-left, default) | ltr (left-to-right) | both\nCROSS_DIRECTION=rtl\n# Fixed x-coordinate for the counting line (overrides LINE_X_FRAC if set)\nLINE_X=\n# Fraction of frame width where the counting line is drawn (default 0.5 = centre)\nLINE_X_FRAC=0.5\n\n# --- Batch management ---\n# Daily cutoff time (HH:MM) – a new day's batch numbering starts after this time.\n# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn_bytetrack.py.\nDAILY_CUTOFF_TIME=20:00\nCUTOFF_TIME=20:00\n# Reset frame counter and ByteTrack track-ID counter back to 0 when the daily cutoff is reached (true/false, default: true)\nRESET_COUNTERS_AT_CUTOFF=true\n# Seconds of inactivity after which the current batch is auto-closed\nBATCH_TIMEOUT_SECONDS=300\n# Seconds a newly-opened batch ignores the talenan label before accepting a close trigger\nIGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30\n# Minimum number of objects required for a batch to be saved as valid\nMIN_OBJECT_PER_BATCH=60\n# Minimum duration in seconds a batch must be open to be saved as valid\nMIN_DURATION_PER_BATCH=60\n\n# --- CSV export ---\n# Write per-crossing events to a CSV file (true/false)\nEXPORT_CSV=true\n# Path where the crossing CSV is written\nCROSS_CSV=/opt/batch-counter/batch_crossings.csv\n\n# --- Rate / performance ---\n# Enable motion detection pre-filter: skip inference on frames with no movement\n# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are\n# skipped, saving NPU/CPU load.\nMOTION_DETECTION_ENABLED=false\n# Mean absolute pixel difference threshold (0–255) to consider a frame as having\n# motion. Lower = more sensitive. Default 5.0.\nMOTION_THRESHOLD=5.0\n# Sliding window in seconds for computing the crossing rate (objects/minute)\nRATE_WINDOW_SEC=60\n# Number of frames to discard at startup to let the stream buffer stabilise\nWARMUP_FRAMES=30\n# Delay in seconds between stream reconnection attempts\nRECONNECT_DELAY_SEC=3\n# Maximum reconnection attempts (0 = infinite)\nMAX_RECONNECT_ATTEMPTS=0\n# Print status log every N processed frames\nFLUSH_EVERY_N_FRAMES=100\n# Seconds after which a tracked but unseen object is pruned from the active set\nTRACKED_PRUNE_SEC=300\n\n# --- Video recording ---\n# Save annotated frames to segmented MP4 files (true/false)\nRECORD_VIDEO=false\n# Duration in seconds of each video segment file\nVIDEO_SEGMENT_SEC=3600\n# Output video FPS (fallback if source FPS is unknown or ≤ 1)\nOUTPUT_FPS=15\n\n# --- Live stream snapshot ---\n# Periodically write the latest annotated frame as JPEG for an external web server\nLIVE_STREAM_ENABLED=false\n# Path to the shared-memory snapshot file (served by nginx / lighttpd)\nLIVE_STREAM_FRAME_PATH=/dev/shm/byetrack-counter/live_frame.jpg\n# JPEG quality (1–100)\nLIVE_STREAM_QUALITY=75\n# Write the snapshot every N frames (lower = more frequent updates)\nLIVE_STREAM_EVERY_N=2\n\n# --- Dashboard (counter_dashboard.py) ---\n# Flask secret key for session/cookie signing — change in production!\nSECRET_KEY=change-me-in-production\n# Bind address for the Flask web server\nDASHBOARD_HOST=0.0.0.0\n# Listen port for the dashboard web UI\nDASHBOARD_PORT=5000\n# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production\nFLASK_DEBUG=false\n# Fallback name for the active-batch JSON state file used by the dashboard\nCURRENT_BATCH_PATH=/tmp/bytetrack_current_batch.json", + "lineStart": 1, + "lineEnd": 161, + "totalLines": 161, + "truncated": false + } + }, + "title": "config.env.example", + "time": { + "start": 1785284240169, + "end": 1785284240183 + } + }, + "id": "prt_fab3ba25c001Bo0KQtIHlu6El0", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_04_y5rZw0rEq6o3tZU2iuyD7373", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/counter_live_rknn.py" + }, + "output": "/opt/bytetrack-counter/counter_live_rknn.py\nfile\n\n1: \"\"\"\n2: Edge production live counter — RTSP + YOLO RKNN + line crossing.\n3: Runs on RK3588 hardware with RKNN model (320×320 input).\n4: Replaces the Jetson/TensorRT variant.\n5: \"\"\"\n6: \n7: import numpy as np\n8: import cv2\n9: import csv\n10: import os\n11: import signal\n12: import time\n13: from datetime import datetime\n14: from pathlib import Path\n15: \n16: from dotenv import load_dotenv\n17: \n18: load_dotenv()\n19: \n20: from rknnlite.api import RKNNLite\n21: from batch_store import BatchStore\n22: \n23: # --- config (override via env / .env) ---\n24: OUTPUT_DIR = os.getenv(\"OUTPUT_DIR\", \"/opt/jetson-counter\")\n25: DB_PATH = os.getenv(\"DB_PATH\", f\"{OUTPUT_DIR}/jetson_counter.db\")\n26: STATE_FILE = os.getenv(\"STATE_FILE\", f\"{OUTPUT_DIR}/current_batch.json\")\n27: SOURCE = os.getenv(\"SOURCE\", \"rtsp://user:pass@192.168.0.100:554/stream1\")\n28: MODEL_PATH = os.getenv(\"MODEL_PATH\", \"/opt/jetson-counter/yolo11n.rknn\")\n29: CAMERA_NAME = os.getenv(\"CAMERA_NAME\", \"CC1\")\n30: OBJECT_LABEL = os.getenv(\"OBJECT_LABEL\", \"ayam-potong\")\n31: CLASS_AYAM = os.getenv(\"CLASS_AYAM\", \"ayam\")\n32: CLASS_TALENAN = os.getenv(\"CLASS_TALENAN\", \"talenan\")\n33: \n34: LINE_X = int(os.getenv(\"LINE_X\")) if os.getenv(\"LINE_X\") else None\n35: LINE_X_FRAC = float(os.getenv(\"LINE_X_FRAC\", \"0.5\"))\n36: CROSS_DIRECTION = os.getenv(\"CROSS_DIRECTION\", \"rtl\").lower()\n37: \n38: IMGSZ = int(os.getenv(\"IMGSZ\", \"320\"))\n39: HALF = os.getenv(\"HALF\", \"false\").lower() == \"true\"\n40: CONF = float(os.getenv(\"CONF\", \"0.3\"))\n41: DEVICE = int(os.getenv(\"DEVICE\", \"0\"))\n42: \n43: # RKNN-specific — core mask for NPU\n44: # 1 = core0, 2 = core1, 3 = core0+core1 (dual), 7 = all three\n45: CORE_MASK = int(os.getenv(\"CORE_MASK\", \"1\"))\n46: \n47: # YOLO decoder config\n48: NUM_CLASSES = int(os.getenv(\"NUM_CLASSES\", \"2\"))\n49: SCORE_SIGMOID = os.getenv(\"SCORE_SIGMOID\", \"false\").lower() == \"true\"\n50: \n51: DAILY_CUTOFF_TIME = os.getenv(\"DAILY_CUTOFF_TIME\", \"20:00\")\n52: BATCH_TIMEOUT_SECONDS = float(os.getenv(\"BATCH_TIMEOUT_SECONDS\", \"300\"))\n53: IGNORE_BATCH_LABEL_TIMEOUT = float(\n54: os.getenv(\"IGNORE_BATCH_LABEL_TIMEOUT_SECONDS\", \"30\")\n55: )\n56: MIN_OBJECT_PER_BATCH = int(os.getenv(\"MIN_OBJECT_PER_BATCH\", \"60\"))\n57: MIN_DURATION_PER_BATCH = int(os.getenv(\"MIN_DURATION_PER_BATCH\", \"60\"))\n58: \n59: EXPORT_CSV = os.getenv(\"EXPORT_CSV\", \"true\").lower() == \"true\"\n60: CROSS_CSV = os.getenv(\"CROSS_CSV\", f\"{OUTPUT_DIR}/batch_crossings.csv\")\n61: \n62: WARMUP_FRAMES = int(os.getenv(\"WARMUP_FRAMES\", \"30\"))\n63: RECONNECT_DELAY_SEC = int(os.getenv(\"RECONNECT_DELAY_SEC\", \"3\"))\n64: MAX_RECONNECT_ATTEMPTS = int(os.getenv(\"MAX_RECONNECT_ATTEMPTS\", \"0\"))\n65: FLUSH_EVERY_N_FRAMES = int(os.getenv(\"FLUSH_EVERY_N_FRAMES\", \"100\"))\n66: TRACKED_PRUNE_SEC = int(os.getenv(\"TRACKED_PRUNE_SEC\", \"300\"))\n67: RECORD_VIDEO = os.getenv(\"RECORD_VIDEO\", \"false\").lower() == \"true\"\n68: VIDEO_SEGMENT_SEC = int(os.getenv(\"VIDEO_SEGMENT_SEC\", \"3600\"))\n69: OUTPUT_FPS = int(os.getenv(\"OUTPUT_FPS\", \"15\"))\n70: \n71: LIVE_STREAM_ENABLED = os.getenv(\"LIVE_STREAM_ENABLED\", \"false\").lower() == \"true\"\n72: LIVE_STREAM_FRAME_PATH = os.getenv(\n73: \"LIVE_STREAM_FRAME_PATH\", \"/dev/shm/jetson-counter/live_frame.jpg\"\n74: )\n75: LIVE_STREAM_QUALITY = int(os.getenv(\"LIVE_STREAM_QUALITY\", \"75\"))\n76: LIVE_STREAM_EVERY_N = int(os.getenv(\"LIVE_STREAM_EVERY_N\", \"2\"))\n77: \n78: RTSP_FFMPEG_OPTIONS = os.getenv(\n79: \"OPENCV_FFMPEG_CAPTURE_OPTIONS\",\n80: \"rtsp_transport;tcp|fflags;nobuffer|flags;low_delay\",\n81: )\n82: \n83: IS_LIVE = SOURCE.lower().startswith((\"rtsp://\", \"http://\"))\n84: \n85: CROSS_FLASH_FRAMES = 12\n86: POPUP_LIFETIME = 20\n87: LINE_PULSE_FRAMES = 12\n88: COUNT_PULSE_FRAMES = 15\n89: BATCH_PULSE_FRAMES = 20\n90: \n91: SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)]\n92: SK_COLORS = [\n93: (0, 255, 255),\n94: (0, 255, 255),\n95: (255, 0, 255),\n96: (255, 0, 255),\n97: (0, 255, 0),\n98: (255, 255, 0),\n99: (0, 0, 255),\n100: (200, 200, 0),\n101: ]\n102: \n103: C_PANEL = (28, 24, 18)\n104: C_BORDER = (90, 85, 75)\n105: C_ACCENT = (255, 200, 60)\n106: C_GREEN = (80, 220, 100)\n107: C_TEXT = (235, 235, 235)\n108: C_MUTED = (150, 150, 150)\n109: C_AYAM_BOX = (0, 165, 255)\n110: C_TALENAN_BOX = (220, 120, 60)\n111: C_LINE_CORE = (180, 220, 255)\n112: C_LINE_GLOW = (100, 160, 220)\n113: \n114: shutdown_requested = False\n115: \n116: \n117: def request_shutdown(signum, frame):\n118: global shutdown_requested\n119: shutdown_requested = True\n120: print(\"\\nShutdown requested — finishing current frame...\")\n121: \n122: \n123: signal.signal(signal.SIGINT, request_shutdown)\n124: signal.signal(signal.SIGTERM, request_shutdown)\n125: \n126: \n127: # =============================================================================\n128: # YOLO output decoder (NMS only — boxes are pre-decoded by the model)\n129: # =============================================================================\n130: \n131: \n132: def _nms(boxes, scores, iou_thr=0.45):\n133: order = np.argsort(scores)[::-1]\n134: keep = []\n135: while len(order) > 0:\n136: idx = order[0]\n137: keep.append(idx)\n138: if len(order) == 1:\n139: break\n140: xx1 = np.maximum(boxes[idx, 0], boxes[order[1:], 0])\n141: yy1 = np.maximum(boxes[idx, 1], boxes[order[1:], 1])\n142: xx2 = np.minimum(boxes[idx, 2], boxes[order[1:], 2])\n143: yy2 = np.minimum(boxes[idx, 3], boxes[order[1:], 3])\n144: w = np.maximum(0.0, xx2 - xx1)\n145: h = np.maximum(0.0, yy2 - yy1)\n146: inter = w * h\n147: area_i = (boxes[idx, 2] - boxes[idx, 0]) * (boxes[idx, 3] - boxes[idx, 1])\n148: area_o = (boxes[order[1:], 2] - boxes[order[1:], 0]) * (\n149: boxes[order[1:], 3] - boxes[order[1:], 1]\n150: )\n151: iou = inter / (area_i + area_o - inter + 1e-16)\n152: order = order[1:][iou < iou_thr]\n153: return np.array(keep)\n154: \n155: \n156: def _compute_iou(box1, boxes2):\n157: \"\"\"IoU of one box (cxcywh) against a set (2D) or single box (1D).\"\"\"\n158: if boxes2.ndim == 1:\n159: boxes2 = boxes2.reshape(1, -1)\n160: cx, cy, w, h = box1\n161: x1, y1 = cx - w / 2, cy - h / 2\n162: x2, y2 = cx + w / 2, cy + h / 2\n163: area1 = w * h\n164: \n165: cxs, cys, ws, hs = boxes2[:, 0], boxes2[:, 1], boxes2[:, 2], boxes2[:, 3]\n166: x1s, y1s = cxs - ws / 2, cys - hs / 2\n167: x2s, y2s = cxs + ws / 2, cys + hs / 2\n168: areas2 = ws * hs\n169: \n170: xx1 = np.maximum(x1, x1s)\n171: yy1 = np.maximum(y1, y1s)\n172: xx2 = np.minimum(x2, x2s)\n173: yy2 = np.minimum(y2, y2s)\n174: inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)\n175: return inter / (area1 + areas2 - inter + 1e-16)\n176: \n177: \n178: # =============================================================================\n179: # Simple IoU tracker (replaces bytetrack — same persist behaviour)\n180: # =============================================================================\n181: \n182: \n183: class SimpleTracker:\n184: def __init__(self, max_age=30, min_hits=1, iou_threshold=0.3):\n185: self.max_age = max_age\n186: self.min_hits = min_hits\n187: self.iou_threshold = iou_threshold\n188: self.tracks = {} # track_id -> {box, cx, age, hits, time_since_update}\n189: self.next_id = 1\n190: \n191: def update(self, detections):\n192: \"\"\"detections: list of (cx, box_cxcywh). Returns (track_map, det_to_track).\"\"\"\n193: now = time.monotonic()\n194: \n195: for tid in self.tracks:\n196: self.tracks[tid][\"time_since_update\"] += 1\n197: \n198: matched_det = set()\n199: matched_track = set()\n200: assignments = [] # (track_id, det_idx)\n201: det_to_track = {} # det_idx → track_id\n202: \n203: if detections and self.tracks:\n204: track_ids = list(self.tracks.keys())\n205: track_boxes = np.stack([self.tracks[t][\"box\"] for t in track_ids], axis=0)\n206: \n207: for di, det in enumerate(detections):\n208: _, det_box = det\n209: ious = np.array(\n210: [\n211: _compute_iou(det_box, track_boxes[t : t + 1])\n212: for t in range(len(track_ids))\n213: ]\n214: )\n215: best_j = int(np.argmax(ious))\n216: if (\n217: ious[best_j] >= self.iou_threshold\n218: and track_ids[best_j] not in matched_track\n219: ):\n220: assignments.append((track_ids[best_j], di))\n221: matched_track.add(track_ids[best_j])\n222: matched_det.add(di)\n223: \n224: for tid, di in assignments:\n225: cx, box = detections[di]\n226: self.tracks[tid][\"cx\"] = cx\n227: self.tracks[tid][\"box\"] = box\n228: self.tracks[tid][\"hits\"] += 1\n229: self.tracks[tid][\"time_since_update\"] = 0\n230: self.tracks[tid][\"last_update\"] = now\n231: det_to_track[di] = tid\n232: \n233: for di, det in enumerate(detections):\n234: if di not in matched_det:\n235: cx, box = det\n236: new_id = self.next_id\n237: self.next_id += 1\n238: self.tracks[new_id] = {\n239: \"cx\": cx,\n240: \"box\": box,\n241: \"hits\": 1,\n242: \"time_since_update\": 0,\n243: \"last_update\": now,\n244: }\n245: det_to_track[di] = new_id\n246: \n247: stale = [\n248: tid\n249: for tid, t in self.tracks.items()\n250: if t[\"time_since_update\"] > self.max_age\n251: ]\n252: for tid in stale:\n253: del self.tracks[tid]\n254: \n255: track_map = {\n256: tid: self.tracks[tid][\"cx\"]\n257: for tid in self.tracks\n258: if self.tracks[tid][\"hits\"] >= self.min_hits\n259: }\n260: return track_map, det_to_track\n261: \n262: \n263: # =============================================================================\n264: # RKNN YOLO wrapper (detect output format: (1, 4+num_classes, N))\n265: # =============================================================================\n266: \n267: \n268: class RKNNYOLO:\n269: def __init__(\n270: self,\n271: model_path,\n272: core_mask=1,\n273: imgsz=320,\n274: conf=0.3,\n275: iou=0.45,\n276: num_classes=2,\n277: num_keypoints=0,\n278: score_sigmoid=False,\n279: ):\n280: self.imgsz = imgsz\n281: self.conf = conf\n282: self.iou = iou\n283: self.num_classes = num_classes\n284: self.num_keypoints = num_keypoints\n285: self.score_sigmoid = score_sigmoid\n286: \n287: self.rknn = RKNNLite(verbose=False)\n288: ret = self.rknn.load_rknn(model_path)\n289: if ret != 0:\n290: raise RuntimeError(f\"Failed to load RKNN model: {model_path}\")\n291: ret = self.rknn.init_runtime(core_mask=core_mask)\n292: if ret != 0:\n293: raise RuntimeError(f\"Failed to init RKNN runtime (core_mask={core_mask})\")\n294: \n295: try:\n296: from rknnlite.api import RKNNLite as _RK\n297: \n298: sdk_ver = self.rknn.get_sdk_version()\n299: print(f\"RKNN SDK version: {sdk_ver}\")\n300: except Exception:\n301: pass\n302: \n303: print(f\"RKNN model loaded: {model_path} imgsz={imgsz} core_mask={core_mask}\")\n304: \n305: def _preprocess(self, frame):\n306: \"\"\"Letterbox-resize to imgsz×imgsz, maintain aspect ratio, BGR→RGB, normalize.\"\"\"\n307: h0, w0 = frame.shape[:2]\n308: scale = min(self.imgsz / h0, self.imgsz / w0)\n309: nh, nw = int(h0 * scale), int(w0 * scale)\n310: resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)\n311: \n312: letterbox = np.full((self.imgsz, self.imgsz, 3), 114, dtype=np.uint8)\n313: dy = (self.imgsz - nh) // 2\n314: dx = (self.imgsz - nw) // 2\n315: letterbox[dy : dy + nh, dx : dx + nw] = resized\n316: \n317: rgb = cv2.cvtColor(letterbox, cv2.COLOR_BGR2RGB)\n318: gains = np.array([scale, scale, dy, dx], dtype=np.float32)\n319: return rgb, gains\n320: \n321: def __call__(self, frame):\n322: \"\"\"Run inference on BGR frame. Returns list of detection dicts.\"\"\"\n323: h0, w0 = frame.shape[:2]\n324: rgb, gains = self._preprocess(frame)\n325: scale, _, pad_y, pad_x = gains\n326: \n327: inp = np.expand_dims(rgb, axis=0)\n328: inp = np.ascontiguousarray(inp.astype(np.uint8))\n329: \n330: outputs = self.rknn.inference(inputs=[inp])\n331: \n332: if len(outputs) == 0:\n333: return []\n334: \n335: out = outputs[0] # (1, 4+num_classes, N) or (1, N, 4+num_classes)\n336: out = np.squeeze(out, axis=0) # (C, N) or (N, C)\n337: \n338: if out.shape[0] == self.num_classes + 4:\n339: out = out.T # (C, N) → (N, C)\n340: \n341: boxes_cxcywh = out[:, :4].copy() # cx, cy, w, h at model resolution\n342: cls_raw = out[:, 4:].copy()\n343: \n344: if self.score_sigmoid:\n345: cls_scores = 1.0 / (1.0 + np.exp(-np.clip(cls_raw, -10, 10)))\n346: else:\n347: cls_scores = cls_raw\n348: \n349: boxes_xyxy = np.stack(\n350: [\n351: boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2,\n352: boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2,\n353: boxes_cxcywh[:, 0] + boxes_cxcywh[:, 2] / 2,\n354: boxes_cxcywh[:, 1] + boxes_cxcywh[:, 3] / 2,\n355: ],\n356: axis=1,\n357: )\n358: \n359: max_scores = cls_scores.max(axis=1)\n360: class_ids = cls_scores.argmax(axis=1)\n361: mask = max_scores > self.conf\n362: \n363: if mask.sum() == 0:\n364: return []\n365: \n366: bboxes = boxes_xyxy[mask].astype(np.float32)\n367: scores = max_scores[mask].astype(np.float32)\n368: clses = class_ids[mask]\n369: \n370: bboxes[:, 0] = (bboxes[:, 0] - pad_x) / scale\n371: bboxes[:, 1] = (bboxes[:, 1] - pad_y) / scale\n372: bboxes[:, 2] = (bboxes[:, 2] - pad_x) / scale\n373: bboxes[:, 3] = (bboxes[:, 3] - pad_y) / scale\n374: \n375: bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w0)\n376: bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h0)\n377: bboxes[:, 2] = np.clip(bboxes[:, 2], 0, w0)\n378: bboxes[:, 3] = np.clip(bboxes[:, 3], 0, h0)\n379: \n380: detections = []\n381: for cls_id in range(self.num_classes):\n382: idx = np.where(clses == cls_id)[0]\n383: if len(idx) == 0:\n384: continue\n385: keep = _nms(bboxes[idx], scores[idx], iou_thr=self.iou)\n386: for k in keep:\n387: j = idx[k]\n388: detections.append(\n389: {\n390: \"bbox\": bboxes[j].tolist(),\n391: \"score\": float(scores[j]),\n392: \"cls\": int(clses[j]),\n393: \"keypoints\": None,\n394: }\n395: )\n396: return detections\n397: \n398: def release(self):\n399: self.rknn.release()\n400: \n401: \n402: # =============================================================================\n403: # Drawing helpers (unchanged from original)\n404: # =============================================================================\n405: \n406: \n407: def resolve_line_x(frame_width):\n408: if LINE_X is not None:\n409: return LINE_X\n410: if LINE_X_FRAC != 0.5:\n411: return int(frame_width * LINE_X_FRAC)\n412: return frame_width // 2\n413: \n414: \n415: def crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION):\n416: if direction == \"ltr\":\n417: return prev_cx < line_x <= cx\n418: if direction == \"both\":\n419: return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx)\n420: return prev_cx > line_x >= cx\n421: \n422: \n423: def now_str():\n424: return datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n425: \n426: \n427: def open_capture(source):\n428: if source.lower().startswith((\"rtsp://\", \"http://\")):\n429: os.environ[\"OPENCV_FFMPEG_CAPTURE_OPTIONS\"] = RTSP_FFMPEG_OPTIONS\n430: cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG)\n431: cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)\n432: return cap\n433: \n434: \n435: def warmup_stream(cap, n=WARMUP_FRAMES):\n436: print(\"Warming up stream...\")\n437: for _ in range(n):\n438: cap.read()\n439: print(\"Stream ready!\")\n440: \n441: \n442: def open_video_writer(path, w, h, fps):\n443: return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*\"avc1\"), fps, (w, h))\n444: \n445: \n446: class CsvLogger:\n447: def __init__(self, path, header):\n448: Path(path).parent.mkdir(parents=True, exist_ok=True)\n449: new_file = not Path(path).exists() or Path(path).stat().st_size == 0\n450: self.file = open(path, \"a\", newline=\"\", buffering=1)\n451: self.writer = csv.writer(self.file)\n452: if new_file:\n453: self.writer.writerow(header)\n454: self.file.flush()\n455: \n456: def write_row(self, row):\n457: self.writer.writerow(row)\n458: self.file.flush()\n459: \n460: def close(self):\n461: self.file.close()\n462: \n463: \n464: class VideoSegmentWriter:\n465: def __init__(self, output_dir, w, h, fps, segment_sec):\n466: self.output_dir = Path(output_dir)\n467: self.output_dir.mkdir(parents=True, exist_ok=True)\n468: self.w, self.h, self.fps = w, h, fps\n469: self.segment_sec = segment_sec\n470: self.segment_start = time.monotonic()\n471: self.writer = None\n472: self._open_next()\n473: \n474: def _segment_path(self):\n475: ts = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n476: return str(self.output_dir / f\"live_{ts}.mp4\")\n477: \n478: def _open_next(self):\n479: if self.writer is not None:\n480: self.writer.release()\n481: path = self._segment_path()\n482: self.writer = open_video_writer(path, self.w, self.h, self.fps)\n483: self.segment_start = time.monotonic()\n484: print(f\"Recording segment: {path}\")\n485: \n486: def write(self, frame):\n487: if time.monotonic() - self.segment_start >= self.segment_sec:\n488: self._open_next()\n489: self.writer.write(frame)\n490: \n491: def release(self):\n492: if self.writer is not None:\n493: self.writer.release()\n494: \n495: \n496: def prune_stale_tracks(tracked, now_mono):\n497: stale = [\n498: tid for tid, (_, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC\n499: ]\n500: for tid in stale:\n501: del tracked[tid]\n502: \n503: \n504: def overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65):\n505: x1, y1 = max(0, x1), max(0, y1)\n506: x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2)\n507: if x2 <= x1 or y2 <= y1:\n508: return\n509: roi = img[y1:y2, x1:x2]\n510: patch = np.full_like(roi, color, dtype=np.uint8)\n511: cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi)\n512: \n513: \n514: def draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4):\n515: font = cv2.FONT_HERSHEY_SIMPLEX\n516: (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1)\n517: x1, y1 = x, y - th - pad_y\n518: x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y\n519: cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1)\n520: cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1)\n521: cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA)\n522: \n523: \n524: def draw_elegant_counting_line(img, line_x, h, pulse_remaining=0):\n525: strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1)\n526: glow_alpha = 0.12 + 0.18 * strength\n527: for offset in (14, 9, 5):\n528: color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW)\n529: cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA)\n530: cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA)\n531: dash_len, gap = 18, 12\n532: y = 0\n533: while y < h:\n534: y_end = min(y + dash_len, h)\n535: cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA)\n536: y += dash_len + gap\n537: cv2.putText(\n538: img,\n539: \"COUNT LINE\",\n540: (line_x - 46, 24),\n541: cv2.FONT_HERSHEY_SIMPLEX,\n542: 0.42,\n543: C_LINE_CORE,\n544: 1,\n545: cv2.LINE_AA,\n546: )\n547: \n548: \n549: def draw_hero_count(img, line_x, h, count, pulse_remaining=0):\n550: text = str(count)\n551: font = cv2.FONT_HERSHEY_SIMPLEX\n552: boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1))\n553: font_scale, thickness = 1.6 + boost, 3\n554: (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness)\n555: pad = 14\n556: tx, ty = line_x - tw // 2, h // 2 + th // 2\n557: overlay_rect(\n558: img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad // 2, C_PANEL, alpha=0.78\n559: )\n560: cv2.rectangle(\n561: img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad // 2), C_LINE_CORE, 2\n562: )\n563: cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA)\n564: \n565: \n566: def draw_hud(\n567: img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate, camera_id, clock\n568: ):\n569: bar_h = 52\n570: overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72)\n571: cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1)\n572: cv2.putText(\n573: img, \"BATCH\", (16, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n574: )\n575: batch_label = str(batch_num) if batch_num else \"—\"\n576: cv2.putText(\n577: img,\n578: batch_label,\n579: (16, 44),\n580: cv2.FONT_HERSHEY_SIMPLEX,\n581: 0.9,\n582: C_ACCENT,\n583: 2,\n584: cv2.LINE_AA,\n585: )\n586: cv2.putText(\n587: img, \"COUNT\", (100, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n588: )\n589: cv2.putText(\n590: img,\n591: str(batch_count),\n592: (100, 44),\n593: cv2.FONT_HERSHEY_SIMPLEX,\n594: 0.9,\n595: C_GREEN,\n596: 2,\n597: cv2.LINE_AA,\n598: )\n599: cv2.putText(\n600: img, \"TOTAL\", (190, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n601: )\n602: cv2.putText(\n603: img,\n604: str(total_ayam),\n605: (190, 44),\n606: cv2.FONT_HERSHEY_SIMPLEX,\n607: 0.7,\n608: C_TEXT,\n609: 1,\n610: cv2.LINE_AA,\n611: )\n612: cv2.putText(\n613: img,\n614: \"UPTIME\",\n615: (280, 20),\n616: cv2.FONT_HERSHEY_SIMPLEX,\n617: 0.45,\n618: C_MUTED,\n619: 1,\n620: cv2.LINE_AA,\n621: )\n622: cv2.putText(\n623: img,\n624: f\"{elapsed_sec / 3600:.1f}h\",\n625: (280, 44),\n626: cv2.FONT_HERSHEY_SIMPLEX,\n627: 0.7,\n628: C_TEXT,\n629: 1,\n630: cv2.LINE_AA,\n631: )\n632: cv2.putText(\n633: img, \"RATE\", (380, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n634: )\n635: cv2.putText(\n636: img,\n637: f\"{rate:.1f}/min\",\n638: (380, 44),\n639: cv2.FONT_HERSHEY_SIMPLEX,\n640: 0.7,\n641: C_ACCENT,\n642: 1,\n643: cv2.LINE_AA,\n644: )\n645: # cv2.putText(img, clock, (w - 180, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.55, C_TEXT, 1, cv2.LINE_AA)\n646: cv2.putText(\n647: img,\n648: f\"CAM {camera_id}\",\n649: (w - 180, 20),\n650: cv2.FONT_HERSHEY_SIMPLEX,\n651: 0.45,\n652: C_MUTED,\n653: 1,\n654: cv2.LINE_AA,\n655: )\n656: \n657: \n658: def draw_footer(img, w, h, frame_idx, live_tag):\n659: bar_h = 28\n660: overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55)\n661: cv2.putText(\n662: img,\n663: f\"{live_tag} | Frame {frame_idx}\",\n664: (12, h - 9),\n665: cv2.FONT_HERSHEY_SIMPLEX,\n666: 0.45,\n667: C_MUTED,\n668: 1,\n669: cv2.LINE_AA,\n670: )\n671: \n672: \n673: def draw_skeleton_bold(img, kpts):\n674: for (a, b), color in zip(SKELETON, SK_COLORS):\n675: if a < len(kpts) and b < len(kpts):\n676: xa, ya = int(kpts[a][0]), int(kpts[a][1])\n677: xb, yb = int(kpts[b][0]), int(kpts[b][1])\n678: if xa > 0 and ya > 0 and xb > 0 and yb > 0:\n679: cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA)\n680: for kp in kpts:\n681: x, y = int(kp[0]), int(kp[1])\n682: if x > 0 and y > 0:\n683: cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA)\n684: cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA)\n685: \n686: \n687: def draw_popups(img, popups, frame_idx):\n688: alive = []\n689: for pop in popups:\n690: age = frame_idx - pop[\"born\"]\n691: if age > POPUP_LIFETIME:\n692: continue\n693: alive.append(pop)\n694: fade = 1.0 - age / POPUP_LIFETIME\n695: y = pop[\"y\"] - int(age * 1.8)\n696: color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade))\n697: cv2.putText(\n698: img,\n699: pop[\"text\"],\n700: (pop[\"x\"], y),\n701: cv2.FONT_HERSHEY_SIMPLEX,\n702: 0.7,\n703: color,\n704: 2,\n705: cv2.LINE_AA,\n706: )\n707: return alive\n708: \n709: \n710: def draw_batch_banner(img, w, batch_num, pulse_remaining):\n711: if pulse_remaining <= 0:\n712: return\n713: text = f\"NEW BATCH {batch_num}\"\n714: font = cv2.FONT_HERSHEY_SIMPLEX\n715: (tw, th), _ = cv2.getTextSize(text, font, 0.8, 2)\n716: x1, y1 = w // 2 - tw // 2 - 16, 62\n717: x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20\n718: overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7)\n719: cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2)\n720: cv2.putText(\n721: img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA\n722: )\n723: \n724: \n725: def connect_stream(source, warmup=WARMUP_FRAMES):\n726: attempts = 0\n727: while not shutdown_requested:\n728: cap = open_capture(source)\n729: if not cap.isOpened():\n730: attempts += 1\n731: if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS:\n732: raise RuntimeError(\n733: f\"Cannot open source after {attempts} attempts: {source}\"\n734: )\n735: print(f\"Cannot open source, retry in {RECONNECT_DELAY_SEC}s...\")\n736: time.sleep(RECONNECT_DELAY_SEC)\n737: continue\n738: if warmup > 0 and source.lower().startswith((\"rtsp://\", \"http://\")):\n739: warmup_stream(cap, warmup)\n740: w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))\n741: h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))\n742: fps = cap.get(cv2.CAP_PROP_FPS)\n743: if not fps or fps <= 1:\n744: fps = OUTPUT_FPS\n745: return cap, w, h, fps\n746: return None, 0, 0, OUTPUT_FPS\n747: \n748: \n749: # =============================================================================\n750: # Main loop\n751: # =============================================================================\n752: \n753: \n754: def run():\n755: global shutdown_requested\n756: \n757: store = BatchStore(\n758: db_path=DB_PATH,\n759: state_file=STATE_FILE,\n760: camera_name=CAMERA_NAME,\n761: object_label=OBJECT_LABEL,\n762: cutoff_time=DAILY_CUTOFF_TIME,\n763: batch_timeout=BATCH_TIMEOUT_SECONDS,\n764: ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT,\n765: min_object_per_batch=MIN_OBJECT_PER_BATCH,\n766: min_duration_per_batch=MIN_DURATION_PER_BATCH,\n767: logger=lambda msg: print(f\"[{now_str()}] {msg}\"),\n768: )\n769: store.start_cutoff_watcher()\n770: \n771: cross_logger = None\n772: if EXPORT_CSV:\n773: cross_logger = CsvLogger(\n774: CROSS_CSV, [\"batch\", \"frame\", \"timestamp\", \"chicken_id\"]\n775: )\n776: \n777: # Load RKNN model\n778: model = RKNNYOLO(\n779: model_path=MODEL_PATH,\n780: core_mask=CORE_MASK,\n781: imgsz=IMGSZ,\n782: conf=CONF,\n783: num_classes=NUM_CLASSES,\n784: score_sigmoid=SCORE_SIGMOID,\n785: )\n786: \n787: # Class IDs — order comes from RKNN model output (class index)\n788: # class index 0 → CLASS_AYAM, index 1 → CLASS_TALENAN (or env-specified)\n789: # Use class names in env order: first CLASS_AYAM → id 0, then CLASS_TALENAN → id 1\n790: CLASS_IDS = {\n791: os.getenv(\"CLASS_AYAM\", \"ayam\"): 0,\n792: os.getenv(\"CLASS_TALENAN\", \"talenan\"): 1,\n793: }\n794: ayam_cls = CLASS_IDS[CLASS_AYAM]\n795: talenan_cls = CLASS_IDS[CLASS_TALENAN]\n796: \n797: ayam_tracker = SimpleTracker(max_age=60)\n798: talenan_tracker = SimpleTracker(max_age=60)\n799: \n800: ayam_line_crossed = set()\n801: talenan_line_crossed = set()\n802: \n803: ayam_cross_flash = {}\n804: talenan_cross_flash = {}\n805: line_pulse = count_pulse = batch_pulse = 0\n806: popups = []\n807: \n808: session_start = time.time()\n809: frame_idx = 0\n810: video_writer = None\n811: \n812: cap, w, h, fps = connect_stream(SOURCE)\n813: if cap is None:\n814: store.shutdown()\n815: model.release()\n816: return\n817: \n818: line_x = resolve_line_x(w)\n819: print(\n820: f\"RKNN counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}\"\n821: )\n822: print(f\"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}\")\n823: print(f\"DB: {DB_PATH}\")\n824: print(f\"State: {STATE_FILE}\")\n825: \n826: if RECORD_VIDEO:\n827: video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC)\n828: \n829: reconnect_count = 0\n830: \n831: while not shutdown_requested:\n832: ret, frame = cap.read()\n833: if not ret:\n834: if not IS_LIVE:\n835: break\n836: reconnect_count += 1\n837: print(\n838: f\"Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s...\"\n839: )\n840: cap.release()\n841: time.sleep(RECONNECT_DELAY_SEC)\n842: cap, w, h, fps = connect_stream(SOURCE)\n843: if cap is None:\n844: break\n845: line_x = resolve_line_x(w)\n846: continue\n847: \n848: now = time.time()\n849: elapsed = now - session_start\n850: mono = time.monotonic()\n851: ayam_crossed_frame = batch_closed_frame = batch_started_frame = False\n852: \n853: # RKNN inference\n854: detections = model(frame)\n855: \n856: if detections:\n857: ayam_dets = [] # list of (cx, xywh_box)\n858: talenan_dets = []\n859: ayam_kpts_map = {} # det_idx → keypoints\n860: talenan_kpts_map = {}\n861: \n862: for di, det in enumerate(detections):\n863: bbox = det[\"bbox\"]\n864: cls_id = det[\"cls\"]\n865: cx = (bbox[0] + bbox[2]) / 2.0\n866: x1, y1, x2, y2 = bbox\n867: wb, hb = x2 - x1, y2 - y1\n868: box_cxcywh = np.array([cx, (y1 + y2) / 2, wb, hb], dtype=np.float32)\n869: \n870: if cls_id == talenan_cls:\n871: talenan_dets.append((cx, box_cxcywh))\n872: if det[\"keypoints\"] is not None:\n873: talenan_kpts_map[len(talenan_dets) - 1] = det[\"keypoints\"]\n874: elif cls_id == ayam_cls:\n875: ayam_dets.append((cx, box_cxcywh))\n876: if det[\"keypoints\"] is not None:\n877: ayam_kpts_map[len(ayam_dets) - 1] = det[\"keypoints\"]\n878: \n879: # Track ayam — returns (track_id → cx, detection_idx → track_id)\n880: ayam_cx_map, ayam_det_to_track = ayam_tracker.update(ayam_dets)\n881: \n882: # Track talenan\n883: talenan_cx_map, talenan_det_to_track = talenan_tracker.update(talenan_dets)\n884: \n885: # Process talenan crossings\n886: for di, (cx, box) in enumerate(talenan_dets):\n887: tid = talenan_det_to_track.get(di)\n888: if tid is None:\n889: continue\n890: if tid in talenan_tracker.tracks:\n891: if tid in talenan_tracked:\n892: prev_cx = talenan_tracked[tid][0]\n893: if (\n894: crossed_line(prev_cx, cx, line_x)\n895: and tid not in talenan_line_crossed\n896: ):\n897: talenan_line_crossed.add(tid)\n898: if store.record_talenan_crossing(tid):\n899: batch_closed_frame = True\n900: talenan_cross_flash[tid] = CROSS_FLASH_FRAMES\n901: popups.append(\n902: {\n903: \"x\": int(cx) - 20,\n904: \"y\": int(box[1]),\n905: \"born\": frame_idx,\n906: \"text\": \"BATCH CLOSED\",\n907: }\n908: )\n909: talenan_tracked[tid] = (cx, mono)\n910: \n911: # Process ayam crossings\n912: for di, (cx, box) in enumerate(ayam_dets):\n913: tid = ayam_det_to_track.get(di)\n914: if tid is None:\n915: continue\n916: if tid in ayam_tracker.tracks:\n917: if tid in ayam_tracked:\n918: prev_cx = ayam_tracked[tid][0]\n919: if (\n920: crossed_line(prev_cx, cx, line_x)\n921: and tid not in ayam_line_crossed\n922: ):\n923: ayam_line_crossed.add(tid)\n924: _, started_new = store.record_ayam_crossing(tid)\n925: if cross_logger:\n926: cross_logger.write_row(\n927: [\n928: store.current_batch_number,\n929: frame_idx,\n930: datetime.now().isoformat(),\n931: tid,\n932: ]\n933: )\n934: ayam_crossed_frame = True\n935: if started_new:\n936: batch_started_frame = True\n937: ayam_cross_flash[tid] = CROSS_FLASH_FRAMES\n938: popups.append(\n939: {\n940: \"x\": int(cx) - 12,\n941: \"y\": int(box[1]),\n942: \"born\": frame_idx,\n943: \"text\": \"+1\",\n944: }\n945: )\n946: ayam_tracked[tid] = (cx, mono)\n947: \n948: # Draw talenan\n949: for di, (cx, box) in enumerate(talenan_dets):\n950: tid = talenan_det_to_track.get(di)\n951: if tid is None:\n952: continue\n953: x1 = int(box[0] - box[2] / 2)\n954: y1 = int(box[1] - box[3] / 2)\n955: x2 = int(box[0] + box[2] / 2)\n956: y2 = int(box[1] + box[3] / 2)\n957: flash = talenan_cross_flash.get(tid, 0)\n958: color = C_GREEN if flash > 0 else C_TALENAN_BOX\n959: cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n960: draw_pill(frame, f\"TALENAN {tid}\", x1, y1 - 4, color)\n961: \n962: # Draw ayam\n963: for di, (cx, box) in enumerate(ayam_dets):\n964: tid = ayam_det_to_track.get(di)\n965: if tid is None:\n966: continue\n967: x1 = int(box[0] - box[2] / 2)\n968: y1 = int(box[1] - box[3] / 2)\n969: x2 = int(box[0] + box[2] / 2)\n970: y2 = int(box[1] + box[3] / 2)\n971: flash = ayam_cross_flash.get(tid, 0)\n972: color = C_GREEN if flash > 0 else C_AYAM_BOX\n973: cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n974: draw_pill(frame, f\"ID {tid}\", x1, y1 - 4, color)\n975: kpts = ayam_kpts_map.get(di)\n976: if kpts is not None:\n977: draw_skeleton_bold(frame, kpts)\n978: \n979: if ayam_crossed_frame:\n980: line_pulse = LINE_PULSE_FRAMES\n981: count_pulse = COUNT_PULSE_FRAMES\n982: if batch_closed_frame:\n983: line_pulse = LINE_PULSE_FRAMES\n984: if batch_started_frame:\n985: batch_pulse = BATCH_PULSE_FRAMES\n986: \n987: batch_num = store.current_batch_number or 0\n988: batch_count = store.current_batch_count\n989: display_total = store.display_total()\n990: rate = (display_total / elapsed * 60) if elapsed > 0 else 0.0\n991: \n992: draw_elegant_counting_line(frame, line_x, h, line_pulse)\n993: draw_hero_count(frame, line_x, h, batch_count, count_pulse)\n994: draw_hud(\n995: frame,\n996: w,\n997: batch_num,\n998: batch_count,\n999: display_total,\n1000: elapsed,\n1001: rate,\n1002: CAMERA_NAME,\n1003: now_str(),\n1004: )\n1005: draw_batch_banner(frame, w, batch_num, batch_pulse)\n1006: draw_footer(frame, w, h, frame_idx, \"LIVE-RKNN\" if IS_LIVE else \"FILE-RKNN\")\n1007: popups = draw_popups(frame, popups, frame_idx)\n1008: \n1009: for flash_store in (ayam_cross_flash, talenan_cross_flash):\n1010: for tid in list(flash_store):\n1011: flash_store[tid] -= 1\n1012: if flash_store[tid] <= 0:\n1013: del flash_store[tid]\n1014: line_pulse = max(0, line_pulse - 1)\n1015: count_pulse = max(0, count_pulse - 1)\n1016: batch_pulse = max(0, batch_pulse - 1)\n1017: \n1018: if video_writer is not None:\n1019: video_writer.write(frame)\n1020: \n1021: if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:\n1022: try:\n1023: Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)\n1024: _, jpeg = cv2.imencode(\n1025: \".jpg\", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY]\n1026: )\n1027: with open(LIVE_STREAM_FRAME_PATH, \"wb\") as f:\n1028: f.write(jpeg.tobytes())\n1029: except Exception:\n1030: pass\n1031: \n1032: frame_idx += 1\n1033: if frame_idx % FLUSH_EVERY_N_FRAMES == 0:\n1034: print(\n1035: f\"[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} \"\n1036: f\"| Total: {display_total} | Uptime {elapsed / 3600:.2f}h\"\n1037: )\n1038: prune_stale_tracks(ayam_tracked, mono)\n1039: prune_stale_tracks(talenan_tracked, mono)\n1040: \n1041: cap.release()\n1042: if video_writer is not None:\n1043: video_writer.release()\n1044: if cross_logger:\n1045: cross_logger.close()\n1046: model.release()\n1047: store.shutdown()\n1048: \n1049: print(\"\\n=== Batch Summary (SQLite) ===\")\n1050: print(f\"Database: {DB_PATH}\")\n1051: \n1052: \n1053: # Tracked state dicts: track_id → (cx, monotonic_time)\n1054: ayam_tracked = {}\n1055: talenan_tracked = {}\n1056: \n1057: \n1058: if __name__ == \"__main__\":\n1059: run()\n\n(End of file - total 1059 lines)\n", + "metadata": { + "preview": "\"\"\"\nEdge production live counter — RTSP + YOLO RKNN + line crossing.\nRuns on RK3588 hardware with RKNN model (320×320 input).\nReplaces the Jetson/TensorRT variant.\n\"\"\"\n\nimport numpy as np\nimport cv2\nimport csv\nimport os\nimport signal\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nfrom rknnlite.api import RKNNLite", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/counter_live_rknn.py", + "text": "\"\"\"\nEdge production live counter — RTSP + YOLO RKNN + line crossing.\nRuns on RK3588 hardware with RKNN model (320×320 input).\nReplaces the Jetson/TensorRT variant.\n\"\"\"\n\nimport numpy as np\nimport cv2\nimport csv\nimport os\nimport signal\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nfrom rknnlite.api import RKNNLite\nfrom batch_store import BatchStore\n\n# --- config (override via env / .env) ---\nOUTPUT_DIR = os.getenv(\"OUTPUT_DIR\", \"/opt/jetson-counter\")\nDB_PATH = os.getenv(\"DB_PATH\", f\"{OUTPUT_DIR}/jetson_counter.db\")\nSTATE_FILE = os.getenv(\"STATE_FILE\", f\"{OUTPUT_DIR}/current_batch.json\")\nSOURCE = os.getenv(\"SOURCE\", \"rtsp://user:pass@192.168.0.100:554/stream1\")\nMODEL_PATH = os.getenv(\"MODEL_PATH\", \"/opt/jetson-counter/yolo11n.rknn\")\nCAMERA_NAME = os.getenv(\"CAMERA_NAME\", \"CC1\")\nOBJECT_LABEL = os.getenv(\"OBJECT_LABEL\", \"ayam-potong\")\nCLASS_AYAM = os.getenv(\"CLASS_AYAM\", \"ayam\")\nCLASS_TALENAN = os.getenv(\"CLASS_TALENAN\", \"talenan\")\n\nLINE_X = int(os.getenv(\"LINE_X\")) if os.getenv(\"LINE_X\") else None\nLINE_X_FRAC = float(os.getenv(\"LINE_X_FRAC\", \"0.5\"))\nCROSS_DIRECTION = os.getenv(\"CROSS_DIRECTION\", \"rtl\").lower()\n\nIMGSZ = int(os.getenv(\"IMGSZ\", \"320\"))\nHALF = os.getenv(\"HALF\", \"false\").lower() == \"true\"\nCONF = float(os.getenv(\"CONF\", \"0.3\"))\nDEVICE = int(os.getenv(\"DEVICE\", \"0\"))\n\n# RKNN-specific — core mask for NPU\n# 1 = core0, 2 = core1, 3 = core0+core1 (dual), 7 = all three\nCORE_MASK = int(os.getenv(\"CORE_MASK\", \"1\"))\n\n# YOLO decoder config\nNUM_CLASSES = int(os.getenv(\"NUM_CLASSES\", \"2\"))\nSCORE_SIGMOID = os.getenv(\"SCORE_SIGMOID\", \"false\").lower() == \"true\"\n\nDAILY_CUTOFF_TIME = os.getenv(\"DAILY_CUTOFF_TIME\", \"20:00\")\nBATCH_TIMEOUT_SECONDS = float(os.getenv(\"BATCH_TIMEOUT_SECONDS\", \"300\"))\nIGNORE_BATCH_LABEL_TIMEOUT = float(\n os.getenv(\"IGNORE_BATCH_LABEL_TIMEOUT_SECONDS\", \"30\")\n)\nMIN_OBJECT_PER_BATCH = int(os.getenv(\"MIN_OBJECT_PER_BATCH\", \"60\"))\nMIN_DURATION_PER_BATCH = int(os.getenv(\"MIN_DURATION_PER_BATCH\", \"60\"))\n\nEXPORT_CSV = os.getenv(\"EXPORT_CSV\", \"true\").lower() == \"true\"\nCROSS_CSV = os.getenv(\"CROSS_CSV\", f\"{OUTPUT_DIR}/batch_crossings.csv\")\n\nWARMUP_FRAMES = int(os.getenv(\"WARMUP_FRAMES\", \"30\"))\nRECONNECT_DELAY_SEC = int(os.getenv(\"RECONNECT_DELAY_SEC\", \"3\"))\nMAX_RECONNECT_ATTEMPTS = int(os.getenv(\"MAX_RECONNECT_ATTEMPTS\", \"0\"))\nFLUSH_EVERY_N_FRAMES = int(os.getenv(\"FLUSH_EVERY_N_FRAMES\", \"100\"))\nTRACKED_PRUNE_SEC = int(os.getenv(\"TRACKED_PRUNE_SEC\", \"300\"))\nRECORD_VIDEO = os.getenv(\"RECORD_VIDEO\", \"false\").lower() == \"true\"\nVIDEO_SEGMENT_SEC = int(os.getenv(\"VIDEO_SEGMENT_SEC\", \"3600\"))\nOUTPUT_FPS = int(os.getenv(\"OUTPUT_FPS\", \"15\"))\n\nLIVE_STREAM_ENABLED = os.getenv(\"LIVE_STREAM_ENABLED\", \"false\").lower() == \"true\"\nLIVE_STREAM_FRAME_PATH = os.getenv(\n \"LIVE_STREAM_FRAME_PATH\", \"/dev/shm/jetson-counter/live_frame.jpg\"\n)\nLIVE_STREAM_QUALITY = int(os.getenv(\"LIVE_STREAM_QUALITY\", \"75\"))\nLIVE_STREAM_EVERY_N = int(os.getenv(\"LIVE_STREAM_EVERY_N\", \"2\"))\n\nRTSP_FFMPEG_OPTIONS = os.getenv(\n \"OPENCV_FFMPEG_CAPTURE_OPTIONS\",\n \"rtsp_transport;tcp|fflags;nobuffer|flags;low_delay\",\n)\n\nIS_LIVE = SOURCE.lower().startswith((\"rtsp://\", \"http://\"))\n\nCROSS_FLASH_FRAMES = 12\nPOPUP_LIFETIME = 20\nLINE_PULSE_FRAMES = 12\nCOUNT_PULSE_FRAMES = 15\nBATCH_PULSE_FRAMES = 20\n\nSKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)]\nSK_COLORS = [\n (0, 255, 255),\n (0, 255, 255),\n (255, 0, 255),\n (255, 0, 255),\n (0, 255, 0),\n (255, 255, 0),\n (0, 0, 255),\n (200, 200, 0),\n]\n\nC_PANEL = (28, 24, 18)\nC_BORDER = (90, 85, 75)\nC_ACCENT = (255, 200, 60)\nC_GREEN = (80, 220, 100)\nC_TEXT = (235, 235, 235)\nC_MUTED = (150, 150, 150)\nC_AYAM_BOX = (0, 165, 255)\nC_TALENAN_BOX = (220, 120, 60)\nC_LINE_CORE = (180, 220, 255)\nC_LINE_GLOW = (100, 160, 220)\n\nshutdown_requested = False\n\n\ndef request_shutdown(signum, frame):\n global shutdown_requested\n shutdown_requested = True\n print(\"\\nShutdown requested — finishing current frame...\")\n\n\nsignal.signal(signal.SIGINT, request_shutdown)\nsignal.signal(signal.SIGTERM, request_shutdown)\n\n\n# =============================================================================\n# YOLO output decoder (NMS only — boxes are pre-decoded by the model)\n# =============================================================================\n\n\ndef _nms(boxes, scores, iou_thr=0.45):\n order = np.argsort(scores)[::-1]\n keep = []\n while len(order) > 0:\n idx = order[0]\n keep.append(idx)\n if len(order) == 1:\n break\n xx1 = np.maximum(boxes[idx, 0], boxes[order[1:], 0])\n yy1 = np.maximum(boxes[idx, 1], boxes[order[1:], 1])\n xx2 = np.minimum(boxes[idx, 2], boxes[order[1:], 2])\n yy2 = np.minimum(boxes[idx, 3], boxes[order[1:], 3])\n w = np.maximum(0.0, xx2 - xx1)\n h = np.maximum(0.0, yy2 - yy1)\n inter = w * h\n area_i = (boxes[idx, 2] - boxes[idx, 0]) * (boxes[idx, 3] - boxes[idx, 1])\n area_o = (boxes[order[1:], 2] - boxes[order[1:], 0]) * (\n boxes[order[1:], 3] - boxes[order[1:], 1]\n )\n iou = inter / (area_i + area_o - inter + 1e-16)\n order = order[1:][iou < iou_thr]\n return np.array(keep)\n\n\ndef _compute_iou(box1, boxes2):\n \"\"\"IoU of one box (cxcywh) against a set (2D) or single box (1D).\"\"\"\n if boxes2.ndim == 1:\n boxes2 = boxes2.reshape(1, -1)\n cx, cy, w, h = box1\n x1, y1 = cx - w / 2, cy - h / 2\n x2, y2 = cx + w / 2, cy + h / 2\n area1 = w * h\n\n cxs, cys, ws, hs = boxes2[:, 0], boxes2[:, 1], boxes2[:, 2], boxes2[:, 3]\n x1s, y1s = cxs - ws / 2, cys - hs / 2\n x2s, y2s = cxs + ws / 2, cys + hs / 2\n areas2 = ws * hs\n\n xx1 = np.maximum(x1, x1s)\n yy1 = np.maximum(y1, y1s)\n xx2 = np.minimum(x2, x2s)\n yy2 = np.minimum(y2, y2s)\n inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)\n return inter / (area1 + areas2 - inter + 1e-16)\n\n\n# =============================================================================\n# Simple IoU tracker (replaces bytetrack — same persist behaviour)\n# =============================================================================\n\n\nclass SimpleTracker:\n def __init__(self, max_age=30, min_hits=1, iou_threshold=0.3):\n self.max_age = max_age\n self.min_hits = min_hits\n self.iou_threshold = iou_threshold\n self.tracks = {} # track_id -> {box, cx, age, hits, time_since_update}\n self.next_id = 1\n\n def update(self, detections):\n \"\"\"detections: list of (cx, box_cxcywh). Returns (track_map, det_to_track).\"\"\"\n now = time.monotonic()\n\n for tid in self.tracks:\n self.tracks[tid][\"time_since_update\"] += 1\n\n matched_det = set()\n matched_track = set()\n assignments = [] # (track_id, det_idx)\n det_to_track = {} # det_idx → track_id\n\n if detections and self.tracks:\n track_ids = list(self.tracks.keys())\n track_boxes = np.stack([self.tracks[t][\"box\"] for t in track_ids], axis=0)\n\n for di, det in enumerate(detections):\n _, det_box = det\n ious = np.array(\n [\n _compute_iou(det_box, track_boxes[t : t + 1])\n for t in range(len(track_ids))\n ]\n )\n best_j = int(np.argmax(ious))\n if (\n ious[best_j] >= self.iou_threshold\n and track_ids[best_j] not in matched_track\n ):\n assignments.append((track_ids[best_j], di))\n matched_track.add(track_ids[best_j])\n matched_det.add(di)\n\n for tid, di in assignments:\n cx, box = detections[di]\n self.tracks[tid][\"cx\"] = cx\n self.tracks[tid][\"box\"] = box\n self.tracks[tid][\"hits\"] += 1\n self.tracks[tid][\"time_since_update\"] = 0\n self.tracks[tid][\"last_update\"] = now\n det_to_track[di] = tid\n\n for di, det in enumerate(detections):\n if di not in matched_det:\n cx, box = det\n new_id = self.next_id\n self.next_id += 1\n self.tracks[new_id] = {\n \"cx\": cx,\n \"box\": box,\n \"hits\": 1,\n \"time_since_update\": 0,\n \"last_update\": now,\n }\n det_to_track[di] = new_id\n\n stale = [\n tid\n for tid, t in self.tracks.items()\n if t[\"time_since_update\"] > self.max_age\n ]\n for tid in stale:\n del self.tracks[tid]\n\n track_map = {\n tid: self.tracks[tid][\"cx\"]\n for tid in self.tracks\n if self.tracks[tid][\"hits\"] >= self.min_hits\n }\n return track_map, det_to_track\n\n\n# =============================================================================\n# RKNN YOLO wrapper (detect output format: (1, 4+num_classes, N))\n# =============================================================================\n\n\nclass RKNNYOLO:\n def __init__(\n self,\n model_path,\n core_mask=1,\n imgsz=320,\n conf=0.3,\n iou=0.45,\n num_classes=2,\n num_keypoints=0,\n score_sigmoid=False,\n ):\n self.imgsz = imgsz\n self.conf = conf\n self.iou = iou\n self.num_classes = num_classes\n self.num_keypoints = num_keypoints\n self.score_sigmoid = score_sigmoid\n\n self.rknn = RKNNLite(verbose=False)\n ret = self.rknn.load_rknn(model_path)\n if ret != 0:\n raise RuntimeError(f\"Failed to load RKNN model: {model_path}\")\n ret = self.rknn.init_runtime(core_mask=core_mask)\n if ret != 0:\n raise RuntimeError(f\"Failed to init RKNN runtime (core_mask={core_mask})\")\n\n try:\n from rknnlite.api import RKNNLite as _RK\n\n sdk_ver = self.rknn.get_sdk_version()\n print(f\"RKNN SDK version: {sdk_ver}\")\n except Exception:\n pass\n\n print(f\"RKNN model loaded: {model_path} imgsz={imgsz} core_mask={core_mask}\")\n\n def _preprocess(self, frame):\n \"\"\"Letterbox-resize to imgsz×imgsz, maintain aspect ratio, BGR→RGB, normalize.\"\"\"\n h0, w0 = frame.shape[:2]\n scale = min(self.imgsz / h0, self.imgsz / w0)\n nh, nw = int(h0 * scale), int(w0 * scale)\n resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)\n\n letterbox = np.full((self.imgsz, self.imgsz, 3), 114, dtype=np.uint8)\n dy = (self.imgsz - nh) // 2\n dx = (self.imgsz - nw) // 2\n letterbox[dy : dy + nh, dx : dx + nw] = resized\n\n rgb = cv2.cvtColor(letterbox, cv2.COLOR_BGR2RGB)\n gains = np.array([scale, scale, dy, dx], dtype=np.float32)\n return rgb, gains\n\n def __call__(self, frame):\n \"\"\"Run inference on BGR frame. Returns list of detection dicts.\"\"\"\n h0, w0 = frame.shape[:2]\n rgb, gains = self._preprocess(frame)\n scale, _, pad_y, pad_x = gains\n\n inp = np.expand_dims(rgb, axis=0)\n inp = np.ascontiguousarray(inp.astype(np.uint8))\n\n outputs = self.rknn.inference(inputs=[inp])\n\n if len(outputs) == 0:\n return []\n\n out = outputs[0] # (1, 4+num_classes, N) or (1, N, 4+num_classes)\n out = np.squeeze(out, axis=0) # (C, N) or (N, C)\n\n if out.shape[0] == self.num_classes + 4:\n out = out.T # (C, N) → (N, C)\n\n boxes_cxcywh = out[:, :4].copy() # cx, cy, w, h at model resolution\n cls_raw = out[:, 4:].copy()\n\n if self.score_sigmoid:\n cls_scores = 1.0 / (1.0 + np.exp(-np.clip(cls_raw, -10, 10)))\n else:\n cls_scores = cls_raw\n\n boxes_xyxy = np.stack(\n [\n boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2,\n boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2,\n boxes_cxcywh[:, 0] + boxes_cxcywh[:, 2] / 2,\n boxes_cxcywh[:, 1] + boxes_cxcywh[:, 3] / 2,\n ],\n axis=1,\n )\n\n max_scores = cls_scores.max(axis=1)\n class_ids = cls_scores.argmax(axis=1)\n mask = max_scores > self.conf\n\n if mask.sum() == 0:\n return []\n\n bboxes = boxes_xyxy[mask].astype(np.float32)\n scores = max_scores[mask].astype(np.float32)\n clses = class_ids[mask]\n\n bboxes[:, 0] = (bboxes[:, 0] - pad_x) / scale\n bboxes[:, 1] = (bboxes[:, 1] - pad_y) / scale\n bboxes[:, 2] = (bboxes[:, 2] - pad_x) / scale\n bboxes[:, 3] = (bboxes[:, 3] - pad_y) / scale\n\n bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w0)\n bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h0)\n bboxes[:, 2] = np.clip(bboxes[:, 2], 0, w0)\n bboxes[:, 3] = np.clip(bboxes[:, 3], 0, h0)\n\n detections = []\n for cls_id in range(self.num_classes):\n idx = np.where(clses == cls_id)[0]\n if len(idx) == 0:\n continue\n keep = _nms(bboxes[idx], scores[idx], iou_thr=self.iou)\n for k in keep:\n j = idx[k]\n detections.append(\n {\n \"bbox\": bboxes[j].tolist(),\n \"score\": float(scores[j]),\n \"cls\": int(clses[j]),\n \"keypoints\": None,\n }\n )\n return detections\n\n def release(self):\n self.rknn.release()\n\n\n# =============================================================================\n# Drawing helpers (unchanged from original)\n# =============================================================================\n\n\ndef resolve_line_x(frame_width):\n if LINE_X is not None:\n return LINE_X\n if LINE_X_FRAC != 0.5:\n return int(frame_width * LINE_X_FRAC)\n return frame_width // 2\n\n\ndef crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION):\n if direction == \"ltr\":\n return prev_cx < line_x <= cx\n if direction == \"both\":\n return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx)\n return prev_cx > line_x >= cx\n\n\ndef now_str():\n return datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n\ndef open_capture(source):\n if source.lower().startswith((\"rtsp://\", \"http://\")):\n os.environ[\"OPENCV_FFMPEG_CAPTURE_OPTIONS\"] = RTSP_FFMPEG_OPTIONS\n cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG)\n cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)\n return cap\n\n\ndef warmup_stream(cap, n=WARMUP_FRAMES):\n print(\"Warming up stream...\")\n for _ in range(n):\n cap.read()\n print(\"Stream ready!\")\n\n\ndef open_video_writer(path, w, h, fps):\n return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*\"avc1\"), fps, (w, h))\n\n\nclass CsvLogger:\n def __init__(self, path, header):\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n new_file = not Path(path).exists() or Path(path).stat().st_size == 0\n self.file = open(path, \"a\", newline=\"\", buffering=1)\n self.writer = csv.writer(self.file)\n if new_file:\n self.writer.writerow(header)\n self.file.flush()\n\n def write_row(self, row):\n self.writer.writerow(row)\n self.file.flush()\n\n def close(self):\n self.file.close()\n\n\nclass VideoSegmentWriter:\n def __init__(self, output_dir, w, h, fps, segment_sec):\n self.output_dir = Path(output_dir)\n self.output_dir.mkdir(parents=True, exist_ok=True)\n self.w, self.h, self.fps = w, h, fps\n self.segment_sec = segment_sec\n self.segment_start = time.monotonic()\n self.writer = None\n self._open_next()\n\n def _segment_path(self):\n ts = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n return str(self.output_dir / f\"live_{ts}.mp4\")\n\n def _open_next(self):\n if self.writer is not None:\n self.writer.release()\n path = self._segment_path()\n self.writer = open_video_writer(path, self.w, self.h, self.fps)\n self.segment_start = time.monotonic()\n print(f\"Recording segment: {path}\")\n\n def write(self, frame):\n if time.monotonic() - self.segment_start >= self.segment_sec:\n self._open_next()\n self.writer.write(frame)\n\n def release(self):\n if self.writer is not None:\n self.writer.release()\n\n\ndef prune_stale_tracks(tracked, now_mono):\n stale = [\n tid for tid, (_, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC\n ]\n for tid in stale:\n del tracked[tid]\n\n\ndef overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65):\n x1, y1 = max(0, x1), max(0, y1)\n x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2)\n if x2 <= x1 or y2 <= y1:\n return\n roi = img[y1:y2, x1:x2]\n patch = np.full_like(roi, color, dtype=np.uint8)\n cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi)\n\n\ndef draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4):\n font = cv2.FONT_HERSHEY_SIMPLEX\n (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1)\n x1, y1 = x, y - th - pad_y\n x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y\n cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1)\n cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1)\n cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA)\n\n\ndef draw_elegant_counting_line(img, line_x, h, pulse_remaining=0):\n strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1)\n glow_alpha = 0.12 + 0.18 * strength\n for offset in (14, 9, 5):\n color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW)\n cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA)\n cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA)\n dash_len, gap = 18, 12\n y = 0\n while y < h:\n y_end = min(y + dash_len, h)\n cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA)\n y += dash_len + gap\n cv2.putText(\n img,\n \"COUNT LINE\",\n (line_x - 46, 24),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.42,\n C_LINE_CORE,\n 1,\n cv2.LINE_AA,\n )\n\n\ndef draw_hero_count(img, line_x, h, count, pulse_remaining=0):\n text = str(count)\n font = cv2.FONT_HERSHEY_SIMPLEX\n boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1))\n font_scale, thickness = 1.6 + boost, 3\n (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness)\n pad = 14\n tx, ty = line_x - tw // 2, h // 2 + th // 2\n overlay_rect(\n img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad // 2, C_PANEL, alpha=0.78\n )\n cv2.rectangle(\n img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad // 2), C_LINE_CORE, 2\n )\n cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA)\n\n\ndef draw_hud(\n img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate, camera_id, clock\n):\n bar_h = 52\n overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72)\n cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1)\n cv2.putText(\n img, \"BATCH\", (16, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n )\n batch_label = str(batch_num) if batch_num else \"—\"\n cv2.putText(\n img,\n batch_label,\n (16, 44),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.9,\n C_ACCENT,\n 2,\n cv2.LINE_AA,\n )\n cv2.putText(\n img, \"COUNT\", (100, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n )\n cv2.putText(\n img,\n str(batch_count),\n (100, 44),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.9,\n C_GREEN,\n 2,\n cv2.LINE_AA,\n )\n cv2.putText(\n img, \"TOTAL\", (190, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n )\n cv2.putText(\n img,\n str(total_ayam),\n (190, 44),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.7,\n C_TEXT,\n 1,\n cv2.LINE_AA,\n )\n cv2.putText(\n img,\n \"UPTIME\",\n (280, 20),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.45,\n C_MUTED,\n 1,\n cv2.LINE_AA,\n )\n cv2.putText(\n img,\n f\"{elapsed_sec / 3600:.1f}h\",\n (280, 44),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.7,\n C_TEXT,\n 1,\n cv2.LINE_AA,\n )\n cv2.putText(\n img, \"RATE\", (380, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA\n )\n cv2.putText(\n img,\n f\"{rate:.1f}/min\",\n (380, 44),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.7,\n C_ACCENT,\n 1,\n cv2.LINE_AA,\n )\n # cv2.putText(img, clock, (w - 180, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.55, C_TEXT, 1, cv2.LINE_AA)\n cv2.putText(\n img,\n f\"CAM {camera_id}\",\n (w - 180, 20),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.45,\n C_MUTED,\n 1,\n cv2.LINE_AA,\n )\n\n\ndef draw_footer(img, w, h, frame_idx, live_tag):\n bar_h = 28\n overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55)\n cv2.putText(\n img,\n f\"{live_tag} | Frame {frame_idx}\",\n (12, h - 9),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.45,\n C_MUTED,\n 1,\n cv2.LINE_AA,\n )\n\n\ndef draw_skeleton_bold(img, kpts):\n for (a, b), color in zip(SKELETON, SK_COLORS):\n if a < len(kpts) and b < len(kpts):\n xa, ya = int(kpts[a][0]), int(kpts[a][1])\n xb, yb = int(kpts[b][0]), int(kpts[b][1])\n if xa > 0 and ya > 0 and xb > 0 and yb > 0:\n cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA)\n for kp in kpts:\n x, y = int(kp[0]), int(kp[1])\n if x > 0 and y > 0:\n cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA)\n cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA)\n\n\ndef draw_popups(img, popups, frame_idx):\n alive = []\n for pop in popups:\n age = frame_idx - pop[\"born\"]\n if age > POPUP_LIFETIME:\n continue\n alive.append(pop)\n fade = 1.0 - age / POPUP_LIFETIME\n y = pop[\"y\"] - int(age * 1.8)\n color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade))\n cv2.putText(\n img,\n pop[\"text\"],\n (pop[\"x\"], y),\n cv2.FONT_HERSHEY_SIMPLEX,\n 0.7,\n color,\n 2,\n cv2.LINE_AA,\n )\n return alive\n\n\ndef draw_batch_banner(img, w, batch_num, pulse_remaining):\n if pulse_remaining <= 0:\n return\n text = f\"NEW BATCH {batch_num}\"\n font = cv2.FONT_HERSHEY_SIMPLEX\n (tw, th), _ = cv2.getTextSize(text, font, 0.8, 2)\n x1, y1 = w // 2 - tw // 2 - 16, 62\n x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20\n overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7)\n cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2)\n cv2.putText(\n img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA\n )\n\n\ndef connect_stream(source, warmup=WARMUP_FRAMES):\n attempts = 0\n while not shutdown_requested:\n cap = open_capture(source)\n if not cap.isOpened():\n attempts += 1\n if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS:\n raise RuntimeError(\n f\"Cannot open source after {attempts} attempts: {source}\"\n )\n print(f\"Cannot open source, retry in {RECONNECT_DELAY_SEC}s...\")\n time.sleep(RECONNECT_DELAY_SEC)\n continue\n if warmup > 0 and source.lower().startswith((\"rtsp://\", \"http://\")):\n warmup_stream(cap, warmup)\n w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))\n h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))\n fps = cap.get(cv2.CAP_PROP_FPS)\n if not fps or fps <= 1:\n fps = OUTPUT_FPS\n return cap, w, h, fps\n return None, 0, 0, OUTPUT_FPS\n\n\n# =============================================================================\n# Main loop\n# =============================================================================\n\n\ndef run():\n global shutdown_requested\n\n store = BatchStore(\n db_path=DB_PATH,\n state_file=STATE_FILE,\n camera_name=CAMERA_NAME,\n object_label=OBJECT_LABEL,\n cutoff_time=DAILY_CUTOFF_TIME,\n batch_timeout=BATCH_TIMEOUT_SECONDS,\n ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT,\n min_object_per_batch=MIN_OBJECT_PER_BATCH,\n min_duration_per_batch=MIN_DURATION_PER_BATCH,\n logger=lambda msg: print(f\"[{now_str()}] {msg}\"),\n )\n store.start_cutoff_watcher()\n\n cross_logger = None\n if EXPORT_CSV:\n cross_logger = CsvLogger(\n CROSS_CSV, [\"batch\", \"frame\", \"timestamp\", \"chicken_id\"]\n )\n\n # Load RKNN model\n model = RKNNYOLO(\n model_path=MODEL_PATH,\n core_mask=CORE_MASK,\n imgsz=IMGSZ,\n conf=CONF,\n num_classes=NUM_CLASSES,\n score_sigmoid=SCORE_SIGMOID,\n )\n\n # Class IDs — order comes from RKNN model output (class index)\n # class index 0 → CLASS_AYAM, index 1 → CLASS_TALENAN (or env-specified)\n # Use class names in env order: first CLASS_AYAM → id 0, then CLASS_TALENAN → id 1\n CLASS_IDS = {\n os.getenv(\"CLASS_AYAM\", \"ayam\"): 0,\n os.getenv(\"CLASS_TALENAN\", \"talenan\"): 1,\n }\n ayam_cls = CLASS_IDS[CLASS_AYAM]\n talenan_cls = CLASS_IDS[CLASS_TALENAN]\n\n ayam_tracker = SimpleTracker(max_age=60)\n talenan_tracker = SimpleTracker(max_age=60)\n\n ayam_line_crossed = set()\n talenan_line_crossed = set()\n\n ayam_cross_flash = {}\n talenan_cross_flash = {}\n line_pulse = count_pulse = batch_pulse = 0\n popups = []\n\n session_start = time.time()\n frame_idx = 0\n video_writer = None\n\n cap, w, h, fps = connect_stream(SOURCE)\n if cap is None:\n store.shutdown()\n model.release()\n return\n\n line_x = resolve_line_x(w)\n print(\n f\"RKNN counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}\"\n )\n print(f\"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}\")\n print(f\"DB: {DB_PATH}\")\n print(f\"State: {STATE_FILE}\")\n\n if RECORD_VIDEO:\n video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC)\n\n reconnect_count = 0\n\n while not shutdown_requested:\n ret, frame = cap.read()\n if not ret:\n if not IS_LIVE:\n break\n reconnect_count += 1\n print(\n f\"Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s...\"\n )\n cap.release()\n time.sleep(RECONNECT_DELAY_SEC)\n cap, w, h, fps = connect_stream(SOURCE)\n if cap is None:\n break\n line_x = resolve_line_x(w)\n continue\n\n now = time.time()\n elapsed = now - session_start\n mono = time.monotonic()\n ayam_crossed_frame = batch_closed_frame = batch_started_frame = False\n\n # RKNN inference\n detections = model(frame)\n\n if detections:\n ayam_dets = [] # list of (cx, xywh_box)\n talenan_dets = []\n ayam_kpts_map = {} # det_idx → keypoints\n talenan_kpts_map = {}\n\n for di, det in enumerate(detections):\n bbox = det[\"bbox\"]\n cls_id = det[\"cls\"]\n cx = (bbox[0] + bbox[2]) / 2.0\n x1, y1, x2, y2 = bbox\n wb, hb = x2 - x1, y2 - y1\n box_cxcywh = np.array([cx, (y1 + y2) / 2, wb, hb], dtype=np.float32)\n\n if cls_id == talenan_cls:\n talenan_dets.append((cx, box_cxcywh))\n if det[\"keypoints\"] is not None:\n talenan_kpts_map[len(talenan_dets) - 1] = det[\"keypoints\"]\n elif cls_id == ayam_cls:\n ayam_dets.append((cx, box_cxcywh))\n if det[\"keypoints\"] is not None:\n ayam_kpts_map[len(ayam_dets) - 1] = det[\"keypoints\"]\n\n # Track ayam — returns (track_id → cx, detection_idx → track_id)\n ayam_cx_map, ayam_det_to_track = ayam_tracker.update(ayam_dets)\n\n # Track talenan\n talenan_cx_map, talenan_det_to_track = talenan_tracker.update(talenan_dets)\n\n # Process talenan crossings\n for di, (cx, box) in enumerate(talenan_dets):\n tid = talenan_det_to_track.get(di)\n if tid is None:\n continue\n if tid in talenan_tracker.tracks:\n if tid in talenan_tracked:\n prev_cx = talenan_tracked[tid][0]\n if (\n crossed_line(prev_cx, cx, line_x)\n and tid not in talenan_line_crossed\n ):\n talenan_line_crossed.add(tid)\n if store.record_talenan_crossing(tid):\n batch_closed_frame = True\n talenan_cross_flash[tid] = CROSS_FLASH_FRAMES\n popups.append(\n {\n \"x\": int(cx) - 20,\n \"y\": int(box[1]),\n \"born\": frame_idx,\n \"text\": \"BATCH CLOSED\",\n }\n )\n talenan_tracked[tid] = (cx, mono)\n\n # Process ayam crossings\n for di, (cx, box) in enumerate(ayam_dets):\n tid = ayam_det_to_track.get(di)\n if tid is None:\n continue\n if tid in ayam_tracker.tracks:\n if tid in ayam_tracked:\n prev_cx = ayam_tracked[tid][0]\n if (\n crossed_line(prev_cx, cx, line_x)\n and tid not in ayam_line_crossed\n ):\n ayam_line_crossed.add(tid)\n _, started_new = store.record_ayam_crossing(tid)\n if cross_logger:\n cross_logger.write_row(\n [\n store.current_batch_number,\n frame_idx,\n datetime.now().isoformat(),\n tid,\n ]\n )\n ayam_crossed_frame = True\n if started_new:\n batch_started_frame = True\n ayam_cross_flash[tid] = CROSS_FLASH_FRAMES\n popups.append(\n {\n \"x\": int(cx) - 12,\n \"y\": int(box[1]),\n \"born\": frame_idx,\n \"text\": \"+1\",\n }\n )\n ayam_tracked[tid] = (cx, mono)\n\n # Draw talenan\n for di, (cx, box) in enumerate(talenan_dets):\n tid = talenan_det_to_track.get(di)\n if tid is None:\n continue\n x1 = int(box[0] - box[2] / 2)\n y1 = int(box[1] - box[3] / 2)\n x2 = int(box[0] + box[2] / 2)\n y2 = int(box[1] + box[3] / 2)\n flash = talenan_cross_flash.get(tid, 0)\n color = C_GREEN if flash > 0 else C_TALENAN_BOX\n cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n draw_pill(frame, f\"TALENAN {tid}\", x1, y1 - 4, color)\n\n # Draw ayam\n for di, (cx, box) in enumerate(ayam_dets):\n tid = ayam_det_to_track.get(di)\n if tid is None:\n continue\n x1 = int(box[0] - box[2] / 2)\n y1 = int(box[1] - box[3] / 2)\n x2 = int(box[0] + box[2] / 2)\n y2 = int(box[1] + box[3] / 2)\n flash = ayam_cross_flash.get(tid, 0)\n color = C_GREEN if flash > 0 else C_AYAM_BOX\n cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)\n draw_pill(frame, f\"ID {tid}\", x1, y1 - 4, color)\n kpts = ayam_kpts_map.get(di)\n if kpts is not None:\n draw_skeleton_bold(frame, kpts)\n\n if ayam_crossed_frame:\n line_pulse = LINE_PULSE_FRAMES\n count_pulse = COUNT_PULSE_FRAMES\n if batch_closed_frame:\n line_pulse = LINE_PULSE_FRAMES\n if batch_started_frame:\n batch_pulse = BATCH_PULSE_FRAMES\n\n batch_num = store.current_batch_number or 0\n batch_count = store.current_batch_count\n display_total = store.display_total()\n rate = (display_total / elapsed * 60) if elapsed > 0 else 0.0\n\n draw_elegant_counting_line(frame, line_x, h, line_pulse)\n draw_hero_count(frame, line_x, h, batch_count, count_pulse)\n draw_hud(\n frame,\n w,\n batch_num,\n batch_count,\n display_total,\n elapsed,\n rate,\n CAMERA_NAME,\n now_str(),\n )\n draw_batch_banner(frame, w, batch_num, batch_pulse)\n draw_footer(frame, w, h, frame_idx, \"LIVE-RKNN\" if IS_LIVE else \"FILE-RKNN\")\n popups = draw_popups(frame, popups, frame_idx)\n\n for flash_store in (ayam_cross_flash, talenan_cross_flash):\n for tid in list(flash_store):\n flash_store[tid] -= 1\n if flash_store[tid] <= 0:\n del flash_store[tid]\n line_pulse = max(0, line_pulse - 1)\n count_pulse = max(0, count_pulse - 1)\n batch_pulse = max(0, batch_pulse - 1)\n\n if video_writer is not None:\n video_writer.write(frame)\n\n if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:\n try:\n Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)\n _, jpeg = cv2.imencode(\n \".jpg\", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY]\n )\n with open(LIVE_STREAM_FRAME_PATH, \"wb\") as f:\n f.write(jpeg.tobytes())\n except Exception:\n pass\n\n frame_idx += 1\n if frame_idx % FLUSH_EVERY_N_FRAMES == 0:\n print(\n f\"[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} \"\n f\"| Total: {display_total} | Uptime {elapsed / 3600:.2f}h\"\n )\n prune_stale_tracks(ayam_tracked, mono)\n prune_stale_tracks(talenan_tracked, mono)\n\n cap.release()\n if video_writer is not None:\n video_writer.release()\n if cross_logger:\n cross_logger.close()\n model.release()\n store.shutdown()\n\n print(\"\\n=== Batch Summary (SQLite) ===\")\n print(f\"Database: {DB_PATH}\")\n\n\n# Tracked state dicts: track_id → (cx, monotonic_time)\nayam_tracked = {}\ntalenan_tracked = {}\n\n\nif __name__ == \"__main__\":\n run()", + "lineStart": 1, + "lineEnd": 1059, + "totalLines": 1059, + "truncated": false + } + }, + "title": "counter_live_rknn.py", + "time": { + "start": 1785284240529, + "end": 1785284240709 + } + }, + "id": "prt_fab3ba35b001zKNIeRyf42jQNF", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + }, + { + "reason": "tool-calls", + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-finish", + "tokens": { + "total": 15862, + "input": 5392, + "output": 214, + "reasoning": 16, + "cache": { + "write": 0, + "read": 10240 + } + }, + "cost": 0.00258274, + "id": "prt_fab3ba5d8001gigTvJdY0dDaxc", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3b9948001NqWLx9PXClyyv8" + } + ] + }, + { + "info": { + "parentID": "msg_fab3b7662001asy7eTmLMm4mEq", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.015093282, + "tokens": { + "total": 50054, + "input": 34054, + "output": 234, + "reasoning": 22, + "cache": { + "write": 0, + "read": 15744 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785284240955, + "completed": 1785284247206 + }, + "finish": "tool-calls", + "id": "msg_fab3ba63b001ubMqr9d9Qa8oQb", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-start", + "id": "prt_fab3ba9cf001LT6TNHAWViUNp6", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "type": "reasoning", + "text": "Now let me read the remaining key files: the service files, templates, and the bytetrack variant.", + "time": { + "start": 1785284244953, + "end": 1785284245454 + }, + "id": "prt_fab3bb5d9001yd9j4YkBy3oqxi", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_00_f8jmNYi8WXfxjMO9KPiv3266", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/bytetrack-counter.service" + }, + "output": "/opt/bytetrack-counter/bytetrack-counter.service\nfile\n\n1: [Unit]\n2: Description=NanoPi Edge YOLO Batch Counter (RTSP + RKNN)\n3: Documentation=file:///opt/bytetrack-counter/DEPLOY.md\n4: After=network-online.target\n5: Wants=network-online.target\n6: \n7: [Service]\n8: Type=simple\n9: User=root\n10: Group=root\n11: \n12: WorkingDirectory=/opt/bytetrack-counter\n13: \n14: EnvironmentFile=/opt/bytetrack-counter/.env\n15: Environment=PYTHONNOUSERSITE=1\n16: Environment=YOLO_CONFIG_DIR=/opt/bytetrack-counter/.ultralytics\n17: Environment=TORCH_HOME=/opt/bytetrack-counter/.torch\n18: Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin\n19: \n20: ExecStart=/opt/bytetrack-counter/venv/bin/python counter_live_rknn_bytetrack.py\n21: \n22: TimeoutStopSec=30\n23: KillSignal=SIGTERM\n24: \n25: Restart=on-failure\n26: RestartSec=10\n27: StartLimitInterval=120s\n28: StartLimitBurst=5\n29: \n30: NoNewPrivileges=true\n31: ProtectHome=true\n32: PrivateTmp=false\n33: \n34: [Install]\n35: WantedBy=multi-user.target\n\n(End of file - total 35 lines)\n", + "metadata": { + "preview": "[Unit]\nDescription=NanoPi Edge YOLO Batch Counter (RTSP + RKNN)\nDocumentation=file:///opt/bytetrack-counter/DEPLOY.md\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser=root\nGroup=root\n\nWorkingDirectory=/opt/bytetrack-counter\n\nEnvironmentFile=/opt/bytetrack-counter/.env\nEnvironment=PYTHONNOUSERSITE=1\nEnvironment=YOLO_CONFIG_DIR=/opt/bytetrack-counter/.ultralytics\nEnvironment=TORCH_HOME=/opt/bytetrack-counter/.torch\nEnvironment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin\n\nExecStart=/opt/bytetrack-counter/venv/bin/python counter_live_rknn_bytetrack.py", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/bytetrack-counter.service", + "text": "[Unit]\nDescription=NanoPi Edge YOLO Batch Counter (RTSP + RKNN)\nDocumentation=file:///opt/bytetrack-counter/DEPLOY.md\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser=root\nGroup=root\n\nWorkingDirectory=/opt/bytetrack-counter\n\nEnvironmentFile=/opt/bytetrack-counter/.env\nEnvironment=PYTHONNOUSERSITE=1\nEnvironment=YOLO_CONFIG_DIR=/opt/bytetrack-counter/.ultralytics\nEnvironment=TORCH_HOME=/opt/bytetrack-counter/.torch\nEnvironment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin\n\nExecStart=/opt/bytetrack-counter/venv/bin/python counter_live_rknn_bytetrack.py\n\nTimeoutStopSec=30\nKillSignal=SIGTERM\n\nRestart=on-failure\nRestartSec=10\nStartLimitInterval=120s\nStartLimitBurst=5\n\nNoNewPrivileges=true\nProtectHome=true\nPrivateTmp=false\n\n[Install]\nWantedBy=multi-user.target", + "lineStart": 1, + "lineEnd": 35, + "totalLines": 35, + "truncated": false + } + }, + "title": "bytetrack-counter.service", + "time": { + "start": 1785284245737, + "end": 1785284245772 + } + }, + "id": "prt_fab3bb7d3001mgQKznucCGwdEP", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_01_aFvuJpL4hnlAmNDM3Iox5868", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/bytetrack-counter-dashboard.service" + }, + "output": "/opt/bytetrack-counter/bytetrack-counter-dashboard.service\nfile\n\n1: [Unit]\n2: Description=Nano Edge Counter Dashboard (Flask, port 5000)\n3: Documentation=file:///opt/bytetrack-counter/DEPLOY.md\n4: After=network-online.target bytetrack-counter.service\n5: Wants=network-online.target\n6: \n7: [Service]\n8: Type=simple\n9: User=root\n10: Group=root\n11: \n12: WorkingDirectory=/opt/bytetrack-counter\n13: \n14: EnvironmentFile=/opt/bytetrack-counter/.env\n15: Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin\n16: Environment=FLASK_DEBUG=false\n17: \n18: ExecStart=/opt/bytetrack-counter/venv/bin/python counter_dashboard.py\n19: \n20: TimeoutStopSec=15\n21: KillSignal=SIGTERM\n22: \n23: Restart=on-failure\n24: RestartSec=5\n25: StartLimitInterval=60s\n26: StartLimitBurst=3\n27: \n28: NoNewPrivileges=true\n29: ProtectHome=true\n30: PrivateTmp=false\n31: \n32: [Install]\n33: WantedBy=multi-user.target\n\n(End of file - total 33 lines)\n", + "metadata": { + "preview": "[Unit]\nDescription=Nano Edge Counter Dashboard (Flask, port 5000)\nDocumentation=file:///opt/bytetrack-counter/DEPLOY.md\nAfter=network-online.target bytetrack-counter.service\nWants=network-online.target\n\n[Service]\nType=simple\nUser=root\nGroup=root\n\nWorkingDirectory=/opt/bytetrack-counter\n\nEnvironmentFile=/opt/bytetrack-counter/.env\nEnvironment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin\nEnvironment=FLASK_DEBUG=false\n\nExecStart=/opt/bytetrack-counter/venv/bin/python counter_dashboard.py\n\nTimeoutStopSec=15", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/bytetrack-counter-dashboard.service", + "text": "[Unit]\nDescription=Nano Edge Counter Dashboard (Flask, port 5000)\nDocumentation=file:///opt/bytetrack-counter/DEPLOY.md\nAfter=network-online.target bytetrack-counter.service\nWants=network-online.target\n\n[Service]\nType=simple\nUser=root\nGroup=root\n\nWorkingDirectory=/opt/bytetrack-counter\n\nEnvironmentFile=/opt/bytetrack-counter/.env\nEnvironment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin\nEnvironment=FLASK_DEBUG=false\n\nExecStart=/opt/bytetrack-counter/venv/bin/python counter_dashboard.py\n\nTimeoutStopSec=15\nKillSignal=SIGTERM\n\nRestart=on-failure\nRestartSec=5\nStartLimitInterval=60s\nStartLimitBurst=3\n\nNoNewPrivileges=true\nProtectHome=true\nPrivateTmp=false\n\n[Install]\nWantedBy=multi-user.target", + "lineStart": 1, + "lineEnd": 33, + "totalLines": 33, + "truncated": false + } + }, + "title": "bytetrack-counter-dashboard.service", + "time": { + "start": 1785284245977, + "end": 1785284246024 + } + }, + "id": "prt_fab3bb8f7001299RcdAR4Pn9So", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_02_P90DorXhzpjuuJ4Hn5zo1862", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/counter_live_rknn_bytetrack.py", + "limit": 60 + }, + "output": "/opt/bytetrack-counter/counter_live_rknn_bytetrack.py\nfile\n\n1: \"\"\"\n2: Edge production live counter — RTSP + YOLO RKNN + ByteTrack + line crossing.\n3: Runs on RK3588 hardware with RKNN model (320×320 input).\n4: Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.\n5: \"\"\"\n6: \n7: import numpy as np\n8: import cv2\n9: import csv\n10: import os\n11: import signal\n12: import time\n13: from collections import deque\n14: from datetime import datetime\n15: from pathlib import Path\n16: \n17: from dotenv import load_dotenv\n18: \n19: load_dotenv()\n20: \n21: from rknnlite.api import RKNNLite\n22: from batch_store import BatchStore\n23: \n24: # --- config (override via env / .env) ---\n25: OUTPUT_DIR = os.getenv(\"OUTPUT_DIR\", \"/opt/jetson-counter\")\n26: DB_PATH = os.getenv(\"DB_PATH\", f\"{OUTPUT_DIR}/jetson_counter.db\")\n27: STATE_FILE = os.getenv(\"STATE_FILE\", f\"{OUTPUT_DIR}/current_batch.json\")\n28: SOURCE = os.getenv(\"SOURCE\", \"rtsp://user:pass@192.168.0.100:554/stream1\")\n29: MODEL_PATH = os.getenv(\"MODEL_PATH\", \"/opt/jetson-counter/yolo11n.rknn\")\n30: CAMERA_NAME = os.getenv(\"CAMERA_NAME\", \"CC1\")\n31: OBJECT_LABEL = os.getenv(\"OBJECT_LABEL\", \"ayam-potong\")\n32: CLASS_AYAM = os.getenv(\"CLASS_AYAM\", \"ayam\")\n33: CLASS_TALENAN = os.getenv(\"CLASS_TALENAN\", \"talenan\")\n34: \n35: LINE_X = int(os.getenv(\"LINE_X\")) if os.getenv(\"LINE_X\") else None\n36: LINE_X_FRAC = float(os.getenv(\"LINE_X_FRAC\", \"0.5\"))\n37: CROSS_DIRECTION = os.getenv(\"CROSS_DIRECTION\", \"rtl\").lower()\n38: \n39: IMGSZ = int(os.getenv(\"IMGSZ\", \"320\"))\n40: HALF = os.getenv(\"HALF\", \"false\").lower() == \"true\"\n41: CONF = float(os.getenv(\"CONF\", \"0.3\"))\n42: DEVICE = int(os.getenv(\"DEVICE\", \"0\"))\n43: \n44: # RKNN NPU core mask\n45: CORE_MASK = int(os.getenv(\"CORE_MASK\", \"1\"))\n46: \n47: # YOLO decoder config\n48: NUM_CLASSES = int(os.getenv(\"NUM_CLASSES\", \"2\"))\n49: SCORE_SIGMOID = os.getenv(\"SCORE_SIGMOID\", \"false\").lower() == \"true\"\n50: \n51: # ByteTrack settings\n52: # Ayam index 0\n53: TRACK_HIGH_THRESH_0 = float(os.getenv(\"TRACK_HIGH_THRESH_0\", \"0.5\"))\n54: TRACK_LOW_THRESH_0 = float(os.getenv(\"TRACK_LOW_THRESH_0\", \"0.1\"))\n55: TRACK_MATCH_THRESH_0 = float(os.getenv(\"TRACK_MATCH_THRESH_0\", \"0.8\"))\n56: TRACK_BUFFER_0 = int(os.getenv(\"TRACK_BUFFER_0\", \"30\"))\n57: TRACK_MIN_HITS_0 = int(os.getenv(\"TRACK_MIN_HITS_0\", \"3\"))\n58: # Talenan index 1\n59: TRACK_HIGH_THRESH_1 = float(os.getenv(\"TRACK_HIGH_THRESH_1\", \"0.5\"))\n60: TRACK_LOW_THRESH_1 = float(os.getenv(\"TRACK_LOW_THRESH_1\", \"0.1\"))\n\n(Showing lines 1-60 of 1373. Use offset=61 to continue.)\n", + "metadata": { + "preview": "\"\"\"\nEdge production live counter — RTSP + YOLO RKNN + ByteTrack + line crossing.\nRuns on RK3588 hardware with RKNN model (320×320 input).\nUses ByteTrack (Kalman filter + two-stage IoU association) for tracking.\n\"\"\"\n\nimport numpy as np\nimport cv2\nimport csv\nimport os\nimport signal\nimport time\nfrom collections import deque\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\nload_dotenv()\n", + "truncated": true, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/counter_live_rknn_bytetrack.py", + "text": "\"\"\"\nEdge production live counter — RTSP + YOLO RKNN + ByteTrack + line crossing.\nRuns on RK3588 hardware with RKNN model (320×320 input).\nUses ByteTrack (Kalman filter + two-stage IoU association) for tracking.\n\"\"\"\n\nimport numpy as np\nimport cv2\nimport csv\nimport os\nimport signal\nimport time\nfrom collections import deque\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nfrom rknnlite.api import RKNNLite\nfrom batch_store import BatchStore\n\n# --- config (override via env / .env) ---\nOUTPUT_DIR = os.getenv(\"OUTPUT_DIR\", \"/opt/jetson-counter\")\nDB_PATH = os.getenv(\"DB_PATH\", f\"{OUTPUT_DIR}/jetson_counter.db\")\nSTATE_FILE = os.getenv(\"STATE_FILE\", f\"{OUTPUT_DIR}/current_batch.json\")\nSOURCE = os.getenv(\"SOURCE\", \"rtsp://user:pass@192.168.0.100:554/stream1\")\nMODEL_PATH = os.getenv(\"MODEL_PATH\", \"/opt/jetson-counter/yolo11n.rknn\")\nCAMERA_NAME = os.getenv(\"CAMERA_NAME\", \"CC1\")\nOBJECT_LABEL = os.getenv(\"OBJECT_LABEL\", \"ayam-potong\")\nCLASS_AYAM = os.getenv(\"CLASS_AYAM\", \"ayam\")\nCLASS_TALENAN = os.getenv(\"CLASS_TALENAN\", \"talenan\")\n\nLINE_X = int(os.getenv(\"LINE_X\")) if os.getenv(\"LINE_X\") else None\nLINE_X_FRAC = float(os.getenv(\"LINE_X_FRAC\", \"0.5\"))\nCROSS_DIRECTION = os.getenv(\"CROSS_DIRECTION\", \"rtl\").lower()\n\nIMGSZ = int(os.getenv(\"IMGSZ\", \"320\"))\nHALF = os.getenv(\"HALF\", \"false\").lower() == \"true\"\nCONF = float(os.getenv(\"CONF\", \"0.3\"))\nDEVICE = int(os.getenv(\"DEVICE\", \"0\"))\n\n# RKNN NPU core mask\nCORE_MASK = int(os.getenv(\"CORE_MASK\", \"1\"))\n\n# YOLO decoder config\nNUM_CLASSES = int(os.getenv(\"NUM_CLASSES\", \"2\"))\nSCORE_SIGMOID = os.getenv(\"SCORE_SIGMOID\", \"false\").lower() == \"true\"\n\n# ByteTrack settings\n# Ayam index 0\nTRACK_HIGH_THRESH_0 = float(os.getenv(\"TRACK_HIGH_THRESH_0\", \"0.5\"))\nTRACK_LOW_THRESH_0 = float(os.getenv(\"TRACK_LOW_THRESH_0\", \"0.1\"))\nTRACK_MATCH_THRESH_0 = float(os.getenv(\"TRACK_MATCH_THRESH_0\", \"0.8\"))\nTRACK_BUFFER_0 = int(os.getenv(\"TRACK_BUFFER_0\", \"30\"))\nTRACK_MIN_HITS_0 = int(os.getenv(\"TRACK_MIN_HITS_0\", \"3\"))\n# Talenan index 1\nTRACK_HIGH_THRESH_1 = float(os.getenv(\"TRACK_HIGH_THRESH_1\", \"0.5\"))\nTRACK_LOW_THRESH_1 = float(os.getenv(\"TRACK_LOW_THRESH_1\", \"0.1\"))", + "lineStart": 1, + "lineEnd": 60, + "totalLines": 1373, + "truncated": true + } + }, + "title": "counter_live_rknn_bytetrack.py", + "time": { + "start": 1785284246442, + "end": 1785284246461 + } + }, + "id": "prt_fab3bba44001f87bMH3lTuKZmh", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_03_5IekWxo8eZuCKMxXI4mn0963", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/uninstall-services.sh" + }, + "output": "/opt/bytetrack-counter/uninstall-services.sh\nfile\n\n1: #!/usr/bin/env bash\n2: # Remove Jetson edge counter systemd services.\n3: # Run on the Jetson: sudo ./uninstall-services.sh\n4: set -euo pipefail\n5: \n6: if [[ \"$(id -u)\" -ne 0 ]]; then\n7: echo \"Run as root: sudo ./uninstall-services.sh\"\n8: exit 1\n9: fi\n10: \n11: for unit in jetson-counter jetson-counter-dashboard; do\n12: systemctl stop \"${unit}\" 2>/dev/null || true\n13: systemctl disable \"${unit}\" 2>/dev/null || true\n14: rm -f \"/etc/systemd/system/${unit}.service\"\n15: done\n16: \n17: systemctl daemon-reload\n18: echo \"Removed jetson-counter and jetson-counter-dashboard services.\"\n\n(End of file - total 18 lines)\n", + "metadata": { + "preview": "#!/usr/bin/env bash\n# Remove Jetson edge counter systemd services.\n# Run on the Jetson: sudo ./uninstall-services.sh\nset -euo pipefail\n\nif [[ \"$(id -u)\" -ne 0 ]]; then\n echo \"Run as root: sudo ./uninstall-services.sh\"\n exit 1\nfi\n\nfor unit in jetson-counter jetson-counter-dashboard; do\n systemctl stop \"${unit}\" 2>/dev/null || true\n systemctl disable \"${unit}\" 2>/dev/null || true\n rm -f \"/etc/systemd/system/${unit}.service\"\ndone\n\nsystemctl daemon-reload\necho \"Removed jetson-counter and jetson-counter-dashboard services.\"", + "truncated": false, + "loaded": [], + "display": { + "type": "file", + "path": "/opt/bytetrack-counter/uninstall-services.sh", + "text": "#!/usr/bin/env bash\n# Remove Jetson edge counter systemd services.\n# Run on the Jetson: sudo ./uninstall-services.sh\nset -euo pipefail\n\nif [[ \"$(id -u)\" -ne 0 ]]; then\n echo \"Run as root: sudo ./uninstall-services.sh\"\n exit 1\nfi\n\nfor unit in jetson-counter jetson-counter-dashboard; do\n systemctl stop \"${unit}\" 2>/dev/null || true\n systemctl disable \"${unit}\" 2>/dev/null || true\n rm -f \"/etc/systemd/system/${unit}.service\"\ndone\n\nsystemctl daemon-reload\necho \"Removed jetson-counter and jetson-counter-dashboard services.\"", + "lineStart": 1, + "lineEnd": 18, + "totalLines": 18, + "truncated": false + } + }, + "title": "uninstall-services.sh", + "time": { + "start": 1785284246730, + "end": 1785284246773 + } + }, + "id": "prt_fab3bbc03001Lnq6bR3dHSKBV8", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_04_XaTxhp7G94JyiAbveNio1027", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/templates" + }, + "output": "/opt/bytetrack-counter/templates\ndirectory\n\ndashboard.html\n\n(1 entries)\n", + "metadata": { + "preview": "dashboard.html", + "truncated": false, + "loaded": [], + "display": { + "type": "directory", + "path": "/opt/bytetrack-counter/templates", + "entries": [ + "dashboard.html" + ], + "offset": 1, + "totalEntries": 1, + "truncated": false + } + }, + "title": "templates", + "time": { + "start": 1785284246986, + "end": 1785284246993 + } + }, + "id": "prt_fab3bbd400016ioCyqgklUEtfx", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + }, + { + "reason": "tool-calls", + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-finish", + "tokens": { + "total": 50054, + "input": 34054, + "output": 234, + "reasoning": 22, + "cache": { + "write": 0, + "read": 15744 + } + }, + "cost": 0.015093282, + "id": "prt_fab3bbe4c0019nHaPhmiqAhdOR", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3ba63b001ubMqr9d9Qa8oQb" + } + ] + }, + { + "info": { + "parentID": "msg_fab3b7662001asy7eTmLMm4mEq", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.001526009, + "tokens": { + "total": 52556, + "input": 1925, + "output": 148, + "reasoning": 435, + "cache": { + "write": 0, + "read": 50048 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785284247247, + "completed": 1785284255745 + }, + "finish": "tool-calls", + "id": "msg_fab3bbece001uI2KIIvs954jfg", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "e0e1414b68e2544fb2d71b977882e90620947d43", + "type": "step-start", + "id": "prt_fab3bc1c2001kavY1kjaybuBjC", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3bbece001uI2KIIvs954jfg" + }, + { + "type": "reasoning", + "text": "Now I have a thorough understanding of the codebase. Let me write the AGENTS.md file. Key observations:\n\n1. **Project type**: Python, on edge Jetson/RK3588 hardware\n2. **No build system** – it's a collection of Python scripts run directly\n3. **Multiple entry points**: \n - `counter_live.py` – original Jetson TensorRT variant\n - `counter_live_rknn.py` – RK3588 variant with simple IoU tracker\n - `counter_live_rknn_bytetrack.py` – RK3588 variant with ByteTrack (this is the one the systemd service runs)\n - `counter_dashboard.py` – Flask dashboard on port 5000\n4. **No tests** – no test framework, no test files\n5. **No lint/typecheck config** – no ruff, mypy, etc.\n6. **Systemd services** – the \"build\" is just systemd service installation\n7. **Key directories**: Actual install path is `/opt/bytetrack-counter` (based on service files), but README says `/opt/jetson-counter`\n8. **Environment**: Requires `.env` file, venv with system-site-packages, RKNN Toolkit Lite2, NVIDIA torch on Jetson\n9. **Important gotchas**:\n - `PYTHONNOUSERSITE=1` is critical for venv isolation\n - Two separate service units: counter and dashboard\n - The service runs `counter_live_rknn_bytetrack.py` by default\n - The `.env` file is not in git (gitignored), must be copied from `config.env.example`\n - The install path in service files is `/opt/bytetrack-counter` but README/DEPLOY mention `/opt/jetson-counter`\n - `numpy<2` is required\n - The project name on disk is `bytetrack-counter` but references `jetson-counter` in docs\n\nLet me write a concise, high-signal AGENTS.md.", + "time": { + "start": 1785284248461, + "end": 1785284254540 + }, + "id": "prt_fab3bc38d001LFx3nSpgSbSOnu", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab3bbece001uI2KIIvs954jfg" + }, + { + "type": "tool", + "tool": "read", + "callID": "call_00_9HbQKv8N5nsoY1OWjyrB5032", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/templates/dashboard.html", + "limit": 20 + }, + "output": "/opt/bytetrack-counter/templates/dashboard.html\nfile\n\n1: \n2: \n3: \n4: \n5: \n6: ZenAI APC Dashboard\n7: \n8: \n9: \r\n \r\n \r\n \r\n
\r\n
\r\n
\r\n
\r\n \r\n
\r\n \r\n
\r\n
\r\n
◆
\r\n
\r\n

ZenAI APC Module

\r\n
Edge Nano counter — YOLO RockChip
\r\n
\r\n
\r\n
\r\n
\r\n --\r\n Counting Day\r\n
\r\n
\r\n \r\n {{ site_name }}\r\n
\r\n \r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n LIVE\r\n
\r\n
--
\r\n
Current Batch Count
\r\n
Batch #-- · --
\r\n
\r\n
\r\n Today\r\n
\r\n
--
\r\n
Total Count Today
\r\n
-- batches
\r\n
\r\n
\r\n Yesterday\r\n
\r\n
--
\r\n
Total Count Yesterday
\r\n
-- batches
\r\n
\r\n
\r\n Average\r\n
\r\n
--
\r\n
Average Per Day
\r\n
-- days recorded
\r\n
\r\n
\r\n \r\n \r\n
\r\n --\r\n --\r\n
\r\n \r\n \r\n
\r\n
\r\n ◉ Live Camera Feed\r\n
\r\n \r\n \r\n waiting...\r\n \r\n \r\n
\r\n
\r\n
\r\n
\r\n
▦
\r\n
Live Feed Unavailable
\r\n
Enable LIVE_STREAM_ENABLED=true in counter .env
\r\n
\r\n \"Live\r\n
Connection lost — retrying...
\r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n
\r\n ◉ Daily Trends\r\n
\r\n \r\n \r\n \r\n
\r\n
\r\n
\r\n \r\n
\r\n
\r\n
\r\n ◉ Quick Stats\r\n
\r\n
\r\n
\r\n
\r\n
\r\n
Grand Total
\r\n
All time
\r\n
\r\n
\r\n --\r\n
\r\n
\r\n
\r\n
\r\n
\r\n
Total Batches
\r\n
All time
\r\n
\r\n
\r\n --\r\n
\r\n
\r\n
\r\n
\r\n
\r\n
Avg per Batch
\r\n
Overall average
\r\n
\r\n
\r\n --\r\n
\r\n
\r\n \r\n
\r\n
◉ Recent Activity
\r\n
\r\n \r\n
\r\n
\r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n
\r\n ◉ Daily Records\r\n
Click on a row to view batch details
\r\n
\r\n
\r\n \r\n \r\n \r\n \r\n
\r\n
\r\n
\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n
DateTotal CountBatchesAvg/BatchStatusAction
\r\n
\r\n
\r\n \r\n
Edge Nano Counter by ZenAi
\r\n
\r\n \r\n \r\n
\r\n
\r\n
\r\n
\r\n

Batch Details

\r\n
--
\r\n
\r\n \r\n
\r\n
\r\n
\r\n
--
\r\n
Total Count
\r\n
\r\n
\r\n
--
\r\n
Total Batches
\r\n
\r\n
\r\n
--
\r\n
Avg Duration (min)
\r\n
\r\n
\r\n
\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n
Batch #CountStartEndDuration
\r\n
\r\n
\r\n
\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n", + "additions": 1, + "deletions": 1, + "status": "modified" + } + ] + }, + "id": "msg_fab49d2270011Pr7ZD751np3Fa", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "type": "text", + "text": "ok implement it", + "id": "prt_fab49d22a001Ax1Mfl8wFJpJWS", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49d2270011Pr7ZD751np3Fa" + } + ] + }, + { + "info": { + "parentID": "msg_fab49d2270011Pr7ZD751np3Fa", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.00046603, + "tokens": { + "total": 75824, + "input": 166, + "output": 119, + "reasoning": 19, + "cache": { + "write": 0, + "read": 75520 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785285169747, + "completed": 1785285172957 + }, + "finish": "tool-calls", + "id": "msg_fab49d2530015rx5ZkBXau2tmC", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "903513119f5f892d9f5642024f6839426e6caf93", + "type": "step-start", + "id": "prt_fab49d6ad001sHU39mF1ngcB0Z", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49d2530015rx5ZkBXau2tmC" + }, + { + "type": "reasoning", + "text": "The user wants me to change the polling interval from 2000ms to 200ms.", + "time": { + "start": 1785285171121, + "end": 1785285171651 + }, + "id": "prt_fab49d7b1001T8IYXFFNg0Yrdn", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49d2530015rx5ZkBXau2tmC" + }, + { + "type": "tool", + "tool": "edit", + "callID": "call_00_kyUacSJi8Bt9GNEA9H8g9941", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/templates/dashboard.html", + "oldString": " setInterval(() => {\n loadCurrentBatch();\n }, 2000);", + "newString": " setInterval(() => {\n loadCurrentBatch();\n }, 200);" + }, + "output": "Edit applied successfully.", + "metadata": { + "diagnostics": {}, + "diff": "Index: /opt/bytetrack-counter/templates/dashboard.html\n===================================================================\n--- /opt/bytetrack-counter/templates/dashboard.html\n+++ /opt/bytetrack-counter/templates/dashboard.html\n@@ -817,9 +817,9 @@\n }, 30000);\n \n setInterval(() => {\n loadCurrentBatch();\n- }, 2000);\n+ }, 200);\n });\n \n async function loadCurrentBatch() {\n try {\n", + "filediff": { + "file": "/opt/bytetrack-counter/templates/dashboard.html", + "patch": "Index: /opt/bytetrack-counter/templates/dashboard.html\n===================================================================\n--- /opt/bytetrack-counter/templates/dashboard.html\n+++ /opt/bytetrack-counter/templates/dashboard.html\n@@ -817,9 +817,9 @@\n }, 30000);\n \n setInterval(() => {\n loadCurrentBatch();\n- }, 2000);\n+ }, 200);\n });\n \n async function loadCurrentBatch() {\n try {\n", + "additions": 1, + "deletions": 1 + }, + "truncated": false + }, + "title": "templates/dashboard.html", + "time": { + "start": 1785285172323, + "end": 1785285172332 + } + }, + "id": "prt_fab49d9ce001zXqv3Zat3iNcof", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49d2530015rx5ZkBXau2tmC" + }, + { + "reason": "tool-calls", + "snapshot": "eb77f5d97d1d69405e7cdcf97de530ec015198a1", + "type": "step-finish", + "tokens": { + "total": 75824, + "input": 166, + "output": 119, + "reasoning": 19, + "cache": { + "write": 0, + "read": 75520 + } + }, + "cost": 0.00046603, + "id": "prt_fab49ddbd0012EO1CyZg7JjzlD", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49d2530015rx5ZkBXau2tmC" + }, + { + "type": "patch", + "hash": "903513119f5f892d9f5642024f6839426e6caf93", + "files": [ + "/opt/bytetrack-counter/templates/dashboard.html" + ], + "id": "prt_fab49ded7001DHeKN3h74d1O0f", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49d2530015rx5ZkBXau2tmC" + } + ] + }, + { + "info": { + "parentID": "msg_fab49d2270011Pr7ZD751np3Fa", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.000562223, + "tokens": { + "total": 76138, + "input": 63, + "output": 148, + "reasoning": 151, + "cache": { + "write": 0, + "read": 75776 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785285172973, + "completed": 1785285179158 + }, + "finish": "tool-calls", + "id": "msg_fab49deed0014Ou1kedtkISA7A", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "eb77f5d97d1d69405e7cdcf97de530ec015198a1", + "type": "step-start", + "id": "prt_fab49e2c2001smNt5XaBUEfO36", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49deed0014Ou1kedtkISA7A" + }, + { + "type": "reasoning", + "text": "Now update the SPEC.md — this was not previously a task, but it changes observable behavior. I should note this in §V or §T. Actually, this is a behavioral improvement, not a spec change. But I should mark something in §T about it being done. Let me add a completed task or just update the relevant task.\n\nLooking at §T, there's no task for this. But I think I should add one marking it as done since it was a user-requested change.\n\nActually, let me just note it — it's a small enough change that doesn't need a full task row. The spec is about the architecture, not every UI tweak. But let me add a T11 in §T as done.", + "time": { + "start": 1785285174832, + "end": 1785285177689 + }, + "id": "prt_fab49e6300016gJhNB6JExJWG0", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49deed0014Ou1kedtkISA7A" + }, + { + "type": "tool", + "tool": "edit", + "callID": "call_00_AeLcScWxXDAtMNf3vdyD1434", + "state": { + "status": "completed", + "input": { + "filePath": "/opt/bytetrack-counter/SPEC.md", + "newString": "T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator|\nT11|x|reduce live-counter poll interval 2000→200ms for real-time feel|", + "oldString": "T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator|" + }, + "output": "Edit applied successfully.", + "metadata": { + "diagnostics": {}, + "diff": "Index: /opt/bytetrack-counter/SPEC.md\n===================================================================\n--- /opt/bytetrack-counter/SPEC.md\n+++ /opt/bytetrack-counter/SPEC.md\n@@ -120,8 +120,9 @@\n T7|.|add dashboard health-check endpoint (no `/health` or `/api/status` exists)|\n T8|.|add model checks on startup — model class names ! validated against CLASS_AYAM/CLASS_TALENAN (TensorRT variant validates; RKNN variants do not)|\n T9|.|reset prev_gray on stream reconnect — stale gray ref causes crash or false motion|V19,B1\n T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator|\n+T11|x|reduce live-counter poll interval 2000→200ms for real-time feel|\n ```\n \n ## §B — Bugs\n \n", + "filediff": { + "file": "/opt/bytetrack-counter/SPEC.md", + "patch": "Index: /opt/bytetrack-counter/SPEC.md\n===================================================================\n--- /opt/bytetrack-counter/SPEC.md\n+++ /opt/bytetrack-counter/SPEC.md\n@@ -120,8 +120,9 @@\n T7|.|add dashboard health-check endpoint (no `/health` or `/api/status` exists)|\n T8|.|add model checks on startup — model class names ! validated against CLASS_AYAM/CLASS_TALENAN (TensorRT variant validates; RKNN variants do not)|\n T9|.|reset prev_gray on stream reconnect — stale gray ref causes crash or false motion|V19,B1\n T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator|\n+T11|x|reduce live-counter poll interval 2000→200ms for real-time feel|\n ```\n \n ## §B — Bugs\n \n", + "additions": 1, + "deletions": 0 + }, + "truncated": false + }, + "title": "SPEC.md", + "time": { + "start": 1785285178885, + "end": 1785285178907 + } + }, + "id": "prt_fab49f15d001em1v9ZqJH9eOkf", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49deed0014Ou1kedtkISA7A" + }, + { + "reason": "tool-calls", + "snapshot": "558750bc9ad57dfdd325cc3eb3ba64f27edd67ee", + "type": "step-finish", + "tokens": { + "total": 76138, + "input": 63, + "output": 148, + "reasoning": 151, + "cache": { + "write": 0, + "read": 75776 + } + }, + "cost": 0.000562223, + "id": "prt_fab49f6c3001g4Vjq2NNZLYNl7", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49deed0014Ou1kedtkISA7A" + }, + { + "type": "patch", + "hash": "eb77f5d97d1d69405e7cdcf97de530ec015198a1", + "files": [ + "/opt/bytetrack-counter/SPEC.md" + ], + "id": "prt_fab49f712001ZloLrbeA5gcf4Y", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49deed0014Ou1kedtkISA7A" + } + ] + }, + { + "info": { + "parentID": "msg_fab49d2270011Pr7ZD751np3Fa", + "role": "assistant", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/opt/bytetrack-counter", + "root": "/opt/bytetrack-counter" + }, + "cost": 0.000350001, + "tokens": { + "total": 76178, + "input": 121, + "output": 25, + "reasoning": 0, + "cache": { + "write": 0, + "read": 76032 + } + }, + "modelID": "deepseek-v4-pro", + "providerID": "deepseek", + "time": { + "created": 1785285179171, + "completed": 1785285181138 + }, + "finish": "stop", + "id": "msg_fab49f723001QJsoXYXC5OokKi", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3" + }, + "parts": [ + { + "snapshot": "558750bc9ad57dfdd325cc3eb3ba64f27edd67ee", + "type": "step-start", + "id": "prt_fab49fa81001LhGfeOnN6FvjC4", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49f723001QJsoXYXC5OokKi" + }, + { + "type": "text", + "text": "Done. `templates/dashboard.html:820` — polling interval `2000` → `200`ms.", + "time": { + "start": 1785285180681, + "end": 1785285181004 + }, + "id": "prt_fab49fd09001Wkb0FWmp5uJfsi", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49f723001QJsoXYXC5OokKi" + }, + { + "reason": "stop", + "snapshot": "558750bc9ad57dfdd325cc3eb3ba64f27edd67ee", + "type": "step-finish", + "tokens": { + "total": 76178, + "input": 121, + "output": 25, + "reasoning": 0, + "cache": { + "write": 0, + "read": 76032 + } + }, + "cost": 0.000350001, + "id": "prt_fab49fe84001o6b83FRG9MeWnz", + "sessionID": "ses_0ca8f0d22ffe6PSdRVjrsVqYP3", + "messageID": "msg_fab49f723001QJsoXYXC5OokKi" + } + ] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0e9c011 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md — bytetrack-counter + +## Architecture + +- **Edge AI counter**: RTSP camera → YOLO RKNN (NPU) → tracking → line-crossing → SQLite + JSON state → Flask dashboard. +- **2 counter scripts**, only 1 deployed: + - `counter_live.py` — Jetson TensorRT variant (CUDA, NOT used on RK3588). + - **`counter_live_rknn_bytetrack.py`** — RK3588 with ByteTrack. **This is what systemd runs.** Reference for C++ port. +- `batch_store.py` — shared SQLite persistence + batch state machine. +- `counter_dashboard.py` — Flask dashboard on port 5000, same DB. + +## No build / test / lint + +There is no build system, no test framework, no linter config, no typechecker. +Do not try to run `pytest`, `ruff`, `mypy`, etc. — they don't exist here. + +## How to run + +```bash +# Copy env (required, .env is gitignored) +cp config.env.example .env + +# Venv (must use system-site-packages for RKNN toolkit) +python3 -m venv --system-site-packages venv +source venv/bin/pip install -r requirements.txt + +# Run counter (RK3588 only — needs rknn-toolkit-lite2 & RKNN model) +PYTHONNOUSERSITE=1 venv/bin/python counter_live_rknn_bytetrack.py + +# Run dashboard +PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py +``` + +## Key environment & install quirks + +- **`PYTHONNOUSERSITE=1`** is mandatory when running from the venv — without it, system/user packages leak in. +- **`.env` is gitignored** — always copy from `config.env.example` first. +- **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility. +- **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`. +- Service user is **`root`**, not `jetson` (despite README saying otherwise). +- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`. +- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588. + +## Code conventions + +- All config lives in `.env` (dotenv), read via `os.getenv()` at module top-level in each script. +- The 3 counter scripts duplicate ~80% of each other (drawing helpers, batch loop, etc). Changes to logic may need replication across variants. +- `batch_store.py` has its own threading (cutoff watcher, batch timeout timer) — thread safety is via a single `state_lock`. +- The dashboard re-creates DB tables on startup (`_ensure_db()`) independently from `batch_store.py`. +- No formal version tracking exists anywhere in this codebase. diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..94cc2a4 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,122 @@ +# Edge Jetson Deploy + +Production counter: **direct LAN RTSP** + **YOLO11n TensorRT** + SQLite batch store. +Replaces MQTT `frigate-counter` on the edge Jetson. + +## Quick install + +```bash +# 1. Copy this folder to Jetson +sudo mkdir -p /opt/jetson-counter +sudo cp -r jetson-counter/* /opt/jetson-counter/ +sudo chown -R jetson:jetson /opt/jetson-counter + +# 2. Configure +cd /opt/jetson-counter +cp config.env.example .env +nano .env # SOURCE, MODEL_PATH, CAMERA_NAME, etc. +sed -i 's/\r$//' .env + +# 3. Venv + services +chmod +x setup-venv.sh install-services.sh +sudo ./setup-venv.sh +sudo ./install-services.sh +``` + +Dashboard: `http://:5000` + +--- + +## YOLO11n TensorRT engine (one-time) + +On the Jetson (must match `IMGSZ` / `HALF` in `.env`): + +```bash +source /opt/jetson-counter/venv/bin/activate +export PYTHONNOUSERSITE=1 + +yolo export model=/media/jetson/DATA/yolo11n.pt format=engine half=True imgsz=416 device=0 +``` + +Verify classes: + +```bash +PYTHONNOUSERSITE=1 python -c " +from ultralytics import YOLO +m = YOLO('/media/jetson/DATA/yolo11n.engine') +print(m.names) +" +``` + +Expect `ayam` and `talenan`. + +--- + +## Direct camera RTSP + +Set in `.env`: + +```env +SOURCE=rtsp://user:pass@192.168.x.x:554/stream1 +``` + +Test before install: + +```bash +ffplay -rtsp_transport tcp -t 5 "$SOURCE" +nc -zv 554 +``` + +--- + +## Cutover from MQTT frigate-counter + +`install-services.sh` automatically: + +1. Disables `frigate-counter` and `frigate-counter-dashboard` +2. Enables `jetson-counter` + `jetson-counter-dashboard` + +Archive old DB (optional): + +```bash +sudo cp /opt/frigate-counter/frigate_counter.db ~/frigate_counter.db.backup +``` + +--- + +## Validation checklist + +```bash +sudo systemctl is-active jetson-counter jetson-counter-dashboard +PYTHONNOUSERSITE=1 /opt/jetson-counter/venv/bin/python -c "import torch; print('cuda', torch.cuda.is_available())" +sudo journalctl -u jetson-counter -n 20 --no-pager +``` + +Good signs: + +- `Stream ready!` +- `Loaded engine size: ... MiB` +- `Frame 100 | Batch ...` + +--- + +## Logs & restart + +```bash +sudo journalctl -u jetson-counter -f +sudo systemctl restart jetson-counter # after .env change +``` + +--- + +## JetPack 6.0 torch wheel + +If `setup-venv.sh` fails on torch URL, list wheels: + +```bash +curl -s https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/ | grep cp310 +``` + +Set `TORCH_WHEEL_URL=...` when running `setup-venv.sh`. + +See also [jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md](../jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md) for torchvision and RTSP issues. diff --git a/README.md b/README.md new file mode 100644 index 0000000..193fd06 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Jetson Edge Counter (Production) + +RTSP + YOLO TensorRT line-crossing counter for edge Jetson. Replaces MQTT `frigate-counter` on site. + +## Architecture + +- **Input:** Direct LAN camera RTSP (low latency) +- **Inference:** YOLO11n `.engine` (TensorRT) on Jetson GPU +- **Logic:** Line crossing (`ayam` count, `talenan` closes batch) via `batch_store.py` +- **Output:** `jetson_counter.db` + `current_batch.json` +- **Dashboard:** Flask on port **5000** + +Batch lifecycle: talenan closes batch → idle until next ayam line cross (count starts at 1). + +## Deploy + +See **[DEPLOY.md](DEPLOY.md)**. + +| Item | Default | +|------|---------| +| Install path | `/opt/jetson-counter` | +| Venv | `/opt/jetson-counter/venv` | +| DB | `/opt/jetson-counter/jetson_counter.db` | +| Dashboard | `http://:5000` | +| Cutoff | `20:00` | + +## Commands + +| Command | Purpose | +|---------|---------| +| `sudo systemctl status jetson-counter` | Counter running? | +| `sudo journalctl -u jetson-counter -f` | Live logs | +| `sudo systemctl restart jetson-counter` | After `.env` change | +| `sudo ./uninstall-services.sh` | Remove services | + +## Key env vars + +| Variable | Purpose | +|----------|---------| +| `SOURCE` | Direct camera RTSP URL | +| `MODEL_PATH` | `.engine` file path | +| `IMGSZ` / `HALF` | Must match engine export | +| `CROSS_DIRECTION` | `rtl` (default), `ltr`, or `both` | +| `LINE_X` / `LINE_X_FRAC` | Counting line position | + +## Files + +| File | Purpose | +|------|---------| +| `counter_live.py` | RTSP + YOLO + line crossing | +| `batch_store.py` | SQLite persistence | +| `counter_dashboard.py` | Flask UI | +| `config.env.example` | Env template | +| `jetson-counter.service` | Counter systemd unit | +| `install-services.sh` | Install + disable legacy MQTT counter | + +## Dev stack + +Lab / comparison: [`jetson-counter-dev/`](../jetson-counter-dev/) (port 8081, separate DB). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..0531c4b --- /dev/null +++ b/SPEC.md @@ -0,0 +1,155 @@ +# SPEC — bytetrack-counter + +## §G — Goal + +RTSP camera → YOLO RKNN (NPU) → ByteTrack → line-crossing counter → SQLite + Flask dashboard. +Count `ayam` (chicken) crossing counting line. Close batch on `talenan` (cutting-board) crossing. +Daily cutoff @ HH:MM resets batch numbering. RK3588 hardware. +**Reference implementation for C++ port in separate repo.** + +## §C — Constraints + +- Python 3.10, `rknn-toolkit-lite2` (NPU), `opencv-python` (RTSP/FFmpeg) +- `numpy<2` (rknn-toolkit-lite2 incompatible with numpy≥2) +- `PYTHONNOUSERSITE=1` ! set or venv breaks +- SQLite for persistence, JSON file for active-batch state +- Flask on port 5000 (dashboard) + 5002 (recounting), systemd supervision +- `counter_live.py` ⊥ run on RK3588 — Jetson TensorRT artifact, ! port target +- `counter_live_rknn_bytetrack.py` — reference for C++ port, this is what systemd runs +- Single deployment: `counter_live_rknn_bytetrack.py` + `counter_dashboard.py` + `recounting_dashboard.py` +- `go2rtc` ! running for recounting MP4 streaming (port 1984) + +## §I — Interfaces + +### Systemd +``` +unit: bytetrack-counter.service → `venv/bin/python counter_live_rknn_bytetrack.py` +unit: bytetrack-counter-dashboard.service → `venv/bin/python counter_dashboard.py` +unit: bytetrack-recounting-dashboard.service → `venv/bin/python recounting_dashboard.py` +env: PYTHONNOUSERSITE=1 ! set in all units +env: EnvironmentFile=/opt/bytetrack-counter/.env +user: root (not jetson) +path: /opt/bytetrack-counter (fixed in service file) +``` + +### `.env` config (40+ vars, gitignored) +``` +env: SOURCE ! set → RTSP URL | file path +env: MODEL_PATH ! set → .rknn file +env: IMGSZ ! set → model input size (e.g. 320) +env: CORE_MASK → NPU core mask (1=core0, 2=core1, 3=dual, 7=all) +env: NUM_CLASSES ! match model output count +env: CONF → detection confidence threshold (default 0.3) +env: DAILY_CUTOFF_TIME → HH:MM, default "20:00" +env: CROSS_DIRECTION → rtl | ltr | both (default rtl) +env: LINE_X | LINE_X_FRAC → counting line position +env: CLASS_AYAM → class name for counted object (index 0) +env: CLASS_TALENAN → class name for batch-close trigger (index 1) +env: RESET_COUNTERS_AT_CUTOFF → defined in example, ! consumed by code ? zombie +env: MOTION_DETECTION_ENABLED | MOTION_THRESHOLD → skip inference on still frames ? +env: TRACK_HIGH_THRESH_{0,1} | TRACK_LOW_THRESH_{0,1} | TRACK_MATCH_THRESH_{0,1} → ByteTrack params per class +env: BATCH_TIMEOUT_SECONDS → auto-close after inactivity (default 300) +env: IGNORE_BATCH_LABEL_TIMEOUT_SECONDS → suppress talenan close after batch start (default 30) +env: MIN_OBJECT_PER_BATCH → min count to persist batch (default 60) +env: MIN_DURATION_PER_BATCH → min seconds to persist batch (default 60) +env: LIVE_STREAM_ENABLED → write annotated JPEG snapshot each N frames +env: EXPORT_CSV → write per-crossing CSV (default true) + +### Recounting dashboard config +``` +env: RECOUNTING_DASHBOARD_PORT → port for recounting UI (default 5002) +env: LIVE_API_URL → base URL of live counter API (default http://localhost:5000) +env: RECOUNT_API_URL → base URL of recounting counter API (second node) +env: GO2RTC_API_URL → go2rtc REST API (default http://localhost:1984) +env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "recount") +``` +``` + +### SQLite +``` +table: batches (date, batch#, camera, label, count, start, end, created_at) + UNIQUE(counting_date, batch_number, camera_name, object_label) +table: daily_summaries (date, camera, label, total_count, total_batches, updated_at) + UNIQUE(counting_date, camera_name, object_label) +``` + +### JSON state file +``` +path: /tmp/bytetrack_current_batch.json (default) +schema: {counting_date, batch_number, count, start_time, last_detection_time, counted_event_ids[]} +``` + +### Flask API +``` +api: GET / → dashboard HTML +api: GET /api/current-batch → {count, batch_number, counting_date, start_time, last_detection_time} +api: GET /api/previous-batch → {date, batch#, count, start/end, duration_minutes} +api: GET /api/summary → {today, yesterday, all_time, average_per_day, best_day} +api: GET /api/daily-data?days=N → [ {date, total_count, total_batches, avg_per_batch} ] +api: GET /api/day-detail/ → {date, total_count, total_batches, total_duration, avg_duration, batches[]} +api: GET /api/recent-batches?limit=N → [ {date, batch#, count, start/end, duration} ] +api: GET /api/available-dates → [ {date, total_count, total_batches} ] +api: GET /api/export-daily-csv?days=N → .xlsx download (named csv, emits xlsx) +api: GET /api/export-day-csv/ → .xlsx download (named csv, emits xlsx) +api: GET /api/live-video → MJPEG stream from shared-memory JPEG + +### Recounting dashboard +``` +api: GET / → recounting HTML +api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch +api: GET /api/recount-progress → proxy to RECOUNT_API_URL:/api/current-batch +api: GET /api/mp4-files → [ {name, path, size, mtime} ... ] +api: POST /api/start-recount {path} → configure go2rtc stream, return {stream_url} +api: POST /api/stop-recount → tear down go2rtc stream +``` +``` + +## §V — Invariants + +``` +V1: NUM_CLASSES must match model output → class 0=ayam, class 1=talenan +V2: line crossing → prev_cx > line_x ≥ cx (rtl) | prev_cx < line_x ≤ cx (ltr) +V3: ∀ track_id → counted at most once per batch (counted_event_ids set) +V4: batch persisted → count ≥ MIN_OBJECT_PER_BATCH & duration ≥ MIN_DURATION_PER_BATCH +V5: dt.time() < DAILY_CUTOFF_TIME → counting_date = today, else tomorrow +V6: talenan crossing → close batch, but ignored ∀ IGNORE_BATCH_LABEL_TIMEOUT_SEC after batch start +V7: batch inactivity ≥ BATCH_TIMEOUT_SECONDS → auto-close +V8: PYTHONNOUSERSITE=1 ! set for venv isolation +V9: .env ! exist before counter or dashboard starts +V10: DB tables ! exist on startup (created if absent, both store & dashboard) +V11: previous batch → last CARRY_IDS (default 50) track IDs carried forward to next batch +V12: ∃ ! batch per (date, batch#, camera, label) — UNIQUE constraint in DB +V13: counter & dashboard share DB path → no process-level coordination +V14: stream disconnect → reconnect with delay (RECONNECT_DELAY_SEC), ! block main loop +V15: model inference → letterbox-resize to IMGSZ×IMGSZ, BGR→RGB, run NPU +V16: NMS postprocessing → iou_thr=0.45, class-aware grouping, score > CONF +V17: tracks pruned after TRACKED_PRUNE_SEC (default 300s) without update +V18: .env missing → scripts fail at import (os.getenv falls back to defaults, may mismatch) +V19: stream reconnect → reset motion detection state (prev_gray = None) +``` + +## §T — Tasks + +``` +id|status|task|cites +T1|.|unify install paths — README/DEPLOY/setup-venv.sh/uninstall say `/opt/jetson-counter`, service files & .env.example say `/opt/bytetrack-counter`| +T2|.|zombie var RESET_COUNTERS_AT_CUTOFF — defined in .env.example, unused in code (confirmed by review)| +T3|.|add version flag — no `--version` or git-derived version exists| +T4|.|keep reference Python clean — counter_live.py is artifact; focus edits on counter_live_rknn_bytetrack.py as port source| +T5|.|rename export routes — routes named `export-daily-csv` but emit `.xlsx`|I.flask +T6|.|fix live-stream snapshot path — some scripts default `/dev/shm/jetson-counter/`, example says `bytetrack-counter`| +T7|.|add dashboard health-check endpoint (no `/health` or `/api/status` exists)| +T8|.|add model checks on startup — model class names ! validated against CLASS_AYAM/CLASS_TALENAN (TensorRT variant validates; RKNN variants do not)| +T9|.|reset prev_gray on stream reconnect — stale gray ref causes crash or false motion|V19,B1 +T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator| +T11|x|reduce live-counter poll interval 2000→200ms for real-time feel| +T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc streaming| +``` + +## §B — Bugs + +``` +id|date|cause|fix +B1|2026-07-29|prev_gray not reset on stream reconnect → cv2.absdiff crash or false motion|V19 +``` + diff --git a/batch_store.py b/batch_store.py new file mode 100644 index 0000000..94ee985 --- /dev/null +++ b/batch_store.py @@ -0,0 +1,379 @@ +""" +Production batch persistence for edge Jetson counter. +Mirrors frigate-counter SQLite schema + current_batch.json contract. +""" +import json +import sqlite3 +import threading +import time +from datetime import datetime, timedelta +from pathlib import Path + + +class BatchStore: + def __init__( + self, + db_path, + state_file, + camera_name, + object_label='ayam-potong', + cutoff_time='20:00', + batch_timeout=300.0, + ignore_batch_label_timeout=30.0, + min_object_per_batch=60, + min_duration_per_batch=60, + carry_ids=50, + logger=print, + ): + self.db_path = db_path + self.state_file = Path(state_file) + self.camera_name = camera_name + self.object_label = object_label + self.cutoff_time_str = cutoff_time + datetime.strptime(cutoff_time, '%H:%M') + + self.batch_timeout = float(batch_timeout) + self.ignore_batch_label_timeout = float(ignore_batch_label_timeout) + self.min_object_per_batch = int(min_object_per_batch) + self.min_duration_per_batch = int(min_duration_per_batch) + self.carry_ids = int(carry_ids) + self.log = logger + + self.state_lock = threading.Lock() + self.batch_timer = None + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + self.previous_state = None + self.shutdown_event = threading.Event() + + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self.state_file.parent.mkdir(parents=True, exist_ok=True) + + self.db = sqlite3.connect(db_path, check_same_thread=False) + self._init_db() + self.current_state = self._load_state() + self.previous_state = self.current_state + + def _init_db(self): + cur = self.db.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + batch_number INTEGER NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + count INTEGER NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, batch_number, camera_name, object_label) + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS daily_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + total_count INTEGER NOT NULL DEFAULT 0, + total_batches INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, camera_name, object_label) + ) + """ + ) + self.db.commit() + + def get_counting_date(self, dt=None): + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, '%H:%M').time() + if dt.time() < cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + + def _load_state(self): + if not self.state_file.exists(): + return None + try: + with open(self.state_file, 'r', encoding='utf-8') as f: + state = json.load(f) + current_date = self.get_counting_date() + if state.get('counting_date') != current_date: + self.log( + f"State file belongs to previous counting day ({state.get('counting_date')}). " + 'Finalizing before fresh start.' + ) + self._insert_batch( + state['counting_date'], + state['batch_number'], + state['count'], + state['start_time'], + datetime.now().isoformat(), + ) + self.state_file.unlink(missing_ok=True) + return None + self.log( + f"Resumed batch #{state['batch_number']} from {state['start_time']} " + f"with count={state['count']}" + ) + self._reset_batch_timer() + return state + except Exception as exc: + self.log(f'Failed to load state file: {exc}') + return None + + def save_state(self): + if self.current_state is None: + self.state_file.unlink(missing_ok=True) + return + with open(self.state_file, 'w', encoding='utf-8') as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + counted_ids = [] + if self.previous_state is not None: + try: + counted_ids = self.previous_state['counted_event_ids'][-self.carry_ids:] + except (KeyError, TypeError): + counted_ids = [] + self.current_state = { + 'counting_date': counting_date, + 'batch_number': batch_number, + 'count': 0, + 'start_time': now, + 'last_detection_time': now, + 'counted_event_ids': counted_ids, + } + self.save_state() + self.log(f'Started batch #{batch_number} for {counting_date} ({self.object_label})') + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_batch_timeout(self): + self.log(f'Batch inactivity timeout ({self.batch_timeout}s) reached') + self.end_batch(closed_by='timeout') + + def _ignore_batch_label(self): + if not self.ignore_batch_label_timer: + self.ignore_batch_label = True + self.ignore_batch_label_timer = threading.Timer( + self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout + ) + self.ignore_batch_label_timer.daemon = True + self.ignore_batch_label_timer.start() + self.log( + f'Ignore batch label for {self.ignore_batch_label_timeout}s' + ) + + def _on_ignore_batch_label_timeout(self): + self.ignore_batch_label_timer = None + self.ignore_batch_label = False + self.log('Ignore batch label cooldown finished') + + def record_ayam_crossing(self, track_id): + """Line-cross equivalent of production ayam-potong MQTT event.""" + with self.state_lock: + counting_date = self.get_counting_date() + started_new = False + if self.current_state is None: + self.start_new_batch(counting_date) + started_new = True + elif self.current_state['counting_date'] != counting_date: + self._end_batch_locked(closed_by='cutoff') + self.start_new_batch(counting_date) + started_new = True + + event_key = str(track_id) + if event_key not in self.current_state['counted_event_ids']: + self.current_state['count'] += 1 + self.current_state['counted_event_ids'].append(event_key) + self.log( + f'Counted ayam (track {track_id}) | batch #{self.current_state["batch_number"]} ' + f'total: {self.current_state["count"]}' + ) + + self.current_state['last_detection_time'] = datetime.now().isoformat() + self.save_state() + self._reset_batch_timer() + return self.current_state['count'], started_new + + def record_talenan_crossing(self, track_id): + """Line-cross equivalent of production telenan MQTT batch close.""" + if self.ignore_batch_label: + return False + with self.state_lock: + self._ignore_batch_label() + self._end_batch_locked(closed_by='talenan') + self.log(f'Batch closed by talenan (track {track_id})') + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + return True + + def end_batch(self, closed_by='manual'): + with self.state_lock: + self._end_batch_locked(closed_by=closed_by) + + def _end_batch_locked(self, closed_by='manual'): + if self.current_state is None: + return False + + self.previous_state = self.current_state + state = self.current_state + + start_time_obj = datetime.fromisoformat(state['start_time']) + end_time_obj = datetime.now() + duration_seconds = (end_time_obj - start_time_obj).total_seconds() + + if (state['count'] < self.min_object_per_batch + or duration_seconds < self.min_duration_per_batch): + self.current_state = None + self.save_state() + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + self.log( + f'Batch #{state["batch_number"]} discarded ' + f'(count={state["count"]}, duration={duration_seconds:.0f}s)' + ) + return False + + end_time = end_time_obj.isoformat() + try: + self._insert_batch( + state['counting_date'], + state['batch_number'], + state['count'], + state['start_time'], + end_time, + ) + cps = state['count'] / duration_seconds if duration_seconds > 0 else 0 + self.log( + f'Batch #{state["batch_number"]} ended | count={state["count"]} | ' + f'duration={duration_seconds:.0f}s | cps={cps:.3f} | closed_by={closed_by}' + ) + except Exception as exc: + self.log(f'Failed to persist batch: {exc}') + return False + + self.current_state = None + self.save_state() + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + return True + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + cur = self.db.cursor() + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (counting_date, batch_number, self.camera_name, self.object_label, count, start_time, end_time), + ) + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + self.db.commit() + + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.log( + f'Daily totals for {counting_date}: {row[0]} objects across {row[1]} batch(es)' + ) + + def cutoff_watcher_loop(self): + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state['counting_date'] != self.get_counting_date(): + self.log('Daily cutoff reached – finalizing batch') + self._end_batch_locked(closed_by='cutoff') + + def start_cutoff_watcher(self): + t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True) + t.start() + return t + + @property + def current_batch_number(self): + if self.current_state is None: + return 0 + return self.current_state['batch_number'] + + @property + def current_batch_count(self): + if self.current_state is None: + return 0 + return self.current_state['count'] + + def get_closed_total_for_day(self, counting_date=None): + if counting_date is None: + counting_date = self.get_counting_date() + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(total_count, 0) + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + return row[0] if row else 0 + + def display_total(self): + return self.get_closed_total_for_day() + self.current_batch_count + + def shutdown(self): + self.shutdown_event.set() + self.end_batch(closed_by='shutdown') + if self.batch_timer: + self.batch_timer.cancel() + self.db.close() diff --git a/bytetrack-counter-dashboard.service b/bytetrack-counter-dashboard.service new file mode 100644 index 0000000..cdc77e7 --- /dev/null +++ b/bytetrack-counter-dashboard.service @@ -0,0 +1,33 @@ +[Unit] +Description=Nano Edge Counter Dashboard (Flask, port 5000) +Documentation=file:///opt/bytetrack-counter/DEPLOY.md +After=network-online.target bytetrack-counter.service +Wants=network-online.target + +[Service] +Type=simple +User=root +Group=root + +WorkingDirectory=/opt/bytetrack-counter + +EnvironmentFile=/opt/bytetrack-counter/.env +Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin +Environment=FLASK_DEBUG=false + +ExecStart=/opt/bytetrack-counter/venv/bin/python counter_dashboard.py + +TimeoutStopSec=15 +KillSignal=SIGTERM + +Restart=on-failure +RestartSec=5 +StartLimitInterval=60s +StartLimitBurst=3 + +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=false + +[Install] +WantedBy=multi-user.target diff --git a/bytetrack-counter.service b/bytetrack-counter.service new file mode 100644 index 0000000..05a54fe --- /dev/null +++ b/bytetrack-counter.service @@ -0,0 +1,35 @@ +[Unit] +Description=NanoPi Edge YOLO Batch Counter (RTSP + RKNN) +Documentation=file:///opt/bytetrack-counter/DEPLOY.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +Group=root + +WorkingDirectory=/opt/bytetrack-counter + +EnvironmentFile=/opt/bytetrack-counter/.env +Environment=PYTHONNOUSERSITE=1 +Environment=YOLO_CONFIG_DIR=/opt/bytetrack-counter/.ultralytics +Environment=TORCH_HOME=/opt/bytetrack-counter/.torch +Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin + +ExecStart=/opt/bytetrack-counter/venv/bin/python counter_live_rknn_bytetrack.py + +TimeoutStopSec=30 +KillSignal=SIGTERM + +Restart=on-failure +RestartSec=10 +StartLimitInterval=120s +StartLimitBurst=5 + +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=false + +[Install] +WantedBy=multi-user.target diff --git a/bytetrack-recounting-dashboard.service b/bytetrack-recounting-dashboard.service new file mode 100644 index 0000000..245fcc5 --- /dev/null +++ b/bytetrack-recounting-dashboard.service @@ -0,0 +1,34 @@ +[Unit] +Description=Edge Counter Recounting Dashboard (Flask, port 5002) +Documentation=file:///opt/bytetrack-counter/DEPLOY.md +After=network-online.target bytetrack-counter-dashboard.service +Wants=network-online.target bytetrack-counter-dashboard.service + +[Service] +Type=simple +User=root +Group=root + +WorkingDirectory=/opt/bytetrack-counter + +EnvironmentFile=/opt/bytetrack-counter/.env +Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin +Environment=PYTHONNOUSERSITE=1 +Environment=FLASK_DEBUG=false + +ExecStart=/opt/bytetrack-counter/venv/bin/python recounting_dashboard.py + +TimeoutStopSec=15 +KillSignal=SIGTERM + +Restart=on-failure +RestartSec=5 +StartLimitInterval=60s +StartLimitBurst=3 + +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=false + +[Install] +WantedBy=multi-user.target diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..a80ea93 --- /dev/null +++ b/config.env.example @@ -0,0 +1,173 @@ +# ============================================================================= +# Edge RK3588 production counter + dashboard +# Shared config for: counter_live_rknn_bytetrack.py + counter_dashboard.py +# Copy to .env on device: cp config.env.example .env && nano .env +# ============================================================================= + +# --- Core paths --- +# Root output directory (logs, DB, video, CSV) +OUTPUT_DIR=/opt/bytetrack-counter +# SQLite database path for batch entries & crossing logs +DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db +# JSON file persisting the current active batch state +STATE_FILE=/tmp/bytetrack_current_batch.json + +# --- Input source --- +# RTSP / HTTP live stream, or a local video file path +SOURCE=rtsp://user:pass@192.168.0.100:554/stream1 +# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags) +OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay + +# --- RKNN model --- +# Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn) +MODEL_PATH=/opt/models/yolo9t.rknn +# Input image size for the model (square, e.g. 320 → 320×320) +IMGSZ=320 +# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant +HALF=false +# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three +CORE_MASK=7 +# Compute device index (reserved; not used at runtime) +DEVICE=0 + +# --- YOLO decoder --- +# Number of object classes the model outputs (e.g. 2 = ayam + talenan) +NUM_CLASSES=2 +# Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits +SCORE_SIGMOID=false + +# --- Detection --- +# Confidence threshold – detections below this are discarded before NMS +CONF=0.3 + +# --- ByteTrack tracking --- +# General for ayam, index 0 +# Detections with score ≥ this get priority matching in the first association stage +TRACK_HIGH_THRESH_0=0.5 +# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage +TRACK_LOW_THRESH_0=0.1 +# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required +TRACK_MATCH_THRESH_0=0.8 +# Frames a track survives without a match before being permanently removed +TRACK_BUFFER_0=30 +# Minimum consecutive (or total) hits needed before a track is considered confirmed +TRACK_MIN_HITS_0=3 + +# For talenan, index 1 +# Detections with score ≥ this get priority matching in the first association stage +TRACK_HIGH_THRESH_1=0.5 +# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage +TRACK_LOW_THRESH_1=0.1 +# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required +TRACK_MATCH_THRESH_1=0.6 +# Frames a track survives without a match before being permanently removed +TRACK_BUFFER_1=30 +# Minimum consecutive (or total) hits needed before a track is considered confirmed +TRACK_MIN_HITS_1=3 + +# --- Display --- +# Site name shown on the dashboard header (top-right) +SITE_NAME=ZenAi + +# --- Object class names --- +# Camera / location identifier shown in HUD and stored in DB +CAMERA_NAME=ZenAi +# Label used for batch grouping in the database +OBJECT_LABEL=ayam-potong +# Class name for the counted object (must match NUM_CLASSES order, index 0) +CLASS_AYAM=ayam +# Class name for the batch-closing trigger object (must match NUM_CLASSES order, index 1) +CLASS_TALENAN=talenan + +# --- Line crossing --- +# Direction for counting: rtl (right-to-left, default) | ltr (left-to-right) | both +CROSS_DIRECTION=rtl +# Fixed x-coordinate for the counting line (overrides LINE_X_FRAC if set) +LINE_X= +# Fraction of frame width where the counting line is drawn (default 0.5 = centre) +LINE_X_FRAC=0.5 + +# --- Batch management --- +# Daily cutoff time (HH:MM) – a new day's batch numbering starts after this time. +# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn_bytetrack.py. +DAILY_CUTOFF_TIME=20:00 +CUTOFF_TIME=20:00 +# Reset frame counter and ByteTrack track-ID counter back to 0 when the daily cutoff is reached (true/false, default: true) +RESET_COUNTERS_AT_CUTOFF=true +# Seconds of inactivity after which the current batch is auto-closed +BATCH_TIMEOUT_SECONDS=300 +# Seconds a newly-opened batch ignores the talenan label before accepting a close trigger +IGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30 +# Minimum number of objects required for a batch to be saved as valid +MIN_OBJECT_PER_BATCH=60 +# Minimum duration in seconds a batch must be open to be saved as valid +MIN_DURATION_PER_BATCH=60 + +# --- CSV export --- +# Write per-crossing events to a CSV file (true/false) +EXPORT_CSV=true +# Path where the crossing CSV is written +CROSS_CSV=/opt/batch-counter/batch_crossings.csv + +# --- Rate / performance --- +# Enable motion detection pre-filter: skip inference on frames with no movement +# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are +# skipped, saving NPU/CPU load. +MOTION_DETECTION_ENABLED=false +# Mean absolute pixel difference threshold (0–255) to consider a frame as having +# motion. Lower = more sensitive. Default 5.0. +MOTION_THRESHOLD=5.0 +# Sliding window in seconds for computing the crossing rate (objects/minute) +RATE_WINDOW_SEC=60 +# Number of frames to discard at startup to let the stream buffer stabilise +WARMUP_FRAMES=30 +# Delay in seconds between stream reconnection attempts +RECONNECT_DELAY_SEC=3 +# Maximum reconnection attempts (0 = infinite) +MAX_RECONNECT_ATTEMPTS=0 +# Print status log every N processed frames +FLUSH_EVERY_N_FRAMES=100 +# Seconds after which a tracked but unseen object is pruned from the active set +TRACKED_PRUNE_SEC=300 + +# --- Video recording --- +# Save annotated frames to segmented MP4 files (true/false) +RECORD_VIDEO=false +# Duration in seconds of each video segment file +VIDEO_SEGMENT_SEC=3600 +# Output video FPS (fallback if source FPS is unknown or ≤ 1) +OUTPUT_FPS=15 + +# --- Live stream snapshot --- +# Periodically write the latest annotated frame as JPEG for an external web server +LIVE_STREAM_ENABLED=false +# Path to the shared-memory snapshot file (served by nginx / lighttpd) +LIVE_STREAM_FRAME_PATH=/dev/shm/byetrack-counter/live_frame.jpg +# JPEG quality (1–100) +LIVE_STREAM_QUALITY=75 +# Write the snapshot every N frames (lower = more frequent updates) +LIVE_STREAM_EVERY_N=2 + +# --- Dashboard (counter_dashboard.py) --- +# Flask secret key for session/cookie signing — change in production! +SECRET_KEY=change-me-in-production +# Bind address for the Flask web server +DASHBOARD_HOST=0.0.0.0 +# Listen port for the dashboard web UI +DASHBOARD_PORT=5000 +# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production +FLASK_DEBUG=false +# Fallback name for the active-batch JSON state file used by the dashboard +CURRENT_BATCH_PATH=/tmp/bytetrack_current_batch.json + +# --- Recounting Dashboard (recounting_dashboard.py) --- +# Bind address and port +RECOUNTING_DASHBOARD_PORT=5002 +# Base URL of the live counter dashboard API (same host) +LIVE_API_URL=http://localhost:5000 +# Base URL of the recounting counter dashboard API (second node) +RECOUNT_API_URL=http://localhost:5001 +# go2rtc REST API base URL for streaming MP4 files +GO2RTC_API_URL=http://localhost:1984 +# go2rtc stream name used for recounting preview +GO2RTC_STREAM_NAME=recount diff --git a/counter_dashboard.py b/counter_dashboard.py new file mode 100644 index 0000000..45e3ad4 --- /dev/null +++ b/counter_dashboard.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +""" +Edge Jetson production counter dashboard. +Reads jetson_counter.db + current_batch.json from jetson-counter stack. +Default port 5000 (replaces frigate-counter dashboard role). +""" + +import json +import os +import sqlite3 +import time +from io import BytesIO +from datetime import datetime, timedelta + +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + +from flask import Flask, render_template, jsonify, request, Response +from werkzeug.serving import WSGIRequestHandler + +from dotenv import load_dotenv +load_dotenv() + + +app = Flask(__name__, template_folder="templates") +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production") + +_DEFAULT_DIR = "/opt/jetson-counter" +DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db") +CURRENT_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{_DEFAULT_DIR}/current_batch.json")) +CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00")) + +LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg") + +SITE_NAME = os.getenv("SITE_NAME", "LIVE") + +DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000")) +DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") +FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true" + +@app.route("/api/live-video") +def api_live_video(): + if not os.path.isfile(LIVE_STREAM_FRAME_PATH): + return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503 + + def generate(): + consecutive_fails = 0 + MAX_FAILS = 30 + while True: + try: + with open(LIVE_STREAM_FRAME_PATH, "rb") as f: + jpeg = f.read() + consecutive_fails = 0 + yield (b"--frame\r\n" + b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") + except FileNotFoundError: + consecutive_fails += 1 + if consecutive_fails >= MAX_FAILS: + return + time.sleep(1.0) + continue + except Exception: + consecutive_fails += 1 + if consecutive_fails >= MAX_FAILS: + return + time.sleep(0.5) + continue + time.sleep(0.05) + return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") + + +def _ensure_db(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + batch_number INTEGER NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + count INTEGER NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, batch_number, camera_name, object_label) + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS daily_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + camera_name TEXT NOT NULL, + object_label TEXT NOT NULL, + total_count INTEGER NOT NULL DEFAULT 0, + total_batches INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(counting_date, camera_name, object_label) + ) + """ + ) + conn.commit() + conn.close() + +_ensure_db() + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME): + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(cutoff_str, "%H:%M").time() + if dt.time() < cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + + +@app.route("/") +def index(): + return render_template("dashboard.html", site_name=SITE_NAME) + + +@app.route("/api/current-batch") +def api_current_batch(): + try: + with open(CURRENT_BATCH_PATH, "r") as f: + data = json.load(f) + return jsonify( + { + "success": True, + "counting_date": data.get("counting_date"), + "batch_number": data.get("batch_number"), + "count": data.get("count", 0), + "start_time": data.get("start_time"), + "last_detection_time": data.get("last_detection_time"), + } + ) + except FileNotFoundError: + return jsonify( + { + "success": False, + "error": "No active batch", + "count": 0, + "batch_number": None, + "counting_date": None, + } + ), 200 + except Exception as e: + return jsonify( + { + "success": False, + "error": str(e), + "count": 0, + "batch_number": None, + "counting_date": None, + } + ), 500 + + +@app.route("/api/previous-batch") +def api_previous_batch(): + try: + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT counting_date, batch_number, count, start_time, end_time, + ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes + FROM batches + ORDER BY end_time DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + conn.close() + if row: + return jsonify( + { + "success": True, + "date": row["counting_date"], + "batch_number": row["batch_number"], + "count": row["count"], + "start_time": row["start_time"], + "end_time": row["end_time"], + "duration_minutes": row["duration_minutes"], + } + ) + return jsonify({"success": False, "error": "No previous batch"}), 200 + except sqlite3.OperationalError as e: + return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 200 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/summary") +def api_summary(): + try: + conn = get_db() + cur = conn.cursor() + today = get_counting_date() + cur.execute( + """ + SELECT COALESCE(total_count, 0) as total_count, + COALESCE(total_batches, 0) as total_batches + FROM daily_summaries + WHERE counting_date = ? + """, + (today,), + ) + today_row = cur.fetchone() + yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat() + cur.execute( + """ + SELECT COALESCE(total_count, 0) as total_count, + COALESCE(total_batches, 0) as total_batches + FROM daily_summaries + WHERE counting_date = ? + """, + (yesterday,), + ) + yesterday_row = cur.fetchone() + cur.execute( + """ + SELECT COALESCE(SUM(total_count), 0) as grand_total, + COALESCE(SUM(total_batches), 0) as grand_batches, + COUNT(DISTINCT counting_date) as total_days + FROM daily_summaries + """ + ) + all_time = cur.fetchone() + cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries") + avg = cur.fetchone() + cur.execute( + """ + SELECT counting_date, total_count + FROM daily_summaries + ORDER BY total_count DESC + LIMIT 1 + """ + ) + best = cur.fetchone() + conn.close() + return jsonify( + { + "today": { + "date": today, + "total_count": today_row["total_count"] if today_row else 0, + "total_batches": today_row["total_batches"] if today_row else 0, + }, + "yesterday": { + "date": yesterday, + "total_count": yesterday_row["total_count"] if yesterday_row else 0, + "total_batches": yesterday_row["total_batches"] if yesterday_row else 0, + }, + "all_time": { + "grand_total": all_time["grand_total"], + "grand_batches": all_time["grand_batches"], + "total_days": all_time["total_days"], + }, + "average_per_day": avg["avg_per_day"] or 0, + "best_day": { + "date": best["counting_date"] if best else None, + "count": best["total_count"] if best else 0, + }, + } + ) + except sqlite3.OperationalError as e: + return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_batches": 0}, "yesterday": {"date": "", "total_count": 0, "total_batches": 0}, "all_time": {"grand_total": 0, "grand_batches": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/daily-data") +def api_daily_data(): + try: + days = request.args.get("days", 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT counting_date, total_count, total_batches, + ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch + FROM daily_summaries + WHERE counting_date >= ? + ORDER BY counting_date ASC + """, + (date_from,), + ) + daily_data = [ + { + "date": row["counting_date"], + "total_count": row["total_count"], + "total_batches": row["total_batches"], + "avg_per_batch": row["avg_per_batch"] or 0, + } + for row in cur.fetchall() + ] + conn.close() + return jsonify(daily_data) + except sqlite3.OperationalError as e: + return jsonify([]), 200 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/day-detail/") +def api_day_detail(date): + try: + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT batch_number, count, start_time, end_time, + ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes + FROM batches + WHERE counting_date = ? + ORDER BY batch_number ASC + """, + (date,), + ) + batches = [] + total_duration = 0 + for row in cur.fetchall(): + duration = row["duration_minutes"] or 0 + total_duration += duration + batches.append( + { + "batch_number": row["batch_number"], + "count": row["count"], + "start_time": row["start_time"], + "end_time": row["end_time"], + "duration_minutes": duration, + } + ) + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? + """, + (date,), + ) + summary = cur.fetchone() + conn.close() + return jsonify( + { + "date": date, + "total_count": summary["total_count"] if summary else 0, + "total_batches": summary["total_batches"] if summary else 0, + "total_duration_minutes": round(total_duration, 1), + "avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0, + "batches": batches, + } + ) + except sqlite3.OperationalError as e: + return jsonify({"date": date, "total_count": 0, "total_batches": 0, "total_duration_minutes": 0, "avg_duration_minutes": 0, "batches": [], "error": f"Database unavailable: {e}"}), 200 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/recent-batches") +def api_recent_batches(): + try: + limit = request.args.get("limit", 10, type=int) + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT counting_date, batch_number, count, start_time, end_time, + ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes + FROM batches + ORDER BY end_time DESC + LIMIT ? + """, + (limit,), + ) + batches = [ + { + "date": row["counting_date"], + "batch_number": row["batch_number"], + "count": row["count"], + "start_time": row["start_time"], + "end_time": row["end_time"], + "duration_minutes": row["duration_minutes"] or 0, + } + for row in cur.fetchall() + ] + conn.close() + return jsonify(batches) + except sqlite3.OperationalError as e: + return jsonify([]), 200 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/available-dates") +def api_available_dates(): + try: + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT counting_date, total_count, total_batches + FROM daily_summaries + ORDER BY counting_date DESC + """ + ) + dates = [ + { + "date": row["counting_date"], + "total_count": row["total_count"], + "total_batches": row["total_batches"], + } + for row in cur.fetchall() + ] + conn.close() + return jsonify(dates) + except sqlite3.OperationalError as e: + return jsonify([]), 200 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +def _excel_response(wb, filename): + output = BytesIO() + wb.save(output) + output.seek(0) + return Response( + output.getvalue(), + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +def _style_header(ws, cols): + header_font = Font(bold=True, color="FFFFFF", size=11) + header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid") + thin_border = Border( + left=Side(style="thin"), right=Side(style="thin"), + top=Side(style="thin"), bottom=Side(style="thin"), + ) + for col_idx, (col_letter, text) in enumerate(cols, 1): + cell = ws.cell(row=1, column=col_idx, value=text) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center") + cell.border = thin_border + ws.freeze_panes = "A2" + + +def _auto_width(ws): + for col in ws.columns: + max_len = 0 + col_letter = col[0].column_letter + for cell in col: + if cell.value is not None: + max_len = max(max_len, len(str(cell.value))) + ws.column_dimensions[col_letter].width = max_len + 4 + + +@app.route("/api/export-daily-csv") +def export_daily_xlsx(): + try: + days = request.args.get("days", 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT counting_date, batch_number, count, start_time, end_time, + ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes + FROM batches + WHERE counting_date >= ? + ORDER BY counting_date ASC, batch_number ASC + """, + (date_from,), + ) + rows = cur.fetchall() + conn.close() + except sqlite3.OperationalError as e: + return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + wb = Workbook() + ws = wb.active + ws.title = "Batch Details" + _style_header(ws, [("A", "Date"), ("B", "Batch #"), ("C", "Count"), ("D", "Start Time"), ("E", "End Time"), ("F", "Duration (min)")]) + + for r_idx, row in enumerate(rows, 2): + ws.cell(row=r_idx, column=1, value=row["counting_date"]) + ws.cell(row=r_idx, column=2, value=row["batch_number"]) + ws.cell(row=r_idx, column=3, value=row["count"]) + ws.cell(row=r_idx, column=4, value=row["start_time"]) + ws.cell(row=r_idx, column=5, value=row["end_time"]) + ws.cell(row=r_idx, column=6, value=row["duration_minutes"] or 0) + + _auto_width(ws) + filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return _excel_response(wb, filename) + + +@app.route("/api/export-day-csv/") +def export_day_xlsx(date): + try: + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT batch_number, count, start_time, end_time, + ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes + FROM batches + WHERE counting_date = ? + ORDER BY batch_number ASC + """, + (date,), + ) + rows = cur.fetchall() + conn.close() + except sqlite3.OperationalError as e: + return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + wb = Workbook() + ws = wb.active + ws.title = f"Day {date}" + _style_header(ws, [("A", "Batch Number"), ("B", "Count"), ("C", "Start Time"), ("D", "End Time"), ("E", "Duration (min)")]) + + for r_idx, row in enumerate(rows, 2): + ws.cell(row=r_idx, column=1, value=row["batch_number"]) + ws.cell(row=r_idx, column=2, value=row["count"]) + ws.cell(row=r_idx, column=3, value=row["start_time"]) + ws.cell(row=r_idx, column=4, value=row["end_time"]) + ws.cell(row=r_idx, column=5, value=row["duration_minutes"] or 0) + + _auto_width(ws) + filename = f"{SITE_NAME}_day_detail_{date}.xlsx" + return _excel_response(wb, filename) + + +if __name__ == "__main__": + WSGIRequestHandler.protocol_version = "HTTP/1.1" + print(f"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}") + print(f"DB: {DB_PATH}") + print(f"State: {CURRENT_BATCH_PATH}") + app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG) diff --git a/counter_live.py b/counter_live.py new file mode 100644 index 0000000..6a60452 --- /dev/null +++ b/counter_live.py @@ -0,0 +1,559 @@ +""" +Edge production live counter — RTSP + YOLO TensorRT + line crossing. +Replaces MQTT frigate-counter on Jetson with local LAN camera inference. +""" +from ultralytics import YOLO +import cv2 +import csv +import numpy as np +import os +import signal +import time +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +load_dotenv() + +from batch_store import BatchStore + +# --- config (override via env / .env) --- +OUTPUT_DIR = os.getenv('OUTPUT_DIR', '/opt/jetson-counter') +DB_PATH = os.getenv('DB_PATH', f'{OUTPUT_DIR}/jetson_counter.db') +STATE_FILE = os.getenv('STATE_FILE', f'{OUTPUT_DIR}/current_batch.json') +SOURCE = os.getenv('SOURCE', 'rtsp://user:pass@192.168.0.100:554/stream1') +MODEL_PATH = os.getenv('MODEL_PATH', '/media/jetson/DATA/yolo11n.engine') +CAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1') +OBJECT_LABEL = os.getenv('OBJECT_LABEL', 'ayam-potong') +CLASS_AYAM = os.getenv('CLASS_AYAM', 'ayam') +CLASS_TALENAN = os.getenv('CLASS_TALENAN', 'talenan') + +LINE_X = int(os.getenv('LINE_X')) if os.getenv('LINE_X') else None +LINE_X_FRAC = float(os.getenv('LINE_X_FRAC', '0.5')) +CROSS_DIRECTION = os.getenv('CROSS_DIRECTION', 'rtl').lower() + +IMGSZ = int(os.getenv('IMGSZ', '416')) +HALF = os.getenv('HALF', 'true').lower() == 'true' +CONF = float(os.getenv('CONF', '0.3')) +DEVICE = int(os.getenv('DEVICE', '0')) +TRACKER = os.getenv('TRACKER', 'bytetrack.yaml') + +DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00') +BATCH_TIMEOUT_SECONDS = float(os.getenv('BATCH_TIMEOUT_SECONDS', '300')) +IGNORE_BATCH_LABEL_TIMEOUT = float(os.getenv('IGNORE_BATCH_LABEL_TIMEOUT_SECONDS', '30')) +MIN_OBJECT_PER_BATCH = int(os.getenv('MIN_OBJECT_PER_BATCH', '60')) +MIN_DURATION_PER_BATCH = int(os.getenv('MIN_DURATION_PER_BATCH', '60')) + +EXPORT_CSV = os.getenv('EXPORT_CSV', 'true').lower() == 'true' +CROSS_CSV = os.getenv('CROSS_CSV', f'{OUTPUT_DIR}/batch_crossings.csv') + +WARMUP_FRAMES = int(os.getenv('WARMUP_FRAMES', '30')) +RECONNECT_DELAY_SEC = int(os.getenv('RECONNECT_DELAY_SEC', '3')) +MAX_RECONNECT_ATTEMPTS = int(os.getenv('MAX_RECONNECT_ATTEMPTS', '0')) +FLUSH_EVERY_N_FRAMES = int(os.getenv('FLUSH_EVERY_N_FRAMES', '100')) +TRACKED_PRUNE_SEC = int(os.getenv('TRACKED_PRUNE_SEC', '300')) +RECORD_VIDEO = os.getenv('RECORD_VIDEO', 'false').lower() == 'true' +VIDEO_SEGMENT_SEC = int(os.getenv('VIDEO_SEGMENT_SEC', '3600')) +OUTPUT_FPS = int(os.getenv('OUTPUT_FPS', '15')) + +LIVE_STREAM_ENABLED = os.getenv('LIVE_STREAM_ENABLED', 'false').lower() == 'true' +LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', '/dev/shm/jetson-counter/live_frame.jpg') +LIVE_STREAM_QUALITY = int(os.getenv('LIVE_STREAM_QUALITY', '75')) +LIVE_STREAM_EVERY_N = int(os.getenv('LIVE_STREAM_EVERY_N', '2')) + +RTSP_FFMPEG_OPTIONS = os.getenv( + 'OPENCV_FFMPEG_CAPTURE_OPTIONS', + 'rtsp_transport;tcp|fflags;nobuffer|flags;low_delay', +) + +IS_LIVE = SOURCE.lower().startswith(('rtsp://', 'http://')) + +CROSS_FLASH_FRAMES = 12 +POPUP_LIFETIME = 20 +LINE_PULSE_FRAMES = 12 +COUNT_PULSE_FRAMES = 15 +BATCH_PULSE_FRAMES = 20 + +SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)] +SK_COLORS = [ + (0, 255, 255), (0, 255, 255), (255, 0, 255), (255, 0, 255), + (0, 255, 0), (255, 255, 0), (0, 0, 255), (200, 200, 0), +] + +C_PANEL = (28, 24, 18) +C_BORDER = (90, 85, 75) +C_ACCENT = (255, 200, 60) +C_GREEN = (80, 220, 100) +C_TEXT = (235, 235, 235) +C_MUTED = (150, 150, 150) +C_AYAM_BOX = (0, 165, 255) +C_TALENAN_BOX = (220, 120, 60) +C_LINE_CORE = (180, 220, 255) +C_LINE_GLOW = (100, 160, 220) + +shutdown_requested = False + + +def request_shutdown(signum, frame): + global shutdown_requested + shutdown_requested = True + print('\nShutdown requested — finishing current frame...') + + +signal.signal(signal.SIGINT, request_shutdown) +signal.signal(signal.SIGTERM, request_shutdown) + + +def resolve_class_ids(names): + name_to_id = {v: k for k, v in names.items()} + missing = [n for n in (CLASS_AYAM, CLASS_TALENAN) if n not in name_to_id] + if missing: + raise ValueError(f'Model missing classes {missing}. Available: {list(names.values())}') + return name_to_id[CLASS_AYAM], name_to_id[CLASS_TALENAN] + + +def box_cx(box): + return (int(box[0]) + int(box[2])) // 2 + + +def resolve_line_x(frame_width): + if LINE_X is not None: + return LINE_X + if LINE_X_FRAC != 0.5: + return int(frame_width * LINE_X_FRAC) + return frame_width // 2 + + +def crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION): + if direction == 'ltr': + return prev_cx < line_x <= cx + if direction == 'both': + return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx) + return prev_cx > line_x >= cx + + +def now_str(): + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +def open_capture(source): + if source.lower().startswith(('rtsp://', 'http://')): + os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = RTSP_FFMPEG_OPTIONS + cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + return cap + + +def warmup_stream(cap, n=WARMUP_FRAMES): + print('Warming up stream...') + for _ in range(n): + cap.read() + print('Stream ready!') + + +def open_video_writer(path, w, h, fps): + return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*'avc1'), fps, (w, h)) + + +class CsvLogger: + def __init__(self, path, header): + Path(path).parent.mkdir(parents=True, exist_ok=True) + new_file = not Path(path).exists() or Path(path).stat().st_size == 0 + self.file = open(path, 'a', newline='', buffering=1) + self.writer = csv.writer(self.file) + if new_file: + self.writer.writerow(header) + self.file.flush() + + def write_row(self, row): + self.writer.writerow(row) + self.file.flush() + + def close(self): + self.file.close() + + +class VideoSegmentWriter: + def __init__(self, output_dir, w, h, fps, segment_sec): + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self.w, self.h, self.fps = w, h, fps + self.segment_sec = segment_sec + self.segment_start = time.monotonic() + self.writer = None + self._open_next() + + def _segment_path(self): + ts = datetime.now().strftime('%Y%m%d_%H%M%S') + return str(self.output_dir / f'live_{ts}.mp4') + + def _open_next(self): + if self.writer is not None: + self.writer.release() + path = self._segment_path() + self.writer = open_video_writer(path, self.w, self.h, self.fps) + self.segment_start = time.monotonic() + print(f'Recording segment: {path}') + + def write(self, frame): + if time.monotonic() - self.segment_start >= self.segment_sec: + self._open_next() + self.writer.write(frame) + + def release(self): + if self.writer is not None: + self.writer.release() + + +def prune_stale_tracks(tracked, now_mono): + stale = [tid for tid, (_, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC] + for tid in stale: + del tracked[tid] + + +def overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65): + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2) + if x2 <= x1 or y2 <= y1: + return + roi = img[y1:y2, x1:x2] + patch = np.full_like(roi, color, dtype=np.uint8) + cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi) + + +def draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4): + font = cv2.FONT_HERSHEY_SIMPLEX + (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1) + x1, y1 = x, y - th - pad_y + x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y + cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1) + cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1) + cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA) + + +def draw_elegant_counting_line(img, line_x, h, pulse_remaining=0): + strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1) + glow_alpha = 0.12 + 0.18 * strength + for offset in (14, 9, 5): + color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW) + cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA) + cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA) + dash_len, gap = 18, 12 + y = 0 + while y < h: + y_end = min(y + dash_len, h) + cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA) + y += dash_len + gap + cv2.putText(img, 'COUNT LINE', (line_x - 46, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.42, C_LINE_CORE, 1, cv2.LINE_AA) + + +def draw_hero_count(img, line_x, h, count, pulse_remaining=0): + text = str(count) + font = cv2.FONT_HERSHEY_SIMPLEX + boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1)) + font_scale, thickness = 1.6 + boost, 3 + (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness) + pad = 14 + tx, ty = line_x - tw // 2, h // 2 + th // 2 + overlay_rect(img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad // 2, C_PANEL, alpha=0.78) + cv2.rectangle(img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad // 2), C_LINE_CORE, 2) + cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA) + + +def draw_hud(img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate, camera_id, clock): + bar_h = 52 + overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72) + cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1) + cv2.putText(img, 'BATCH', (16, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + batch_label = str(batch_num) if batch_num else '—' + cv2.putText(img, batch_label, (16, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_ACCENT, 2, cv2.LINE_AA) + cv2.putText(img, 'COUNT', (100, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + cv2.putText(img, str(batch_count), (100, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_GREEN, 2, cv2.LINE_AA) + cv2.putText(img, 'TOTAL', (190, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + cv2.putText(img, str(total_ayam), (190, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA) + cv2.putText(img, 'UPTIME', (280, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + cv2.putText(img, f'{elapsed_sec / 3600:.1f}h', (280, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA) + cv2.putText(img, 'RATE', (380, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + cv2.putText(img, f'{rate:.1f}/min', (380, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_ACCENT, 1, cv2.LINE_AA) + cv2.putText(img, clock, (w - 180, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.55, C_TEXT, 1, cv2.LINE_AA) + cv2.putText(img, f'CAM {camera_id}', (w - 180, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + + +def draw_footer(img, w, h, frame_idx, live_tag): + bar_h = 28 + overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55) + cv2.putText(img, f'{live_tag} | Frame {frame_idx}', (12, h - 9), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA) + + +def draw_skeleton_bold(img, kpts): + for (a, b), color in zip(SKELETON, SK_COLORS): + if a < len(kpts) and b < len(kpts): + xa, ya = int(kpts[a][0]), int(kpts[a][1]) + xb, yb = int(kpts[b][0]), int(kpts[b][1]) + if xa > 0 and ya > 0 and xb > 0 and yb > 0: + cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA) + for kp in kpts: + x, y = int(kp[0]), int(kp[1]) + if x > 0 and y > 0: + cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA) + cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA) + + +def draw_popups(img, popups, frame_idx): + alive = [] + for pop in popups: + age = frame_idx - pop['born'] + if age > POPUP_LIFETIME: + continue + alive.append(pop) + fade = 1.0 - age / POPUP_LIFETIME + y = pop['y'] - int(age * 1.8) + color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade)) + cv2.putText(img, pop['text'], (pop['x'], y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, cv2.LINE_AA) + return alive + + +def draw_batch_banner(img, w, batch_num, pulse_remaining): + if pulse_remaining <= 0: + return + text = f'NEW BATCH {batch_num}' + font = cv2.FONT_HERSHEY_SIMPLEX + (tw, th), _ = cv2.getTextSize(text, font, 0.8, 2) + x1, y1 = w // 2 - tw // 2 - 16, 62 + x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20 + overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7) + cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2) + cv2.putText(img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA) + + +def connect_stream(source, warmup=WARMUP_FRAMES): + attempts = 0 + while not shutdown_requested: + cap = open_capture(source) + if not cap.isOpened(): + attempts += 1 + if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS: + raise RuntimeError(f'Cannot open source after {attempts} attempts: {source}') + print(f'Cannot open source, retry in {RECONNECT_DELAY_SEC}s...') + time.sleep(RECONNECT_DELAY_SEC) + continue + if warmup > 0 and source.lower().startswith(('rtsp://', 'http://')): + warmup_stream(cap, warmup) + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) + if not fps or fps <= 1: + fps = OUTPUT_FPS + return cap, w, h, fps + return None, 0, 0, OUTPUT_FPS + + +def run(): + global shutdown_requested + + store = BatchStore( + db_path=DB_PATH, + state_file=STATE_FILE, + camera_name=CAMERA_NAME, + object_label=OBJECT_LABEL, + cutoff_time=DAILY_CUTOFF_TIME, + batch_timeout=BATCH_TIMEOUT_SECONDS, + ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT, + min_object_per_batch=MIN_OBJECT_PER_BATCH, + min_duration_per_batch=MIN_DURATION_PER_BATCH, + logger=lambda msg: print(f'[{now_str()}] {msg}'), + ) + store.start_cutoff_watcher() + + cross_logger = None + if EXPORT_CSV: + cross_logger = CsvLogger(CROSS_CSV, ['batch', 'frame', 'timestamp', 'chicken_id']) + + model = YOLO(MODEL_PATH) + ayam_cls, talenan_cls = resolve_class_ids(model.names) + + ayam_tracked = {} + talenan_tracked = {} + ayam_line_crossed = set() + talenan_line_crossed = set() + + ayam_cross_flash = {} + talenan_cross_flash = {} + line_pulse = count_pulse = batch_pulse = 0 + popups = [] + + session_start = time.time() + frame_idx = 0 + video_writer = None + + cap, w, h, fps = connect_stream(SOURCE) + if cap is None: + store.shutdown() + return + + line_x = resolve_line_x(w) + print(f'Jetson counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}') + print(f'Model: {MODEL_PATH} | imgsz={IMGSZ} half={HALF}') + print(f'DB: {DB_PATH}') + print(f'State: {STATE_FILE}') + + if RECORD_VIDEO: + video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC) + + reconnect_count = 0 + + while not shutdown_requested: + ret, frame = cap.read() + if not ret: + if not IS_LIVE: + break + reconnect_count += 1 + print(f'Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s...') + cap.release() + time.sleep(RECONNECT_DELAY_SEC) + cap, w, h, fps = connect_stream(SOURCE) + if cap is None: + break + line_x = resolve_line_x(w) + continue + + now = time.time() + elapsed = now - session_start + mono = time.monotonic() + ayam_crossed_frame = batch_closed_frame = batch_started_frame = False + + results = model.track( + frame, + device=DEVICE, + persist=True, + conf=CONF, + imgsz=IMGSZ, + half=HALF, + tracker=TRACKER, + verbose=False, + ) + r = results[0] + + if r.boxes.id is not None: + ids = r.boxes.id.int().tolist() + boxes = r.boxes.xyxy.tolist() + clss = r.boxes.cls.int().tolist() + kpts_all = r.keypoints.xy.tolist() if r.keypoints else [] + + talenan_items, ayam_items = [], [] + for i, (track_id, box, cls_id) in enumerate(zip(ids, boxes, clss)): + cx = box_cx(box) + x1, y1, x2, y2 = [int(v) for v in box] + kpts = kpts_all[i] if i < len(kpts_all) else None + item = (track_id, cx, x1, y1, x2, y2, kpts) + if cls_id == talenan_cls: + talenan_items.append(item) + elif cls_id == ayam_cls: + ayam_items.append(item) + + for track_id, cx, x1, y1, x2, y2, _ in talenan_items: + if track_id in talenan_tracked: + prev_cx, _ = talenan_tracked[track_id] + if crossed_line(prev_cx, cx, line_x) and track_id not in talenan_line_crossed: + talenan_line_crossed.add(track_id) + if store.record_talenan_crossing(track_id): + batch_closed_frame = True + talenan_cross_flash[track_id] = CROSS_FLASH_FRAMES + popups.append({'x': cx - 20, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': 'BATCH CLOSED'}) + talenan_tracked[track_id] = (cx, mono) + + for track_id, cx, x1, y1, x2, y2, kpts in ayam_items: + if track_id in ayam_tracked: + prev_cx, _ = ayam_tracked[track_id] + if crossed_line(prev_cx, cx, line_x) and track_id not in ayam_line_crossed: + ayam_line_crossed.add(track_id) + _, started_new = store.record_ayam_crossing(track_id) + if cross_logger: + cross_logger.write_row([ + store.current_batch_number, frame_idx, + datetime.now().isoformat(), track_id, + ]) + ayam_crossed_frame = True + if started_new: + batch_started_frame = True + ayam_cross_flash[track_id] = CROSS_FLASH_FRAMES + popups.append({'x': cx - 12, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': '+1'}) + ayam_tracked[track_id] = (cx, mono) + + for track_id, cx, x1, y1, x2, y2, _ in talenan_items: + flash = talenan_cross_flash.get(track_id, 0) + color = C_GREEN if flash > 0 else C_TALENAN_BOX + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2) + draw_pill(frame, f'TALENAN {track_id}', x1, y1 - 4, color) + + for track_id, cx, x1, y1, x2, y2, kpts in ayam_items: + flash = ayam_cross_flash.get(track_id, 0) + color = C_GREEN if flash > 0 else C_AYAM_BOX + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2) + draw_pill(frame, f'ID {track_id}', x1, y1 - 4, color) + if kpts is not None: + draw_skeleton_bold(frame, kpts) + + if ayam_crossed_frame: + line_pulse = LINE_PULSE_FRAMES + count_pulse = COUNT_PULSE_FRAMES + if batch_closed_frame: + line_pulse = LINE_PULSE_FRAMES + if batch_started_frame: + batch_pulse = BATCH_PULSE_FRAMES + + batch_num = store.current_batch_number or 0 + batch_count = store.current_batch_count + display_total = store.display_total() + rate = (display_total / elapsed * 60) if elapsed > 0 else 0.0 + + draw_elegant_counting_line(frame, line_x, h, line_pulse) + draw_hero_count(frame, line_x, h, batch_count, count_pulse) + draw_hud(frame, w, batch_num, batch_count, display_total, elapsed, rate, CAMERA_NAME, now_str()) + draw_batch_banner(frame, w, batch_num, batch_pulse) + draw_footer(frame, w, h, frame_idx, 'LIVE' if IS_LIVE else 'FILE') + popups = draw_popups(frame, popups, frame_idx) + + for flash_store in (ayam_cross_flash, talenan_cross_flash): + for tid in list(flash_store): + flash_store[tid] -= 1 + if flash_store[tid] <= 0: + del flash_store[tid] + line_pulse = max(0, line_pulse - 1) + count_pulse = max(0, count_pulse - 1) + batch_pulse = max(0, batch_pulse - 1) + + if video_writer is not None: + video_writer.write(frame) + + if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0: + try: + Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True) + _, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY]) + with open(LIVE_STREAM_FRAME_PATH, 'wb') as f: + f.write(jpeg.tobytes()) + except Exception: + pass + + frame_idx += 1 + if frame_idx % FLUSH_EVERY_N_FRAMES == 0: + print( + f'[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} ' + f'| Total: {display_total} | Uptime {elapsed / 3600:.2f}h' + ) + prune_stale_tracks(ayam_tracked, mono) + prune_stale_tracks(talenan_tracked, mono) + + cap.release() + if video_writer is not None: + video_writer.release() + if cross_logger: + cross_logger.close() + store.shutdown() + + print('\n=== Batch Summary (SQLite) ===') + print(f'Database: {DB_PATH}') + + +if __name__ == '__main__': + run() diff --git a/counter_live_rknn_bytetrack.py b/counter_live_rknn_bytetrack.py new file mode 100644 index 0000000..c7a0362 --- /dev/null +++ b/counter_live_rknn_bytetrack.py @@ -0,0 +1,1373 @@ +""" +Edge production live counter — RTSP + YOLO RKNN + ByteTrack + line crossing. +Runs on RK3588 hardware with RKNN model (320×320 input). +Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking. +""" + +import numpy as np +import cv2 +import csv +import os +import signal +import time +from collections import deque +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +from rknnlite.api import RKNNLite +from batch_store import BatchStore + +# --- config (override via env / .env) --- +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/opt/jetson-counter") +DB_PATH = os.getenv("DB_PATH", f"{OUTPUT_DIR}/jetson_counter.db") +STATE_FILE = os.getenv("STATE_FILE", f"{OUTPUT_DIR}/current_batch.json") +SOURCE = os.getenv("SOURCE", "rtsp://user:pass@192.168.0.100:554/stream1") +MODEL_PATH = os.getenv("MODEL_PATH", "/opt/jetson-counter/yolo11n.rknn") +CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1") +OBJECT_LABEL = os.getenv("OBJECT_LABEL", "ayam-potong") +CLASS_AYAM = os.getenv("CLASS_AYAM", "ayam") +CLASS_TALENAN = os.getenv("CLASS_TALENAN", "talenan") + +LINE_X = int(os.getenv("LINE_X")) if os.getenv("LINE_X") else None +LINE_X_FRAC = float(os.getenv("LINE_X_FRAC", "0.5")) +CROSS_DIRECTION = os.getenv("CROSS_DIRECTION", "rtl").lower() + +IMGSZ = int(os.getenv("IMGSZ", "320")) +HALF = os.getenv("HALF", "false").lower() == "true" +CONF = float(os.getenv("CONF", "0.3")) +DEVICE = int(os.getenv("DEVICE", "0")) + +# RKNN NPU core mask +CORE_MASK = int(os.getenv("CORE_MASK", "1")) + +# YOLO decoder config +NUM_CLASSES = int(os.getenv("NUM_CLASSES", "2")) +SCORE_SIGMOID = os.getenv("SCORE_SIGMOID", "false").lower() == "true" + +# ByteTrack settings +# Ayam index 0 +TRACK_HIGH_THRESH_0 = float(os.getenv("TRACK_HIGH_THRESH_0", "0.5")) +TRACK_LOW_THRESH_0 = float(os.getenv("TRACK_LOW_THRESH_0", "0.1")) +TRACK_MATCH_THRESH_0 = float(os.getenv("TRACK_MATCH_THRESH_0", "0.8")) +TRACK_BUFFER_0 = int(os.getenv("TRACK_BUFFER_0", "30")) +TRACK_MIN_HITS_0 = int(os.getenv("TRACK_MIN_HITS_0", "3")) +# Talenan index 1 +TRACK_HIGH_THRESH_1 = float(os.getenv("TRACK_HIGH_THRESH_1", "0.5")) +TRACK_LOW_THRESH_1 = float(os.getenv("TRACK_LOW_THRESH_1", "0.1")) +TRACK_MATCH_THRESH_1 = float(os.getenv("TRACK_MATCH_THRESH_1", "0.6")) +TRACK_BUFFER_1 = int(os.getenv("TRACK_BUFFER_1", "30")) +TRACK_MIN_HITS_1 = int(os.getenv("TRACK_MIN_HITS_1", "3")) + +DAILY_CUTOFF_TIME = os.getenv("DAILY_CUTOFF_TIME", "20:00") +BATCH_TIMEOUT_SECONDS = float(os.getenv("BATCH_TIMEOUT_SECONDS", "300")) +IGNORE_BATCH_LABEL_TIMEOUT = float( + os.getenv("IGNORE_BATCH_LABEL_TIMEOUT_SECONDS", "30") +) +MIN_OBJECT_PER_BATCH = int(os.getenv("MIN_OBJECT_PER_BATCH", "60")) +MIN_DURATION_PER_BATCH = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + +EXPORT_CSV = os.getenv("EXPORT_CSV", "true").lower() == "true" +CROSS_CSV = os.getenv("CROSS_CSV", f"{OUTPUT_DIR}/batch_crossings.csv") + +RATE_WINDOW_SEC = int(os.getenv("RATE_WINDOW_SEC", "60")) +WARMUP_FRAMES = int(os.getenv("WARMUP_FRAMES", "30")) +RECONNECT_DELAY_SEC = int(os.getenv("RECONNECT_DELAY_SEC", "3")) +MAX_RECONNECT_ATTEMPTS = int(os.getenv("MAX_RECONNECT_ATTEMPTS", "0")) +FLUSH_EVERY_N_FRAMES = int(os.getenv("FLUSH_EVERY_N_FRAMES", "100")) +TRACKED_PRUNE_SEC = int(os.getenv("TRACKED_PRUNE_SEC", "300")) +RECORD_VIDEO = os.getenv("RECORD_VIDEO", "false").lower() == "true" +VIDEO_SEGMENT_SEC = int(os.getenv("VIDEO_SEGMENT_SEC", "3600")) +OUTPUT_FPS = int(os.getenv("OUTPUT_FPS", "15")) + +LIVE_STREAM_ENABLED = os.getenv("LIVE_STREAM_ENABLED", "false").lower() == "true" +LIVE_STREAM_FRAME_PATH = os.getenv( + "LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg" +) +LIVE_STREAM_QUALITY = int(os.getenv("LIVE_STREAM_QUALITY", "75")) +LIVE_STREAM_EVERY_N = int(os.getenv("LIVE_STREAM_EVERY_N", "2")) + +RTSP_FFMPEG_OPTIONS = os.getenv( + "OPENCV_FFMPEG_CAPTURE_OPTIONS", + "rtsp_transport;tcp|fflags;nobuffer|flags;low_delay", +) + +IS_LIVE = SOURCE.lower().startswith(("rtsp://", "http://")) + +MOTION_DETECTION_ENABLED = os.getenv("MOTION_DETECTION_ENABLED", "false").lower() == "true" +MOTION_THRESHOLD = float(os.getenv("MOTION_THRESHOLD", "5.0")) + +CROSS_FLASH_FRAMES = 12 +POPUP_LIFETIME = 20 +LINE_PULSE_FRAMES = 12 +COUNT_PULSE_FRAMES = 15 +BATCH_PULSE_FRAMES = 20 + +SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)] +SK_COLORS = [ + (0, 255, 255), + (0, 255, 255), + (255, 0, 255), + (255, 0, 255), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (200, 200, 0), +] + +C_PANEL = (28, 24, 18) +C_BORDER = (90, 85, 75) +C_ACCENT = (255, 200, 60) +C_GREEN = (80, 220, 100) +C_TEXT = (235, 235, 235) +C_MUTED = (150, 150, 150) +C_AYAM_BOX = (0, 165, 255) +C_TALENAN_BOX = (220, 120, 60) +C_LINE_CORE = (180, 220, 255) +C_LINE_GLOW = (100, 160, 220) + +shutdown_requested = False + + +def request_shutdown(signum, frame): + global shutdown_requested + shutdown_requested = True + print("\nShutdown requested — finishing current frame...") + + +signal.signal(signal.SIGINT, request_shutdown) +signal.signal(signal.SIGTERM, request_shutdown) + + +# ============================================================================= +# YOLO output decoder (NMS only — boxes are pre-decoded by the model) +# ============================================================================= + + +def _nms(boxes, scores, iou_thr=0.45): + order = np.argsort(scores)[::-1] + keep = [] + while len(order) > 0: + idx = order[0] + keep.append(idx) + if len(order) == 1: + break + xx1 = np.maximum(boxes[idx, 0], boxes[order[1:], 0]) + yy1 = np.maximum(boxes[idx, 1], boxes[order[1:], 1]) + xx2 = np.minimum(boxes[idx, 2], boxes[order[1:], 2]) + yy2 = np.minimum(boxes[idx, 3], boxes[order[1:], 3]) + w = np.maximum(0.0, xx2 - xx1) + h = np.maximum(0.0, yy2 - yy1) + inter = w * h + area_i = (boxes[idx, 2] - boxes[idx, 0]) * (boxes[idx, 3] - boxes[idx, 1]) + area_o = (boxes[order[1:], 2] - boxes[order[1:], 0]) * ( + boxes[order[1:], 3] - boxes[order[1:], 1] + ) + iou = inter / (area_i + area_o - inter + 1e-16) + order = order[1:][iou < iou_thr] + return np.array(keep) + + +# ============================================================================= +# IoU helpers (xyxy format) +# ============================================================================= + + +def _ious_xyxy(boxes_a, boxes_b): + """Pairwise IoU: (N,4) vs (M,4) → (N,M) matrix.""" + n, m = len(boxes_a), len(boxes_b) + if n == 0 or m == 0: + return np.zeros((n, m), dtype=np.float32) + xx1 = np.maximum(boxes_a[:, None, 0], boxes_b[None, :, 0]) + yy1 = np.maximum(boxes_a[:, None, 1], boxes_b[None, :, 1]) + xx2 = np.minimum(boxes_a[:, None, 2], boxes_b[None, :, 2]) + yy2 = np.minimum(boxes_a[:, None, 3], boxes_b[None, :, 3]) + iw = np.maximum(0.0, xx2 - xx1) + ih = np.maximum(0.0, yy2 - yy1) + inter = iw * ih + area_a = (boxes_a[:, 2] - boxes_a[:, 0]) * (boxes_a[:, 3] - boxes_a[:, 1]) + area_b = (boxes_b[:, 2] - boxes_b[:, 0]) * (boxes_b[:, 3] - boxes_b[:, 1]) + return inter / (area_a[:, None] + area_b[None, :] - inter + 1e-16) + + +def _greedy_match(cost_matrix, threshold=0.3): + """Greedy linear assignment. Returns pairs (row_idx, col_idx).""" + if cost_matrix.size == 0: + return [] + n, m = cost_matrix.shape + flat = [(cost_matrix[i, j], i, j) for i in range(n) for j in range(m)] + flat.sort() + row_used = set() + col_used = set() + pairs = [] + for cost, i, j in flat: + if cost >= threshold: + break + if i in row_used or j in col_used: + continue + row_used.add(i) + col_used.add(j) + pairs.append((i, j)) + return pairs + + +# ============================================================================= +# Kalman filter box tracker (state: x, y, w, h, vx, vy, vw, vh) +# ============================================================================= + + +class KalmanBoxTracker: + count = 0 + + def __init__(self, bbox_xyxy): + KalmanBoxTracker.count += 1 + self.track_id = KalmanBoxTracker.count + + x1, y1, x2, y2 = bbox_xyxy + w, h = x2 - x1, y2 - y1 + x, y = x1 + w / 2, y1 + h / 2 + + self.kf = _KalmanFilter() + self.kf.x[:4, 0] = np.array([x, y, w, h], dtype=np.float32) + + self.time_since_update = 0 + self.hits = 1 + self.hit_streak = 1 + self.age = 1 + + def predict(self): + if self.kf.x[6] + self.kf.x[2] <= 0: + self.kf.x[6] *= 0.0 + self.kf.predict() + self.age += 1 + self.time_since_update += 1 + + def update(self, bbox_xyxy): + self.time_since_update = 0 + self.hits += 1 + self.hit_streak += 1 + + x1, y1, x2, y2 = bbox_xyxy + w, h = x2 - x1, y2 - y1 + x, y = x1 + w / 2, y1 + h / 2 + self.kf.update(np.array([x, y, w, h], dtype=np.float32)) + + def get_state(self): + """Returns xyxy bbox from Kalman state.""" + xx = self.kf.x[:4, 0] + x, y, w, h = xx[0], xx[1], xx[2], xx[3] + x1 = x - w / 2 + y1 = y - h / 2 + x2 = x + w / 2 + y2 = y + h / 2 + return np.array([x1, y1, x2, y2], dtype=np.float32) + + def get_cx(self): + return float(self.kf.x[0, 0]) + + +class _KalmanFilter: + """8-state constant-velocity Kalman filter for bounding box tracking.""" + + def __init__(self): + ndim, dt = 4, 1.0 + self.motion_mat = np.eye(2 * ndim, 2 * ndim, dtype=np.float32) + for i in range(ndim): + self.motion_mat[i, ndim + i] = dt + self.update_mat = np.eye(ndim, 2 * ndim, dtype=np.float32) + + self._std_weight_position = 1.0 / 20 + self._std_weight_velocity = 1.0 / 160 + + self.x = np.zeros((8, 1), dtype=np.float32) + self.P = np.eye(8, dtype=np.float32) * 10.0 + + def predict(self): + std_pos = [ + self._std_weight_position * self.x[2], + self._std_weight_position * self.x[3], + self._std_weight_position * self.x[2], + self._std_weight_position * self.x[3], + ] + std_vel = [ + self._std_weight_velocity * self.x[2], + self._std_weight_velocity * self.x[3], + self._std_weight_velocity * self.x[2], + self._std_weight_velocity * self.x[3], + ] + Q = np.diag(np.square(np.concatenate([std_pos, std_vel]))) + + self.x = self.motion_mat @ self.x + self.P = self.motion_mat @ self.P @ self.motion_mat.T + Q + + def update(self, z): + R = np.diag( + np.square( + [ + self._std_weight_position * z[2], + self._std_weight_position * z[3], + self._std_weight_position * z[2], + self._std_weight_position * z[3], + ] + ) + ) + + H = self.update_mat + S = H @ self.P @ H.T + R + K = self.P @ H.T @ np.linalg.inv(S) + + y = z.reshape(4, 1) - H @ self.x + self.x = self.x + K @ y + I_KH = np.eye(8) - K @ H + self.P = I_KH @ self.P @ I_KH.T + K @ R @ K.T + + +# ============================================================================= +# ByteTrack multi-object tracker +# ============================================================================= + + +class ByteTracker: + """ByteTrack: two-stage association with Kalman filter prediction.""" + + def __init__( + self, + track_high_thresh=0.5, + track_low_thresh=0.1, + match_thresh=0.8, + track_buffer=30, + min_hits=3, + ): + self.high_thresh = track_high_thresh + self.low_thresh = track_low_thresh + self.match_thresh = match_thresh + self.track_buffer = track_buffer + self.min_hits = min_hits + self.tracked_tracks = [] + self.lost_tracks = [] + self.removed_tracks = [] + self.frame_id = 0 + + def update(self, boxes_xyxy, scores): + self.frame_id += 1 + + # --- separate detections by score --- + if len(boxes_xyxy) > 0: + remain = scores > self.low_thresh + remain_orig_idx = np.where(remain)[0] + dets = boxes_xyxy[remain] + det_scores = scores[remain] + is_high = det_scores > self.high_thresh + is_low = ~is_high + else: + remain_orig_idx = np.zeros(0, dtype=np.int64) + dets = np.zeros((0, 4), dtype=np.float32) + det_scores = np.zeros(0, dtype=np.float32) + is_high = np.zeros(0, dtype=bool) + is_low = np.zeros(0, dtype=bool) + + # --- Kalman predict all existing tracks --- + track_pool = self.tracked_tracks + self.lost_tracks + num_tracks = len(track_pool) + + # Per-frame tracking results + matched_track_idx = set() + det_to_track = {} + tracked_map = {} + lost_map = {} + + # Pre-allocate these for scoping + high_idx = np.array([], dtype=np.int64) + low_idx = np.array([], dtype=np.int64) + match_pairs_high = [] + + if num_tracks > 0: + track_boxes = np.zeros((num_tracks, 4), dtype=np.float32) + for ti, trk in enumerate(track_pool): + trk.predict() + track_boxes[ti] = trk.get_state() + + # --- first association: high-score ↔ all tracks --- + high_idx = np.where(is_high)[0] + high_dets = dets[is_high] + + unmatched_tracks = list(range(num_tracks)) + + if len(high_dets) > 0: + iou_mat = _ious_xyxy(high_dets, track_boxes) + cost_mat = 1.0 - iou_mat + matches = _greedy_match(cost_mat, threshold=1.0 - self.match_thresh) + + for di, ti in matches: + det_global = int(high_idx[di]) + orig_idx = int(remain_orig_idx[det_global]) + track_pool[ti].update(dets[det_global]) + track_pool[ti].hit_streak = max(1, track_pool[ti].hit_streak) + matched_track_idx.add(ti) + det_to_track[orig_idx] = track_pool[ti].track_id + tracked_map[track_pool[ti].track_id] = track_pool[ti].get_cx() + match_pairs_high.append((det_global, ti)) + + unmatched_tracks = [ + t for t in range(num_tracks) if t not in matched_track_idx + ] + + # --- second association: low-score ↔ unmatched tracks --- + low_idx = np.where(is_low)[0] + low_dets = dets[is_low] + + if len(low_dets) > 0 and len(unmatched_tracks) > 0: + unmatched_boxes = track_boxes[unmatched_tracks] + iou_mat = _ious_xyxy(low_dets, unmatched_boxes) + cost_mat = 1.0 - iou_mat + matches2 = _greedy_match( + cost_mat, threshold=1.0 - self.match_thresh + ) + + for di, uti in matches2: + det_global = int(low_idx[di]) + pool_idx = unmatched_tracks[uti] + orig_idx = int(remain_orig_idx[det_global]) + track_pool[pool_idx].update(dets[det_global]) + track_pool[pool_idx].hit_streak = max( + 1, track_pool[pool_idx].hit_streak + ) + matched_track_idx.add(pool_idx) + det_to_track[orig_idx] = track_pool[pool_idx].track_id + tracked_map[track_pool[pool_idx].track_id] = track_pool[ + pool_idx + ].get_cx() + + # --- reset hit_streak for unmatched tracks --- + for ti, trk in enumerate(track_pool): + if ti not in matched_track_idx: + trk.hit_streak = 0 + + # --- lifecycle management --- + new_tracked = [] + new_lost = [] + for trk in track_pool: + if trk.time_since_update > self.track_buffer: + self.removed_tracks.append(trk) + elif trk.time_since_update > 0: + new_lost.append(trk) + else: + new_tracked.append(trk) + self.tracked_tracks = new_tracked + self.lost_tracks = new_lost + + # --- confirmed tracks (both tracked and lost) --- + for trk in self.tracked_tracks + self.lost_tracks: + if trk.hit_streak >= self.min_hits or trk.hits >= self.min_hits: + tracked_map.setdefault(trk.track_id, trk.get_cx()) + + for trk in self.lost_tracks: + if trk.hit_streak >= self.min_hits or trk.hits >= self.min_hits: + lost_map[trk.track_id] = trk.get_cx() + + # --- new tracks from unmatched high-score dets --- + high_all = np.where(is_high)[0] + matched_det_ids = set(det_to_track.keys()) + for dg in high_all: + orig_idx = int(remain_orig_idx[int(dg)]) + if orig_idx not in matched_det_ids: + trk = KalmanBoxTracker(dets[int(dg)]) + self.tracked_tracks.append(trk) + det_to_track[orig_idx] = trk.track_id + tracked_map[trk.track_id] = trk.get_cx() + + return tracked_map, det_to_track, lost_map + + +# ============================================================================= +# RKNN YOLO wrapper (detect output format: (1, 4+num_classes, N)) +# ============================================================================= + + +class RKNNYOLO: + def __init__( + self, + model_path, + core_mask=1, + imgsz=320, + conf=0.3, + iou=0.45, + num_classes=2, + num_keypoints=0, + score_sigmoid=False, + ): + self.imgsz = imgsz + self.conf = conf + self.iou = iou + self.num_classes = num_classes + self.num_keypoints = num_keypoints + self.score_sigmoid = score_sigmoid + + self.rknn = RKNNLite(verbose=False) + ret = self.rknn.load_rknn(model_path) + if ret != 0: + raise RuntimeError(f"Failed to load RKNN model: {model_path}") + ret = self.rknn.init_runtime(core_mask=core_mask) + if ret != 0: + raise RuntimeError(f"Failed to init RKNN runtime (core_mask={core_mask})") + + try: + sdk_ver = self.rknn.get_sdk_version() + print(f"RKNN SDK version: {sdk_ver}") + except Exception: + pass + + print(f"RKNN model loaded: {model_path} imgsz={imgsz} core_mask={core_mask}") + + def _preprocess(self, frame): + h0, w0 = frame.shape[:2] + scale = min(self.imgsz / h0, self.imgsz / w0) + nh, nw = int(h0 * scale), int(w0 * scale) + resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR) + + letterbox = np.full((self.imgsz, self.imgsz, 3), 114, dtype=np.uint8) + dy = (self.imgsz - nh) // 2 + dx = (self.imgsz - nw) // 2 + letterbox[dy : dy + nh, dx : dx + nw] = resized + + rgb = cv2.cvtColor(letterbox, cv2.COLOR_BGR2RGB) + gains = np.array([scale, scale, dy, dx], dtype=np.float32) + return rgb, gains + + def __call__(self, frame): + h0, w0 = frame.shape[:2] + rgb, gains = self._preprocess(frame) + scale, _, pad_y, pad_x = gains + + inp = np.expand_dims(rgb, axis=0) + inp = np.ascontiguousarray(inp.astype(np.uint8)) + + outputs = self.rknn.inference(inputs=[inp]) + + if len(outputs) == 0: + return [] + + out = outputs[0] + out = np.squeeze(out, axis=0) + + if out.shape[0] == self.num_classes + 4: + out = out.T + + boxes_cxcywh = out[:, :4].copy() + cls_raw = out[:, 4:].copy() + + if self.score_sigmoid: + cls_scores = 1.0 / (1.0 + np.exp(-np.clip(cls_raw, -10, 10))) + else: + cls_scores = cls_raw + + boxes_xyxy = np.stack( + [ + boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2, + boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2, + boxes_cxcywh[:, 0] + boxes_cxcywh[:, 2] / 2, + boxes_cxcywh[:, 1] + boxes_cxcywh[:, 3] / 2, + ], + axis=1, + ) + + max_scores = cls_scores.max(axis=1) + class_ids = cls_scores.argmax(axis=1) + mask = max_scores > self.conf + + if mask.sum() == 0: + return [] + + bboxes = boxes_xyxy[mask].astype(np.float32) + scores = max_scores[mask].astype(np.float32) + clses = class_ids[mask] + + bboxes[:, 0] = (bboxes[:, 0] - pad_x) / scale + bboxes[:, 1] = (bboxes[:, 1] - pad_y) / scale + bboxes[:, 2] = (bboxes[:, 2] - pad_x) / scale + bboxes[:, 3] = (bboxes[:, 3] - pad_y) / scale + + bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w0) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h0) + bboxes[:, 2] = np.clip(bboxes[:, 2], 0, w0) + bboxes[:, 3] = np.clip(bboxes[:, 3], 0, h0) + + detections = [] + for cls_id in range(self.num_classes): + idx = np.where(clses == cls_id)[0] + if len(idx) == 0: + continue + keep = _nms(bboxes[idx], scores[idx], iou_thr=self.iou) + for k in keep: + j = idx[k] + detections.append( + { + "bbox": bboxes[j].tolist(), + "score": float(scores[j]), + "cls": int(clses[j]), + "keypoints": None, + } + ) + return detections + + def release(self): + self.rknn.release() + + +# ============================================================================= +# Drawing helpers +# ============================================================================= + + +def resolve_line_x(frame_width): + if LINE_X is not None: + return LINE_X + if LINE_X_FRAC != 0.5: + return int(frame_width * LINE_X_FRAC) + return frame_width // 2 + + +def crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION): + if direction == "ltr": + return prev_cx < line_x <= cx + if direction == "both": + return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx) + return prev_cx > line_x >= cx + + +def now_str(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def open_capture(source): + if source.lower().startswith(("rtsp://", "http://")): + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS + cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + return cap + + +def warmup_stream(cap, n=WARMUP_FRAMES): + print("Warming up stream...") + for _ in range(n): + cap.read() + print("Stream ready!") + + +def open_video_writer(path, w, h, fps): + return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"avc1"), fps, (w, h)) + + +class CsvLogger: + def __init__(self, path, header): + Path(path).parent.mkdir(parents=True, exist_ok=True) + new_file = not Path(path).exists() or Path(path).stat().st_size == 0 + self.file = open(path, "a", newline="", buffering=1) + self.writer = csv.writer(self.file) + if new_file: + self.writer.writerow(header) + self.file.flush() + + def write_row(self, row): + self.writer.writerow(row) + self.file.flush() + + def close(self): + self.file.close() + + +class VideoSegmentWriter: + def __init__(self, output_dir, w, h, fps, segment_sec): + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self.w, self.h, self.fps = w, h, fps + self.segment_sec = segment_sec + self.segment_start = time.monotonic() + self.writer = None + self._open_next() + + def _segment_path(self): + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return str(self.output_dir / f"live_{ts}.mp4") + + def _open_next(self): + if self.writer is not None: + self.writer.release() + path = self._segment_path() + self.writer = open_video_writer(path, self.w, self.h, self.fps) + self.segment_start = time.monotonic() + print(f"Recording segment: {path}") + + def write(self, frame): + if time.monotonic() - self.segment_start >= self.segment_sec: + self._open_next() + self.writer.write(frame) + + def release(self): + if self.writer is not None: + self.writer.release() + + +def prune_stale_tracks(tracked, now_mono): + stale = [ + tid for tid, (_, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC + ] + for tid in stale: + del tracked[tid] + + +def overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65): + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2) + if x2 <= x1 or y2 <= y1: + return + roi = img[y1:y2, x1:x2] + patch = np.full_like(roi, color, dtype=np.uint8) + cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi) + + +def draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4): + font = cv2.FONT_HERSHEY_SIMPLEX + (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1) + x1, y1 = x, y - th - pad_y + x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y + cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1) + cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1) + cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA) + + +def draw_elegant_counting_line(img, line_x, h, pulse_remaining=0): + strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1) + glow_alpha = 0.12 + 0.18 * strength + for offset in (14, 9, 5): + color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW) + cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA) + cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA) + dash_len, gap = 18, 12 + y = 0 + while y < h: + y_end = min(y + dash_len, h) + cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA) + y += dash_len + gap + cv2.putText( + img, + "COUNT LINE", + (line_x - 46, 24), + cv2.FONT_HERSHEY_SIMPLEX, + 0.42, + C_LINE_CORE, + 1, + cv2.LINE_AA, + ) + + +def draw_hero_count(img, line_x, h, count, pulse_remaining=0): + text = str(count) + font = cv2.FONT_HERSHEY_SIMPLEX + boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1)) + font_scale, thickness = 1.4 + boost, 3 + (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness) + pad = 14 + tx, ty = line_x - tw // 2, h // 3 + th // 2 + overlay_rect( + img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad, C_PANEL, alpha=0.78 + ) + cv2.rectangle( + img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad), C_LINE_CORE, 2 + ) + cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA) + + +def draw_hud(img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate): + bar_h = 40 + overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72) + cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1) + cv2.putText( + img, "BATCH", (14, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + batch_label = str(batch_num) if batch_num else "--" + cv2.putText( + img, + batch_label, + (14, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + C_ACCENT, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, "COUNT", (90, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + str(batch_count), + (90, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + C_GREEN, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, "TOTAL", (170, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + str(total_ayam), + (170, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_TEXT, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, + "UPTIME", + (250, 14), + cv2.FONT_HERSHEY_SIMPLEX, + 0.32, + C_MUTED, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, + f"{elapsed_sec / 3600:.1f}h", + (250, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_TEXT, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, "RATE", (340, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + f"{rate:.1f}/min", + (340, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_ACCENT, + 1, + cv2.LINE_AA, + ) + + +def draw_footer(img, w, h, frame_idx, live_tag, inf_ms=0.0, model_name=""): + bar_h = 28 + overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55) + cv2.putText( + img, + f"{live_tag} | {model_name} | Frame {frame_idx} | Inf {inf_ms:.1f}ms", + (12, h - 9), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_MUTED, + 1, + cv2.LINE_AA, + ) + + +def draw_skeleton_bold(img, kpts): + for (a, b), color in zip(SKELETON, SK_COLORS): + if a < len(kpts) and b < len(kpts): + xa, ya = int(kpts[a][0]), int(kpts[a][1]) + xb, yb = int(kpts[b][0]), int(kpts[b][1]) + if xa > 0 and ya > 0 and xb > 0 and yb > 0: + cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA) + for kp in kpts: + x, y = int(kp[0]), int(kp[1]) + if x > 0 and y > 0: + cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA) + cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA) + + +def draw_popups(img, popups, frame_idx): + alive = [] + for pop in popups: + age = frame_idx - pop["born"] + if age > POPUP_LIFETIME: + continue + alive.append(pop) + fade = 1.0 - age / POPUP_LIFETIME + y = pop["y"] - int(age * 1.8) + color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade)) + cv2.putText( + img, + pop["text"], + (pop["x"], y), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + color, + 2, + cv2.LINE_AA, + ) + return alive + + +def draw_batch_banner(img, w, batch_num, pulse_remaining): + if pulse_remaining <= 0: + return + text = f"NEW BATCH {batch_num}" + font = cv2.FONT_HERSHEY_SIMPLEX + (tw, th), _ = cv2.getTextSize(text, font, 0.8, 2) + x1, y1 = w // 2 - tw // 2 - 16, 62 + x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20 + overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7) + cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2) + cv2.putText( + img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA + ) + + +def connect_stream(source, warmup=WARMUP_FRAMES): + attempts = 0 + while not shutdown_requested: + cap = open_capture(source) + if not cap.isOpened(): + attempts += 1 + if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS: + raise RuntimeError( + f"Cannot open source after {attempts} attempts: {source}" + ) + print(f"Cannot open source, retry in {RECONNECT_DELAY_SEC}s...") + time.sleep(RECONNECT_DELAY_SEC) + continue + if warmup > 0 and source.lower().startswith(("rtsp://", "http://")): + warmup_stream(cap, warmup) + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) + if not fps or fps <= 1: + fps = OUTPUT_FPS + return cap, w, h, fps + return None, 0, 0, OUTPUT_FPS + + +# ============================================================================= +# Main loop +# ============================================================================= + + +def run(): + global shutdown_requested + + store = BatchStore( + db_path=DB_PATH, + state_file=STATE_FILE, + camera_name=CAMERA_NAME, + object_label=OBJECT_LABEL, + cutoff_time=DAILY_CUTOFF_TIME, + batch_timeout=BATCH_TIMEOUT_SECONDS, + ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT, + min_object_per_batch=MIN_OBJECT_PER_BATCH, + min_duration_per_batch=MIN_DURATION_PER_BATCH, + logger=lambda msg: print(f"[{now_str()}] {msg}"), + ) + store.start_cutoff_watcher() + + cross_logger = None + if EXPORT_CSV: + cross_logger = CsvLogger( + CROSS_CSV, ["batch", "frame", "timestamp", "chicken_id"] + ) + + model = RKNNYOLO( + model_path=MODEL_PATH, + core_mask=CORE_MASK, + imgsz=IMGSZ, + conf=CONF, + num_classes=NUM_CLASSES, + score_sigmoid=SCORE_SIGMOID, + ) + + ayam_cls = int(os.getenv("AYAM_CLASS_ID", "0")) + talenan_cls = int(os.getenv("TALENAN_CLASS_ID", "1")) + + ayam_tracker = ByteTracker( + track_high_thresh=TRACK_HIGH_THRESH_0, + track_low_thresh=TRACK_LOW_THRESH_0, + match_thresh=TRACK_MATCH_THRESH_0, + track_buffer=TRACK_BUFFER_0, + min_hits=TRACK_MIN_HITS_0, + ) + talenan_tracker = ByteTracker( + track_high_thresh=TRACK_HIGH_THRESH_1, + track_low_thresh=TRACK_LOW_THRESH_1, + match_thresh=TRACK_MATCH_THRESH_1, + track_buffer=TRACK_BUFFER_1, + min_hits=TRACK_MIN_HITS_1, + ) + + ayam_line_crossed = set() + talenan_line_crossed = set() + + ayam_cross_flash = {} + talenan_cross_flash = {} + line_pulse = count_pulse = batch_pulse = 0 + popups = [] + + session_start = time.time() + frame_idx = 0 + inf_ms = 0.0 + prev_gray = None + video_writer = None + crossing_times = deque() + + cap, w, h, fps = connect_stream(SOURCE) + if cap is None: + store.shutdown() + model.release() + return + + line_x = resolve_line_x(w) + print( + f"RKNN+ByteTrack counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}" + ) + print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}") + print( + f"ByteTrack Index 0: high_thresh={TRACK_HIGH_THRESH_0} low_thresh={TRACK_LOW_THRESH_0} " + f"match_thresh={TRACK_MATCH_THRESH_0} buffer={TRACK_BUFFER_0}" + ) + print( + f"ByteTrack Index 1: high_thresh={TRACK_HIGH_THRESH_1} low_thresh={TRACK_LOW_THRESH_1} " + f"match_thresh={TRACK_MATCH_THRESH_1} buffer={TRACK_BUFFER_1}" + ) + print(f"DB: {DB_PATH}") + print(f"State: {STATE_FILE}") + + if RECORD_VIDEO: + video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC) + + reconnect_count = 0 + + while not shutdown_requested: + ret, frame = cap.read() + if not ret: + if not IS_LIVE: + break + reconnect_count += 1 + print( + f"Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s..." + ) + cap.release() + time.sleep(RECONNECT_DELAY_SEC) + cap, w, h, fps = connect_stream(SOURCE) + if cap is None: + break + line_x = resolve_line_x(w) + continue + + now = time.time() + elapsed = now - session_start + mono = time.monotonic() + ayam_crossed_frame = batch_closed_frame = batch_started_frame = False + + skip_inference = False + if MOTION_DETECTION_ENABLED: + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + if prev_gray is not None: + diff = cv2.absdiff(gray, prev_gray) + mean_diff = cv2.mean(diff)[0] + skip_inference = mean_diff < MOTION_THRESHOLD + prev_gray = gray + + detections = [] + if not skip_inference: + inf_start = time.time() + detections = model(frame) + inf_ms = inf_ms * 0.9 + (time.time() - inf_start) * 1000 * 0.1 + + if detections: + ayam_boxes_xyxy = [] + ayam_scores = [] + ayam_kpts_list = [] + ayam_cx_list = [] + + talenan_boxes_xyxy = [] + talenan_scores = [] + talenan_kpts_list = [] + talenan_cx_list = [] + + for det in detections: + bbox = det["bbox"] + score = det["score"] + cls_id = det["cls"] + kpts = det["keypoints"] + cx = (bbox[0] + bbox[2]) / 2.0 + + if cls_id == talenan_cls: + talenan_boxes_xyxy.append(bbox) + talenan_scores.append(score) + talenan_kpts_list.append(kpts) + talenan_cx_list.append(cx) + elif cls_id == ayam_cls: + ayam_boxes_xyxy.append(bbox) + ayam_scores.append(score) + ayam_kpts_list.append(kpts) + ayam_cx_list.append(cx) + + ayam_boxes_xyxy = np.array(ayam_boxes_xyxy, dtype=np.float32) + ayam_scores = np.array(ayam_scores, dtype=np.float32) + talenan_boxes_xyxy = np.array(talenan_boxes_xyxy, dtype=np.float32) + talenan_scores = np.array(talenan_scores, dtype=np.float32) + + ayam_track_map, ayam_det_to_track, ayam_lost_map = ayam_tracker.update( + ayam_boxes_xyxy, ayam_scores + ) + talenan_track_map, talenan_det_to_track, talenan_lost_map = ( + talenan_tracker.update(talenan_boxes_xyxy, talenan_scores) + ) + + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + if len(ayam_boxes_xyxy) > 0 or len(talenan_boxes_xyxy) > 0: + ayam_scores_str = ( + f" ayam scores: {ayam_scores.round(3).tolist()}" + if len(ayam_scores) > 0 + else "" + ) + talenan_scores_str = ( + f" talenan scores: {talenan_scores.round(3).tolist()}" + if len(talenan_scores) > 0 + else "" + ) + ayam_tracks_str = ( + f" ayam det→track: {dict(ayam_det_to_track)}" + if ayam_det_to_track + else "" + ) + talenan_tracks_str = ( + f" talenan det→track: {dict(talenan_det_to_track)}" + if talenan_det_to_track + else "" + ) + crossing_str = ( + f" ayam_line_crossed: {sorted(ayam_line_crossed)}" + if ayam_line_crossed + else "" + ) + # Show cx values for one tracked object to check movement + cx_sample = "" + if len(ayam_cx_list) > 0 and ayam_det_to_track: + sample_tid = list(ayam_det_to_track.values())[0] + sample_cx = ayam_cx_list[ + list(ayam_det_to_track.keys())[0] + ] + prev = ( + ayam_tracked.get(sample_tid, (None,))[0] + if ayam_tracked.get(sample_tid) + else None + ) + cx_sample = ( + f" sample tid={sample_tid} prev_cx={prev} cx={sample_cx:.1f}" + ) + print( + f"[DEBUG F{frame_idx}] ayam_dets={len(ayam_boxes_xyxy)} " + f"talenan_dets={len(talenan_boxes_xyxy)} " + f"line_x={line_x}{ayam_scores_str}{talenan_scores_str}" + f"{ayam_tracks_str}{talenan_tracks_str}{crossing_str}" + f"{cx_sample}" + ) + + # Process talenan crossings + for di in range(len(talenan_boxes_xyxy)): + tid = talenan_det_to_track.get(di) + if tid is None: + continue + cx = talenan_cx_list[di] + bbox = talenan_boxes_xyxy[di] + + if tid in talenan_tracked: + prev_cx = talenan_tracked[tid][0] + if ( + crossed_line(prev_cx, cx, line_x) + and tid not in talenan_line_crossed + ): + talenan_line_crossed.add(tid) + if store.record_talenan_crossing(tid): + batch_closed_frame = True + talenan_cross_flash[tid] = CROSS_FLASH_FRAMES + popups.append( + { + "x": int(cx) - 20, + "y": int((bbox[1] + bbox[3]) / 2), + "born": frame_idx, + "text": "BATCH CLOSED", + } + ) + talenan_tracked[tid] = (cx, mono) + + # Process ayam crossings (including lost tracks for line-cross continuity) + for di in range(len(ayam_boxes_xyxy)): + tid = ayam_det_to_track.get(di) + if tid is None: + continue + cx = ayam_cx_list[di] + + if tid in ayam_tracked: + prev_cx = ayam_tracked[tid][0] + if ( + crossed_line(prev_cx, cx, line_x) + and tid not in ayam_line_crossed + ): + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + print( + f"[DEBUG F{frame_idx}] CROSS DETECTED: tid={tid} " + f"prev_cx={prev_cx:.1f} → cx={cx:.1f} line_x={line_x}" + ) + ayam_line_crossed.add(tid) + _, started_new = store.record_ayam_crossing(tid) + if cross_logger: + cross_logger.write_row( + [ + store.current_batch_number, + frame_idx, + datetime.now().isoformat(), + tid, + ] + ) + ayam_crossed_frame = True + if started_new: + batch_started_frame = True + crossing_times.append(mono) + ayam_cross_flash[tid] = CROSS_FLASH_FRAMES + popups.append( + { + "x": int(cx) - 12, + "y": int( + (ayam_boxes_xyxy[di][1] + ayam_boxes_xyxy[di][3]) + / 2 + ), + "born": frame_idx, + "text": "+1", + } + ) + ayam_tracked[tid] = (cx, mono) + + # Also track lost tracks for line-crossing continuity + for tid, cx in ayam_lost_map.items(): + if tid not in ayam_tracked: + ayam_tracked[tid] = (cx, mono) + + # Draw talenan + for di in range(len(talenan_boxes_xyxy)): + tid = talenan_det_to_track.get(di) + if tid is None: + continue + bbox = talenan_boxes_xyxy[di] + x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]) + flash = talenan_cross_flash.get(tid, 0) + color = C_GREEN if flash > 0 else C_TALENAN_BOX + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2) + draw_pill(frame, f"TALENAN {tid}", x1, y1 - 4, color) + + # Draw ayam + for di in range(len(ayam_boxes_xyxy)): + tid = ayam_det_to_track.get(di) + if tid is None: + continue + bbox = ayam_boxes_xyxy[di] + x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]) + flash = ayam_cross_flash.get(tid, 0) + color = C_GREEN if flash > 0 else C_AYAM_BOX + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2) + draw_pill(frame, f"ID {tid}", x1, y1 - 4, color) + kpts = ayam_kpts_list[di] if di < len(ayam_kpts_list) else None + if kpts is not None: + draw_skeleton_bold(frame, kpts) + + if ayam_crossed_frame: + line_pulse = LINE_PULSE_FRAMES + count_pulse = COUNT_PULSE_FRAMES + if batch_closed_frame: + line_pulse = LINE_PULSE_FRAMES + if batch_started_frame: + batch_pulse = BATCH_PULSE_FRAMES + + batch_num = store.current_batch_number or 0 + batch_count = store.current_batch_count + display_total = store.display_total() + while crossing_times and mono - crossing_times[0] > RATE_WINDOW_SEC: + crossing_times.popleft() + rate = (len(crossing_times) / RATE_WINDOW_SEC * 60) if crossing_times else 0.0 + + draw_elegant_counting_line(frame, line_x, h, line_pulse) + draw_hero_count(frame, line_x, h, batch_count, count_pulse) + draw_hud( + frame, + w, + batch_num, + batch_count, + display_total, + elapsed, + rate, + ) + draw_batch_banner(frame, w, batch_num, batch_pulse) + draw_footer( + frame, + w, + h, + frame_idx, + "LIVE" if IS_LIVE else "FILE", + inf_ms, + Path(MODEL_PATH).name, + ) + popups = draw_popups(frame, popups, frame_idx) + + for flash_store in (ayam_cross_flash, talenan_cross_flash): + for tid in list(flash_store): + flash_store[tid] -= 1 + if flash_store[tid] <= 0: + del flash_store[tid] + line_pulse = max(0, line_pulse - 1) + count_pulse = max(0, count_pulse - 1) + batch_pulse = max(0, batch_pulse - 1) + + if video_writer is not None: + video_writer.write(frame) + + if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0: + try: + Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True) + _, jpeg = cv2.imencode( + ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY] + ) + with open(LIVE_STREAM_FRAME_PATH, "wb") as f: + f.write(jpeg.tobytes()) + except Exception: + pass + + frame_idx += 1 + if frame_idx % FLUSH_EVERY_N_FRAMES == 0: + print( + f"[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} " + f"| Total: {display_total} | Uptime {elapsed / 3600:.2f}h" + ) + prune_stale_tracks(ayam_tracked, mono) + prune_stale_tracks(talenan_tracked, mono) + + cap.release() + if video_writer is not None: + video_writer.release() + if cross_logger: + cross_logger.close() + model.release() + store.shutdown() + + print("\n=== Batch Summary (SQLite) ===") + print(f"Database: {DB_PATH}") + + +ayam_tracked = {} +talenan_tracked = {} + + +if __name__ == "__main__": + run() diff --git a/install-services.sh b/install-services.sh new file mode 100644 index 0000000..8a37f94 --- /dev/null +++ b/install-services.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Install edge Jetson counter + dashboard; disable legacy MQTT frigate-counter. +# Run on the Jetson: sudo ./install-services.sh +set -euo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/opt/jetson-counter}" +VENV_DIR="${VENV_DIR:-/opt/jetson-counter/venv}" +SERVICE_USER="${SERVICE_USER:-jetson}" + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Run as root: sudo ./install-services.sh" + exit 1 +fi + +if [[ ! -f "${INSTALL_DIR}/.env" ]]; then + echo "Missing ${INSTALL_DIR}/.env" + echo " cp ${INSTALL_DIR}/config.env.example ${INSTALL_DIR}/.env && nano ${INSTALL_DIR}/.env" + exit 1 +fi + +if [[ ! -x "${VENV_DIR}/bin/python" ]]; then + echo "Missing venv: ${VENV_DIR}/bin/python" + echo " sudo ./setup-venv.sh" + exit 1 +fi + +sed -i 's/\r$//' "${INSTALL_DIR}/.env" 2>/dev/null || true + +mkdir -p "${INSTALL_DIR}/.ultralytics" "${INSTALL_DIR}/.torch" +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}" + +# Disable legacy MQTT counter (replace mode) +for legacy in frigate-counter frigate-counter-dashboard; do + if systemctl is-enabled "${legacy}" &>/dev/null; then + systemctl disable --now "${legacy}" || true + echo "Disabled legacy ${legacy}" + fi +done + +for unit in jetson-counter jetson-counter-dashboard; do + sed -e "s|/opt/jetson-counter|${INSTALL_DIR}|g" \ + -e "s|User=jetson|User=${SERVICE_USER}|g" \ + -e "s|Group=jetson|Group=${SERVICE_USER}|g" \ + "${INSTALL_DIR}/${unit}.service" > "/etc/systemd/system/${unit}.service" + echo "Installed /etc/systemd/system/${unit}.service" +done + +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}" + +PYTHONNOUSERSITE=1 "${VENV_DIR}/bin/python" -c " +from ultralytics import YOLO +import torch +print('import ok | cuda', torch.cuda.is_available()) +" || { + echo "Import check failed — fix venv before starting services." + exit 1 +} + +systemctl daemon-reload +systemctl reset-failed jetson-counter jetson-counter-dashboard 2>/dev/null || true +systemctl enable jetson-counter jetson-counter-dashboard +systemctl restart jetson-counter jetson-counter-dashboard + +echo "" +systemctl --no-pager status jetson-counter jetson-counter-dashboard || true +echo "" +echo "Logs: sudo journalctl -u jetson-counter -f" +echo "Dashboard: http://$(hostname -I | awk '{print $1}'):5000" diff --git a/recounting_dashboard.py b/recounting_dashboard.py new file mode 100644 index 0000000..680c114 --- /dev/null +++ b/recounting_dashboard.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Recounting dashboard — consumes live counter + recount APIs, +lists OUTPUT_DIR MP4 files, streams via go2rtc for preview. +""" +import json +import os +import subprocess +import requests +from datetime import datetime +from pathlib import Path + +from flask import Flask, render_template, jsonify, request, Response +from werkzeug.serving import WSGIRequestHandler + +from dotenv import load_dotenv +load_dotenv() + +app = Flask(__name__, template_folder="templates") +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production") + +OUTPUT_DIR = os.getenv("OUTPUT_DIR", os.getenv("OUTPUT_DIR", "/opt/bytetrack-counter")) +LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000") +RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001") +GO2RTC_API_URL = os.getenv("GO2RTC_API_URL", "http://localhost:1984") +GO2RTC_STREAM_NAME = os.getenv("GO2RTC_STREAM_NAME", "recount") + +SITE_NAME = os.getenv("SITE_NAME", "RECOUNT") + +DASHBOARD_PORT = int(os.getenv("RECOUNTING_DASHBOARD_PORT", "5002")) +DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") +FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true" + +_http_session = requests.Session() +_http_session.timeout = 3 + + +def _api_get(base_url, path, default=None): + try: + resp = _http_session.get(f"{base_url}{path}") + if resp.status_code == 200: + return resp.json() + except Exception: + pass + return default + + +@app.route("/") +def index(): + return render_template("recounting.html", site_name=SITE_NAME, output_dir=OUTPUT_DIR) + + +@app.route("/api/live-progress") +def api_live_progress(): + data = _api_get(LIVE_API_URL, "/api/current-batch") + if data and data.get("success"): + return jsonify(data) + return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Live unreachable"}), 200 + + +@app.route("/api/recount-progress") +def api_recount_progress(): + data = _api_get(RECOUNT_API_URL, "/api/current-batch") + if data and data.get("success"): + return jsonify(data) + return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Recount unreachable"}), 200 + + +@app.route("/api/mp4-files") +def api_mp4_files(): + files = [] + output = Path(OUTPUT_DIR) + if output.exists(): + for f in sorted(output.rglob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True): + st = f.stat() + files.append({ + "name": f.name, + "path": str(f), + "size": st.st_size, + "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), + }) + return jsonify(files) + + +@app.route("/api/start-recount", methods=["POST"]) +def start_recount(): + data = request.get_json(force=True) or {} + mp4_path = data.get("path", "") + if not mp4_path: + return jsonify({"success": False, "error": "Missing 'path'"}), 400 + if not os.path.isfile(mp4_path): + return jsonify({"success": False, "error": f"File not found: {mp4_path}"}), 404 + + src = f"ffmpeg:{mp4_path}#video=h264#hardware" + try: + resp = requests.put( + f"{GO2RTC_API_URL}/api/streams", + params={"name": GO2RTC_STREAM_NAME, "src": src}, + timeout=5, + ) + if resp.status_code not in (200, 201): + return jsonify({"success": False, "error": f"go2rtc returned {resp.status_code}: {resp.text}"}), 502 + except Exception as e: + return jsonify({"success": False, "error": f"go2rtc unreachable: {e}"}), 502 + + stream_url = f"{GO2RTC_API_URL}/api/stream.mjpeg?src={GO2RTC_STREAM_NAME}" + return jsonify({"success": True, "stream_url": stream_url, "file": Path(mp4_path).name}) + + +@app.route("/api/stop-recount", methods=["POST"]) +def stop_recount(): + try: + requests.delete(f"{GO2RTC_API_URL}/api/streams", params={"name": GO2RTC_STREAM_NAME}, timeout=5) + except Exception: + pass + return jsonify({"success": True}) + + +if __name__ == "__main__": + WSGIRequestHandler.protocol_version = "HTTP/1.1" + print(f"Recounting dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}") + print(f"Live API: {LIVE_API_URL}") + print(f"Recount API: {RECOUNT_API_URL}") + print(f"go2rtc API: {GO2RTC_API_URL}") + print(f"OUTPUT_DIR: {OUTPUT_DIR}") + app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f9305e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +numpy<2 +rknn-toolkit-lite2 +opencv-python +flask +python-dotenv +openpyxl diff --git a/setup-venv.sh b/setup-venv.sh new file mode 100644 index 0000000..07116e2 --- /dev/null +++ b/setup-venv.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# One-time venv for edge Jetson counter — NVIDIA torch required (not PyPI). +set -euo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/opt/jetson-counter}" +VENV_DIR="${VENV_DIR:-/opt/jetson-counter/venv}" +SERVICE_USER="${SERVICE_USER:-jetson}" +TORCH_WHEEL_URL="${TORCH_WHEEL_URL:-https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/torch-2.4.0a0+3bcc3cddb5.nv24.07.16234504-cp310-cp310-linux_aarch64.whl}" + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Run as root: sudo ./setup-venv.sh" + exit 1 +fi + +apt-get install -y libopenblas-base libopenmpi-dev libomp-dev 2>/dev/null || true + +mkdir -p "${INSTALL_DIR}" +if [[ ! -x "${VENV_DIR}/bin/python" ]]; then + python3 -m venv --system-site-packages "${VENV_DIR}" +fi +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}" + +sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install --upgrade pip +sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install "numpy<2" + +if ! sudo -u "${SERVICE_USER}" PYTHONNOUSERSITE=1 "${VENV_DIR}/bin/python" -c "import torch; assert torch.cuda.is_available()" 2>/dev/null; then + echo "Installing NVIDIA Jetson torch wheel..." + sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install --no-cache-dir "${TORCH_WHEEL_URL}" +fi + +sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install ultralytics flask opencv-python + +sudo -u "${SERVICE_USER}" PYTHONNOUSERSITE=1 "${VENV_DIR}/bin/python" -c " +import torch +from ultralytics import YOLO +import cv2 +import flask +print('venv ok | torch', torch.__version__, '| cuda', torch.cuda.is_available()) +" + +echo "" +echo "If torchvision import fails for ultralytics, copy/build torchvision into venv." +echo "Next: cp config.env.example .env && nano .env && sudo ./install-services.sh" diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..82b7a0c --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,1217 @@ + + + + + +ZenAI APC Dashboard + + + + + + +
+
+
+
+ +
+ +
+
+
◆
+
+

ZenAI APC Module

+
Edge Nano counter — YOLO RockChip
+
+
+
+
+ -- + Counting Day +
+
+ + {{ site_name }} +
+ +
+
+ + +
+
+ LIVE +
+
--
+
Current Batch Count
+
Batch #-- · --
+
+
+ Today +
+
--
+
Total Count Today
+
-- batches
+
+
+ Yesterday +
+
--
+
Total Count Yesterday
+
-- batches
+
+
+ Average +
+
--
+
Average Per Day
+
-- days recorded
+
+
+ + +
+ -- + -- +
+ + +
+
+ ◉ Live Camera Feed +
+ + + waiting... + + +
+
+
+
+
▦
+
Live Feed Unavailable
+
Enable LIVE_STREAM_ENABLED=true in counter .env
+
+ +
Connection lost — retrying...
+
+
+ + +
+
+
+ ◉ Daily Trends +
+ + + +
+
+
+ +
+
+
+ ◉ Quick Stats +
+
+
+
+
+
Grand Total
+
All time
+
+
+ -- +
+
+
+
+
+
Total Batches
+
All time
+
+
+ -- +
+
+
+
+
+
Avg per Batch
+
Overall average
+
+
+ -- +
+
+ +
+
◉ Recent Activity
+
+ +
+
+
+
+ + +
+
+
+ ◉ Daily Records +
Click on a row to view batch details
+
+
+ + + + +
+
+
+ + + + + + + + + + + + + + +
DateTotal CountBatchesAvg/BatchStatusAction
+
+
+ + +
+ + + + + + + + + + diff --git a/templates/recounting.html b/templates/recounting.html new file mode 100644 index 0000000..eeaf4e8 --- /dev/null +++ b/templates/recounting.html @@ -0,0 +1,630 @@ + + + + + +{{ site_name }} Recounting + + + + + +
+
+
+ +
+ +
+
+
✖
+
+

{{ site_name }} Recounting

+
Edge counter batch replay
+
+
+
+
+ + LIVE +
+
+ + RECOUNT +
+
+
+ + +
+
+ ◉ Live Counter +
+
--
+
Objects Counted
+
Batch #-- · --
+
+ waiting... +
+
+
+
+ ◉ Recount Progress +
+
--
+
Objects Recounted
+
Batch #-- · --
+
+ waiting... +
+
+
+
+ + +
+
+
+ ◉ Preview +
+ + + stopped + +
+
+
+
+
▶
+
Select an MP4 file to stream
+
Requires go2rtc on this host
+
+ +
+
+
+
+ ◉ Recordings + +
+
{{ output_dir }}
+
+
+ Loading files... +
+
+
+ + +
+
+
+ + +
+ + + + + diff --git a/uninstall-services.sh b/uninstall-services.sh new file mode 100644 index 0000000..1f3fa11 --- /dev/null +++ b/uninstall-services.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Remove Jetson edge counter systemd services. +# Run on the Jetson: sudo ./uninstall-services.sh +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Run as root: sudo ./uninstall-services.sh" + exit 1 +fi + +for unit in jetson-counter jetson-counter-dashboard; do + systemctl stop "${unit}" 2>/dev/null || true + systemctl disable "${unit}" 2>/dev/null || true + rm -f "/etc/systemd/system/${unit}.service" +done + +systemctl daemon-reload +echo "Removed jetson-counter and jetson-counter-dashboard services."