Camera Streaming
WebRTC streaming and inference pipelines for Kataglyphis.
The ObservANT in one minute. A board with a camera runs the omni-observant
service: it finds the camera, looks for cats with a detection model and serves the web app
on port 8080.
-
Install: a new camera in five steps: the release's
.debon Debian, Ubuntu or Raspberry Pi OS (amd64, arm64; riscv64 builds on the board), or without sudo the tarball'sobservant --install --user. It starts at once and at every boot, and updates itself from the releases every night. -
Watch:
http://<board>:8080/. The Stream page plays the camera, says whether a cat is there, lists the visits with their pictures and videos, and shows the other ObservANT cameras (The Stream page). -
Adjust: the Camera page turns the picture, sets how sure detection must be, renames the camera and marks a spot as "not a cat" (ObservANT package).
-
Get told:
notify_urlsends each arrival to a webhook; Home Assistant turns it into a phone notification with the picture and the video (Home Assistant). -
Check:
/healthzsays whether the camera delivers, and what it saw (Detection, visits and/healthz). -
Configure:
/etc/omni-accelerant/observant.tomldocuments every setting you would change; what the page changes wins over it.
The detection model
DEIM-D-FINE-N, licensed Apache-2.0, fetched by checksum and never tracked (owner, 2026-10-14). It replaced YOLO26n and YOLOv10m, both AGPL-3.0, which sat in AccelerANTgine's and OxidANT's git trees. It is DEIM's COCO checkpoint: 80 classes, 4 M parameters, 7 GFLOPs, 43.0 AP, a 640x640 input, a 15 MB file.
- Where it comes from. ModelANT builds it
from DEIM's pinned commit and checkpoint, exports it with its post-processing into the
[1, 300, 6]layout the Rust parser already read (x1, y1, x2, y2, score, class) and releases it with its license and notice. Its preprocessing is a plain resize, the Rust default; the ONNX names it in its metadata (kataglyphis.preprocess). - How a build gets it.
third_party/OxidANT/resources/models/models.locknames each file with its URL and SHA-256.third_party/OxidANT/scripts/linux/fetch-models.shputs them intoresources/models(or--dest DIR) through a cache in~/.cache/kataglyphis-models, and fails on any other bytes; on Windows,scripts\windows\Get-Models.ps1. The native lane, the Linux bundle, the ObservANT packages and OxidANT's Windows build call it, so a fresh clone needs the network once. Before you start the app from a checkout, run it yourself. Packaging and its tests take the file names from the lock's first.onnxrow, so a lock naming another model needs no script edit;test-verify-gpu-candidate.sh,test-profile-gpu-packages.shand the detector model suites run on a fixture lock that does.web.yml's package jobs also run the real packagers on such a lock (test-observant-fixture-lock.sh,Test-ObservANTFixtureLock.ps1), reusing the producer just built (about 2 minutes on Linux), and check the finished tar, .deb, AppImage and Windows zip; the flatpak's model files, the fixture MSI and MSIX, and running that model are not covered. - What it changed on the boards (2026-10-14, against YOLO26n, ONNX Runtime on 2 threads on a Pi 5, 12 arrival pictures from the three ObservANT cameras and one cat photo): it found the cats more often and more surely. A cat at the feeding trough went from 0.42 to 0.63; a cat lying on the sofa, which YOLO26n missed, scored 0.64. No new false cats. A run costs about 260 ms instead of 190 ms.
- Another model. Put it into ModelANT and move the lock's lines. For a one-off,
model = "..."inobservant.tomlorKATAGLYPHIS_ONNX_MODELnames any[1, N, 6]ONNX file. The old models left the repositories' history in the family's history rewrite of 2026-10-17.
Measuring performance
The code's benchmarks live in each project's own tooling, and CI tracks them (2026-10-14, owner): a change that costs time shows in the run's summary. The yardstick is the median of the default branch's last 20 runs on that arch, not the run before, so a slowdown that creeps in a few per cent per commit still shows (2026-10-15). Above +25 % a run warns; a gated value at more than double the median fails it, once 3 earlier runs exist. A shared runner is noisy, so only stable values are gated, and a run the report failed never joins the history.
- Rust: OxidANT's Criterion benches under
cargo bench, run by its Linux lanes: what a frame pays before the model, and a whole detection on the real model (OxidANT's AGENTS.md). - Flutter:
integration_test/performance_test.dart, driven in profile mode byflutter drive --profile -d linux --driver=test_driver/perf_driver.dart --target=integration_test/performance_test.dart. Flutter'swatchPerformancetimes the frames of the app's start, three rounds through every tab, and resizes across both layout breakpoints; the driver keeps them inout/perf/frontend.json, and ANTfrastructure'sfrontend-perf-report.pymakes the table (frames, build and raster time on average, at the 90th percentile and at worst, missed build budgets). The native lane runs it under Xvfb on x64 (run_frontend_performance); the hub'sfrontend-perf-reportaction keeps the default branch's last 20 results in.perf-baseline/. Gated: the average and the 90th-percentile build time; the worst frame is one frame and only warns.
When a frame is slow, find what it builds. The report says that a scenario got slower; a
profile timeline says where. Write an entry point outside the tree, say
build/probe/main.dart, that sets debugProfileBuildsEnabled = true (and
debugProfileBuildsEnabledUserWidgets) before calling the app's main(), run flutter run --profile --trace-startup -d linux -t build/probe/main.dart under Xvfb, then
python3 third_party/ANTfrastructure/linux/scripts/05-frameworks/flutter/flutter-trace-summary.py build/start_up_timeline.json. It lists the
slowest UI and raster frames and the slowest UI frame's self time per event, per widget with
that flag. That found the 2026-10-15 start-up fix: StreamPage held 938 ms of a 963 ms build,
because its initState enumerated cameras synchronously. Without the flag the same command
still splits a frame into BUILD, LAYOUT, PAINT, SEMANTICS and garbage collection. A DevTools
Export timeline file reads the same way.
Two tools measure what the boards really do, which no runner can. Repeat them after a change that could cost time there, and compare with the baseline below.
scripts/linux/observant/bench/model.sh PICTURE MODEL.onnx..., on a board: the installed service runs each model nonstop on a still picture on a spare port (no camera, no state, the live service untouched) and prints its time per run, runs per second, CPU and memory.scripts/linux/observant/bench/viewer.sh BOARD [SECONDS], from another host: the API's response times; the service idle and while one phone-sized headless Chrome plays the stream; and that page's cold load: the files on the boot path with their times, the renderer the browser got, the first Flutter frame and the first video frame, the requests and main-thread time once it runs, and when the signalling socket opened and answered.
The baseline, 2026-10-14 (Pi 5 boards, the SpacemiT K3 board, the web client a headless Chrome on a Pi 5):
| What | Pi 5 | SpacemiT K3 (riscv64) |
|---|---|---|
| A model run in the service, DEIM-D-FINE-N / YOLO26n | 332 / 233 ms | 592 / 404 ms |
| The model alone at 1 / 2 / 4 threads (DEIM-D-FINE-N) | 420 / 258 / 265 ms | |
| Preprocessing, postprocessing per run | 3 ms, < 0.01 ms | |
| Per frame: I420 to RGBA, pixelation, motion thumbnail | 1.7, 0.7, 0.5 ms | |
| A snapshot JPEG | 10.7 ms | |
| The service idle, with one viewer (% of one core) | 3 to 4.5 %, 18 to 19 % | 1 %, 49 % |
| Memory idle, with one viewer | 180 to 190 MB, +30 MB | 245 MB, +27 MB |
/healthz, /snapshot.jpg over the LAN |
0.3 to 4 ms, 0.3 to 19 ms | 3 ms, 38 ms |
The cold page is 4.9 MB in 105 to 112 requests: the renderer skwasm_heavy.wasm (2.2 MB)
and main.dart.wasm (1.5 MB) on the boot path, then six font files (0.5 MB), then the Rust
core (65 kB). The first Flutter frame came after 4.5 to 6.4 s on the board with Wi-Fi at
0.7 MB/s, the first video frame 3 to 3.5 s later. An open page then makes 48 requests a
minute and keeps the main thread 1.9 % busy.
What it says:
- The model is 99 % of a detection. Two threads are the fastest setting on a Pi 5; four are no faster.
- Every viewer costs an encoder: webrtcsink encodes once per viewer, about 15 % of a Pi 5 core and half a K3 core each.
- Plain HTTP costs 0.75 MB a visit: Flutter loads
skwasm_heavy(2.29 MB compressed) instead ofskwasm(1.54 MB), since without a secure context the browser has noImageDecoder(What a browser downloads under The Stream page), and without cross-origin isolation it renders on one thread.
Rust-owned webcam inference (local, no WebRTC)
On Windows and Linux the Stream page runs a fully local webcam → ONNX → texture pipeline owned end-to-end by Rust: no signalling server, no browser. Video frames never cross the Dart bridge; only detection metadata does.
Linux has the same design as of 2026-09-16; a frame has crossed it since 2026-10-05. It is an addition: the WebRTC stream below stays.
The Rust core is in every build, with no feature switch (owner, 2026-10-20; here since 2026-10-21). OxidANT gates by target alone, so the page sends Windows and Linux to
RustWebcamViewby target, and the Linux C++ GStreamer MethodChannel fallback is gone: there the plugin only creates the texture Rust pushes into. Android and macOS, where the Rust webcam cannot run, keep the plugin's MethodChannel pipeline.listCameras()runs only when someone asks for the camera list: it starts libcamera's device monitor, which held the app's first frame 0.94 s on a Pi 5 while the page called it on start (2026-10-15).The packaged artifacts carry their GStreamer/ONNX Runtime/model closure (Relocatable Linux bundles), and
check-knt-abi.shruns in the lane.A test-pattern frame reaches the texture on every native lane run.
integration_test/webcam_frame_test.dartstartsRustWebcamViewonvideotestsrcunder Xvfb and polls the plugin'shasPushedFrame, the flagknt_push_framesets. A camera changes only the source, so the push path needs none. A green lane is still not a working camera: real V4L2 capture on Linux remains a manual check.Classes, backend and provider.
RustWebcamViewsends its class ids inWebcamStreamConfig.classes(default[0], person; the picker offers OxidANT'sdetectorLabels()), labels each box with its class name, and shows the event'sbackendandexecutionProvider, for exampleort on cpu.KATAGLYPHIS_ORT_DEVICE=xnnpackordnnlselect OxidANT's CPU accelerators; a missing provider fails the start withProviderUnavailableinstead of running on the CPU.
omni_accelerant --detect-check IMAGEdecodes the image with the engine's codecs, runs OxidANT'sdetect_rgbawith classes[15], and exits 0 only for at least two cats at score 0.4 or more. The release lane runs it on the packaged bundle withthird_party/OxidANT/crates/inference/tests/fixtures/two_cats.jpgand with a flat-colour 64x64 PNG from the hub'st_write_png, which must exit 1 (run_detect_checkinscripts/linux/lib/container-steps.sh). It checks CPU inference through the package; it does not check a GPU provider.
Data flow:
crates/media (gstreamer-rs) src/webcam_engine.rs src/api/webcam.rs (frb)
┌ webcam_inference_available()
mfvideosrc → ksvideosrc ┌ pushes RGBA into the Flutter │ list_cameras() (async)
→ videoconvert → videoscale │ texture via the native plugin's│ start_webcam_inference()
→ RGBA appsink ───────────►│ knt_push_frame C ABI │ → Stream<DetectionEvent>
(latest-frame slot) └ runs PersonDetector (ONNX/ort) ─┘ stop_webcam_inference()
- Rust source selection:
mfvideosrc(Media Foundation) first, thenksvideosrc, thenautovideosrc;videotestsrcfor containers/CI (no camera).mfvideosrcneeds themediafoundationGStreamer plugin and a Windows client host (Server Core has no Media Foundation platform:platforms.md, themediafoundationrow); without it the pipeline falls back toksvideosrc. - Inference:
ort(ONNX Runtime) is loaded viaload-dynamic(ORT_DYLIB_PATH→ next-to-exe →C:\runtime\lib\onnxruntime-source\bin), refusing any file that is not the image's chain build (AGENTS.md § 4, the ONNX Runtime bullet); the DirectML execution provider on Windows with CPU fallback, and tract when ORT cannot load the model. No crate features: therust_builderCMake → Cargokit passes no--features, and the target decides what is compiled in. - Display: the native plugin (
packages/kataglyphis_native_inference) exports a C ABI (knt_create_textureimplied via thecreatemethod,knt_push_frame,knt_api_version) that Rust resolves withlibloading. The Flutter UI is aTexture(textureId)with aCustomPaintbox overlay fed by theDetectionEventstream (lib/Pages/StreamPage/rust_webcam_view.dart).
Run it: launch the app (Start-Windows.ps1), open the Stream tab, pick a camera
(or Test pattern), optionally set a model path + score threshold, and press Start.
The bundled GStreamer plugins must include the capture source: the build's DLL-bundling step
stages gstmediafoundation.dll/gstwinks.dll + GStreamer core DLLs into the runner.
mfvideosrc comes from the image's own GStreamer, which ANTfrastructure's
windows/scripts/build/Build-GstreamerFromSource.ps1 builds with
-Dgst-plugins-bad:mediafoundation=enabled.
Linux GPU build selection
The default build uses the CPU ONNX Runtime. For a GPU build, set
ORT_LIB_LOCATION=/usr/local/lib/onnxruntime-gpu/lib and
KATAGLYPHIS_ORT_PROVIDER=cuda or migraphx while configuring the app. The build stages
the matching provider, links AccelerANTgine to that same runtime and sets it as the app's
default device. A conflicting ONNXRUNTIME_ROOT fails configuration; an explicit
KATAGLYPHIS_ORT_DEVICE still overrides that default. An explicit GPU request defaults
the inference backend to ORT, so an ORT failure does not silently select tract. A GPU
package accepts cpu, auto, or its own provider (cuda or migraphx); other device
values stop the app with an error instead of silently selecting CPU.
MIGraphX requires an x64 Linux build and an OxidANT dependency that advertises its
migraphx device capability. The recorded OxidANT pin does not yet have that capability,
so an AMD build stops at configuration until the dependency is updated. The build matrix
allows CPU and CUDA on x64 and arm64. GPU packages and their host CUDA or ROCm runtime
dependencies are not yet verified for distribution; staging the provider alone does not
make an installable GPU artifact. The package scripts require the newer shared host-runtime
helpers, use -nvidia or -amd names, and reject Flatpak for these host-runtime variants.
The current ANTfrastructure pin does not yet contain those helpers.
The dispatch-only Linux GPU candidates (unverified) workflow covers NVIDIA x64 and
arm64 and AMD x64. It requires both strict-provider capabilities from the recorded
OxidANT pin and the GPU packaging helpers from ANTfrastructure before it pulls an
image; the current pins fail that preflight. After those pins are updated, it verifies
the image's core and selected provider against one wheel, builds tar, DEB and AppImage
candidates, and checks the finished GPU runtime and notices against that wheel. It
uploads them with unverified-omni- artifact names and does not publish releases.
The clean-host checks run --onnx-check with ONNX Runtime CPU selected on each
finished package, then check normal startup. The self-check loads the packaged
model and runs a blank frame through the desktop detector bridge; it returns a
nonzero exit status on failure. It does not inspect provider placement or prove
GPU acceleration. An explicit KATAGLYPHIS_ONNX_MODEL still overrides the
packaged model.
After the required dependency updates, a GPU lane in the matching registry image must
select formats that use the host runtime, for example:
export ORT_LIB_LOCATION=/usr/local/lib/onnxruntime-gpu/lib
export KATAGLYPHIS_ORT_PROVIDER=cuda # migraphx on Linux x64 only
bash scripts/linux/ci/lane-native-linux.sh --package-formats tar,appimage,deb
The lane runs its application smoke and integration checks with the CPU ORT device for GPU builds, so they can run on hosted runners without a GPU. Those checks do not verify GPU inference; that requires a separate profile on a machine with the matching GPU and host runtime.
Checking the knt ABI
scripts/linux/check-knt-abi.sh verifies the C ABI the Rust webcam engine depends on:
knt_api_version and knt_push_frame are exported from the built plugin, callable from
outside the library, and return their documented error codes (-1 bad arguments, -2
unknown texture id). Its Windows twin, scripts/windows/Test-KntAbi.ps1, loads the plugin
DLL into pwsh on the device: windows-x64.yml runs it on the host after the container
build, windows-arm64.yml on windows-11-arm.
Rust resolves that ABI by name at runtime with libloading, so a rename, a dropped
export or a visibility change is no compile error on either side: the app builds, ships, and
silently never shows a frame. The script dlopens the plugin exactly as Rust does, so a
failure here is one Rust would hit too.
It cannot check that a real frame reaches the screen: frames end in a GTK texture. The native
lane has a display since 2026-10-01 (it starts the bundle and drives the integration test
under the image's xvfb-run) and pushes a test-pattern frame through it since 2026-10-05
(above). A real camera's frame on screen is a manual check; on the Windows dev box through
the usbipd attach --wsl route in AGENTS.md § 4.
Run it after a native build. The artefact is an ELF .so, so on a Windows host it runs in a
container with the lane's build volume mounted:
nerdctl run --rm --platform linux/amd64 `
-v "C:\GitHub\OmniAccelerANT:/workspace" -w /workspace `
--mount "type=volume,source=kataglyphis-lane-native-x64-workspace-build,target=/workspace/build" `
ghcr.io/kataglyphis/kataglyphis_beschleuniger:latest `
bash scripts/linux/check-knt-abi.sh
Expected output:
ok knt_api_version = 1
ok knt_push_frame bad args -> -1
ok knt_push_frame unknown texture -> -2
knt ABI OK
Relocatable Linux bundles
The native lane packages four formats from one bundle tree. Until 2026-09-17 that tree
assumed the host had the image's GStreamer: the plugin DT_NEEDed seven libgst* sonames,
bundle/lib carried none of them, and the .deb declared only libc6, libstdc++6, libgtk-3-0. No lane caught it, because the image's ld.so cache has everything.
What travels in the bundle now (scripts/linux/bundle-runtime-closure.sh, run by the lane for
release builds, before packaging). Since 2026-10-19 the walk is the hub's
elf-closure.sh
with its Flutter-bundle options; the ObservANT packagers call it with --ldd:
- the GStreamer closure, resolved from DT_NEEDED against pkg-config's
libdir(neverldd: the image also carries a distro GStreamer, andlddmay pick that one), plus the pipeline plugins listed inscripts/linux/lib/bundle-runtime.sh; - ONNX Runtime, the image's chain build and nothing else (owner rule 2026-09-23).
libonnxruntime*resolves only fromORT_LIB_LOCATION, else/usr/local/lib/onnxruntime-cpu/lib, and only once that directory'slibonnxruntime.soproves to be the chain build: ORT embeds its source path through__FILE__, the chain's is/opt/onnxruntime/onnxruntime/core/, and the variable alone proves nothing. Never from the ld.so cache, where/opt/opencv5/libcarries a second copy that sorts first.libAccelerANTgine.soneedslibonnxruntime.so.1and the Rust core dlopenslibonnxruntime.so, so it is already there when the packer starts (rust_builder/linux/CMakeLists.txtstages both sonames from the same chain directory on everyflutter build linux): the packer copies nothing for it and printsKept (already in bundle/lib, not copied)with each file's sha256, so itsN file(s) copiedlist never shows ORT; - the 15 MB detector model at
data/resources/models/deim_dfine_n_coco.onnx, with its license and notice, fetched and checked from OxidANT'smodels.lock(The detection model): the standard model, bundled by default (owner decision 2026-10-01).KATAGLYPHIS_BUNDLE_MODEL=0drops it for an artifact that then needsKATAGLYPHIS_ONNX_MODELat run time; - an
$ORIGINrpath on every bundled ELF, since RUNPATH is not transitive (linux-build-basics.md§ What a package carries; this bundle is the measurement). The runner carries$ORIGIN/lib:$ORIGIN/../libbecause the flatpak manifest installs the binary into/app/binwith the libraries in/app/lib; - GCC 16.2's
libstdc++andlibgcc_s(2026-10-16), from the compiler's own runtime directory: the image has the distro's older copy too, and the code needsGLIBCXX_3.4.36, which no distro ships yet; - the image's loader and glibc (2026-10-17): its ORT and orc need glibc 2.43. The hub's
bundle-loader.sh --besideleaves the runner asomni_accelerant.binwithld-linux-*.so.1beside it (so Flutter still findsdata/), puts glibc inbundle/lib/glibcand makesomni_accelerantthe launcher. The closure gate leaves glibc to--library-pathand checks the.binbehind the launcher.
The launcher takes the bundled glibc only when the host's is older
(linux-build-basics.md § glibc travels, and a newer host glibc wins;
this app's flatpak is the case that forced it). The native lane starts the bundle 20 s on both
paths (KATAGLYPHIS_BUNDLED_GLIBC=1 forces the second) and the app flatpak 20 s on its runtime.
On a real desktop under the bundled loader (2026-10-17, the 2.2.0 dry run's arm64 tarball
unpacked on himbeere2: Debian 13, glibc 2.41, labwc on Wayland) the app drew its window and
pages with Impeller on OpenGL ES; the process mapped the bundled libc.so.6 beside the host's
libEGL_mesa and libgallium-26.2.2, so the host's Mesa runs under the newer bundled glibc.
The closure gate runs inside the image, where every library is present anyway; the outside view
is the clean-host job after each Linux lane, the hub's clean-host-package-check on a bare
ubuntu-26.04 (-arm) runner (§ The proof).
At runtime the plugin's ELF constructor (runtime_paths.cc) points GST_PLUGIN_PATH,
ORT_DYLIB_PATH and KATAGLYPHIS_ONNX_MODEL at those siblings, each only when the file exists
and the environment names none, so a user override always wins. For ONNX Runtime that override
must still be a chain build: OxidANT's loader (ort_runtime.rs) refuses any file without the
chain's source path.
Only the bundled GStreamer plugins load (owner, 2026-10-17), by the hub's four settings
(§ GStreamer loads from the package alone):
runtime_paths.cc sets the plugin paths and GST_REGISTRY_FORK=no wherever it finds the
bundled plugins, each unless set; AccelerANTgine's library does the same from its own constructor
and adds GST_REGISTRY, one per installed copy (AccelerANTgine 24de58f). The lane's launch
smokes fail on GStreamer's External plugin loader failed warning.
This plugin's bundle has no scanner and no webrtcsink, and check-bundle-gst-elements.sh refuses
libgstrswebrtc.so there: an in-process scan of a fresh registry can leave webrtcsink without codecs.
The ObservANT and producer bundles carry webrtcsink, so they must ship the scanner.
scripts/linux/check-bundle-gst-elements.sh hands the hub's gstreamer-bundle.sh check every
element the pipelines name (GST_PIPELINE_ELEMENTS in scripts/linux/lib/bundle-runtime.sh).
test/pinned_artefacts_test.dart keeps that list in step with the Dart pipelines' Linux
sources; OxidANT's capture.rs adds videoscale. A new element goes into that list and its
plugin into GST_BUNDLED_PLUGIN_NAMES, or the lane fails.
The GStreamer-element and knt-ABI check CLIs share scripts/linux/lib/bundle-options.sh,
which uses the shared flag loop; cli-common.sh owns their architecture and
build-mode verdicts. The native lane tests help, invalid values and a mutated option target
with scripts/linux/tests/test-bundle-options.sh before it builds.
scripts/linux/check-bundle-closure.sh grades the result headlessly (one second, no display,
no container-in-container):
- every DT_NEEDED of the runner and of every bundle lib is bundled or in the documented system
allowlist, and every bundled dependency is reachable through an
$ORIGIN-relative RUNPATH; - the pipeline plugins exist;
- ANTfrastructure's G6 census (
linux/scripts/06-packaging/check-ort-provenance.sh <bundle>) finds every ONNX Runtime binary in the bundle byte-identical to the image's chain ORT, and every importer's ld.so lookup (RUNPATH,$ORIGINexpanded) landing on it. A dlopen-only user such asliboxidant.sogets an$ORIGINRUNPATH from the packer for exactly that.
liboxidant.so also holds the chain directory /opt/onnxruntime/onnxruntime/core/ as a
string, because OxidANT's loader checks the ORT it loads for it. That makes it an importer,
never an ORT build, but only to a census that takes a whole NUL-terminated __FILE__ source
path as the ORT fingerprint. A hub whose census still counts the bare directory reads the text
rustc packs before it as a relative build root and fails the correct bundle with UNPROVEN /lib/liboxidant.so (run 35928030957, both arches). This repo's hub pin has read whole paths
only since 502b9089 (2026-09-24); test-check-bundle-closure.sh carries the bytes around that
string in the real liboxidant.so. The census runs whenever a bundled file is ORT-named or
names the ORT ABI (OrtGetApiBase and G6's other markers), not only when a libonnxruntime*
file is present: an ORT user with nothing beside it, or an ORT under another name, is exactly
what G6's verdicts exist to refuse. Which copy a process loads at run time it does not decide;
runtime_paths.cc (above) points ORT_DYLIB_PATH at the bundled one.
A missing GStreamer lib or a foreign ONNX Runtime fails the lane here instead of on the first
target machine. Both gates run in lane-native-linux.sh after flutter build linux;
scripts/linux/tests/test-check-bundle-closure.sh, which mutation-tests when the census runs
and that its verdict decides, runs in the same lane's code-quality batch before the build.
Its synthetic ELF records and the GStreamer element test's gst-inspect output now come from
ANTfrastructure's shared fixtures; the local tests still set DT_STRSZ and Name-field variants
that exercise their bundle-specific failure paths.
The system allowlist is the GTK desktop stack the .deb's Depends stand for; GStreamer, ONNX
Runtime and the camera stack are deliberately not in it, they are the bundle's job. So the
.deb declares only the desktop libraries, which assumes the target has a desktop stack. Not
covered by any of this: a frame from a real camera (the lane pushes a test pattern, Rust-owned
webcam inference above).
WebRTC pipelines (Linux / web)
The ObservANT package
scripts/linux/observant/package-observant.sh + package/ (2026-10-05) turns the producer
below (Cat detection stream (Rust, native)) into one installable service: the producer,
its GStreamer plugins, the chain ORT (G6-proved), the model, the web build and the image's
loader, glibc and libstdc++ in /opt/omni-observant (What the bundle carries, under
Installing it). Run it inside :latest with the web lane's build/web as its web root,
privileged (nerdctl run --privileged): it also builds a flatpak, and flatpak-builder
runs in bubblewrap.
bash scripts/linux/observant/package-observant.sh --web-root build/web
# out/omni-observant_<version>_<deb-arch>.deb Debian, Ubuntu, Raspberry Pi OS
# out/omni-observant-<version>-<uname-arch>.AppImage any glibc distro
# out/omni-observant-<version>-<uname-arch>.flatpak any flatpak desktop; USB cameras, sign-in start
# out/omni-observant-<version>-linux-<deb-arch>.tar.gz the same bundle, unpacked
The Flatpak build uses ANTfrastructure's committed OSTree ref as its verdict:
flatpak-builder can return success without an app or fail after committing one.
The packager also requires a nonempty copied bundle. The stubbed
scripts/linux/tests/test-observant-flatpak-verdict.sh checks both exit/ref
combinations and an empty bundle without building an image.
- The
.debcarries a systemd unit, enabled at install, and a ufw profile./etc/omni-accelerant/observant.tomlis not a conffile, on purpose (why: What the .deb does on install, under Installing it). - The unit is sandboxed; its comment names what stays open for the camera, the RAM check and GStreamer's ORC (its score: Tests).
- On riscv64 it writes the
.deband the tarball only, built natively on the board in that board's own riscv64:latest(a 16 KiB-page Pi cannot emulate it): the freedesktop runtime has no riscv64 build and no AppImage runtime is staged for one. No CI lane builds it;web.ymluploads all four for x64 and arm64. package/*is pinned to LF in.gitattributes, because those files land on the target verbatim.
Install it with sudo apt install ./omni-observant_<version>_<arch>.deb, or anywhere else
with sudo ./omni-observant-<version>-<arch>.AppImage --install, then open
http://<host>:8080/ from any browser on the network (Installing it).
Everything it serves is on that one port, plain HTTP:
| Path | Answers |
|---|---|
/ |
the web app; its Stream page plays the camera and shows the cat's status, visits and last arrival |
/healthz |
JSON: ok, or false with the problem; the seconds since the last frame and model run (frame_age_s, model_age_s); the build (version), the browser tabs on the stream (viewers), the other ObservANT cameras (peers), the privacy filter (privacy: off or pixelate), whether the picture is too dark to detect anything (dark: the model then idles, and ok stays) and stats, sampled every 2 s: capture_fps, model_ms per run, model_runs_per_s, cpu_percent of one core, cores, rss_mb, load_1m, temperature_c and uptime_s (the CPU, memory, load and temperature from /proc and /sys, so null off Linux); the live camera, its frame size and rate, model_runs, rotate, score, and under detection the watched in_view, last_seen, visits, last_score, recent visits, the newest clip's arrival and every kept one's (clips), and the boxes drawn now, as fractions of the picture |
/visits |
JSON: every visit of the last 30 days, newest first, as [arrival, stay in seconds] (null while still in view), and days; read by the Stream page's Visits panel, which counts them per day and per hour in the viewer's time zone (2026-10-15). Kept in visits.json beside recent, at most 10 000 visits; a file from before starts from its recent |
/snapshot.jpg |
the latest frame, boxes drawn; encoded once per frame however many ask |
/arrival.jpg |
the frame the last arrival was confirmed on; ?t=<arrival time> that arrival's own, of the last five; 404 until a cat has come, or once that arrival's is dropped |
/arrival.mp4 |
the last arrival as an H.264 clip, 3 s before it to 7 s after; ?t=<arrival time> that arrival's own, of the last five; byte ranges answered; 404 until one is encoded, or once that arrival's is dropped |
/settings |
GET: the name, rotate, score, ignore and classes the settings page can change, and the privacy filter, which locks classes. POST any of them as JSON (application/json only), e.g. {"rotate": 90}: 0, 90, 180 or 270 degrees clockwise, a score from 0.05 to 0.95, a name of up to 64 characters, up to 16 spots marked "not a cat" as [x1, y1, x2, y2] fractions of the picture ({"ignore": []} forgets them), and the watched classes, one to all 80 COCO names ({"classes": ["cat", "dog"]}) |
/webrtc-ws |
the WebRTC signalling, forwarded to the producer's own server on loopback :8443; at most max_viewers tabs at once (8), 503 for one more; a tab that vanished without closing, a laptop asleep, frees its place within about 2.5 min (TCP keepalive, 2026-10-14) |
Only ObservANT's own page may act (2026-10-13). /webrtc-ws and a POST /settings
answer 403 when a browser's Origin is not the address the request was sent to, so another
site's page cannot open the camera or change it. A tool such as curl, which sends no
Origin, passes, and reads stay open to the other ObservANT cameras' pages.
Every request must name the board by a local address (2026-10-14): an IP, a name without
a dot, or one ending in .local, .lan, .home, .internal, .home.arpa or
.fritz.box, else 403. Before, only acting requests were checked, so a website that pointed
a public name at the board (DNS rebinding) could read /snapshot.jpg and the clips as its
own. A reverse proxy under a public name goes into allowed_hosts in observant.toml.
At most 128 connections at once, and a request head within 10 s (2026-10-14), so a device that opens more, or holds sockets without sending, no longer uses up the service's files; the 129th is closed unanswered until one frees.
A model that keeps failing shows (2026-10-14). After three failed runs in a row /healthz
says ok: false with problem: "the model fails: …", so the Home Assistant outage alert
fires. Each failed run clears the drawn boxes, so none stays sharp under the privacy filter;
the journal gets the first failure and then one line a minute.
The Camera page changes ObservANT while it runs (2026-10-13): the web app's own page in
the bottom bar, for a board that is easily moved or a spot with false alarms. The Stream page
carries the same ⟲ and ⟳ beside the camera's name (2026-10-16), so a picture on its side is
turned where it is seen; both post {"rotate": …} to /settings, which the camera keeps in
its settings.json, so every device keeps its own.
- ⟲ and ⟳ turn the picture a quarter each way. ObservANT turns it itself, before the model
sees it, so detection,
/arrival.jpgand the clips turn with the stream. The running capture follows on its next frame, with no restart and no reconnect. At 90 and 270 the upright picture is bordered into the frame, black at its sides, instead of stretched: until then a quarter turn squeezed a 4:3 picture into 4:3 the other way round, and a cupboard looked square. A Pi camera turned a quarter (rpicam-vidor libcamera) captures at 480x360, which its ISP scales for free, so only the borders are added (2026-10-14): resampling the turned 480x640 cost a Pi 20 % of a core, the borders 3 %. Turning such a camera between landscape and portrait restarts its capture once, about a second; a USB webcam keeps its size and turns live. - A slider sets the detection score, how sure the model must be before a cat counts, from the next model run. Higher means fewer false alarms, lower finds a cat in poor light. Under it the page names the last detection's score ("Last detection: 34 %"), real or false, so the slider can go just above a false one.
- "Not a cat" (2026-10-14) appears under the preview while a box is drawn. It marks the boxed spot, such as a drying rack, as no cat: a detection that overlaps it by more than half (intersection over union 0.5) is then dropped before it counts, is drawn or scores. A real cat in front of it still counts, since its box differs. The marked spots show grey over the preview; "Watch again" forgets them all, and so does turning the picture, as they no longer fit it.
- "Recent detections" (2026-10-14) lists the latest five arrivals with their pictures,
each with "Not a cat", since a false one is seldom on screen when it happens: himbeere2's
drying rack counted once at 09:09 for under three minutes. Each visit in
/healthz'srecentkeeps theboxesit arrived with, and the button adds those as ignored spots; one already ignored reads "Ignored". Visits from before that keep no boxes and are not listed. - A field renames the camera. The next webhook event,
/healthzand the web page (within 5 s) carry the new name; the WebRTC stream's own name, which a GStreamer viewer shows, stays the oneobservant.tomlgave it at start. - A preview of
/snapshot.jpg, reloaded every 3 s, shows the result, and a table what the camera runs: its sensor, frame size and rate, whether detection runs, the model's runs and the visits counted.
Each change is kept in the state directory (settings.json) and wins over the same key in
observant.toml, which the service cannot write; a key the page never set still follows the
file. /settings takes JSON only, so another site's page cannot change the camera with a
plain form post, and it checks a whole post before it applies any of it. On a page that is
not an ObservANT camera's own, the Camera page says no ObservANT answers.
The web build ships compressed. The packager runs the producer's --precompress over its
copy of the web build, so each file the server gzips has a zopfli .gz beside it: about 5 %
smaller than the server's own gzip, and nothing to compress on the board. A first visit from a
phone on the LAN is 4.46 MB on the wire, about 4.62 MB before (himbeere2, 2026-10-12); over
loopback 3.75 MB, 3.93 before. The gap is Flutter's renderer: skwasm_heavy (2.2 MB gzipped,
its own codecs inside) instead of skwasm (1.5 MB), because plain HTTP at a LAN address is no
secure context (What a browser downloads); only a certificate the phones trust would change
that. The copy leaves out what no browser fetches from this build: the
*.symbols debug maps, and the wimp and webparagraph renderers, which Flutter's loader
fetches only when the _flutter.loader.load call in flutter_bootstrap.js asks for them; the
packager stops if it ever does. The .deb is 88 MB, 81 MB before.
Where it runs
The owner's cameras on the ObservANT package, as their /healthz reported them on 2026-10-15
(himbeergsaelzweckle on 2026-10-16). Each serves its page on http://<host>:8080/
(http://himbeergsaelz.local:8080/ and so on) and keeps its own rotation in its
settings.json: turn a camera with ⟲/⟳ on its own Stream page, and the table's "turned"
follows.
| Host | Board | Camera | Version | Updates | One detection |
|---|---|---|---|---|---|
| himbeere2 ("Cat Cam Büro") | Raspberry Pi 5, Debian 13 | imx219 (CSI), turned 180° | 2.1.0 | nightly, from the releases | 0.26 s |
| tabblscatcam ("Cat Cam Futtertrog") | Raspberry Pi 5, Debian 13 | Camera Module 3, imx708 (CSI), turned 270° | 2.1.0 | nightly, from the releases | 0.30 s |
| himbeerGsaelz (since 2026-10-15) | Raspberry Pi 3 Model B, Debian 13 | Camera Module 3 Wide, imx708_wide (CSI), turned 180° | 2.1.0 | nightly, from the releases | 2.05 s |
| himbeergsaelzweckle (since 2026-10-16) | Raspberry Pi 4 Model B (8 GB), Debian 13 | Camera Module 3 Wide, imx708_wide (CSI), turned 180° | 2.1.0 | nightly, from the releases, as a user install (no sudo) | 0.91 s |
| mintberrycrunch ("Mintberry Cat Cam") | SpacemiT K3 (riscv64), Ubuntu 26.04 | Logitech C270 (USB) | 2.1.0 | by hand: built on the board, since no release carries riscv64 | 0.59 s |
- The first release reached a camera by itself. v2.1.0 was published at 22:32 on
2026-10-15. himbeere2's
omni-observant-update.service, started by hand a minute later, downloaded its.deb, checked it againstSHA256SUMS, installed it over 2.0.0, and the service came back with its 155 visits; a second run said "2.1.0 is up to date". tabblscatcam did the same from its timer alone at 03:37 on 2026-10-16 and came back with all 224 visits. - himbeerGsaelz's config says
inference = "on". A 1 GB Pi 3 reports 905 MiB, and 2.1.0'sautoskips the model below 1024 MiB; the next release'sautotakes 768 MiB (OxidANT f8a2dfc), so a new Pi 3 detects without the setting. Its model runs at 2 s, about seven times the Pi 5's, but only while something moves. - himbeergsaelzweckle is a user install, because no sudo password was at hand (Without sudo under Installing it; its field test below). The first real release to reach it will be the one after 2.1.0.
- A Pi Zero 2 W ran the earlier container shape, not this package, and so did a Pi 4 before himbeergsaelzweckle (the board table under Cat detection stream).
- Every camera lists the other four as
peers(2026-10-16), so each one's Stream page shows all five. The list sits in each camera'sobservant.toml; a new camera goes into all of them. - Two updates, two owners. ObservANT updates itself nightly from the releases (Updates from
the releases); the boards' operating systems update weekly through ANTfrastructure's Ansible
run (
linux/ansible/README.mdthere), which reboots a board only when its kernel asks for it. The Pi 4's camera came back by itself after such a reboot on 2026-10-16. - A camera added or moved goes into this table, with what its
/healthzsays.
Installing it
A new camera in five steps, on a Raspberry Pi or any Debian or Ubuntu board (arm64 or amd64):
- Flash the board, give it a name, and copy your SSH key to it
(
ssh-copy-id <user>@<host>.local). Attach the camera. - Fetch the newest release's package and check it, on the board:
v=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/Kataglyphis/OmniAccelerANT/releases/latest | sed 's|.*/tag/v||') arch=$(dpkg --print-architecture) base=https://github.com/Kataglyphis/OmniAccelerANT/releases/download/v$v curl -fsSLO "$base/omni-observant_${v}_${arch}.deb" && curl -fsSLO "$base/SHA256SUMS" sha256sum --check --ignore-missing SHA256SUMS sudo apt install ./omni-observant_${v}_${arch}.deb. The service starts, finds the camera by itself (camera = "auto", below) and comes back after every reboot; the nightly update timer keeps it on the newest release from then on (Updates from the releases). With ufw on,sudo ufw allow OmniObservANT.- Open
http://<host>.local:8080/. Turn the picture upright with ⟲/⟳ beside the camera's name on the Stream page (from the release after 2.1.0; on 2.1.0, on the Camera page), and name the camera on the Camera page; until then it shows its host name (from the release after 2.1.0; 2.1.0 calls every unnamed camera "ObservANT"). Each camera keeps both in itssettings.json. - Add the camera to Where it runs above.
Without sudo (from the release after 2.1.0), the tarball beside the .deb installs for the
calling user instead of step 3. The user needs the video group, which Raspberry Pi OS and
Debian give the first user:
curl -fsSLO "$base/omni-observant-${v}-linux-${arch}.tar.gz"
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf "omni-observant-${v}-linux-${arch}.tar.gz"
./omni-observant/observant --install --user
It copies the bundle to ~/.local/opt/omni-observant and sets up omni-observant.service and
the nightly omni-observant-update.timer under the user's systemd, with linger, so both start
at boot without a login. The settings are the user's own
~/.config/omni-observant/observant.toml (mode 600); the visits and settings.json live in
~/.local/state/omni-observant/. --no-autostart and
~/.local/opt/omni-observant/observant --uninstall --user [--purge] work as for the system
install. It refuses root, and refuses beside a system-wide install, since both would serve port
8080. Moving to the .deb later: --uninstall --user, step 3, and copy settings.json and
visits.json to /var/lib/omni-observant/ (owner omni-observant).
The AppImage either runs or installs. Started plainly, it runs ObservANT in the foreground
as the calling user, which needs the video group. --install copies it to
/opt/omni-observant and sets up the same service as the .deb:
- the unit goes to
/etc/systemd/system, andomni-observantto/usr/local/bin; --no-autostartleaves the service disabled;- running it again with a newer AppImage upgrades, keeping the settings and the enabled or disabled state;
omni-observant --uninstall [--purge]removes it;--install --userinstalls for the calling user instead, with no root (Without sudo above).
The installer is libexec/observant-install in the bundle (package/observant-install in the
tree), so the tarball's sudo ./observant --install does the same. It refuses while the .deb
is installed; the .deb's postinst calls its setup step too, and uses it to create the
user. Without FUSE, set APPIMAGE_EXTRACT_AND_RUN=1.
The flatpak starts at sign-in, not at boot, and takes USB cameras only. Install it with
flatpak install ./omni-observant-<version>-<arch>.flatpak; a desktop without the
freedesktop runtime it names fetches that from Flathub. Start it with flatpak run org.kataglyphis.omni-observant.
- It runs the same bundle, from
/app/observanton the image's freedesktop runtime, in a sandbox that has the network, every device (--device=all, as the app's flatpak has) and the host's~/.config/autostart. - Its command,
package/flatpak-omni-observant, seeds a per-user~/.var/app/org.kataglyphis.omni-observant/config/omni-accelerant/observant.tomlfrom the template on the first run and writes the sign-in entry.flatpak run --command=omni-observant org.kataglyphis.omni-observant --autostart off|onswitches it. - A flatpak cannot carry a systemd unit, so it starts when the user who ran it once signs in, never at boot.
- It cannot use a Raspberry Pi camera:
rpicam-vidis a host tool the sandbox cannot see, and the image's libcamera cannot drive a Pi 5. On a Pi, use the .deb or the AppImage.
What the bundle carries. Everything lives in /opt/omni-observant:
- the producer;
- the GStreamer plugins it uses (
rswebrtcand its WebRTC/DTLS/SRTP stack included), and NSS's crypto modules besidelibnss3; - the image's chain ONNX Runtime, proved by the hub's G6 census before packaging;
deim_dfine_n_coco.onnx(Apache-2.0; its license and notice inshare/doc/omni-observant/);- the web build;
- the image's own loader, glibc and GCC 16
libstdc++. The chain ORT needsGLIBC_2.43andGLIBCXX_3.4.36, which no current distro ships.
The observant launcher runs the producer through that loader with --library-path, never
LD_LIBRARY_PATH, so a child it starts, such as the host's rpicam-vid, keeps the host's
libraries. libcamera is the one library left to the host, because its IPA and tuning files
must match the host kernel.
What the .deb does on install.
- It creates the
omni-observantsystem user and adds it tovideoandrender. - It enables and starts
omni-observant.service, so ObservANT comes back after a reboot and after any exit of its own (Restart=always). The unit starts aftertime-sync.target, so on a board without a clock, where a time-wait-sync service is enabled, the first visit is not stamped with yesterday's time.systemctl disable --now omni-observantturns that off, and upgrades keep the choice.OMNI_OBSERVANT_AUTOSTART=0on the first install leaves the service disabled.
setupwrites/etc/omni-accelerant/observant.tomlonly when it is absent (the first install), with every setting commented out; nothing overwrites it.omni-observant --print-configshows the settings in effect.- Only root and the service's group read it (
640 root:omni-observant), because anotify_urlcan carry a webhook's secret. An install before 2026-10-13 wrote it world-readable; the next upgrade takes other users' read away and keeps its content. - It is deliberately not a conffile. dpkg asks about an edited conffile on every upgrade that changes it (keep or replace), and over SSH or in an unattended upgrade nobody answers, so the install hangs. Found on himbeere2, 2026-10-05.
apt purgeremoves it.
- Only root and the service's group read it (
sudo ufw allow OmniObservANTopens8080/tcpand the WebRTC range40000:40099/udp.
Cameras
How it picks a camera (camera = "auto"), in this order:
- A Raspberry Pi camera through the host's
rpicam-vid. The image's libcamera cannot drive a Pi 5, which is why the host tool goes first. libcamerasrc.- A USB webcam (
uvcvideo), MJPEG when it offers no raw format. - A test pattern as a stand-in, which re-probes every 30 s, so a camera plugged in later is picked up.
Detection, visits and /healthz
/healthz (fields: the endpoint table under The ObservANT package) gives last_seen in Unix
seconds and the latest 20 visits, newest first (recent: arrived, and stayed_s once the
cat has left). A quick look without WebRTC:
curl -o cat.jpg http://<host>:8080/snapshot.jpg.
ok is false when the camera does not deliver (2026-10-13), and problem says why:
"waiting for the first frame", "no frame for 25 s" after 20 s without one, or "no camera
found, streaming a test pattern". Until then ok was always true, so a monitor saw an
ObservANT whose camera had gone as healthy. The Stream page and the Camera page show the
problem in orange above the cat.
A box over nearly the whole picture is not a cat (2026-10-17, owner): a detection that
covers more than max_box_area of the frame (default 0.9) is dropped before it can count, as
one over a "Not a cat" spot is. Eight of himbeere2's last 20 visits on 2026-10-16/17 were such
boxes, 0.95 to 0.99 of the frame at scores near 0.3; a cat close to the lens fills about 0.7.
max_box_area = 1 keeps every box.
Counting. A cat counts after two detections in a row and is out of view after about 3 s
without one, so a lone hit near the score threshold draws no box and logs nothing. Its visit
ends a minute after its last sighting (2026-10-14): a cat back sooner, from behind the sofa or
the feeding bowl, continues the visit and is not announced again. At the feeding trough one cat
walking about had made 21 visits in an hour, 10 to 90 s apart. The departure event and
stayed_s come a minute later now, and stayed_s counts from the first sighting to the last.
Model pacing. While nothing moves and no cat is in sight the model runs only every 5 s
(idle_inference_fps), and not at all while the picture is black; motion brings back
inference_fps, and /healthz counts the runs (model_runs). On a Pi 5 that took a still,
empty scene from 74 % to 12 % of a core, and a dark room at night to 2 %. A cat in view that
does not move, asleep or a false one, is looked at half as often (2026-10-13): himbeere2's
drying rack, counted as a cat at 0.34 all afternoon, went from 69 % to 36 % of a core. Leaving
is motion, so the cat is out of view as fast as before. inference = "auto" turns the model
off below 768 MiB of RAM: a 512 MB Pi Zero 2 W reports about 430 MiB, a 1 GB Pi 3 905 MiB. It
was 1 GiB until 2026-10-15, which kept the Pi 3 from detecting at all.
A cat must move to arrive (2026-10-14). A detection starts a visit only where the picture
changed in the last 10 s, in at least two 8-pixel blocks inside its box or one block around
it, so a still object the model takes for a cat, like himbeere2's drying rack (92 "visits" by
13:40 on 2026-10-13), can no longer start one. Once a cat is in view, every detection keeps its
visit going, so a cat that sits down or falls asleep stays counted. What the camera sees as it
starts counts as just arrived, so a cat already there when the service starts is counted then.
The motion comes from the same 2 Hz looks that pace the model; with that pacing off
(idle_inference_fps at or above inference_fps, or inference_fps = 0), every detection
may start a visit, as before.
A live /healthz from himbeere2 (2026-10-14), its lists cut to two entries each and the
peers' addresses left out:
{
"camera": "Raspberry Pi camera via rpicam-vid (imx219 [3280x2464 10-bit RGGB] (...))",
"detection": {
"boxes": [],
"clip": 1791965878,
"clips": [1791965878, 1791961744],
"in_view": false,
"last_score": 0.6694751977920532,
"last_seen": 1791965880,
"recent": [
{ "arrived": 1791965878, "boxes": [[0.001, 0.693, 0.608, 1.0]], "stayed_s": 0 },
{ "arrived": 1791961744, "stayed_s": 172 }
],
"visits": 154
},
"frame": { "fps": 30, "height": 480, "width": 640 },
"frame_age_s": 0,
"inference": true,
"model_age_s": 0,
"model_runs": 2146,
"name": "Cat Cam Büro",
"ok": true,
"peers": ["http://<other-cat-cam>:8080", "http://<third-cat-cam>:8080"],
"problem": null,
"rotate": 180,
"score": 0.25,
"version": "2.0.0 (e7a7957)",
"viewers": 0
}
Before the first cat, last_score, last_seen and clip are null, and clips and
recent are empty.
model_runs counts since the service started. visits, last_seen, last_score, recent
and /arrival.jpg survive a restart: the service keeps them in /var/lib/omni-observant, its
systemd state directory, on every arrival and departure and once more when it stops. A visit
the stop cut short is closed at the cat's last sighting when the service starts again; after a
power cut, which writes nothing, at the last arrival or departure written. The flatpak keeps
them in its own ~/.var/app/…/.local/state, the Windows ObservANT in
%LOCALAPPDATA%\omni-observant.
The Stream page reads the same /healthz every 5 s while it shows, and at once when it comes
back from another tab (CatStatusLine in lib/Pages/StreamPage/cat_status.dart). Under the
video it puts ObservANT's name; "Cat in view", "Cat last seen: 14:32" or "No cat seen yet",
in English or German; the three latest visits ("Recent visits: 14:32 (3 min) · 11:05 (10
min)"; one still going on has no stay yet); and from the first visit on the last arrival's
picture. Its URL carries the visit count (/arrival.jpg?visit=3), not last_seen, which
moves on every model run while a cat stays, so the page fetches the picture once per visit. On
the photo, with the cat in view for 35 s, headless Chrome sent seven /healthz requests and
one /arrival.jpg (2026-10-12). Served from anywhere else, where /healthz is no
ObservANT's answer, it shows only the title.
Since 2026-10-14 the page also:
- says "Camera unreachable since 14:32" in orange once an ObservANT camera that answered stops answering, and keeps what it said last, its name, visits and picture;
- folds out the whole visit log ("All 20 visits") under the three latest;
- shows each earlier video with its arrival's picture (
/arrival.jpg?t=), so the right one is found at a glance; - closes the video in a tab hidden for a minute. ObservANT encodes for each viewer, so a forgotten tab cost a core's share all night; a glance at another tab keeps the picture, and showing the tab again opens a new session at once.
A tab you come back to plays again (2026-10-14). The owner's old tab stayed on "Der Videostream wird ausgehandelt" while a new one played: after a laptop sleeps or the Wi-Fi changes, the browser can hold a signalling socket that looks open and carries nothing, and gstwebrtc-api reconnects only on a close event, which never came, so the page asked a dead socket for a stream. Reproduced in headless Chrome through a proxy that silences the page's open connections and keeps new ones working. Three guards now:
- a tab that was hidden long enough to close its session rebuilds the signalling connection when it shows again, before it asks for a stream;
- a session that gets no answer within 15 s rebuilds it too;
- a picture whose decoded-frame count stands still for 10 s while streaming in a visible tab (the camera restarted behind the dead socket) closes the session and rebuilds it.
The cat line's and the overview's requests give up after 8 s, so one on a dead connection no longer waits for minutes. Measured: back after 75 s with the socket silenced, the video played again within 3 s; with the service restarted after the return, 9 s after it.
An overview of the other ObservANT cameras (2026-10-14). List them in observant.toml as
peers, each one's origin, on every ObservANT:
peers = ["http://192.168.1.51:8080", "http://192.168.1.45:8080"]
The Stream page then shows a card per peer under its own cat: the peer's name, a picture
refreshed every 10 s, and its cat line, or "Not reachable"; a tap opens that ObservANT's page.
A peer answers this page's reads (/healthz, /snapshot.jpg, /arrival.jpg,
/arrival.mp4) with Access-Control-Allow-Origin and Cross-Origin-Resource-Policy: cross-origin only when it lists this page's origin among its own peers; nothing else, and
no page outside the list, gets those headers. So list them both ways, and by the address the
browser uses.
Arrival picture, video and webhook
It can tell you the cat came. Set notify_url in observant.toml and the service POSTs a
JSON event there whenever a cat arrives or leaves:
{"event": "arrived", "name": "ObservANT", "time": 1791763273, "class": "cat",
"detections": 2, "best_score": 0.76, "snapshot": "/arrival.jpg"}
{"event": "clip", "name": "ObservANT", "time": 1791763273,
"best_score": 0.76, "clip": "/arrival.mp4", "snapshot": "/arrival.jpg"}
{"event": "left", "name": "ObservANT", "time": 1791763375, "stayed_s": 42,
"arrived": 1791763273, "best_score": 0.76}
The departure is posted a minute after the cat was last seen, so its time lies that much
after arrived plus stayed_s.
/arrival.jpg is the frame the arrival was confirmed on, boxes included, so a notification can
show what triggered it. Each of the last five arrivals keeps its own, /arrival.jpg?t=<time>
(2026-10-13), as the clips do: until then a false visit a minute later replaced the picture a
cat's notification showed.
A Home Assistant webhook trigger takes the events as they are: its URL
http://<home-assistant>:8123/api/webhook/<id> is the notify_url, and trigger.json.event
tells arrival from departure. https works too. Deliveries run on a thread of their own with a
5 s timeout. A failed one is tried again after 5 s, 15 s, 45 s and 2 min, so a Home Assistant
restart loses no visit; the events stay in order, and one older than ten minutes is dropped,
since a notification that late helps nobody. The log names only the URL's scheme and host,
because a webhook's path is often its secret.
Each arrival is a 10 s video too. The capture thread hands every annotated frame to
clip.rs, which keeps one in three (10 fps of the 30) for the last 3 s as buffer references,
so nothing is copied. An arrival takes those and the next 7 s to a thread of its own, which
encodes them with openh264enc at 800 kbit/s (about 1 MB a clip at 640x480), h264parse and
mp4mux faststart=true, so the index comes first and a browser starts playing at once.
/arrival.mp4 serves it with byte ranges, which Safari's <video> needs; /healthz names the
newest one's arrival as detection.clip; and notify_url gets a clip event once it is
written, with the arrival's time and best_score. A second arrival during a clip's 7 s gets
a clip of its own (2026-10-13): up to three record at once and share their frames, and two
finished ones wait for the encoder.
The last five clips stay addressable by arrival, as clip-<time>.mp4 in the state
directory, across a restart too: /arrival.mp4?t=<time> serves that arrival's own, and 404
once it is dropped, never a newer one. A false visit right after the cat (33 of them on
himbeere2's morning of 2026-10-12) would otherwise have put its clip behind the cat's
notification. The arrival event carries the visit log's own time, the one the clip is kept
under. The Stream page links the newest clip by its time once detection.clip is the newest
visit, so it never offers the previous cat's clip, and lists the other kept ones beside it
("Earlier videos: ▶ 18:07 ▶ 18:05", from detection.clips).
Measured on himbeere2. The packaged service, run on the cat photo at 30 fps (2026-10-12),
confirmed the arrival at 16:21:40 and served the clip at 16:21:47: seven seconds of
post-roll, and encoding and writing in under one. It was H.264 Constrained Baseline, which
every phone and browser plays, 640x480 at 10 fps, 78 frames (the pre-roll was short, as the
instance had just started) and 603 kB; /arrival.mp4 answered Range: bytes=0-99 with
206. With its own camera that evening (18:03 to 18:08) six arrivals of the drying rack at the
bottom of the frame made one clip each, the full 100 frames, of 108 to 182 kB: a still room
compresses far below the bitrate's ceiling. Memory plateaus rather than grows: forty 100-frame
640x480 encodes in a row (a release build, 0.6 s each) held the process at 70 MB from the
sixth on, glibc keeping the freed frames for the next clip, and the live service sat at 282 MB
for 20 minutes after fourteen arrivals, that plateau plus the model's.
clip_seconds sets the length (0 records none). Frames wait in memory until encoded, about
46 MB for 10 s, which is why the setting stops at 60. The package bundles isomp4 for
mp4mux (openh264 and videoparsersbad were in it already); a GStreamer without one of the
three records no clip and says which at start. test-observant-package.sh checks that the
photo's arrival yields one, that it is an MP4 that answers a byte range, that its event
reaches the webhook, and that a restart keeps it.
Home Assistant
A phone notification with the picture, through Home Assistant. This automation sends an
arrival to the Home Assistant companion app, with /arrival.jpg as its image and the live page
behind a tap. Running on himbeere2 since 2026-10-12 (Home Assistant 2026.10.1):
# automations.yaml; both values live in secrets.yaml, so this file can be committed.
- id: catcam_arrival_notification
alias: Katze ist da (Cat Cam)
mode: parallel # the clip's update must not wait behind the arrival's run
triggers:
- trigger: webhook
webhook_id: !secret catcam_webhook_id # notify_url ends in this
allowed_methods: [POST]
local_only: true
variables:
catcam: !secret catcam_base_url # e.g. http://<cat-cam-host>:8080
video: "{{ trigger.json.event == 'clip' }}"
conditions:
- condition: template
value_template: >-
{{ trigger.json.event in ['arrived', 'clip']
and (trigger.json.best_score | float(0)) >= 0.45 }}
actions:
# The arrival sends the picture; its clip, some seconds later, updates the same notification.
- action: notify.mobile_app_<phone>
data:
title: "🐱 Katze ist da"
message: >-
{{ trigger.json.name }}: um
{{ trigger.json.time | timestamp_custom('%H:%M') }} gesehen,
{{ ((trigger.json.best_score | float(0)) * 100) | round(0) | int }} % sicher.
data:
image: "{{ catcam }}/arrival.jpg?t={{ trigger.json.time }}"
clickAction: >-
{{ catcam ~ ('/arrival.mp4?t=' ~ trigger.json.time if video else '/') }}
tag: catcam-arrival
alert_once: true
- A webhook id is a password. Generate it (
python3 -c 'import secrets; print("catcam-" + secrets.token_urlsafe(24))') and keep it out of git. - Notify above 0.45, count from
score. On 2026-10-12 a white object at the bottom of himbeere2's frame made 33 "visits" between 0.26 and 0.44, and a real cat at the feeding trough scored 0.72 (2026-10-14). The condition keeps those off the phone while the visit log still counts them; raisingscoreinstead would drop them from both. It is not enough on its own: at 21:57 the same object scored 0.65 and reached the phone (BACKLOG, False arrivals). For a spot like that, raise that one camera'sscoreabove the false object's on the web app's Camera page (since 2026-10-13): it applies at the next model run, needs no SSH, and leaves the other cameras as they are. - The companion app's
minimalbuild has no push service. It receives notifications only over its own connection to Home Assistant, so set Persistent connection to Always in its server settings; thefullbuild gets them through Firebase. A push while the app is not connected fails withnot connected to local push notificationsand is gone, so himbeere2's automations send each notification three times a minute apart under onetagwithalert_once: true, andcontinue_on_erroron the call: a repeat replaces a delivered one silently and reaches a phone that reconnected. Each run then lasts about two minutes, so givemode: parallelamaxwell above its default of 10: on the morning of 2026-10-14 three cameras' arrivals, clips and departures overran it and Home Assistant dropped 30 events (Maximum number of runs exceeded), their counts included. himbeere2 runsmax: 40. - The picture loads from the phone, so
catcam_base_urlmust be an address the phone can reach: an IP or a name its network resolves, not loopback.
A test arrival posted to that webhook reached the owner's phone on 2026-10-12.
The same notification learns when the cat left. The departure (a minute after the last
sighting, Detection, visits and /healthz) names the arrival it ends (arrived) and that
arrival's best_score, so an automation that lets left through its condition too
(himbeere2's does; the one above does not) takes it under the same 0.45 and updates the
notification by its tag: "Katze war da … War 4 Min. da." A clip event carries stayed_s too
when the visit was over before its clip was encoded; with a visit lasting a minute past the
last sighting and a clip of at most 60 s, that hardly happens. himbeere2's automation keeps the
last departure in an input_text ("<arrival time> <seconds>"), so its minute-apart tries of
an arrival keep the stay a departure in between added.
At 21:00 a summary. Each notified arrival appends its time to another input_text, which
the midnight reset clears with the day's counter, and a time trigger sends "Heute 2
Katzenbesuche: 07:12, 18:30." or "Heute keine Katze gesehen." under a tag of its own.
More than one ObservANT. Each posts to a webhook of its own, and one automation takes them
all: a webhook trigger per camera with an id, and variables that map that id to the camera's
base URL (both in secrets.yaml), its video sensor and the place a notification names ("Katze
ist am Futtertrog"). The tag carries the id (catcam-arrival-<id>), so one camera's
notification never replaces another's, and the stay is kept as "<id> <arrival> <seconds>".
The counters count both, and the summary names the place with each time ("21:57 Büro, 22:30
Futtertrog"). A second rest entry polls the other camera's /healthz, and the outage alert
watches both online sensors, under one tag per camera. Since 2026-10-12 himbeere2's Home
Assistant takes its own cat cam ("Cat Cam Büro", on loopback) and tabblscatcam's ("Cat Cam
Futtertrog", a second Pi 5 with a Camera Module 3); a departure posted from tabblscatcam to its
webhook updated that camera's notification and stay.
Statistics and an outage alert, from /healthz. Home Assistant's rest integration polls
http://127.0.0.1:8080/healthz every 30 s when it runs on ObservANT's host:
detection.in_view, detection.last_seen, detection.visits,
detection.recent[0].stayed_s and ok become sensors, and with ObservANT unreachable they all
turn unavailable. On himbeere2 (2026-10-12):
-
the arrival automation increments two counters, today and total, so they count only what reached the phone; a template sensor with
state_class: total_increasingmirrors total, which lets astatistics-graphcard draw visits per day, and a time trigger resets today at midnight; -
binary_sensor.cat_cam_onlineunavailable or off for five minutes sends "ObservANT offline", and its return after such an outage sends "ObservANT wieder da"; a Home Assistant restart's few seconds stay quiet. Stoppingomni-observantfor 5 min 45 s sent the first at 5 min, on the dot. -
Off and unavailable say different things (2026-10-14). Since
/healthzreportsok: falsefor a camera without a picture, the online sensor is off while ObservANT answers but sees nothing, and unavailable only when it does not answer. A sensor per camera keeps the reason, and the alert names it: "hat seit 5 Minuten kein Bild (no frame for 300 s)" for the first, "antwortet seit 5 Minuten nicht" for the second.sensor: - name: ObservANT Problem unique_id: catcam_problem icon: mdi:camera-off value_template: "{{ value_json.problem or 'ok' }}" -
A sensor that has no value yet needs
availability, notnone. Aresttemplate that returnsnonerenders the text "None", which a timestamp sensor logs as an invalid timestamp on every poll and a duration sensor refuses at load (ValueError: ... has the non-numeric value: 'None'). A new camera has no sighting yet, and a visit in progress has a nullstayed_s, solast_seenand the stay are guarded withavailabilitytemplates ({{ value_json.detection.last_seen is number }}) and show unavailable instead.
The Stream page
An open page survives a restart of the service. The page sees its producer go and come
back and consumes the new one, with no reload: on himbeere2 (2026-10-12) headless Chrome's
video was playing at 30 fps again about 10 s after systemctl restart omni-observant, as an
upgrade does it.
A language or theme change keeps the picture. Either remounts the video box, and the
stream's state lives outside it: one WebRTCStreamController per page load
(lib/Pages/StreamPage/stream_controller.dart) registers the signalling listeners once. A
remounted view attaches to the session as it was. A view that comes back after the session's
30 s keep-alive opens a new one at once from the producers the client already knows, because
gstwebrtc-api never repeats a connected or producerAdded that has fired. A failed session
is retried after 3 s. In headless Chrome on a test pattern (2026-10-12) the video kept counting
about 33 frames a second through a switch to English and back, a theme toggle and two page
changes. Before, a language switch left the box at "Connecting to the signalling server".
What a browser downloads (measured on himbeere2, 2026-10-14, Chrome 154 on the LAN): the
first visit 4.5 MB in about 60 requests, the video playing after 3.6 s; every later visit
0.11 MB, the video after 2.9 s, because the server answers each file with an ETag and
Cache-Control: no-cache, and an unchanged one costs a 304 without a body. Half of the first
visit is Flutter's renderer, skwasm_heavy.wasm (2.2 MB gzipped). Flutter takes the heavy one
when the browser has no native image decoder, and Chrome offers WebCodecs' ImageDecoder only
in a secure context, which plain HTTP on a LAN address is not. So over http://<board>:8080
every browser gets it; served over HTTPS or from localhost, Chrome would take the lighter
skwasm.wasm. The app itself, main.dart.wasm, is 1.5 MB, the six Roboto files about 0.5 MB.
Visit statistics (2026-10-15). A "Visits" button shows the visits of the last two weeks,
today's count, the average stay, and bars per day and per hour of the day, counted from
/visits in the viewer's time zone (lib/Pages/StreamPage/visit_stats.dart).
One port reaches it. The page and the /webrtc-ws signalling share :8080: the service
forwards the WebSocket upgrade to its own signalling server on loopback :8443. Media goes
over host candidates only unless stun_server is set, so the stream stays on the LAN. No TLS,
no serve.sh, no container.
A TURN relay needs its credentials (2026-10-21). Each turnServers entry in
assets/settings/webrtc_settings.json is {"urls": "turn:<host>:3478", "username": "<user>", "credential": "<password>"}, urls a string or a list. Chrome refuses every peer connection
over a TURN URL without both, so an entry missing one is skipped with a log line. The committed
list stays empty: real credentials go into the served copy, assets/assets/settings/webrtc_settings.json
under the web root (build/web before a deploy, /opt/omni-observant/web on a board, where a
package upgrade replaces it), never into a repository. Every viewer's browser receives them, so
give the relay a user of its own.
Questions on the Stream page
When /healthz says "ask": true, the Stream page under Visits offers Ask about the visits: a
question field from ANThology's chat widgets and a Summarise today button. They call ObservANT's
/ask and /summary
on the page's own origin, with the browser's offset from UTC and the app's language.
- A camera without a model shows nothing. So does an older camera, and every native build, whose Stream page plays the local webcam and has no ObservANT to ask.
- The answer comes from the visit figures, not the picture, and a language model can be wrong. The panel says so, and What the model was told shows the figures behind the last answer.
- Failures read as the camera's words: no model answering, a question already running, a model that failed. The stop button closes the request, which cancels the answer on the camera.
- A camera needs a model named in its config. An ObservANT package carries no GenAI library
and no weights, so name an endpoint (
llm_base_url,llm_model) or a chain install throughONNX_GENAI_ROOTandllm_model_dir. - A camera's overview of its peers offers no questions:
/asktakes only its own page.
Tests: test/visit_questions_test.dart (the conversation and the panel on a fake camera) and, in
Chrome, test/observant_api_web_test.dart (the POST, its status and the cancel).
What it detects
Cats by default, any of the 80 COCO classes on request (2026-10-14). classes in
observant.toml names them (["cat"] unless set); the Camera page's "What it detects" adds
one from a menu that filters as you type, or removes one by its chip; the last cannot go. A
change applies at the next model run and stays across restarts. Everything that counts a cat
counts any of them: visits, the webhook events, the arrival photos and clips, the boxes. Each
visit in /healthz's recent and each arrived event names its class, the class of its
best detection; /healthz lists classes. all_classes = true still counts every class. The
privacy filter keeps every watched class sharp, so watching person shows people. While it is
on, only observant.toml sets the classes: the Camera page shows them without the editor,
POST /settings refuses other ones, and classes a page saved earlier give way at the next
start (2026-10-14).
Stats for developers
A "Stats" button under the camera's name (2026-10-14) opens a panel that refreshes every
2 s while open: what ObservANT delivers and costs (/healthz's stats: frames per second
against the configured rate, the model's time per run and runs per second, its CPU share of
one core, the cores, memory, the 1-minute load, the board's temperature, how long it has run,
the viewers) and what this browser makes of the stream (frames per second received, the
picture's size, frames dropped so far). Closed, it polls nothing.
The privacy filter
For showing ObservANT in public without showing the home (2026-10-14). With
privacy = "pixelate" in observant.toml, everything but the detected animals leaves the
camera as coarse blocks: 32 pixels at 640 wide, each its own mean colour. Only the boxes stay
sharp, each grown by 15 % on every side, since the model's boxes are up to half a second old.
With no box the whole picture is blocks, people included.
- It applies to everything that leaves the camera: the live stream,
/snapshot.jpg, the arrival photos and the clips. The model still sees the whole picture, so detection works as before. - Only the settings file sets it, then
sudo systemctl restart omni-observant. No page can turn it off, so a visitor at a demo cannot either; nor can one unblur people by watchingperson, since the filter locks the classes toobservant.toml's (What it detects)./healthzsays"privacy": "pixelate"; the Stream page shows "Privacy filter on" under the camera's name, and the Camera page names it in its table. - Arrival photos and clips recorded before it was turned on stay as they were. The other
ObservANT cameras in
peersshow their own pictures, filtered only where they set it too. - It costs little: one pass over each frame, no second model.
Tests
test-observant-package.sh checks all three packages, and CI runs it: web.yml's
observant jobs, on x64 and arm64, right after packaging. It runs as root in a privileged
:latest, prints one PASS or FAIL per check with its seconds, counts them on the hub's
lib/test-harness.sh and exits 0 only when all pass. A package without debugutilsbad reds
five of its checks. Locally:
bash scripts/linux/observant/test-observant-package.sh # root, inside :latest; finds out/'s packages
What it checks, for the .deb and the AppImage each installed in :latest and run as the
unit runs it, with a scrubbed environment as omni-observant:
- no shared object maps from outside the bundle, idle or with a viewer connected;
- the package's own files: the service user is in the
videogroup, the settings file is640 root:omni-observant(an older world-readable one is made private), and the unit restarts whatever ended it and starts once the clock is set; /healthzsaysokwith noproblemwhile frames come, and names the installed package's version;- the Camera page's settings:
/settingsturns the picture and sets the score and the name, a restart keeps them, and it refuses a form post, a bad value and another site's page; - "Not a cat" over the photo's boxes ends its visit, and the still photo watched again starts none;
apt removemasks the unit and a reinstall unmasks and enables it; the AppImage's--installrefuses a non-root caller, and its installed copy refuses--install;- every setting the shipped
observant.tomldocuments, uncommented, is one the service reads (--print-configtakes them all); - the installed unit loads without a warning (
systemd-analyze verify) and keeps its sandbox:systemd-analyze securityrates it 2.5 or less (1.8 on 2026-10-12, 8.7 before the unit was sandboxed); - headless Chrome played the stream over the LAN address;
- a tab put in the background, its connections then silenced by a proxy that keeps them open
and carries nothing (
lib/silencing-proxy.py, as a sleeping laptop leaves them), plays again after it shows (lib/cdp-tab-return.mjs, 2026-10-14); - the bundled model found the cats in ANThology's
Summy&Thundyphoto (best score 0.41 since 2026-10-13, 0.76 before: the portrait photo is bordered into the 4:3 frame now instead of stretched, so the cats are smaller),/healthzcounted the visit and/snapshot.jpgserved the frame as a JPEG; - the arrival reached a stand-in webhook as a JSON POST, and
/arrival.jpgserved the frame it was seen on; - the arrival's clip was encoded and named first in
/healthz'sclips,/arrival.mp4served it as an MP4 and answered a byte range,?t=served that arrival's own and404for another, and the clip's event reached the stand-in webhook; - the visit log timed the stay while the cat was in view, and a restart kept the visit, the arrival frame and the clip, and closed that visit at its last sighting;
- the web build left out the
wimpandwebparagraphrenderers and the*.symbolsdebug maps, every file the server gzips came with its zopfli.gz, and the servedmain.dart.wasmwas that.gzbyte for byte; - SIGTERM exits 0, and no inference fails on the way out;
- a first start on a fresh registry still streams when the plugin directory lists
libgstrswebrtc.sofirst (a tmpfs bind-mounted over the bundle'slib/gstreamer-1.0). An in-process registry scan registers webrtcsink before its encoders in that order, its codec list stays empty and the stream fails withNo codec available for encoding stream video_0; which order a host gets depends on its filesystem, so the other checks pass or fail by chance. The launchers name the bundled scanner inGST_PLUGIN_SCANNER, which keeps OxidANT'sensure_gst_initializedfrom switching to the in-process scan (OxidANT'sdocs/observant.md§ A fresh registry and webrtcsink); - the AppImage's
--install, a second--installover it, and--uninstall --purgeleft the expected files, and then none; - the .deb installed over an edited
observant.tomlwith no terminal, kept the edit, anddpkg --purgeremoved it; - the flatpak, installed system-wide (a user install needs a D-Bus session the container
lacks), answered
/healthzfrom its sandbox, kept the cat's visits in its own state directory, and headless Chrome played its stream over the LAN address. Its first start wrote the sign-in entry,--autostart offremoved it, andflatpak uninstallleft nothing.
Results: on amd64 in :latest (2026-10-06) all 32 checks passed, and Chrome played 572 frames
from the sandboxed stream; the flatpak bundle is 63 MB. On arm64 on himbeere2 (2026-10-12) all
43 checks passed, with about 510 frames to Chrome each time, and all 47 once the arrival clip added
four; all 51 on 2026-10-13, three of them on the precompressed web build; all 69 on 2026-10-14,
in 244 s (each line names its seconds; the tab that comes back takes 113 of them, as it must
stay hidden past the page's 60 s). CI passed the 41 from before the visit log on both
architectures (web.yml run 38197312619).
Field tests on real boards
What each board showed when a package or install path first ran on it; today's cameras are in Where it runs.
On a Raspberry Pi 5 (himbeere2: imx219, Debian 13, kernel 6.18), 2026-10-05. The arm64
packages were built natively on the board in :latest (CI's web.yml builds them on
ubuntu-26.04-arm). The .deb ran as the systemd unit:
- the camera came through
rpicam-vid, withrotate = 180from the settings; - exactly one
rpicam-vidchild ran, and no shared object mapped from outside the bundle; - headless Chrome on another LAN host played 821 frames in 28 s;
- with
inference_fps = 2the service used 132 % of one core, and the board ran at 63 °C; - the same path, fed ANThology's photo, delivered the green YOLO boxes to that browser;
- after a reboot the unit was active about 11 s after the kernel started, on the Pi camera again, and the browser played about 30 fps;
- a firewall needs rules: with ufw active,
sudo ufw allow OmniObservANTopens 8080 and the WebRTC UDP range; himbeere2's existing rules already covered both; - a dark room is a black stream: the imx219 has no IR, and the sensor itself read a mean Y of 0.5 out of 255 at night.
On a SpacemiT riscv64 board (mintberrycrunch: Ubuntu 26.04, 8 cores, 16 GB, Logitech C270),
2026-10-13. package-observant.sh built the riscv64 .deb natively on the board in the
riscv64 :latest in 7 min 53 s (the producer's cargo build 6 min 10 s); a Raspberry Pi 5
cannot emulate it, because its 16 KiB pages break QEMU on the image's libraries. The unit:
- found the webcam on its own (
v4l2src /dev/video9 (C270 HD WEBCAM)) among the SoC's codec and ISP nodes; - needed
sudo ufw allow OmniObservANT, as the package says, before the LAN reached it; - streamed to headless Chrome on himbeere2 at about 24 fps (767 frames in 35 s);
- used 15 % of one core and 282 MB with the room empty, the model running every 5 s.
The AppImage on himbeere2, 2026-10-06:
- run plainly as the login user, it mounted through FUSE, and Chrome on another host played 844 frames in 28 s;
- the switch a user might make,
.deb→apt remove→ AppImage--install→--uninstall→.deb, ended enabled and running at every step, on the Pi camera, withrotate = 180kept. It took two fixes:observant-installtreats the removed .deb's mask as a first install, and it clears that package'sdeb-systemd-helperrecord, settingDPKG_MAINTSCRIPT_PACKAGE, since the tool refuses to run outside dpkg otherwise. Without that, the returning .deb read the AppImage's--uninstallas the admin's disable and stayed off; --installwrote a unit byte-identical to the .deb's.
On a real desktop (Ubuntu, GNOME, amd64), 2026-10-08. CI's flatpak from web run
37664412593 installed as a user install. flatpak install --user fetched its runtime from
Flathub: freedesktop 24.08, which Flathub marks end-of-life, because that run's :latest
predates the 26.08 refs (hub CON82). Its first run seeded the per-user observant.toml, wrote
~/.config/autostart/org.kataglyphis.omni-observant.desktop, loaded the chain ORT and the
model from /app/observant, and answered /healthz (test pattern, no camera on that host).
The host's Chrome played 582 frames from the LAN address.
The user install on a Raspberry Pi 4 (himbeergsaelzweckle: 8 GB, Debian 13, Camera Module 3
Wide), 2026-10-16. 2.1.0's tarball predates --install --user, so its libexec/ scripts
and share/omni-observant/ units were the repository's, plus a version file. Under the
user's real systemd:
--install --userreplaced a hand-written user unit, keptrotate = 180fromsettings.json, turned linger on and armed the update timer;- the camera came through
rpicam-vidat 30 fps, with 7 % CPU, 216 MB and 0.91 s per detection; - the timer's own
omni-observant-update.servicetook a local stand-in release "2.1.1": checked its tarball againstSHA256SUMS, installed it, restarted the camera and left no download.
What no board has shown yet, such as the flatpak's start at a real sign-in or a USB webcam on
amd64 or arm64, is in BACKLOG.md under Open — ObservANT.
Updates from the releases
Since 2026-10-15 the .deb updates itself. omni-observant-update.timer, enabled at install
like the service, runs /opt/omni-observant/libexec/omni-observant-update as root every night
at 03:30, spread over 45 minutes. It follows GitHub's releases/latest redirect to the newest
v<version> tag, and only when that version is newer than the installed one (never a
downgrade) downloads omni-observant_<version>_<arch>.deb, checks it against the release's
SHA256SUMS and installs it with dpkg -i; the package's postinst restarts the service. No
release yet, a latest release from before the v tags, or one without this architecture's
package (riscv64, which no CI lane builds) leaves the camera as it is.
- Off:
sudo systemctl disable --now omni-observant-update.timer, which an upgrade respects. - Check at once:
sudo systemctl start omni-observant-update.service;journalctl -u omni-observant-updateshows what it did. scripts/linux/observant/test-observant-update.shruns it inweb.yml's package jobs against a local stand-in for GitHub with fake packages: an update, no downgrade, a failing checksum and a missing architecture.
A user install updates itself the same way, without root (2026-10-16). Its user timer runs
~/.local/opt/omni-observant/libexec/omni-observant-update --user, which compares the newest
release with the bundle's share/omni-observant/version, downloads the release's tarball
(omni-observant-<version>-linux-<arch>.tar.gz; no FUSE needed, unlike the AppImage), checks
it against SHA256SUMS and runs its observant --install --user. The download goes to
~/.cache/omni-observant/update and is removed after: /tmp can be RAM, and a Pi Zero 2 W
has 512 MB. Without dpkg the versions compare with sort -V. systemctl --user start omni-observant-update.service checks at once. test-observant-update.sh covers it too, as
uid 1001 and once with no dpkg on the PATH; test-observant-package.sh installs the
AppImage's --install --user as a plain user, runs it, upgrades it and purges it.
The Windows ObservANT
scripts/windows/observant/, built and tested in :winamd64 (2026-10-06), is the Windows twin
of the package above. scripts/windows/observant/Package-ObservANT.ps1 builds one install
folder and the zip and the MSI around it (observant.wxs through the hub's
Invoke-MsiPackage). Like package-observant.sh it stamps the build into the exe as
OBSERVANT_VERSION (the version and the short commit), which /healthz and the Camera page
show. Run it inside :winamd64 with the web lane's build:
pwsh -File scripts\windows\observant\Package-ObservANT.ps1 -WebRoot <build\web>
# out\omni-observant-<version>-windows-x64.msi (one click: double-click it)
# out\omni-observant-<version>-windows-x64.zip (the same folder, portable)
# with -Msix also:
# out\omni-observant-<version>-windows-x64.msix (signed; see "The MSIX" below)
# out\omni-observant-<version>-windows-x64-testcert.cer (only without -MsixPfx)
Windows arm64 (since 2026-10-16). -Arch arm64 cross-builds the producer for
aarch64-pc-windows-msvc inside the image's arm64 bundle (:winarm64), takes its DLLs from
the arm64 runtime and the VC++ runtime from Visual Studio's arm64 redist, never the x64
container's System32, and names the files …-windows-arm64.*. An x64 host cannot run them,
so web.yml's observant-windows-arm64-test installs the MSI on windows-11-arm: first the
hub's arch gate over the unzipped package (67 binaries, all arm64, every import resolved),
then the same Test-ObservANTWindows.ps1, which there also checks the real firewall rule.
That host has no GStreamer of its own, so the viewer checks say SKIP; the x64 run plays one.
Media Foundation on a desktop loads Microsoft's own Store codecs (HEVC, VP9), which the module
check counts as Windows. ONNX Runtime's own imports are part of the DLL closure: its
msvcp140_1.dll was missing from the first arm64 package, and from the x64 one on a PC
without the VC++ redist.
One folder. C:\Program Files\ObservANT holds:
kataglyphis_observant.exe;- the DLLs it and its plugins import (the hub's
Copy-PeImportClosure), plus the VC++ runtime, which a clean Windows lacks; - the GStreamer plugin subset in
lib\gstreamer-1.0, with Media Foundation and kernel streaming for the camera, andopenh264as the stream's codec. openh264 is a required plugin on Windows, because the image builds novpx; - the plugin scanner;
- the chain ONNX Runtime from
Copy-ChainOrtBeside, proved by the hub's G6 census (New-OrtProvenPayload) before packaging; models\deim_dfine_n_coco.onnx(its license and notice inshare\omni-observant\) and the web build.
The web build is cut and compressed as on Linux (2026-10-17; § The web build ships compressed).
Package-ObservANT.ps1 leaves out the wimp and webparagraph renderers and the *.symbols
maps, about 18 of the build's 55 MB, and stops if flutter_bootstrap.js ever asks for those
renderers. On x64 it runs the producer's --precompress over its copy, so the service sends
zopfli .gz files. The arm64 package has none: its producer cannot run on the amd64 host that
packages it, so that service gzips at run time, as every Windows one did before.
Test-ObservANTWindows.ps1 checks the cut, that main.dart.wasm goes out gzipped and unzips
to the installed file, and on x64 that it is the packaged .gz byte for byte and every file
the server gzips has one.
The exe points GStreamer, ONNX Runtime and the model at that folder itself (OxidANT's
install.rs), so it needs no launcher and nothing on PATH.
The MSI installs per machine. It adds:
- a Start menu folder, ObservANT, with ObservANT and ObservANT page;
- an entry (shortcut) in the all-users Startup folder, so ObservANT starts minimized at every logon. A logon task, by owner decision on 2026-10-06: a boot-time service would run in Session 0, and nobody has yet shown that a service can open a webcam there.
Both entries are features: deselect them in the installer, or switch autostart off later in
Task Manager under Startup apps. Settings go in %ProgramData%\omni-accelerant\observant.toml;
the template ships in share\omni-observant.
The MSI opens Windows Firewall to the local subnet, so Windows does not ask. The rule is
bound to the exe, not to ports, so a changed http_port or ICE range in observant.toml stays
covered. It carries IgnoreFailure: a Windows without the firewall service, such as the
image's process-isolated container, still installs. Packaging needs WiX's firewall extension,
which the hub's final stage installs (WIX_FIREWALL_EXT_VERSION).
Test-ObservANTWindows.ps1 checks the MSI inside :winamd64 as admin. It and
Test-ObservANTMsix.ps1 use ANTfrastructure's WindowsSmokeTest.Common assertions and exit
with the failed check count. In the image, 14 checks passed on 2026-10-06:
- the MSI's firewall rule: LAN-only, bound to the exe, installing where it cannot apply;
- the silent install, its files, the Startup entry and the shortcuts;
- the installed exe run with a scrubbed environment, only System32 on PATH:
/healthzand the page answer, and every loaded module comes from the install folder or Windows, idle and with a viewer; - a
webrtcsrcviewer decoding 60 frames: its EOS, which onlyidentity eos-after=60sends, is the proof. After it the signaller can lose its peer beforegst-launchtears down and exit 1 withsend failed because receiver is gone(once in the 2.2.0 dry run, 2026-10-18); that error alone after the EOS still passes, any other fails; - the bundled model on the bundled ORT finding the cat;
- a silent uninstall that leaves nothing.
The container has no firewall service, so the test prints SKIP for the rule itself; on a host
whose firewall runs, two more checks run: the rule exists after the install and is gone after
the uninstall. web.yml's observant-windows job packages and tests the MSI around each run's
web build.
On a real Windows 11 host (the dev box, no camera, 2026-10-06), the MSI installed and uninstalled cleanly through UAC:
- its firewall rule was an inbound allow for the exe, limited to
LocalSubnet; - started from the Startup shortcut, the exe served the page and a test pattern
(
no camera found), with all 128 modules from its folder or Windows; - a browser on another device on the LAN played the stream through that rule.
The MSIX (-Msix) packs the same folder, which G6 has already proved, with
AppxManifest.xml. It installs per user, needs no admin, and adds three things:
- a
desktop:StartupTaskfor the logon start, which Startup apps turns off; - the
omni-observantalias, the Linux packages' command name; - two firewall rules, TCP 8080 and UDP 40000-40099, on private and domain networks. MSIX rules
take ports and profiles but no remote scope, so unlike the MSI's rule they are not limited
to the local subnet, and a changed
http_portor ICE range is not covered.
An MSIX installs only when Windows trusts its signer:
-MsixPfxsigns with your certificate; its password goes inMSIX_PFX_PASSWORD, and-MsixPublishermust equal its subject.- Without it, a certificate made for that build signs, and its
.cerlands beside the package. Import it intoLocalMachine\TrustedPeople(admin), then double-click the.msix. Package-ObservANT.ps1refuses an unsigned result, because the hub's signing step only warns when it fails.
Test-ObservANTMsix.ps1 checks the MSIX.
- Everywhere, in CI too: the signature against the publisher, the manifest's startup task, alias and firewall rules, and the payload; six checks.
- With
-Install, on a desktop host: it installs for the current user, finds the rules in Windows Firewall, starts the alias, reads/healthz, checks that every module comes from the package or Windows, and removes the package again.
Server Core cannot deploy an MSIX (0x80073D19), so CI runs only the first half. On the dev
box (2026-10-06), with the test certificate trusted: all 12 checks passed, himbeere2 reached the
page through the MSIX's rules, and the certificate came out of the store afterwards.
Not verified yet: a camera on Windows, the start at a real sign-in (the MSI's Startup entry and the MSIX's startup task), and arm64.
Cat detection stream (Rust, native)
third_party/OxidANT/crates/observant (kataglyphis_observant) is the maintained producer:
V4L2 or libcamera capture → ONNX detector (cats = COCO class 15) → boxes burned into the RGBA
frames → webrtcsink. It runs its own signalling server (run-signalling-server=true, plain
ws://, default port 8443), so no separate signalling process is needed.
cd third_party/OxidANT
cargo build --release -p kataglyphis_observant
# The image's chain ONNX Runtime - OxidANT's loader refuses any other (2026-09-23),
# so this runs in the :latest image, where the path is the loader's fallback too.
ORT_DYLIB_PATH=/usr/local/lib/onnxruntime-cpu/lib/libonnxruntime.so \
target/release/kataglyphis_observant --v4l2 /dev/video0
Since OxidANT d788e1a this binary is the service the package above installs:
- It also answers on
:8080: the web build from--web-root, plus/webrtc-wsand/healthz.--http-port 0turns it off. - Its signalling server listens on loopback only. A
serve.shon the same host still reaches it; aserve.sh --producer-hoston another host needs the producer started with--signalling-host 0.0.0.0, whichrun-producer-pi.shpasses. - The old camera flags keep working;
--camera auto,--configand--print-configare added beside them.
Flags.
--teststreams avideotestsrcpattern;--image <file>(or$KATAGLYPHIS_CAT_IMAGE) loops a still image. There is no default picture any more (OxidANT 4e68a02, 2026-09-15; ANThology'sThundy.jpgis only named in the error hint). Without one of these the producer takescamerafrom its settings,"auto"by default: a Raspberry Pi camera if attached, else a USB webcam. The model default still resolves relative to the crate, so any checkout works.--score,--width/--height/--fps,--all-classesand--nametune the stream.--cert/--keyenable WSS on the built-in server, but terminate TLS in a proxy instead, asserve.shdoes (why: No secure context needed).--libcameracaptures throughlibcamerasrcinstead ofv4l2src; use it for the Raspberry Pi CSI camera, whoserp1-cfeV4L2 nodes carry raw Bayer thatvideoconvertcannot process.--no-inferenceskips the model entirely (no ORT, no detector, frames published unannotated), for camera/WebRTC bring-up and for hosts too weak to run the model.--rotate 90|180|270turns the stream clockwise withvideoflip(180 for a camera mounted upside down), and so do the page's ⟲ and ⟳ while it runs; inference sees the turned frame, so the boxes still line up.
Inference runs on a background thread, so the WebRTC stream keeps camera rate while the boxes lag one inference behind (seconds per frame on a Pi 5 CPU).
Serve the web build with the /webrtc-ws proxy. serve.sh also sends COOP/COEP, which only
the optional Rust core needs (No secure context needed); the Stream page streams without
them, so --http drops TLS and the certificate warning with it:
# once, in the repo root: the web frontend. web/pkg/ is a generated frb
# artefact (gitignored); this is what ci-container-run-web-linux.sh runs, with the
# image's dated nightly (the floating one breaks in :latest, AGENTS.md § 4)
nightly="$(sed -n 's/^RUST_NIGHTLY_TOOLCHAIN=//p' third_party/ANTfrastructure/linux/scripts/01-core/versions.env)"
rustup toolchain install "$nightly" --component rust-src --target wasm32-unknown-unknown # :latest has it
command -v flutter_rust_bridge_codegen || cargo install --locked --version 2.13.0 flutter_rust_bridge_codegen # the pin in third_party/OxidANT/Cargo.toml
flutter_rust_bridge_codegen build-web --release --rust-root third_party/OxidANT --wasm-pack-rustup-toolchain "$nightly"
flutter build web --release --wasm --no-web-resources-cdn
scripts/linux/observant/serve.sh # :8444 TLS, proxies to :8443
scripts/linux/observant/serve.sh --http # :8444 plain HTTP, no certificate
One native Linux host does everything, a Raspberry Pi included: the producer above, this web
build and serve.sh. serve.sh also takes --producer-host/--producer-port to front a
producer on another board, and --state-dir so several instances (one per producer) can run
side by side.
The boards run the ObservANT package (the current list: Where it runs):
producer, web build and page together on http://<board>:8080/. The producer on :8443 with
serve.sh + the web build on :8444 is the manual shape, for a board without the package or
an ad-hoc look at another board via --producer-host. The boards that shape was brought up on,
before the package:
| Board | Arch | Camera | Producer | Board-side quirks |
|---|---|---|---|---|
| Raspberry Pi 5 | arm64 | imx219 (CSI) | Rust, inference | host-libcamera swap (rp1/pisp entity rename + libpisp 1.7) |
| Raspberry Pi Zero 2 W | arm64 | imx708 (CSI, mounted upside down) | gst-launch, no AI (512 MB) |
host swap (vc4), videoflip method=rotate-180 |
| Raspberry Pi 4 | arm64 | imx708 (CSI, mounted upside down) | Rust, inference | host swap (vc4), /dev/dma_heap ACLs, GCC 16 libs for ORT, --rotate 180 |
| SpacemiT X100 | riscv64 | Logitech C270 (USB) | Rust, inference | no libcamera; camera ACL + udev rule, UFW 8443/8444 + UDP |
All four run the same :latest (a multi-arch index) and the same web build; the board-side
pieces are the producer, serve.sh and nginx.
Starting is one command, and autostart is opt-in. Each board carries a small local
~/cat-cam.sh start|stop|status that brings up (or tears down) its producer and its nginx
together and prints the board's own URL; after a reboot that script is the whole procedure. A
board that should come up on boot opts in with a systemd unit whose ExecStart is that script:
the Zero runs cat-cam.service (Type=oneshot, RemainAfterExit=yes, User=<user>,
ExecStop=~/cat-cam.sh stop), the others deliberately have none. start removes a stale
container first: one left in Created state after a crash blocks the named run with
name-store error and would fail the boot start.
signalingServerUrl in assets/settings/webrtc_settings.json is host-relative by default
(/webrtc-ws); the web client resolves it against the page's origin, so the same build works
on localhost, a LAN IP and a Raspberry Pi. Open https://<host>:8444/, on the phone too, and
accept the certificate warning, or run serve.sh --http and open http://<host>:8444/, which
has none.
USB cameras work with --v4l2 /dev/videoX. For a Pi's CSI camera use --libcamera; on a
Raspberry Pi 5 running the :latest image the container route is:
# The producer runner lives in OxidANT, which owns crates/observant.
third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh --build
scripts/linux/observant/serve.sh # :8444 TLS, proxies to :8443
Why the Pi 5 needs a runner script. The image ships upstream libcamera 0.7.2 / libpisp 1.5,
which cannot drive a Pi 5 on kernel 6.18: the kernel renamed the rp1-cfe media entities to
underscores (rp1-cfe-fe_image0) and moved to the libpisp 1.7 uAPI, so the pipeline handler
cannot acquire the CFE and the upstream IPA segfaults when isolation is forced.
third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh collects the host's Raspberry
Pi OS libcamera stack (0.7.2+rpt, which matches the kernel) plus its library closure into
OxidANT's own third_party/OxidANT/build/observant/hostlibs, mounts that ahead of the image's
copy, grants the rootless container ACL access to /dev/{video,media,dma_heap}*, and runs with
seccomp=unconfined (the IPA proxy forks). GStreamer 1.29, webrtcsink, ONNX Runtime and the
Rust binary still come from the image.
Build with the runner (--build): it mounts OxidANT at /workspace, and a binary built from
the superproject layout instead carries a compiled-in model path pointing at
/workspace/third_party/OxidANT/resources/..., which does not exist under that mount; the
producer then dies with No ONNX backend available. Cargo caches by source mtime, so after
switching contexts touch crates/observant/src/main.rs forces the recompile.
Raspberry Pi Zero 2 W. The image runs there too, but the Rust producer does not fit the
board (512 MB RAM, no practical way to build on it), and the image's libcamera cannot drive the
Zero's camera either: with rpi/vc4 on the imx708 its isolated IPA process worker dies on
start (Failed to call start: -110, then the socket is unreachable), while the host's rpt
build runs the threaded proxy and works. The working no-AI recipe is the container plus the
same host-libcamera swap, driven by gst-launch. Copy the closure collected by OxidANT's
third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh (or refresh it there with
--libs-only) to the Zero, then:
# On the Zero. ~/cat-cam/hostlibs is the dev host's
# third_party/OxidANT/build/observant/hostlibs, and IMAGE is the family CI
# reference — composed on the dev host by
# bash third_party/ANTfrastructure/linux/scripts/ci-image-ref.sh
# and carried over, never typed out, so a tag bump in the hub's versions.env
# reaches this recipe too.
IMAGE=<the line that command printed>
sudo nerdctl run --rm --name zero-producer --user 0:0 --privileged \
--network host -v /dev:/dev -v /run/udev:/run/udev:ro \
-v /usr/lib/aarch64-linux-gnu/libcamera:/usr/lib/aarch64-linux-gnu/libcamera:ro \
-v /usr/share/libcamera:/usr/share/libcamera:ro \
-v "$HOME/cat-cam/hostlibs":/hostlibs:ro \
-e LD_LIBRARY_PATH=/hostlibs:/opt/gstreamer/lib/multiarch:/opt/gstreamer/lib:/usr/local/lib:/opt/opencv5/lib:/usr/lib/aarch64-linux-gnu \
--entrypoint bash "${IMAGE}" \
-lc 'exec gst-launch-1.0 -e \
webrtcsink name=ws run-signalling-server=true signalling-server-host=0.0.0.0 signalling-server-port=8443 meta="meta,name=Zero-Cat-Cam" \
libcamerasrc ! video/x-raw,format=RGB,width=640,height=480,framerate=15/1 ! videoconvert ! videoflip method=rotate-180 ! video/x-raw,format=I420 ! vp8enc deadline=1 ! ws.'
The videoflip is there because this Zero's camera is mounted upside down (drop it, or change
the method, for an upright camera; --rotate is the producer's equivalent).
Two traps bite anyone wiring this by hand:
- the image's
entrypoint.shsourceslibcamera-env.sh, which re-prepends/opt/libcamera/liband silently overrides theLD_LIBRARY_PATHabove, hence--entrypoint bash.:latestsince 2026-09-29 appends its libcamera after a caller's path instead (hub CON23), so the bypass should no longer be needed; it stays here until a board has run the recipe without it; webrtcsink'smetamust be a space-free structure in gst-launch (meta="meta,name=Zero-Cat-Cam"; a space fails to parse).
For its own homepage, install nginx on the board (sudo apt install nginx; it lands in
/usr/sbin, which is not on the user's PATH), open UFW (8444/tcp plus the WebRTC UDP
range), copy the web build and serve.sh over, and run it there:
# from the dev host (<board> is the target's ssh name)
rsync -a build/web/ <board>:cat-cam/build/web/
rsync -a scripts/linux/observant/serve.sh \
<board>:cat-cam/scripts/linux/observant/serve.sh
# on the Zero (serve.sh derives its repo root from its own path, so the
# scripts/linux/observant/ layout under ~/cat-cam is deliberate)
cd ~/cat-cam && PATH=/usr/sbin:$PATH bash scripts/linux/observant/serve.sh
Media is WebRTC UDP, browser ↔ Zero, direct on the LAN. --no-inference is the next step up
from this gst-launch bring-up. If the container is too heavy for the board,
scripts/linux/observant/package-producer-bundle.sh exports a container-less aarch64 bundle
(producer + pruned GStreamer + the image's glibc, ~180 MB, in build/observant/pi-bundle/)
that runs against the host's libcamera with no container and no toolchain; --deploy HOST
rsyncs it to HOST:observant/. Its ONNX Runtime is the image's chain copy
(ORT_LIB_LOCATION), and the assembling image run ends with ANTfrastructure's G6 census over
the finished bundle: every ORT binary byte-identical to that image's chain ORT, and the
producer, given an $ORIGIN/../lib RUNPATH (the same directory run.sh passes to the bundled
loader), resolving to it.
Its binary probe and container Cargo build reuse
OxidANT's producer helper.
The packager builds a missing binary automatically and forwards --platform;
OxidANT's Pi runner still requires --build before compiling.
Other VC4/unicam Pis (e.g. Pi 4). The same host-libcamera swap applies, but the Rust
producer fits there, and it needs no board-specific runner: the Pi 5's runner works as-is.
It collects the host libcamera closure, ACLs /dev/{video,media,dma_heap}* (the dma_heap nodes
matter here, or libcamera reports Could not open any dma-buf provider and registration fails
with -12/ENOMEM) and takes the image's loader paths from its own media-env.sh, which put
the source-built GCC's lib64 (/opt/gcc-16.2.0/lib64 today) on LD_LIBRARY_PATH, as the
image's ONNX Runtime needs (GLIBCXX_3.4.36 not found otherwise). The Pi 4's camera here is
mounted upside down, so:
third_party/OxidANT/scripts/linux/observant/run-producer-pi.sh --build --rotate 180
Its homepage works like the Zero's: nginx is preinstalled on Pi OS, and serve.sh runs with
PATH=/usr/sbin:$PATH from a repo checkout (~/OmniAccelerANT here; its board-local producer
wrapper is ~/zweckle-producer.sh).
RISC-V SoC (SpacemiT X100). :latest is a multi-arch index (amd64/arm64/riscv64), so the
same tag runs there natively and the producer builds inside the container in minutes on 8
cores; GStreamer, v4l2src and ONNX Runtime are all riscv64 builds in the image. A USB webcam
(e.g. a Logitech C270) needs no libcamera: run with --v4l2 /dev/videoN. On Ubuntu the
host-level work is permissions and firewall: the node is root:video 660 and the user is
usually not in video, so grant an ACL (sudo setfacl -m u:$USER:rw /dev/videoN), and UFW
needs 8443/tcp plus the WebRTC UDP range (sudo ufw allow 32768:60999/udp), because the
browser connects directly to the board for media. The udev rule keeps the ACL across a re-plug
(the C270 re-enumerates, and a hand-set ACL dies with the old node):
printf 'SUBSYSTEM=="video4linux", RUN+="/usr/bin/setfacl -m u:%s:rw /dev/%%k"\n' "$USER" \
| sudo tee /etc/udev/rules.d/99-catcam-acl.rules
sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=video4linux
For its own homepage: sudo apt install nginx, UFW 8444/tcp plus the WebRTC UDP range, then
the same web-build + serve.sh copy as the Zero. Its producer, from an OxidANT checkout carried
over to ~/OxidANT (IMAGE as above):
# build once (model paths resolve because OxidANT is mounted at /workspace)
nerdctl run --rm --user 0:0 --network host -v "$HOME/OxidANT":/workspace \
-v kataglyphis-cat-target:/cargo-target -v kataglyphis-cat-cargo:/cargo-home \
-e CARGO_TARGET_DIR=/cargo-target -e CARGO_HOME=/cargo-home \
--entrypoint bash "$IMAGE" -lc \
'cd /workspace && cargo build --release --locked -p kataglyphis_observant'
# run (detached; the board's wrapper is ~/x100-producer.sh)
nerdctl run -d --rm --name x100-producer --user 0:0 --privileged --network host \
-v /dev:/dev -v "$HOME/OxidANT":/workspace \
-v kataglyphis-cat-target:/cargo-target \
-e ORT_DYLIB_PATH=/usr/local/lib/onnxruntime-cpu/lib/libonnxruntime.so -e RUST_LOG=info \
--entrypoint /cargo-target/release/kataglyphis_observant "$IMAGE" \
--v4l2 /dev/video9 --listen-port 8443 --name "Cat Cam"
To front it from another host instead (serve.sh --producer-host), add
--signalling-host 0.0.0.0 to the producer and give serve.sh its IP, not its mDNS
name: nginx resolves proxy_pass hostnames once at startup, so a DHCP or mDNS address
change leaves it proxying into the void, with a 101 in the access log and no connection on
the producer.
Without the Rust producer, any GStreamer pipeline can feed the page: webrtcsink runs the
signalling server itself, and serve.sh proxies /webrtc-ws to it, as the Zero's recipe above
does. A USB webcam, in :latest (checked there with videotestsrc on 2026-10-16):
gst-launch-1.0 -e webrtcsink name=ws run-signalling-server=true signalling-server-host=127.0.0.1 \
signalling-server-port=8443 meta="meta,name=USB-Cam" \
v4l2src device=/dev/video0 ! image/jpeg,width=640,height=360,framerate=30/1 ! jpegdec ! videoconvert ! ws.
Then serve.sh, or flutter run -d web-server as getting-started.md
shows. A standalone signalling server, if one is wanted, is the image's
/opt/gstreamer/bin/gst-webrtc-signalling-server.
No secure context needed
The Stream page needs no secure context; only the Rust wasm core wants isolation, and the
web boots without it. The page is receive-only gstwebrtc-api JS. pkg/oxidant_bg.wasm is a
threads build whose shared memory Firefox and Safari refuse unless the page is cross-origin
isolated (COOP/COEP, honoured only on HTTPS or localhost); Chromium creates it anyway. On web
main() therefore treats a failed RustLib.init() as optional (lib/src/boot/rust_core.dart):
the app starts, and only the About page's Rust demo goes dark. No code ever checked
crossOriginIsolated, whatever older notes said.
Measured 2026-10-05 on the web lane's build with :latest's Chrome for Testing 154, over plain
HTTP from a non-loopback address: the app boots with the core, and boots without it when
oxidant_bg.wasm is blocked, the path Firefox and Safari take (neither was run).
serve.sh --http serves that plain-HTTP page, no certificate warning. Without --http it
terminates TLS on 8444, because the GStreamer signalling server cannot end TLS for this repo's
certificates (the signaller's rustls rejects a self-signed CA certificate with
CaUsedAsEndEntity); the producer's --cert/--key exist, the proxy is the supported path.
Either way it proxies /webrtc-ws to the producer's plain ws:// server (port 8443).
Troubleshooting
- An ObservANT camera reports cats that are not there. Open its page, Camera in the bottom bar. While the false box shows in the preview, press "Not a cat": ObservANT ignores that spot for good. A false arrival from earlier is under "Recent detections", with the same button. Since 2026-10-14 only a detection where something moved starts a visit, so a still object rarely does. For a false cat that moves, the line under the detection slider names the last detection's score: move the slider above it. Too high and a cat in poor light is missed too: a real cat at the feeding trough scored 0.72, the drying rack 0.26 to 0.65.
- The picture is sideways after the board was moved. ⟲ and ⟳ on the same page turn it a quarter at a time; detection, the arrival picture and the clips turn with it.
GST_DEBUG=2orGST_DEBUG=3shows pipeline performance and caps negotiation.- A stream that fails to start: check the camera device permissions (
/dev/video*). - The port
serve.shproxies to (--producer-port, 8443 by default) must be the one the producer's signalling server listens on. - The page loads but the app never starts: check the
main.dart.mjsContent-Type. Flutter's wasm bootstrap loads it with a dynamicimport(), which browsers reject forapplication/octet-stream;serve.shmaps.mjstoapplication/javascriptfor exactly that reason. - The same trap one extension over, likelier on an older board: nginx added
application/wasmto its bundledmime.typesin 1.21.x, so Debian bullseye, Raspberry Pi OS bullseye and Ubuntu 22.04 (all nginx 1.18.0) servemain.dart.wasmasapplication/octet-stream, andWebAssembly.compileStreaming()refuses it: a blank page with nothing in the console naming the cause.serve.shdeclares.wasmitself, so the nginx version stops mattering; if you serve the build some other way, check it withcurl -kI https://<host>:8444/main.dart.wasm | grep -i content-type. ERR_CERT_COMMON_NAME_INVALIDwith no click-through: the browser holds a certificate generated beforeserve.shstarted writing asubjectAltName. The cert is cached in--state-dir, andserve.shnow regenerates a CN-only one on sight; if you pinned or exported the old one, deletebuild/observant/tls/and let it rebuild.- On a host firewall (e.g. UFW on Raspberry Pi OS), allow
8444/tcpand the WebRTC media UDP range (32768:60999/udp, LAN-scoped is enough); otherwise the browser connects for signalling but ICE never completes. - Each
serve.shinstance (one per board) listens on its own port, and the dev host's firewall has to allow every one of them, not just the first: a second board's page stays unreachable while the first board's still works, which reads like a producer problem but is a missingufw allow 8446/tcp.