leptons1618@anishgiri.dev:~/case-studies/arccluster less writeup.md
ArcCluster: Local-First Desktop Monitor for a Face Detection Agent
Two processes, one socket, one data directory — and the handful of places where that arrangement is honest about being unfinished.
- published
- September 22, 2026
- read
- 5 min
- stack
- 9 tools
- outcomes
- 5
- media
- —
01The Problem
Deploying a face detection and clustering system on a Jetson edge device required a local-only desktop monitor that could receive real-time events without cloud dependencies, handle model swapping (YuNet ONNX detector, ONNX/histogram embedder) without code changes, and synchronize state across a Rust desktop app and a Python pipeline over an unreliable local network.
02The Solution
Built a Tauri desktop shell that serves a Dioxus WebAssembly UI from the local filesystem, paired with a Python pipeline on Jetson that runs YuNet ONNX detection with a histogram fallback embedder and online cosine-similarity clustering; both sides share a data directory (ARC_DATA_DIR) for events.jsonl and clusters.json and push updates over a TCP JSONL socket (default 0.0.0.0:8787) with all model and clustering knobs controlled by environment variables.
Two processes and a socket
ArcCluster is a face detection and clustering pipeline for a Jetson edge device, with a desktop window to watch it. The shape of it is simpler than the feature list suggests: a Python process does the capture, detection, embedding and clustering; a Tauri process owns the UI and the files; and they talk over a plain TCP socket carrying one JSON object per line.
The Python side is three stages in a row — detect, embed, cluster — chosen at startup from environment variables. The Tauri side is a Dioxus UI compiled to WebAssembly that never touches the filesystem or the socket directly. It calls Tauri commands, and those commands read the shared data directory.
camera ──▶ YuNet / Haar ──▶ embedder ──▶ clusterer ──▶ events.jsonl
│
clusters.json ◀───────┘
│
TCP JSONL ──┴──▶ Tauri ──▶ window
Swapping models without rebuilding
The point of the env-var surface is that changing a model or a threshold is not a recompile. These are the names, verified against python/config.py:
| Variable | Default | What it does |
|---|---|---|
ARC_DETECTOR | yunet | yunet (ONNX, 320×320) or haar |
ARC_DETECTOR_MODEL | ./models/face_detection_yunet_2023mar.onnx | YuNet weights |
ARC_DET_THRESH | 0.6 | YuNet score gate; NMS fixed at 0.3 |
ARC_EMBEDDER_BACKEND | onnx | onnx or hist |
ARC_EMBEDDER | ./models/face_embedding.onnx | embedder weights |
ARC_CLUSTER_SIM | 0.62 | cosine threshold for assignment |
ARC_CLUSTER_MIN | 3 | faces a cluster needs before it can match |
ARC_STREAM | rtsp://example/stream | what cv2.VideoCapture opens |
ARC_CAMERA | JetsonCam | label written into every event |
ARC_FRAME_INTERVAL | 2 | process every Nth frame |
ARC_DATA_DIR | ./data | where the two files live |
The Tauri process reads three more of its own: ARC_DATA_DIR, ARC_IPC_HOST and ARC_IPC_PORT.
One gotcha worth knowing. Those two processes default to different directories. Python falls back to ./data, relative to wherever you launched it; Tauri falls back to the platform app data directory. Set ARC_DATA_DIR explicitly or the UI will show you an empty room while the pipeline fills a folder you never named.
Clustering is deliberately slow to trust itself
A face joins a cluster only when the cosine similarity against the nearest centroid clears 0.62 and that cluster already holds at least 3 faces. Centroids update as a running mean, so a cluster’s idea of a face sharpens as it sees more of them.
The ARC_CLUSTER_MIN gate is the interesting part. A brand-new cluster starts at zero faces, so it cannot match anything, so the next three faces start clusters of their own. It is a hysteresis that stops a single bad embedding from poisoning a cluster — at the cost of a slow start-up, and of never really converging if the stream is thin.
The cost on the other side is that centroids are not persisted. clusters.json stores ids, counts, labels and status; on restart the clusterer reloads the counts and rebuilds centroids from whatever embeddings arrive next. A cluster that had 50 faces wakes up with 50 and an empty centroid, and the first face to arrive becomes its definition. Swap the embedder and cluster identity drifts, because the new vectors are being compared against centroids that were never saved.
Persistence is two files and no more
events.jsonl is append-only, one event per line, growing until somebody prunes it. clusters.json is truncated and rewritten in full on every save. A ClusterRecord is a string id like C000, a face count, a label that starts as Unlabeled, and a status that starts as Review.
The socket is the weakest part, on purpose
The IPC listener binds 0.0.0.0:8787 by default and spawns a thread per connection. The Python client connects lazily to 127.0.0.1:8787 with a one-second timeout, and on any write error closes the socket and tries again on the next send.
There is no ack, no heartbeat, no retry loop, no backoff, no queue, and no encryption or authentication. A message that fails to send is dropped. That is fine for a Jetson on a bench next to a workstation and not fine for anything else, and the code is honest about it — this is a trusted-LAN design that chose simplicity over guarantees.
What is not finished
Three things, plainly:
- The stream controls are stubs.
connect_streamanddisconnect_streamset a value in the Tauri app state and stop there; the Python pipeline readsARC_STREAMfrom its own environment. Nothing in the UI starts or stops capture. - The models are not shipped. Both ONNX files have to be dropped into
./models/by hand. No download step, no checksum, no version pin. - The histogram embedder is a placeholder.
ARC_EMBEDDER_BACKEND=histcomputes an 8×8×8 colour histogram — deterministic and dependency-free, which is what makes it useful for testing the plumbing, but it has no idea what a face is. The code calls it that in a comment. Treat clustering underhistas a smoke test, not a result.
03Achievements
- A YuNet ONNX detector and an ONNX or histogram embedder, both swappable at startup through `ARC_*` environment variables, so no model change needs a recompile.
- Cluster assignment is gated on both similarity and cluster size, so one bad embedding cannot define a face on its own.
- The desktop UI is a Dioxus WebAssembly bundle served locally by Tauri — no CDN, no webfonts, no network dependency at render time.
- Face events and cluster updates cross to the UI as TCP JSONL, with the client reconnecting on its next send after any write error.
- Persistence is two plain files: append-only `events.jsonl` and a fully rewritten `clusters.json`, both readable without this software.