Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f01601d1d7 | ||
|
|
14ebfb7779 | ||
|
|
79656cd661 |
No files matched your search
@@ -9,6 +9,7 @@
|
|||||||
- `batch_store.py` — shared SQLite persistence + batch state machine.
|
- `batch_store.py` — shared SQLite persistence + batch state machine.
|
||||||
- `counter_dashboard.py` — Flask dashboard on port 5000, same DB.
|
- `counter_dashboard.py` — Flask dashboard on port 5000, same DB.
|
||||||
- `recounting_dashboard.py` — Flask recounting dashboard on port 5002, consumes live + recount APIs.
|
- `recounting_dashboard.py` — Flask recounting dashboard on port 5002, consumes live + recount APIs.
|
||||||
|
- `recounting_dashboard_upload.py` — Flask upload+recount dashboard on port 5003; uploads MP4 first, then launches `RECOUNT_CMD --source <mp4>`; START RECOUNT stays disabled until an upload completes.
|
||||||
|
|
||||||
## No build / test / lint
|
## No build / test / lint
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ PYTHONNOUSERSITE=1 venv/bin/python counter_live_rknn_bytetrack.py
|
|||||||
|
|
||||||
# Run dashboard
|
# Run dashboard
|
||||||
PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py
|
PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py
|
||||||
|
|
||||||
|
# Run upload recounting dashboard
|
||||||
|
PYTHONNOUSERSITE=1 venv/bin/python recounting_dashboard_upload.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key environment & install quirks
|
## Key environment & install quirks
|
||||||
@@ -42,6 +46,8 @@ PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py
|
|||||||
- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`.
|
- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`.
|
||||||
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
|
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
|
||||||
- **Reset flow**: `POST /api/reset` → deletes state JSON + touches `{SHM_DIR}/.reset`. `batch_store._check_reset_signal()` watches this marker and clears in-memory state on next crossing.
|
- **Reset flow**: `POST /api/reset` → deletes state JSON + touches `{SHM_DIR}/.reset`. `batch_store._check_reset_signal()` watches this marker and clears in-memory state on next crossing.
|
||||||
|
- **Upload filename contract**: uploads are expected as `batch_XX_YYYYMMDD_HHmmSS.mp4` (XX = batch number, timestamp = date). `recounting_dashboard_upload.py` parses both to query the live API's `/api/day-detail/<YYYY-MM-DD>` for the recorded count.
|
||||||
|
- **`RECOUNT_CMD`** in the upload dashboard is a template with a `{path}` placeholder, e.g. `bytetrack-counter config.env --source {path}`; the uploaded file path is `shlex.quote()`d before substitution.
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ Daily cutoff @ HH:MM resets batch numbering. RK3588 hardware.
|
|||||||
- `numpy<2` (rknn-toolkit-lite2 incompatible with numpy≥2)
|
- `numpy<2` (rknn-toolkit-lite2 incompatible with numpy≥2)
|
||||||
- `PYTHONNOUSERSITE=1` ! set or venv breaks
|
- `PYTHONNOUSERSITE=1` ! set or venv breaks
|
||||||
- SQLite for persistence, JSON file for active-batch state
|
- SQLite for persistence, JSON file for active-batch state
|
||||||
- Flask on port 5000 (dashboard) + 5002 (recounting), systemd supervision
|
- Flask on port 5000 (dashboard) + 5002 (recounting) + 5003 (upload recounting), systemd supervision
|
||||||
- `counter_live.py` ⊥ run on RK3588 — Jetson TensorRT artifact, ! port target
|
- `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
|
- `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`
|
- Single deployment: `counter_live_rknn_bytetrack.py` + `counter_dashboard.py` + `recounting_dashboard.py` + `recounting_dashboard_upload.py`
|
||||||
- `go2rtc` ! running for recounting MP4 streaming (port 1984)
|
- `go2rtc` ! running for recounting MP4 streaming (port 1984)
|
||||||
|
|
||||||
## §I — Interfaces
|
## §I — Interfaces
|
||||||
@@ -63,6 +63,14 @@ 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_API_URL → go2rtc REST API (default http://localhost:1984)
|
||||||
env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "recount")
|
env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "recount")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Upload recounting dashboard config
|
||||||
|
```
|
||||||
|
env: RECOUNTING_UPLOAD_PORT → port for upload UI (default 5003)
|
||||||
|
env: UPLOAD_DIR → dir for uploaded MP4s (default <repo>/uploads)
|
||||||
|
env: RECOUNT_CMD → replay command template with {path} placeholder (default "bytetrack-counter config.env --source {path}")
|
||||||
|
env: RECOUNTING_UPLOAD_TEMPLATE → template (default recounting_upload.html)
|
||||||
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
### SQLite
|
### SQLite
|
||||||
@@ -107,6 +115,22 @@ api: GET /api/download-mp4?file=<path> → download selected MP4
|
|||||||
api: POST /api/start-recount {path} → reset recount counter + launch ffmpeg RTSP, return {stream_url}
|
api: POST /api/start-recount {path} → reset recount counter + launch ffmpeg RTSP, return {stream_url}
|
||||||
api: POST /api/stop-recount → kill ffmpeg + clear current file
|
api: POST /api/stop-recount → kill ffmpeg + clear current file
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Upload recounting dashboard
|
||||||
|
```
|
||||||
|
api: GET / → upload recounting HTML (template via RECOUNTING_UPLOAD_TEMPLATE env var)
|
||||||
|
api: POST /api/upload → multipart .mp4 (≤512MB), saved as original filename in UPLOAD_DIR; returns {name, path, size}
|
||||||
|
api: GET /api/uploads → list uploaded MP4s sorted by mtime desc → [ {name, path, size, mtime} ]
|
||||||
|
api: POST /api/delete-upload {path} → delete uploaded MP4 (path restricted to UPLOAD_DIR)
|
||||||
|
api: POST /api/start-recount {path} → reset recount API then launch RECOUNT_CMD (path shlex-quoted); START RECOUNT disabled until upload completes
|
||||||
|
api: POST /api/stop-recount → kill recount process + clear current file
|
||||||
|
api: GET /api/state → {streaming, file, file_name, stream_url, batch_number, batch_date}
|
||||||
|
api: GET /api/proxy-stream → proxy MJPEG from RECOUNT_API_URL:/api/live-video (same-origin)
|
||||||
|
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/batch-result?file=<path> → parse batch num + date (YYYY-MM-DD) from filename batch_XX_YYYYMMDD_HHmmSS.mp4, query live API /api/day-detail/<date>, fallback recent-batches
|
||||||
|
api: GET /api/download-mp4?file=<path> → download selected MP4
|
||||||
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
## §V — Invariants
|
## §V — Invariants
|
||||||
@@ -132,6 +156,7 @@ 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)
|
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)
|
V19: stream reconnect → reset motion detection state (prev_gray = None)
|
||||||
V20: reset → delete STATE_FILE + touch {SHM_DIR}/.reset; batch_store._check_reset_signal() watches marker & clears in-memory state before next crossing
|
V20: reset → delete STATE_FILE + touch {SHM_DIR}/.reset; batch_store._check_reset_signal() watches marker & clears in-memory state before next crossing
|
||||||
|
V21: upload filename batch_XX_YYYYMMDD_HHmmSS.mp4 → batch# + date parsed; batch-result queries /api/day-detail/<YYYY-MM-DD> on live API
|
||||||
```
|
```
|
||||||
|
|
||||||
## §T — Tasks
|
## §T — Tasks
|
||||||
@@ -152,6 +177,7 @@ 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|
|
T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc streaming|
|
||||||
T13|x|add template system — DASHBOARD_TEMPLATE env var selects dashboard.html|lamborghini|tesla|
|
T13|x|add template system — DASHBOARD_TEMPLATE env var selects dashboard.html|lamborghini|tesla|
|
||||||
T14|x|harden reset API — separate SHM marker creation from state file cleanup; return 500 on SHM failure|V20
|
T14|x|harden reset API — separate SHM marker creation from state file cleanup; return 500 on SHM failure|V20
|
||||||
|
T15|x|add upload recounting dashboard — MP4 upload, progress bar, RECOUNT_CMD replay|V21
|
||||||
```
|
```
|
||||||
|
|
||||||
## §B — Bugs
|
## §B — Bugs
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ RECOUNT_CMD = os.getenv("RECOUNT_CMD", "bytetrack-counter config.env --source {p
|
|||||||
_http_session = requests.Session()
|
_http_session = requests.Session()
|
||||||
_http_session.timeout = 3
|
_http_session.timeout = 3
|
||||||
|
|
||||||
_FILENAME_RE = re.compile(r"batch[_-]?(\d+)", re.IGNORECASE)
|
_FILENAME_RE = re.compile(r"batch[_-]?(\d+)[_-](\d{8})[_-]\d{6}", re.IGNORECASE)
|
||||||
|
|
||||||
_recount_proc = None
|
_recount_proc = None
|
||||||
_recount_lock = threading.Lock()
|
_recount_lock = threading.Lock()
|
||||||
@@ -56,7 +56,8 @@ _current_file = None
|
|||||||
def _parse_filename(filename):
|
def _parse_filename(filename):
|
||||||
m = _FILENAME_RE.search(filename)
|
m = _FILENAME_RE.search(filename)
|
||||||
if m:
|
if m:
|
||||||
return int(m.group(1)), None
|
raw = m.group(2)
|
||||||
|
return int(m.group(1)), f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -136,9 +137,7 @@ def api_upload():
|
|||||||
return jsonify({"success": False, "error": "Only .mp4 files allowed"}), 400
|
return jsonify({"success": False, "error": "Only .mp4 files allowed"}), 400
|
||||||
|
|
||||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
safe = secure_filename(f.filename)
|
dest_name = secure_filename(f.filename)
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
||||||
dest_name = f"{ts}_{safe}"
|
|
||||||
dest_path = Path(UPLOAD_DIR) / dest_name
|
dest_path = Path(UPLOAD_DIR) / dest_name
|
||||||
|
|
||||||
f.save(str(dest_path))
|
f.save(str(dest_path))
|
||||||
@@ -256,9 +255,13 @@ def api_proxy_stream():
|
|||||||
def api_batch_result():
|
def api_batch_result():
|
||||||
fpath = request.args.get("file", "")
|
fpath = request.args.get("file", "")
|
||||||
filename = Path(fpath).name
|
filename = Path(fpath).name
|
||||||
batch_num, _ = _parse_filename(filename)
|
batch_num, date_str = _parse_filename(filename)
|
||||||
if batch_num is None:
|
if batch_num is None:
|
||||||
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
|
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
|
||||||
|
if not date_str:
|
||||||
|
date_str = _folder_date(fpath, Path(UPLOAD_DIR))
|
||||||
|
if not date_str:
|
||||||
|
return jsonify({"success": False, "error": "Cannot determine date from filename"}), 200
|
||||||
|
|
||||||
def _match_in_batches(batches):
|
def _match_in_batches(batches):
|
||||||
for b in batches:
|
for b in batches:
|
||||||
@@ -266,20 +269,18 @@ def api_batch_result():
|
|||||||
return b
|
return b
|
||||||
return None
|
return None
|
||||||
|
|
||||||
date_str = _folder_date(fpath, Path(UPLOAD_DIR))
|
data = _api_get(LIVE_API_URL, f"/api/day-detail/{date_str}")
|
||||||
if date_str:
|
if data:
|
||||||
data = _api_get(LIVE_API_URL, f"/api/day-detail/{date_str}")
|
b = _match_in_batches(data.get("batches", []))
|
||||||
if data:
|
if b:
|
||||||
b = _match_in_batches(data.get("batches", []))
|
return jsonify({
|
||||||
if b:
|
"success": True,
|
||||||
return jsonify({
|
"batch_number": batch_num,
|
||||||
"success": True,
|
"date": date_str,
|
||||||
"batch_number": batch_num,
|
"count": b["count"],
|
||||||
"date": date_str,
|
"start_time": b.get("start_time"),
|
||||||
"count": b["count"],
|
"end_time": b.get("end_time"),
|
||||||
"start_time": b.get("start_time"),
|
})
|
||||||
"end_time": b.get("end_time"),
|
|
||||||
})
|
|
||||||
|
|
||||||
recent = _api_get(LIVE_API_URL, "/api/recent-batches?limit=100")
|
recent = _api_get(LIVE_API_URL, "/api/recent-batches?limit=100")
|
||||||
if recent:
|
if recent:
|
||||||
|
|||||||
@@ -349,6 +349,32 @@
|
|||||||
.upload-status.success { color: var(--accent4); }
|
.upload-status.success { color: var(--accent4); }
|
||||||
.upload-status.error { color: var(--accent3); }
|
.upload-status.error { color: var(--accent3); }
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--cell-bg);
|
||||||
|
border: 1px solid var(--cell-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.upload-progress .upload-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.15s ease;
|
||||||
|
}
|
||||||
|
.upload-progress-percent {
|
||||||
|
display: none;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
|
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
|
||||||
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
|
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
|
||||||
@@ -482,6 +508,10 @@
|
|||||||
<button class="btn btn-ghost" id="btn-upload" onclick="uploadFile()">UPLOAD</button>
|
<button class="btn btn-ghost" id="btn-upload" onclick="uploadFile()">UPLOAD</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-progress" id="upload-progress">
|
||||||
|
<div class="upload-progress-bar" id="upload-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-progress-percent" id="upload-progress-percent"></div>
|
||||||
<div class="upload-status" id="upload-status"></div>
|
<div class="upload-status" id="upload-status"></div>
|
||||||
|
|
||||||
<input type="file" id="upload-file-input" accept=".mp4" style="display:none"
|
<input type="file" id="upload-file-input" accept=".mp4" style="display:none"
|
||||||
@@ -558,6 +588,14 @@ function setPendingFile(file) {
|
|||||||
document.getElementById('pending-filename').classList.add('has-file');
|
document.getElementById('pending-filename').classList.add('has-file');
|
||||||
document.getElementById('upload-status').textContent = '';
|
document.getElementById('upload-status').textContent = '';
|
||||||
document.getElementById('upload-status').className = 'upload-status';
|
document.getElementById('upload-status').className = 'upload-status';
|
||||||
|
showUploadProgress(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadProgress(show) {
|
||||||
|
document.getElementById('upload-progress').style.display = show ? 'block' : 'none';
|
||||||
|
document.getElementById('upload-progress-percent').style.display = show ? 'block' : 'none';
|
||||||
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||||
|
document.getElementById('upload-progress-percent').textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUploadStatus(msg, cls) {
|
function showUploadStatus(msg, cls) {
|
||||||
@@ -566,7 +604,7 @@ function showUploadStatus(msg, cls) {
|
|||||||
el.className = 'upload-status ' + (cls || '');
|
el.className = 'upload-status ' + (cls || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile() {
|
function uploadFile() {
|
||||||
if (!pendingUploadFile) {
|
if (!pendingUploadFile) {
|
||||||
showUploadStatus('Pick a file first', 'error');
|
showUploadStatus('Pick a file first', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -575,32 +613,63 @@ async function uploadFile() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'UPLOADING...';
|
btn.textContent = 'UPLOADING...';
|
||||||
showUploadStatus('Uploading...', '');
|
showUploadStatus('Uploading...', '');
|
||||||
try {
|
showUploadProgress(true);
|
||||||
var form = new FormData();
|
|
||||||
form.append('file', pendingUploadFile);
|
var xhr = new XMLHttpRequest();
|
||||||
var res = await fetch('/api/upload', { method: 'POST', body: form });
|
xhr.open('POST', '/api/upload');
|
||||||
var data = await res.json();
|
|
||||||
if (!data.success) {
|
xhr.upload.onprogress = function(e) {
|
||||||
showUploadStatus(data.error || 'Upload failed', 'error');
|
if (e.lengthComputable) {
|
||||||
btn.disabled = false;
|
var pct = Math.round((e.loaded / e.total) * 100);
|
||||||
btn.textContent = 'UPLOAD';
|
document.getElementById('upload-progress-bar').style.width = pct + '%';
|
||||||
return;
|
document.getElementById('upload-progress-percent').textContent = pct + '%';
|
||||||
}
|
}
|
||||||
showUploadStatus('Upload complete: ' + data.name, 'success');
|
};
|
||||||
selectedFile = data.path;
|
|
||||||
document.getElementById('btn-start').disabled = false;
|
xhr.onload = function() {
|
||||||
document.getElementById('btn-download').disabled = false;
|
showUploadProgress(false);
|
||||||
document.getElementById('btn-delete').disabled = false;
|
try {
|
||||||
document.getElementById('pending-filename').textContent = 'no file chosen';
|
var data = JSON.parse(xhr.responseText);
|
||||||
document.getElementById('pending-filename').classList.remove('has-file');
|
if (!data.success) {
|
||||||
pendingUploadFile = null;
|
showUploadStatus(data.error || 'Upload failed', 'error');
|
||||||
loadUploads();
|
btn.disabled = false;
|
||||||
loadBatchResult(data.path);
|
btn.textContent = 'UPLOAD';
|
||||||
} catch (err) {
|
return;
|
||||||
showUploadStatus('Upload error: ' + err.message, 'error');
|
}
|
||||||
}
|
showUploadStatus('Upload complete: ' + data.name, 'success');
|
||||||
btn.disabled = false;
|
selectedFile = data.path;
|
||||||
btn.textContent = 'UPLOAD';
|
document.getElementById('btn-start').disabled = false;
|
||||||
|
document.getElementById('btn-download').disabled = false;
|
||||||
|
document.getElementById('btn-delete').disabled = false;
|
||||||
|
document.getElementById('pending-filename').textContent = 'no file chosen';
|
||||||
|
document.getElementById('pending-filename').classList.remove('has-file');
|
||||||
|
pendingUploadFile = null;
|
||||||
|
loadUploads();
|
||||||
|
loadBatchResult(data.path);
|
||||||
|
} catch (err) {
|
||||||
|
showUploadStatus('Upload error: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'UPLOAD';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
showUploadProgress(false);
|
||||||
|
showUploadStatus('Upload error: network error', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'UPLOAD';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onabort = function() {
|
||||||
|
showUploadProgress(false);
|
||||||
|
showUploadStatus('Upload aborted', '');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'UPLOAD';
|
||||||
|
};
|
||||||
|
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('file', pendingUploadFile);
|
||||||
|
xhr.send(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(path, name) {
|
function selectFile(path, name) {
|
||||||
|
|||||||
Reference in new issue
Block a user