← Back to Projects

leptons1618@anishgiri.dev:~/case-studies/arccluster less writeup.md

Computer Vision2025 September 22, 2026 5 min read

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:

VariableDefaultWhat it does
ARC_DETECTORyunetyunet (ONNX, 320×320) or haar
ARC_DETECTOR_MODEL./models/face_detection_yunet_2023mar.onnxYuNet weights
ARC_DET_THRESH0.6YuNet score gate; NMS fixed at 0.3
ARC_EMBEDDER_BACKENDonnxonnx or hist
ARC_EMBEDDER./models/face_embedding.onnxembedder weights
ARC_CLUSTER_SIM0.62cosine threshold for assignment
ARC_CLUSTER_MIN3faces a cluster needs before it can match
ARC_STREAMrtsp://example/streamwhat cv2.VideoCapture opens
ARC_CAMERAJetsonCamlabel written into every event
ARC_FRAME_INTERVAL2process every Nth frame
ARC_DATA_DIR./datawhere 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_stream and disconnect_stream set a value in the Tauri app state and stop there; the Python pipeline reads ARC_STREAM from 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=hist computes 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 under hist as a smoke test, not a result.

03Achievements

  1. 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.
  2. Cluster assignment is gated on both similarity and cluster size, so one bad embedding cannot define a face on its own.
  3. The desktop UI is a Dioxus WebAssembly bundle served locally by Tauri — no CDN, no webfonts, no network dependency at render time.
  4. Face events and cluster updates cross to the UI as TCP JSONL, with the client reconnecting on its next send after any write error.
  5. Persistence is two plain files: append-only `events.jsonl` and a fully rewritten `clusters.json`, both readable without this software.

04Stack

RustTauriDioxusPythonONNX RuntimeYuNetTokioWebAssemblyCSS