Plugin reference¶
The contract every plugin is written against: plugin API 2. New to plugins? Start with the tutorial track.
AvialSync discovers plugins from the avialsync.loaders and avialsync.sessions entry-point
groups, and from drop-ins in ~/.avialsync/plugins/: single *.py files and package folders with an
__init__.py, plus pure-Python libraries in its lib/ folder. Drop-ins load in the installed
application exactly as from source. The highest non-zero can_open(path) score wins; when two
plugins tie, the import review asks the user rather than letting load order decide. File →
Plugins lists, reloads and switches plugins off. A plugin must be importable without importing Qt.
Every plugin class may also define api_version (default: the running version; another version is
refused), plugin_id(), settings() and configure() (Plugin settings).
Time-series plugins¶
Subclass TimeSeriesSource, implement can_open, open, channels, and
read_chunks, then register the class in your package metadata:
[project.entry-points."avialsync.loaders"]
my_format = "my_plugin:MySource"
open() and read_chunks() execute in an importer worker thread. Each yielded
pair contains one-dimensional, globally chronological NumPy arrays. Preserve
NaNs; AvialSync owns cache construction, decimation, and gap detection.
Duplicate timestamps keep the final value. Sort non-monotonic input or raise
NonMonotonicTimeError; never emit decreasing timestamps.
See examples/plugins/avialsync-plugin-example for an installable toy binary
loader.
Claiming a whole recording folder¶
can_open(path) is offered directories as well as files, so a rig with its own
folder layout can be supported without changing AvialSync:
@classmethod
def can_open(cls, path: Path) -> float:
return 1.0 if path.is_dir() and (path / "myrig.marker").exists() else 0.0
When a plugin claims a directory, the drop scan stops there and hands the whole
folder to it instead of recursing into the loose files inside. Your open()
receives the directory, and the folder becomes one source with as many
channels as you declare.
To present one folder as several sources with different roles — separate
videos, pose data routed to the 3D view, a sensor trace, all sharing one anchor
time — implement SessionSource instead.
Session plugins¶
A session is a directory that is a recording rather than one that merely
contains files. Subclass SessionSource and publish it under the
avialsync.sessions entry-point group, or drop the module into
~/.avialsync/plugins/:
from avialsync.core.source import SessionItem, SessionLayout, SessionSource
class MyRigSession(SessionSource):
@classmethod
def can_open(cls, path: Path) -> float:
return 1.0 if (path / "rig.marker").exists() else 0.0
def scan(self, path: Path, registry) -> SessionLayout:
items = [
SessionItem(
video,
registry.find_best_loader(video),
source_epoch=start, # when this camera's first frame was exposed
)
for video, start in cameras(path)
]
items.append(
SessionItem(
path / "wheel.csv",
MyEncoderLoader,
{"role": ""},
source_epoch=midnight, # its timestamps count from midnight
)
)
return SessionLayout(
items=items,
session_epoch=min(start for _video, start in cameras(path)),
anchor_epoch=...,
camera_fps=...,
)
The session scanner supplies the candidate files and their labels, kinds, and import defaults. They are shown in the same batch review used for ordinary drops, where the user confirms or changes each choice. A session plugin never imports its candidates without that review.
[project.entry-points."avialsync.sessions"]
myrig = "my_plugin:MyRigSession"
can_open is asked about every dropped directory before per-file scanning, so
keep it cheap — a marker file or a name pattern, not a directory walk. scan
runs off the UI thread and may read files.
Return session-wide settings as SessionLayout fields, not as extra items:
session_epoch (the UTC instant you want master-clock zero to be — usually when
the recording started), anchor_epoch (the UTC instant relative timestamps are
measured from — it also switches the display to wall-clock time), camera_fps,
skeleton (body-part pairs; declaring them takes precedence over the
skeleton the 3D view otherwise detects from pairwise rigidity), and
rotary (a RotaryHint naming the channel that carries a running wheel’s
cumulative angle in degrees, and the wheel’s bar count and radius when your rig
records them — it pre-fills the wheel setup in Props and is never applied on its own).
It may also carry a generic PeriodicRotaryMotion plus motion_sign: AvialSync
shows that as a reviewable rotation proposal after the wheel is placed, and the
user explicitly applies it. Keep your rig’s model-file schema, unit conversion,
and any timing interpretation inside the plugin. Set a
SessionItem.loader of None to let capability resolution pick one, which is
what you should do for ordinary video.
A recording that comes in parts – trials, recordings – declares them in
segments, (start, end, name) in Unix seconds, placed with
avialsync.core.trials.place: each part at its recorded start, the time between
parts kept as a gap, and a part without a start or overlapping the one before
left out and named. Pass Placement.BACK_TO_BACK only when your format defines
its parts that way. Nothing more is needed for the rest: each stream’s Data
Streams lane is empty between parts where it has no frame or sample, and View
→ Collapse Gaps Without Data hides only the time no stream recorded. Declare a
stream recorded in every part once, joined: a JoinedVideoSource item whose
config lists parts ({"path", "start"}, start in seconds from the item’s
source_epoch) plays the files as one video in place, any codec; a
JoinedTimeSeriesSource item adds part_loader ("module:Class") and each
part’s own config, and reads every part with it (both in
avialsync.loaders.joined_parts). One camera is then one pane and one lane,
one tracked body one 3D object.
session_epoch and anchor_epoch are commonly different, and both are useful:
a rig whose logs are written as seconds since midnight has an anchor_epoch of
that midnight, while its master zero is the instant the cameras started, hours
later. Declare session_epoch rather than letting it be derived — sources load
concurrently, so a derived zero would depend on which file happened to finish
first.
Optional: recordings split across folders¶
Some rigs write one recording into several folders that only mean something
together, such as a microscope’s trials and the camera PC’s recordings of them.
scan sees one folder at a time and cannot pair them. Override scan_together
to claim several dropped paths at once:
def scan_together(self, paths, loaded, registry):
mine = [path for path in paths if belongs_to_my_rig(path)]
if len(mine) < 2:
return None # leave every path to scan()
return lay_out_together(mine), mine
It is asked once per drop, before any path is scanned on its own. Return the
layout and the dropped paths it used (those are not scanned again), or None.
loaded lists the sources already open as SessionItems whose source_epoch
is where each source’s own zero sits now, so a later drop can be placed against
what the user already has, including any hand corrections. The default claims
nothing.
Say where your timestamps start, never where the source should go¶
SessionItem.source_epoch is the UTC instant your file’s t=0 is, and it is
the only timing question a session has to answer. The application derives the
placement from it (session_zero - source_epoch), so you never compute an
offset:
Your file’s timestamps are… |
Declare |
|---|---|
seconds from its own first frame (a video container) |
that frame’s UTC instant |
seconds since midnight |
that midnight |
Unix epoch seconds already |
|
something with no knowable instant |
nothing; it keeps its own zero |
Always declare an absolute instant. A session-relative number declares nothing: 34526 is equally 09:35:26 and a nine-hour elapsed time, and the application cannot tell which you meant.
Never put a placement in config. config is hashed into the cache
key, so an offset there lets a re-placement invalidate the samples underneath it
— and it lands in the offset control the user nudges by hand, which is how an
AOL session came to open with -34526 s already typed into every camera.
source_epoch is a SessionItem field for exactly that reason, as label and
coverage_group are.
If your scan leaves something out, say so in warnings. A folder holding one
unreadable recording beside three good ones should still yield the three — do not
fail the whole scan — but returning fewer items and nothing else leaves a session
that looks complete while missing data. Each string is shown to the user; the log
is not.
return SessionLayout(items=items, warnings=[f"{name} could not be read — {why}"])
config reaches the loader as its import config, and is hashed into the sidecar
cache key — so put what the loader needs to read the file there, and nothing
about where the file belongs in time. One key is interpreted by the application:
role routes a source away from the plot rows — "pose3d" to the 3D view,
"overlay2d" (with overlay_video) to that camera’s overlay.
For a direct file import, a TimeSeriesSource that can yield coordinate channels can override
pose_roles() to offer "pose3d" and/or "overlay2d" in the import review. The default is
empty, so existing plugins keep their current behavior. The user chooses the role and, for 2D,
the target video. A session scanner can declare the same choice in each SessionItem.config;
the review starts with that choice selected. The application checks for complete _x/_y or
_x/_y/_z channel groups after import and plots the channels if the declared pose is unusable.
A bare offset in config still works, and means the whole source-to-master
mapping rather than a correction on top of a placement. Prefer source_epoch:
it composes with the session zero, survives a source being re-placed, and leaves
the offset control free for what the user actually uses it for.
If your scanner raises, the folder falls back to per-file scanning and the
reason appears in Help → Diagnostics; a broken plugin never makes a folder
unopenable. loaders/aol_session_loader.py is a full worked example.
Naming your format¶
The import dialog lists whatever the registry found, and each format supplies
its own label — override display_name() to control it, and
display_aliases() to be offered under several. Both have defaults, so this is
optional.
Optional: single-pass bulk ingest¶
read_chunks is called once per channel, so a format parsed in one pass is
re-parsed for every channel it declares — an 80-channel file, 80 times. If your
format is like that, you may also define read_all_chunks:
def read_all_chunks(self) -> Iterator[dict[str, tuple[np.ndarray, np.ndarray]]]:
"""Yield {channel_name: (t, v)} per chunk, every declared channel aligned."""
AvialSync uses it in place of the per-channel calls when it is present, and
falls back to read_chunks when it is not. This is an optional extension, not
part of the abstract contract: it is absent from TimeSeriesSource, and a
plugin that never defines it is fully supported. If you do define it, every
chunk must carry every channel you declared, on the same rows, under the same
ordering, duplicate, and NaN rules as read_chunks — both paths must build the
same cache from the same file.
Optional: messages the recording carries¶
Many formats store prose the experimenter wrote — an acquisition system’s
annotation stream, a commented file header, a note appended when the session
stopped. If yours does, return it from messages():
from avialsync.core.messages import Message
def messages(self) -> list[Message]:
"""Return free-text records, in this source's own timeline."""
return [Message(text="stimulus on", time=61.4, channel="MessageCenter")]
It is called once after open(), on the import thread, and has a default that
returns nothing, so a plugin that never defines it is fully supported. Times are
in your source’s timeline — the same one read_chunks yields — so the session’s
alignment moves a note and the samples it describes together.
Leave time as None for anything the file did not timestamp. A header or a
closing comment has no place on the clock, and AvialSync shows those as untimed
notes rather than pinning them to the start of the recording. Do not substitute
0.0: that asserts a moment the file never recorded.
Messages are displayed read-only, in their own Inspector page and timeline lane. They are not annotations — the user’s own markers are separate, editable, and exported as their work.
Do not return a note the file wrote about itself. A sync preamble — the line
declaring the recording’s start sample or its software clock — places the clock
rather than saying anything a person wrote, and listing it puts the same two rows
at the head of every recording a user opens. Parse it for the times you need and
withhold it, as both Open Ephys readers do with sync_messages.txt and with
messages.events’ Software time: and start time: lines.
If your format stores a message as a sample number, divide by the rate onto the
same axis your read_chunks timestamps use. Rebasing onto the first recorded
sample when your samples are not rebased offsets every note from the trace it
describes by the recording’s own start.
Video plugins¶
Subclass VideoSource and implement every abstract method. open() runs in a
background worker. If needs_conversion() is true, prepare(progress_cb) must
create a playable cached file and return it; AvialSync opens only
media_path() after preparation succeeds. time_bounds() returns
(metadata_start, metadata_start + duration) when a UTC start is available,
otherwise (0.0, duration). start_time() is only a metadata guess: the user
offset always takes precedence.
video_metadata() is an optional, source-compatible extension with a default implementation.
Override it to return VideoMetadata when the format exposes codec, byte size, and
timestamp-derived CFR/VFR evidence.
Imaging plugins¶
Subclass ImagingSource for a time-indexed image stack that is not a video container.
open(path, config) returns ImagingMetadata with a one-dimensional, strictly increasing
frame_times array in source seconds, image shape, pixel dtype, and channel_count.
read_frame(index, channel=0) returns one two-dimensional NumPy plane at that presentation index
and channel, in the file’s own pixel units. close() releases the handle. The application opens
and reads the source on a background thread, requests only the planes the current picture needs,
and coalesces requests during playback. A plugin should use native slice or page reads and must
not materialize the entire stack. Publish it under avialsync.loaders.
When the file does not settle something only the user can answer — a frame rate, which dataset or
series, the axis order, a depth plane — raise avialsync.core.errors.ImagingChoiceRequired with
the config key as choice (fps, dataset, series, axes or z) and the valid answers as
options when the file lists them. The application asks, adds the answer to config, opens
again, and saves the answer with the session. Never substitute a default. The HDF5/TIFF built-ins
demonstrate the contract; a session plugin can also provide these choices in SessionItem.config.
Trigger lines¶
There is no trigger-plugin kind. A loader declares a trigger line on the channel that carries it:
ChannelInfo(trigger=..., trigger_for=...), with trigger one of frame_strobe,
frame_trigger, sync_train or sparse_events. The sync wizard starts from the declared kind;
the user still accepts the fit. See Trigger lines.
Keep laboratory-specific parsing and semantics in the plugin. AvialSync preserves raw timestamps, performs the alignment, shows the matched evidence and fit quality, and requires user acceptance before changing a source mapping. It does not provide acquisition drivers or built-in scientific analysis.