Architecture¶
flowchart TD
S["event stream: a camera SDK, an adapter, your code"] -->|"EVENT_DTYPE arrays"| I
subgraph P ["producer thread"]
I["Engine.ingest()"] --> A["Accumulator: validation, range and bounds checks, watermark"]
A --> K["kernel state"]
K -->|"at most once per interval"| B["fresh frame + SnapshotMeta"]
end
B --> L["published Snapshot, one reference"]
L --> C1["consumer: viewer"]
L --> C2["consumer: your analysis"]
L --> C3["consumer: ..."]
The producer does all of the work: validation, accumulation and publication run inside
ingest(), on the thread that calls it. Consumers only read the latest published snapshot.
Nothing flows from a consumer back to the producer.
Components¶
| component | what it owns |
|---|---|
Accumulator (frames2py.Accumulator) |
Accumulation through one kernel: structural validation, the timestamp-range check, the bounds check, the watermark, the out-of-bounds count, the kernel's state. Synchronous; no threads, no publication. |
Engine (frames2py.Engine) |
An Accumulator (internal, not exposed), the publication cadence, the published snapshot, the lifecycle (start, stop, reset), producer ownership and statistics. |
Kernel (frames2py.kernels.Kernel) |
The representation: how in-bounds events change the state, how the state is read out, what happens at a window boundary. A public protocol; the five built-in kernels implement it and so can yours. |
Snapshot publisher (frames2py.publish.ImmutablePublisher) |
The hand-off between the producer and consumers: each publication is a new buffer, stored with its metadata as one Snapshot. |
The ingest path¶
Each Engine.ingest(events) call does, in order, on the caller's thread:
- Producer check. A call from any thread other than the producer's raises
RuntimeErrorand changes nothing. - Lifecycle check. While stopped,
ingest()is a no-op. - Structural validation of the array: type, fields, field dtypes, dimensionality,
contiguity.
TypeErroron failure. Values are not inspected. - Timestamp-range check over the whole call: any
t >= 2**63rejects the call withValueError, before any state or statistic changes. - Bounds check: one
max()overxand one overy. Only if an event lies outside the sensor is a mask built; those events are counted and dropped from the kernel's view. - Kernel accumulation of the in-bounds events, with the kernel's own strategy.
- Publication, if this is the first call or
snapshot_interval_mshas elapsed since the last publication.
The results never depend on how Frames2Py internally splits a call. (exp_decay does depend
on how you split events into calls; see Kernels.)
Rules the design keeps¶
ingest() never waits on consumers. Frames2Py puts no synchronisation on the producer
path that consumer activity can hold or control: no lock a consumer takes, no condition
variable, no retry or spin loop caused by readers, no queue that waits for consumers to
drain, no waiting for a consumer to release a buffer. snapshot() and stats take no lock.
This is not a promise that the producer is never delayed: it runs in CPython, where the GIL
(on standard builds), CPython's own per-object locks, garbage collection, the allocator and
thread scheduling can all delay a thread. "Never waits" also does not mean "fast":
ingest() does real CPU work on the caller's thread. The lifecycle calls (start, stop,
reset) share a lock with ingest() so that each runs whole; they are control calls, not
consumers. See Lifecycle and threads.
The Engine never calls consumer code. It doesn't know its consumers exist. There are no
callbacks, hooks, observers or notifications. A consumer pulls snapshot() when it wants
to.
The recorder stays off the ingest path. The recorder is a sink
your loop writes to next to ingest(). The Engine never calls it and never waits for it;
ingest() does the same work with or without a recorder. The recorder starts no thread.
Optional extras stay optional. import frames2py needs NumPy only. Adapters, the
recorder and the viewer import their libraries (dv-processing, h5py, hdf5plugin, pyglet)
only when a function that needs them is called, and raise ImportError naming the extra if
the library is missing.
No unbounded history. The Engine keeps the current kernel state and the latest published snapshot, nothing older. A consumer that wants history keeps it.
How the hand-off works¶
Each publication allocates a new array, the kernel's state is read into it, the array is
marked read-only, and the frame is stored together with its SnapshotMeta as one Snapshot
object in a one-element list. snapshot() loads that list item and returns it: it never
copies, never retries, and never sees a buffer that is still being written. Frame and
metadata always come from the same publication, because they are one object.
What this relies on, stated precisely:
- Documented by CPython: reading and writing a single list item are atomic operations.
- CPython implementation behaviour, not a Python language guarantee: that a reader which loads the new item also sees the frame data written before the store. On free-threaded CPython 3.14, the store is a release store made under the list's per-object lock, and the load is a sequentially consistent atomic load that takes a guarded reference, falling back to the list's lock. On standard builds the GIL orders them.
So the hand-off does involve synchronisation: CPython locks the list during the store. What Frames2Py avoids is synchronisation that consumers can hold. This is why the Engine refuses free-threaded builds whose CPython source behaviour hasn't been checked (see Lifecycle and threads).
What is deliberately absent¶
- No timer thread. Publication happens only inside
ingest()andstop(). If the producer stops callingingest(), the events of the current window stay unpublished until the nextingest()orstop(). - No buffering between producer and kernel. Each call is accumulated before
ingest()returns. There is nothing to overflow and nothing is dropped, except out-of-bounds events, which are counted. - No multi-producer mode. One Engine has one producer thread.
- No format decoding in the core. Adapters turn files into
EVENT_DTYPEarrays at the edge; the core only sees arrays.