A single person walking under the overhead camera was generating both an entry and an exit within a few seconds — the line-crossing logic treated a blob's traversal into one side of the frame and out the other as two separate events whenever the track spawned near the line, oscillated against shadows, or churned at creation. Replaced line-crossing semantics with directional traversal: - Each track records spawn_y at creation and a counted flag. - An event fires only if the track is not yet counted, spawned firm on one side of the line (|spawn_y - line_y| > CV_TRAVERSAL_MARGIN_PX), and is now firm on the opposite side. Direction of travel determines entry vs exit. The track is then flagged counted — one trip, one count. - Cooldown remains as a secondary safety net. main.cpp: single/double LED pulse on entry/exit detections. Saves and restores the current LED state so upload (yellow-on) and no-WiFi indicators aren't clobbered. Tests updated to walk blobs beyond the margin and register two new cases: wobble-at-line doesn't count, and a reversed full traversal doesn't double-count on the same track. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.3 KiB
DoorCounter
Retail door traffic counter using M5Stack TimerCamera-F (ESP32 + OV3660). Counts entries/exits via overhead camera CV, passively scans BLE foot traffic, and reports hourly to logs.research.bike.
Hardware
- Device: M5Stack TimerCamera-F (ESP32-S, OV3660, PSRAM, WiFi/BLE)
- Mount: Overhead, camera pointing straight down, centered above doorway
- Power: USB (any phone charger)
Firmware
Built with PlatformIO. Target: timercam.
cd firmware
pio run -t upload --upload-port /dev/ttyUSB0
What it does
| Module | Behavior |
|---|---|
| CV pipeline | 5 fps, 96×96 grayscale, blob tracking, directional traversal count (origin→destination, once per track) with per-direction cooldown safety net |
| Detection LED | Single blink on entry, double blink on exit (preserves upload/no-WiFi status LED) |
| BLE scanner | Continuous passive scan; deinits during hourly upload to free heap |
| Reporter | Hourly HMAC-signed POST; 60s boot report for fast connectivity check |
| Provisioning | Captive portal AP on first boot for WiFi setup |
| OTA | Arduino OTA; operator push via ota_push.py |
Reporting intervals
- First report: 60 seconds after NTP sync (connectivity check)
- Subsequent reports: every 3600 seconds
Directional counting
Each tracked blob fires at most one event over its lifetime, and only
when it has genuinely traversed the frame — specifically, when its spawn
position and current position are both at least CV_TRAVERSAL_MARGIN_PX
(14 px ≈ 15% of the 96×96 frame) from the line, and on opposite sides.
- Top half → bottom half traversal = entry
- Bottom half → top half traversal = exit
A blob that appears near the line and wobbles across it does not count
(both positions are within the margin band). A blob that fully traverses
then reverses under the same track also does not double-count (the track
is flagged counted). If tracking churns — the track dies mid-traversal
and respawns on the other side — a new track with a new spawn on the
crossed side is the normal path to a correct count.
See firmware/lib/cv/cv.h for margin and cv.cpp for the crossing logic.
Crossing cooldown (safety net)
On top of directional counting, each direction enforces a cooldown between
counted events. Default: CV_CROSSING_COOLDOWN_FRAMES = 5 (≈0.8s at 5 fps).
Entries and exits maintain separate cooldowns, so a real entry immediately
followed by a real exit still counts both.
Operator Setup
1. Flash firmware
cd firmware
pio run -t upload --upload-port /dev/ttyUSB0
2. Provision device identity
python tools/flash_device.py \
--port /dev/ttyUSB0 \
--device-id dc-0042 \
--location-id retailer-123 \
--hmac-secret <32-byte-hex> \
--wifi-ssid "StoreWiFi" \
--wifi-password "secret"
WiFi credentials are optional — if omitted, device starts captive portal on boot.
Re-provision after firmware uploads. Flashing firmware via
pio run -t uploadmay clear the NVS partition on this board. If the device boots into a ~1 Hz LED blink (the "not provisioned" fatal state) after a firmware update, re-runflash_device.pywith the same credentials. See Troubleshooting.
3. OTA updates
python tools/ota_push.py \
--host dc-0042.local \
--firmware firmware/.pio/build/timercam/firmware.bin
End User Setup
- Mount device overhead, camera pointing straight down
- Plug into USB power
- Connect phone to
DoorCounter-SetupWiFi - Browser opens automatically → enter store WiFi password → done
LED indicators: Red = no WiFi · Blue = counting · Yellow = uploading · Brief flash (×1) on entry · Brief flash (×2) on exit
API
Endpoint: http://logs.research.bike
| Endpoint | Data |
|---|---|
POST /api/v1/camera/events/batch |
Hourly entry/exit counts |
POST /api/v1/events/batch |
Hourly BLE proximity records |
POST /api/v1/heartbeat |
Device health (uptime, RSSI, pending records) |
All requests are HMAC-SHA256 signed. See design spec for full API shapes and auth scheme.
Project Structure
DoorCounter/
├── firmware/
│ ├── platformio.ini
│ ├── lib/
│ │ ├── cv/ — CV pipeline (blob tracking, line cross, cooldown)
│ │ └── hmac/ — HMAC-SHA256 signing library
│ └── src/
│ ├── main.cpp — FreeRTOS tasks, boot sequence
│ ├── config.* — NVS read/write
│ ├── provisioning.* — captive portal
│ ├── camera.* — frame capture + CV pipeline
│ ├── ble_scanner.* — BLE passive scan
│ └── reporter.* — hourly batch POST + local buffer
├── tools/
│ ├── flash_device.py — NVS provisioning script
│ ├── ota_push.py — OTA push script
│ └── serial_monitor.py — reset + read serial with timestamps (diagnostic)
├── docs/
│ ├── server-prompt-crossing-cooldown.md — server-side coordination notes
│ └── superpowers/specs/2026-04-13-door-counter-design.md
└── server/ — API server (separate deployment)
Troubleshooting
| Symptom | Likely cause | Remedy |
|---|---|---|
~1 Hz LED blink after boot, no serial beyond esp_core_dump_flash: No core dump partition found! |
NVS missing device_id / location_id / hmac_secret. Commonly triggered by a firmware upload wiping NVS. |
Re-run flash_device.py with the device's known credentials. |
Device stays on DoorCounter-Setup AP instead of joining customer WiFi |
SSID/password in NVS wrong, or network out of range. | Connect phone to DoorCounter-Setup → captive portal → re-enter WiFi. Or reflash NVS with correct --wifi-ssid / --wifi-password. |
| No entries/exits counted for a known-walking doorway | WiFi captive portal still up (camera task starts only after connect); or camera blocked/unfocused. | Check LED: solid on = booting/uploading, off = counting. Run serial_monitor.py to see [CV] entry/exit log lines. |
Capture a boot log with timestamps:
python tools/serial_monitor.py --port /dev/ttyUSB0 --reset --timestamp --seconds 30