Skip to content

Experiment API

Configuration

body_eye_sync.experiment.config

Serialisable definition of an experiment: its inputs and the pipeline to run.

AudioInput

Bases: _Input

Audio recorded on its own device, e.g. a directional microphone.

Embedded audio in video files is handled as part of video playback; this input is for separate audio files.

Source code in src/body_eye_sync/experiment/config.py
class AudioInput(_Input):
    """Audio recorded on its own device, e.g. a directional microphone.

    Embedded audio in video files is handled as part of video playback; this
    input is for separate audio files.
    """

    glasses_video: str | None = Field(
        None,
        description=(
            "Optional id of the glasses video worn by the participant this "
            "recording captures"
        ),
    )

BodyPoseStep

Bases: _Model

Per-box body-pose detection. Fields mirror detect_body_poses.

Source code in src/body_eye_sync/experiment/config.py
class BodyPoseStep(_Model):
    """Per-box body-pose detection. Fields mirror ``detect_body_poses``."""

    model_name: str = Field(
        "yolo26m-pose.pt",
        description="Ultralytics YOLO pose weights.",
        json_schema_extra={
            "choices": [
                "yolo26n-pose.pt",
                "yolo26s-pose.pt",
                "yolo26m-pose.pt",
                "yolo26l-pose.pt",
                "yolo26x-pose.pt",
            ]
        },
    )
    conf: float = Field(
        0.25, ge=0.0, le=1.0, description="Minimum pose detection confidence."
    )

ExperimentConfig

Bases: _Model

The serialisable definition of an experiment: its inputs and the pipeline to run.

Source code in src/body_eye_sync/experiment/config.py
class ExperimentConfig(_Model):
    """The serialisable definition of an experiment: its inputs and the pipeline to run."""

    version: int = CURRENT_VERSION
    glasses_videos: list[GlassesVideoInput] = Field(default_factory=list)
    fixed_videos: list[FixedVideoInput] = Field(default_factory=list)
    audio: list[AudioInput] = Field(default_factory=list)
    pipeline: Pipeline = Field(default_factory=Pipeline)

    @model_validator(mode="after")
    def _check(self) -> ExperimentConfig:
        ids = (
            [video.id for video in self.glasses_videos]
            + [video.id for video in self.fixed_videos]
            + [audio.id for audio in self.audio]
        )
        duplicates = {i for i in ids if ids.count(i) > 1}
        if duplicates:
            raise ValueError(f"duplicate input ids: {sorted(duplicates)}")

        glasses_ids = {i.id for i in self.glasses_videos}
        unknown = {
            a.glasses_video
            for a in self.audio
            if a.glasses_video is not None and a.glasses_video not in glasses_ids
        }
        if unknown:
            raise ValueError(f"unknown glasses video ids: {sorted(unknown)}")
        return self

FaceDetectionStep

Bases: _Model

Per-box face detection. Fields mirror detect_faces.

Source code in src/body_eye_sync/experiment/config.py
class FaceDetectionStep(_Model):
    """Per-box face detection. Fields mirror ``detect_faces``."""

    model_name: str = Field(
        "antelopev2",
        description="InsightFace model pack.",
        json_schema_extra={
            "choices": [
                "antelopev2",
                "buffalo_l",
                "buffalo_m",
                "buffalo_s",
                "buffalo_sc",
            ]
        },
    )
    det_size: int = Field(
        640, ge=64, le=2048, description="Detector input size in pixels."
    )
    det_thresh: float = Field(
        0.5, ge=0.0, le=1.0, description="Minimum face detection confidence."
    )
    embeddings_per_track: int = Field(
        32,
        ge=0,
        description=("Number of best face embeddings to keep per tracklet"),
    )

FixedVideoInput

Bases: _Input

Video recorded by a camera at a fixed position in the room.

Source code in src/body_eye_sync/experiment/config.py
class FixedVideoInput(_Input):
    """Video recorded by a camera at a fixed position in the room."""

GlassesVideoInput

Bases: _Input

Video and gaze data recorded by a participant's glasses-mounted camera.

Source code in src/body_eye_sync/experiment/config.py
class GlassesVideoInput(_Input):
    """Video and gaze data recorded by a participant's glasses-mounted camera."""

    gaze_path: Path = Field(
        description="Gaze samples recorded alongside this video, as a TSV file."
    )

ObjectTrackingStep

Bases: _Model

Object detection + ReID tracking. Fields mirror detect_tracklets.

choices in a field's json_schema_extra are suggested values the GUI offers in an editable combobox; a custom value is still allowed.

Source code in src/body_eye_sync/experiment/config.py
class ObjectTrackingStep(_Model):
    """Object detection + ReID tracking. Fields mirror ``detect_tracklets``.

    ``choices`` in a field's ``json_schema_extra`` are suggested values the GUI
    offers in an editable combobox; a custom value is still allowed.
    """

    detector: str = Field(
        "yolo26m",
        description="Object detector model.",
        json_schema_extra={
            "choices": [
                "yolo26n",
                "yolo26s",
                "yolo26m",
                "yolo26l",
                "yolo26x",
            ]
        },
    )
    reid: str = Field(
        "osnet_x1_0_msmt17",
        description="Re-identification model used to keep track ids stable.",
        json_schema_extra={
            "choices": [
                "osnet_x0_25_msmt17",
                "osnet_x0_5_msmt17",
                "osnet_x0_75_msmt17",
                "osnet_x1_0_msmt17",
                "osnet_ain_x1_0_msmt17",
                "mobilenetv2_x1_0_msmt17",
                "mobilenetv2_x1_4_msmt17",
                "resnet50_msmt17",
                "clip_market1501",
                "clip_duke",
            ]
        },
    )
    tracker: str = Field(
        "botsort",
        description="Multi-object tracking algorithm.",
        json_schema_extra={
            "choices": [
                "botsort",
                "bytetrack",
                "ocsort",
                "deepocsort",
                "hybridsort",
                "strongsort",
                "imprassoc",
                "boosttrack",
            ]
        },
    )
    object_classes: list[int] = Field(
        default=[0],
        description="COCO class ids to detect and track (0 = person).",
    )
    embeddings_per_track: int = Field(
        32,
        ge=0,
        description=(
            "Number of best body-appearance (ReID) embeddings to keep per tracklet"
        ),
    )

Pipeline

Bases: _Model

What to run for each type of input.

Source code in src/body_eye_sync/experiment/config.py
class Pipeline(_Model):
    """What to run for each type of input."""

    glasses_video: VideoPipeline = Field(default_factory=VideoPipeline)
    fixed_video: VideoPipeline = Field(default_factory=VideoPipeline)
    speech: SpeechPipeline | None = Field(default_factory=SpeechPipeline)
    speech_post_processing: SpeechPostProcessingSettings = Field(
        default_factory=SpeechPostProcessingSettings
    )

SpeechPipeline

Bases: StepPipeline

The stages run over all inputs that contain audio.

Transcription is the only one: it says what was said, and who said it is settled afterwards, by comparing the experiment's recordings with each other.

Source code in src/body_eye_sync/experiment/config.py
class SpeechPipeline(StepPipeline):
    """The stages run over all inputs that contain audio.

    Transcription is the only one: it says what was said, and who said it is
    settled afterwards, by comparing the experiment's recordings with each other.
    """

    transcription: TranscriptionStep = Field(default_factory=TranscriptionStep)

SpeechPostProcessingSettings

Bases: _Model

How transcripts are combined to form experiment-wide speaker turns.

Source code in src/body_eye_sync/experiment/config.py
class SpeechPostProcessingSettings(_Model):
    """How transcripts are combined to form experiment-wide speaker turns."""

    split_gap_seconds: float = Field(
        0.75,
        ge=0,
        description=(
            "Split a segment when consecutive words are separated by at "
            "least this many seconds. Sentence-ending punctuation also splits."
        ),
    )
    split_on_sentence_end: bool = Field(
        True,
        description="Split after words ending in sentence punctuation (. ! ? …).",
    )
    split_on_comma: bool = Field(
        True,
        description=(
            "Split at commas when both adjacent clauses meet the minimum word "
            "count and duration below."
        ),
    )
    minimum_clause_words: int = Field(
        4,
        ge=1,
        description=("Minimum number of words required on each side of a comma split."),
    )
    minimum_clause_seconds: float = Field(
        0.5,
        ge=0,
        description=(
            "Minimum duration required on each side of a comma split, in seconds."
        ),
    )
    floor_percentile: float = Field(
        10.0,
        ge=0,
        le=100,
        description=(
            "Use this percentile of each recording's levels as its quiet floor."
        ),
    )
    live_above_floor_db: float = Field(
        10.0,
        ge=0,
        description=(
            "A recording counts as carrying speech when its level is this "
            "many dB above its own quiet floor."
        ),
    )
    ownership_share: float = Field(
        0.5,
        ge=0,
        le=1,
        description=(
            "Keep a piece when this recording is the loudest for more than this "
            "fraction of its active frames, or live for more than this fraction of all frames."
        ),
    )
    fuzzy_agreement: float = Field(
        0.6,
        ge=0,
        le=1,
        description=(
            "Treat overlapping text as the same voice when its "
            "normalized character similarity exceeds this fraction."
        ),
    )

StepPipeline

Bases: _Model

Base class for pipeline configurations edited by the shared GUI.

Source code in src/body_eye_sync/experiment/config.py
class StepPipeline(_Model):
    """Base class for pipeline configurations edited by the shared GUI."""

TimelineConfig

Bases: _Model

Where one recording sits on the experiment's shared clock.

Source code in src/body_eye_sync/experiment/config.py
class TimelineConfig(_Model):
    """Where one recording sits on the experiment's shared clock."""

    offset: float = Field(
        0.0,
        description=(
            "Seconds to add to this input's own clock to place it on the shared "
            "experiment timeline."
        ),
    )
    rate: float = Field(
        1.0,
        gt=0,
        description=(
            "Experiment seconds per second of this input's own clock. Every "
            "device counts time on its own crystal, and two of them differ by "
            "tens of parts per million, which is tens of milliseconds across a "
            "long recording."
        ),
    )

TranscriptionStep

Bases: _Model

Speech transcription. Fields mirror transcribe.

Source code in src/body_eye_sync/experiment/config.py
class TranscriptionStep(_Model):
    """Speech transcription. Fields mirror ``transcribe``."""

    model_name: str = Field(
        "primeline/whisper-large-v3-turbo-german",
        description=(
            "Whisper model. The primeLine models are accuracy-tuned for German; "
            "CrisperWhisper models produce verbatim transcripts; large-v3 is "
            "the strongest general multilingual choice."
        ),
        json_schema_extra={
            "choices": [
                "primeline/whisper-large-v3-turbo-german",
                "primeline/whisper-large-v3-german",
                "nyralabs/CrisperWhisper2.0_large",
                "nyralabs/CrisperWhisper2.0_medium",
                "large-v3",
                "large-v3-turbo",
                "distil-large-v3",
                "tiny",
                "base",
                "small",
                "medium",
            ]
        },
    )
    language: str | None = Field(
        "de",
        description=(
            "ISO 639-1 language code of the recording, e.g. 'de'. German is the "
            "accuracy-first default; leave unset to detect the language from the "
            "first 30 seconds."
        ),
    )
    beam_size: int = Field(5, ge=1, description="Decoding beam width.")
    vad_filter: bool = Field(
        False,
        description=(
            "Skip silent stretches, which speeds up the pass and suppresses text "
            "invented over silence."
        ),
    )

VideoPipeline

Bases: StepPipeline

The stages run over a video input.

Both video types use this same set of stages, but as independent blocks, so e.g. a room camera can be tracked with a different detector than the glasses cameras.

Source code in src/body_eye_sync/experiment/config.py
class VideoPipeline(StepPipeline):
    """The stages run over a video input.

    Both video types use this same set of stages, but as independent blocks, so
    e.g. a room camera can be tracked with a different detector than the glasses
    cameras.
    """

    object_tracking: ObjectTrackingStep = Field(default_factory=ObjectTrackingStep)
    face_detection: FaceDetectionStep | None = None
    body_pose: BodyPoseStep | None = None

validate_input_id(input_id)

Return an input id, having checked it is safe for generated names.

Source code in src/body_eye_sync/experiment/config.py
def validate_input_id(input_id: str) -> str:
    """Return an input id, having checked it is safe for generated names."""
    if not input_id:
        raise ValueError("input id cannot be empty")
    if any(char in input_id for char in ("/", "\\", "[", "]")) or input_id in (
        ".",
        "..",
    ):
        raise ValueError(f"input id cannot contain reserved characters: {input_id!r}")
    return input_id

Runtime Experiment

body_eye_sync.experiment.experiment

Experiment contains the loaded inputs, their results and the pipeline to run

Experiment

An experiment: its inputs, their results, and the pipeline to run.

The inputs own their settings, each in the runtime class for its type; :class:ExperimentConfig is the on-disk form, converted to and from when the experiment is saved and loaded. Input ids are unique across the types, and inputs are added, removed and renamed through this class so they stay that way.

Source code in src/body_eye_sync/experiment/experiment.py
class Experiment:
    """An experiment: its inputs, their results, and the pipeline to run.

    The inputs own their settings, each in the runtime class for its type;
    :class:`ExperimentConfig` is the on-disk form, converted to and from when
    the experiment is saved and loaded. Input ids are unique across the types,
    and inputs are added, removed and renamed through this class so they stay
    that way.
    """

    def __init__(self, config: ExperimentConfig, folder: str | Path | None = None):
        self.folder = Path(folder) if folder is not None else None
        self.pipeline: Pipeline = config.pipeline
        self.glasses_videos = [
            GlassesVideo(
                id=spec.id,
                path=self._resolve(spec.path),
                gaze_path=self._resolve(spec.gaze_path),
                timeline=Timeline.from_config(spec.timeline),
            )
            for spec in config.glasses_videos
        ]
        self.fixed_videos = [
            FixedVideo(
                id=spec.id,
                path=self._resolve(spec.path),
                timeline=Timeline.from_config(spec.timeline),
            )
            for spec in config.fixed_videos
        ]
        glasses_by_id = {video.id: video for video in self.glasses_videos}
        self.audio = [
            Audio(
                id=spec.id,
                path=self._resolve(spec.path),
                glasses_video=glasses_by_id.get(spec.glasses_video),
                timeline=Timeline.from_config(spec.timeline),
            )
            for spec in config.audio
        ]
        self.speech_turns = SpeechTurns()

    def _load_stored_data(self) -> None:
        """Load any existing outputs owned by this experiment from its folder."""
        for data in self.inputs:
            self._load_results(data)
        self._load_speech_turns()

    @property
    def inputs(self) -> list[Video | Audio]:
        """Every input of every type, for what applies to all of them."""
        return [*self.glasses_videos, *self.fixed_videos, *self.audio]

    def add_glasses_video(self, spec: GlassesVideoInput) -> GlassesVideo:
        """Add a glasses video input, returning its :class:`GlassesVideo`."""
        self._check_id(spec.id)
        video = GlassesVideo(
            id=spec.id,
            path=self._resolve(spec.path),
            gaze_path=self._resolve(spec.gaze_path),
            timeline=Timeline.from_config(spec.timeline),
        )
        self.glasses_videos.append(video)
        return video

    def add_fixed_video(self, spec: FixedVideoInput) -> FixedVideo:
        """Add a fixed video input, returning its :class:`FixedVideo`."""
        self._check_id(spec.id)
        video = FixedVideo(
            id=spec.id,
            path=self._resolve(spec.path),
            timeline=Timeline.from_config(spec.timeline),
        )
        self.fixed_videos.append(video)
        return video

    def add_audio(self, spec: AudioInput) -> Audio:
        """Add an audio input, returning its :class:`Audio`.

        Raises :class:`ValueError` if it names a glasses video that is not in
        this experiment.
        """
        self._check_id(spec.id)
        glasses_video = None
        if spec.glasses_video is not None:
            glasses_video = next(
                (v for v in self.glasses_videos if v.id == spec.glasses_video), None
            )
            if glasses_video is None:
                raise ValueError(f"unknown glasses video id: {spec.glasses_video!r}")
        audio = Audio(
            id=spec.id,
            path=self._resolve(spec.path),
            glasses_video=glasses_video,
            timeline=Timeline.from_config(spec.timeline),
        )
        self.audio.append(audio)
        return audio

    def remove_input(self, data: Video | Audio) -> None:
        """Remove an input from the experiment, leaving its output files alone.

        Raises :class:`ValueError` if it is a glasses video that audio inputs
        still refer to.
        """
        if isinstance(data, GlassesVideo):
            used_by = sorted(a.id for a in self.audio if a.glasses_video is data)
            if used_by:
                raise ValueError(
                    f"glasses video {data.id!r} is still used by audio inputs: {used_by}"
                )
        for inputs in (self.glasses_videos, self.fixed_videos, self.audio):
            if any(existing is data for existing in inputs):
                inputs.remove(data)
                return
        raise ValueError(f"input {data.id!r} is not in this experiment")

    def rename_input(self, data: Video | Audio, new_id: str) -> None:
        """Give an input a new id, moving its output directory if it exists."""
        if new_id == data.id:
            return
        self._check_id(new_id)
        old_id = data.id
        old_output_dir = None
        if self.folder is not None:
            old_output_dir = self.output_dir_for(data)
        data.id = new_id
        if old_output_dir is not None and old_output_dir.exists():
            try:
                old_output_dir.rename(self.output_dir_for(data))
            except OSError:
                data.id = old_id
                raise

    def _check_id(self, input_id: str) -> None:
        """Check an id can name an output directory, and nothing else uses it."""
        validate_input_id(input_id)
        if any(data.id == input_id for data in self.inputs):
            raise ValueError(f"duplicate input id: {input_id!r}")
        if self.folder is not None and (self.output_dir / input_id).exists():
            raise ValueError(f"outputs already exist for input id: {input_id!r}")

    def _require_folder(self) -> Path:
        if self.folder is None:
            raise ValueError("experiment has no folder; load or save it first")
        return self.folder

    def _resolve(self, path: Path) -> Path:
        """An input path as used at runtime: absolute where the folder allows."""
        if path.is_absolute() or self.folder is None:
            return path
        return (self.folder / path).resolve()

    def _store(self, path: Path) -> Path:
        """An input path as written to disk: relative where it is under the folder."""
        if self.folder is not None and path.is_relative_to(self.folder.resolve()):
            return path.relative_to(self.folder.resolve())
        return path

    @property
    def output_dir(self) -> Path:
        """Where per-input Parquet outputs live, inside the folder."""
        return self._require_folder() / OUTPUTS_DIRNAME

    def output_dir_for(self, data: Video | Audio) -> Path:
        """The output directory an input owns, inside :attr:`output_dir`."""
        return self.output_dir / data.id

    def _load_results(self, data: Video | Audio) -> None:
        """Fill an input from its stored results, skipping any it cannot read."""
        if self.folder is None:
            return
        directory = self.output_dir_for(data)
        try:
            data.load(directory)
        except (OSError, ValueError) as exc:
            data.clear()
            logger.warning(
                "ignoring unreadable results for input %r in %s: %s",
                data.id,
                directory,
                exc,
            )

    def _load_speech_turns(self) -> None:
        """Fill the experiment's speech turns from its output directory."""
        if self.folder is None:
            return
        try:
            self.speech_turns.load(self.output_dir)
        except (OSError, ValueError) as exc:
            self.speech_turns.clear()
            logger.warning(
                "ignoring unreadable speech turns in %s: %s", self.output_dir, exc
            )

    def config(self) -> ExperimentConfig:
        """The experiment in its on-disk form."""
        return ExperimentConfig(
            glasses_videos=[
                GlassesVideoInput(
                    id=v.id,
                    path=self._store(v.video_path),
                    gaze_path=self._store(v.gaze_path),
                    timeline=v.timeline.to_config(),
                )
                for v in self.glasses_videos
            ],
            fixed_videos=[
                FixedVideoInput(
                    id=v.id,
                    path=self._store(v.video_path),
                    timeline=v.timeline.to_config(),
                )
                for v in self.fixed_videos
            ],
            audio=[
                AudioInput(
                    id=a.id,
                    path=self._store(a.audio_path),
                    timeline=a.timeline.to_config(),
                    glasses_video=(
                        a.glasses_video.id if a.glasses_video is not None else None
                    ),
                )
                for a in self.audio
            ],
            pipeline=self.pipeline,
        )

    @classmethod
    def load(cls, folder: str | Path) -> Experiment:
        """Load the experiment in ``folder``: its inputs and their results."""
        folder = Path(folder)
        with (folder / CONFIG_FILENAME).open("r", encoding="utf-8") as f:
            data = yaml.safe_load(f) or {}
        version = data.get("version", CURRENT_VERSION)
        if version > CURRENT_VERSION:
            raise ValueError(
                f"experiment version {version} is newer than supported "
                f"{CURRENT_VERSION}; please upgrade body-eye-sync"
            )
        experiment = cls(ExperimentConfig.model_validate(data), folder)
        experiment._load_stored_data()
        return experiment

    def save(self, folder: str | Path | None = None) -> None:
        """Write the experiment, and every input's results, into the folder.

        ``folder`` defaults to the current :attr:`folder` and becomes it when
        given. The config is always written; an input is written only once it
        has results.
        """
        if folder is not None:
            self.folder = Path(folder)
        folder = self._require_folder()
        folder.mkdir(parents=True, exist_ok=True)
        with (folder / CONFIG_FILENAME).open("w", encoding="utf-8") as f:
            yaml.safe_dump(self.config().model_dump(mode="json"), f, sort_keys=False)
        for data in self.inputs:
            if data.has_data():
                data.save(self.output_dir_for(data))
        if self.speech_turns.has_data():
            self.speech_turns.save(self.output_dir)

inputs property

Every input of every type, for what applies to all of them.

output_dir property

Where per-input Parquet outputs live, inside the folder.

add_audio(spec)

Add an audio input, returning its :class:Audio.

Raises :class:ValueError if it names a glasses video that is not in this experiment.

Source code in src/body_eye_sync/experiment/experiment.py
def add_audio(self, spec: AudioInput) -> Audio:
    """Add an audio input, returning its :class:`Audio`.

    Raises :class:`ValueError` if it names a glasses video that is not in
    this experiment.
    """
    self._check_id(spec.id)
    glasses_video = None
    if spec.glasses_video is not None:
        glasses_video = next(
            (v for v in self.glasses_videos if v.id == spec.glasses_video), None
        )
        if glasses_video is None:
            raise ValueError(f"unknown glasses video id: {spec.glasses_video!r}")
    audio = Audio(
        id=spec.id,
        path=self._resolve(spec.path),
        glasses_video=glasses_video,
        timeline=Timeline.from_config(spec.timeline),
    )
    self.audio.append(audio)
    return audio

add_fixed_video(spec)

Add a fixed video input, returning its :class:FixedVideo.

Source code in src/body_eye_sync/experiment/experiment.py
def add_fixed_video(self, spec: FixedVideoInput) -> FixedVideo:
    """Add a fixed video input, returning its :class:`FixedVideo`."""
    self._check_id(spec.id)
    video = FixedVideo(
        id=spec.id,
        path=self._resolve(spec.path),
        timeline=Timeline.from_config(spec.timeline),
    )
    self.fixed_videos.append(video)
    return video

add_glasses_video(spec)

Add a glasses video input, returning its :class:GlassesVideo.

Source code in src/body_eye_sync/experiment/experiment.py
def add_glasses_video(self, spec: GlassesVideoInput) -> GlassesVideo:
    """Add a glasses video input, returning its :class:`GlassesVideo`."""
    self._check_id(spec.id)
    video = GlassesVideo(
        id=spec.id,
        path=self._resolve(spec.path),
        gaze_path=self._resolve(spec.gaze_path),
        timeline=Timeline.from_config(spec.timeline),
    )
    self.glasses_videos.append(video)
    return video

config()

The experiment in its on-disk form.

Source code in src/body_eye_sync/experiment/experiment.py
def config(self) -> ExperimentConfig:
    """The experiment in its on-disk form."""
    return ExperimentConfig(
        glasses_videos=[
            GlassesVideoInput(
                id=v.id,
                path=self._store(v.video_path),
                gaze_path=self._store(v.gaze_path),
                timeline=v.timeline.to_config(),
            )
            for v in self.glasses_videos
        ],
        fixed_videos=[
            FixedVideoInput(
                id=v.id,
                path=self._store(v.video_path),
                timeline=v.timeline.to_config(),
            )
            for v in self.fixed_videos
        ],
        audio=[
            AudioInput(
                id=a.id,
                path=self._store(a.audio_path),
                timeline=a.timeline.to_config(),
                glasses_video=(
                    a.glasses_video.id if a.glasses_video is not None else None
                ),
            )
            for a in self.audio
        ],
        pipeline=self.pipeline,
    )

load(folder) classmethod

Load the experiment in folder: its inputs and their results.

Source code in src/body_eye_sync/experiment/experiment.py
@classmethod
def load(cls, folder: str | Path) -> Experiment:
    """Load the experiment in ``folder``: its inputs and their results."""
    folder = Path(folder)
    with (folder / CONFIG_FILENAME).open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}
    version = data.get("version", CURRENT_VERSION)
    if version > CURRENT_VERSION:
        raise ValueError(
            f"experiment version {version} is newer than supported "
            f"{CURRENT_VERSION}; please upgrade body-eye-sync"
        )
    experiment = cls(ExperimentConfig.model_validate(data), folder)
    experiment._load_stored_data()
    return experiment

output_dir_for(data)

The output directory an input owns, inside :attr:output_dir.

Source code in src/body_eye_sync/experiment/experiment.py
def output_dir_for(self, data: Video | Audio) -> Path:
    """The output directory an input owns, inside :attr:`output_dir`."""
    return self.output_dir / data.id

remove_input(data)

Remove an input from the experiment, leaving its output files alone.

Raises :class:ValueError if it is a glasses video that audio inputs still refer to.

Source code in src/body_eye_sync/experiment/experiment.py
def remove_input(self, data: Video | Audio) -> None:
    """Remove an input from the experiment, leaving its output files alone.

    Raises :class:`ValueError` if it is a glasses video that audio inputs
    still refer to.
    """
    if isinstance(data, GlassesVideo):
        used_by = sorted(a.id for a in self.audio if a.glasses_video is data)
        if used_by:
            raise ValueError(
                f"glasses video {data.id!r} is still used by audio inputs: {used_by}"
            )
    for inputs in (self.glasses_videos, self.fixed_videos, self.audio):
        if any(existing is data for existing in inputs):
            inputs.remove(data)
            return
    raise ValueError(f"input {data.id!r} is not in this experiment")

rename_input(data, new_id)

Give an input a new id, moving its output directory if it exists.

Source code in src/body_eye_sync/experiment/experiment.py
def rename_input(self, data: Video | Audio, new_id: str) -> None:
    """Give an input a new id, moving its output directory if it exists."""
    if new_id == data.id:
        return
    self._check_id(new_id)
    old_id = data.id
    old_output_dir = None
    if self.folder is not None:
        old_output_dir = self.output_dir_for(data)
    data.id = new_id
    if old_output_dir is not None and old_output_dir.exists():
        try:
            old_output_dir.rename(self.output_dir_for(data))
        except OSError:
            data.id = old_id
            raise

save(folder=None)

Write the experiment, and every input's results, into the folder.

folder defaults to the current :attr:folder and becomes it when given. The config is always written; an input is written only once it has results.

Source code in src/body_eye_sync/experiment/experiment.py
def save(self, folder: str | Path | None = None) -> None:
    """Write the experiment, and every input's results, into the folder.

    ``folder`` defaults to the current :attr:`folder` and becomes it when
    given. The config is always written; an input is written only once it
    has results.
    """
    if folder is not None:
        self.folder = Path(folder)
    folder = self._require_folder()
    folder.mkdir(parents=True, exist_ok=True)
    with (folder / CONFIG_FILENAME).open("w", encoding="utf-8") as f:
        yaml.safe_dump(self.config().model_dump(mode="json"), f, sort_keys=False)
    for data in self.inputs:
        if data.has_data():
            data.save(self.output_dir_for(data))
    if self.speech_turns.has_data():
        self.speech_turns.save(self.output_dir)

Preprocessing Experiments

body_eye_sync.experiment.preprocess

Prepare an experiment's inputs for the pipeline, e.g. aligning them and correcting their clock rates.

align_experiment(experiment, *, progress=None)

Measure where each input starts and write the offsets onto the inputs.

Source code in src/body_eye_sync/experiment/preprocess.py
def align_experiment(
    experiment: Experiment, *, progress: Progress | None = None
) -> Alignment:
    """Measure where each input starts and write the offsets onto the inputs."""
    inputs = recordings(experiment)
    if len(inputs) < 2:
        # Nothing to align against: one recording is its own timeline.
        return Alignment(offsets={})
    alignment = align_media(
        {name: data.path for name, data in inputs.items()}, progress=progress
    )
    for name, offset in alignment.offsets.items():
        if name in inputs:
            inputs[name].timeline.offset = offset
    return alignment

apply_clock_rates(experiment, analysis)

Write an analysis' findings onto the inputs, returning the ids changed.

Significant drift fits replace the whole timeline. Successfully measured inputs without significant drift keep their alignment offset and return to a unit rate, clearing a correction an earlier analysis applied.

Source code in src/body_eye_sync/experiment/preprocess.py
def apply_clock_rates(experiment: Experiment, analysis: ClockRateAnalysis) -> list[str]:
    """Write an analysis' findings onto the inputs, returning the ids changed.

    Significant drift fits replace the whole timeline. Successfully measured
    inputs without significant drift keep their alignment offset and return to
    a unit rate, clearing a correction an earlier analysis applied.
    """
    inputs = recordings(experiment)
    changed = []
    for name in analysis.points.keys() | analysis.fits.keys():
        if name not in inputs:
            continue
        data = inputs[name]
        fit = analysis.fits.get(name)
        offset = data.timeline.offset if fit is None else fit.offset
        rate = 1.0 if fit is None else fit.rate
        if (data.timeline.offset, data.timeline.rate) == (offset, rate):
            continue
        data.timeline.offset = offset
        data.timeline.rate = rate
        changed.append(name)
    return changed

clear_clock_rates(experiment)

Drop every input's clock-rate correction, returning the ids changed.

The offsets are left as they are: those say where each recording starts, which alignment worked out, and are not this correction's to undo.

Source code in src/body_eye_sync/experiment/preprocess.py
def clear_clock_rates(experiment: Experiment) -> list[str]:
    """Drop every input's clock-rate correction, returning the ids changed.

    The offsets are left as they are: those say where each recording starts,
    which alignment worked out, and are not this correction's to undo.
    """
    cleared = []
    for name, data in recordings(experiment).items():
        if not data.timeline.corrects_drift:
            continue
        data.timeline.rate = 1.0
        cleared.append(name)
    return cleared

has_corrected_clock_rates(experiment)

Whether any input carries a clock-rate correction to clear.

Source code in src/body_eye_sync/experiment/preprocess.py
def has_corrected_clock_rates(experiment: Experiment) -> bool:
    """Whether any input carries a clock-rate correction to clear."""
    return any(data.timeline.corrects_drift for data in recordings(experiment).values())

recordings(experiment)

The experiment's inputs that have a recording to measure, keyed by id.

Source code in src/body_eye_sync/experiment/preprocess.py
def recordings(experiment: Experiment) -> dict[str, Video | Audio]:
    """The experiment's inputs that have a recording to measure, keyed by id."""
    return {data.id: data for data in experiment.inputs if data.path is not None}

Running Experiments

body_eye_sync.experiment.run

Run an :class:~body_eye_sync.experiment.experiment.Experiment non-interactively.

attribute_speech(experiment)

Work out the experiment's speech turns and write them beside the inputs.

Source code in src/body_eye_sync/experiment/run.py
def attribute_speech(experiment: Experiment) -> None:
    """Work out the experiment's speech turns and write them beside the inputs."""
    if experiment.pipeline.speech is None:
        return
    attribute_experiment_speech(experiment)
    if not experiment.speech_turns.has_data():
        return
    experiment.speech_turns.save(experiment.output_dir)
    logger.info("wrote speech_turns to %s", experiment.output_dir)

run_audio(experiment, audio)

Run the speech pipeline stages over audio.

Source code in src/body_eye_sync/experiment/run.py
def run_audio(experiment: Experiment, audio: Audio) -> None:
    """Run the speech pipeline stages over ``audio``."""
    if experiment.pipeline.speech is None:
        return
    audio_path = audio.audio_path
    if audio_path is None or not audio_path.exists():
        raise FileNotFoundError(f"input {audio.id!r} audio not found: {audio_path}")
    _run_speech_pipeline(
        audio.speech, audio.loudness, audio_path, experiment.pipeline.speech
    )

run_experiment(experiment, *, force=False)

Run the whole pipeline over all inputs, returning each one's output directory.

Source code in src/body_eye_sync/experiment/run.py
def run_experiment(experiment: Experiment, *, force: bool = False) -> dict[str, Path]:
    """Run the whole pipeline over all inputs, returning each one's output directory."""
    runs = [
        *((video, run_glasses_video) for video in experiment.glasses_videos),
        *((video, run_fixed_video) for video in experiment.fixed_videos),
        *((audio, run_audio) for audio in experiment.audio),
    ]
    results: dict[str, Path] = {}
    for data, run in runs:
        directory = experiment.output_dir_for(data)
        if data.has_results(directory) and not force:
            logger.info("skipping input %r: %s already has results", data.id, directory)
            results[data.id] = directory
            continue

        logger.info("running input %r", data.id)
        run(experiment, data)
        if not data.has_data():
            # Nothing to write: an audio input with the speech pipeline off.
            logger.info("input %r produced no results", data.id)
            continue
        data.save(directory)
        logger.info("wrote %s", directory)
        results[data.id] = directory

    attribute_speech(experiment)
    return results

run_fixed_video(experiment, video)

Run the fixed video pipeline stages over video.

Source code in src/body_eye_sync/experiment/run.py
def run_fixed_video(experiment: Experiment, video: FixedVideo) -> None:
    """Run the fixed video pipeline stages over ``video``."""
    _run_video_pipeline(video, experiment.pipeline.fixed_video)
    _run_video_speech(video, experiment.pipeline.speech)

run_glasses_video(experiment, video)

Run the glasses video pipeline stages over video.

Source code in src/body_eye_sync/experiment/run.py
def run_glasses_video(experiment: Experiment, video: GlassesVideo) -> None:
    """Run the glasses video pipeline stages over ``video``."""
    _run_video_pipeline(video, experiment.pipeline.glasses_video)
    _run_video_speech(video, experiment.pipeline.speech)

Input Timelines

body_eye_sync.experiment.timeline

Where an input's own clock sits on the shared experiment timeline.

Every recording is made on its own device, which starts whenever that device was switched on and counts time on its own crystal. Two crystals differ by tens of parts per million, so a :class:Timeline contains an offset and a rate.

Timeline dataclass

Conversions between one recording's clock and the experiment's.

Source code in src/body_eye_sync/experiment/timeline.py
@dataclass
class Timeline:
    """Conversions between one recording's clock and the experiment's."""

    offset: float = 0.0
    #: Experiment seconds per second of this recording's own clock.
    rate: float = 1.0

    @property
    def drift_ppm(self) -> float:
        """How far this recording's clock runs from the experiment's, in ppm."""
        return (self.rate - 1.0) * 1e6

    @property
    def corrects_drift(self) -> bool:
        """Whether this timeline carries a measured clock-rate difference."""
        return self.rate != 1.0

    @classmethod
    def from_config(cls, config: TimelineConfig) -> Self:
        """Build runtime timeline state from its serialisable form."""
        return cls(offset=config.offset, rate=config.rate)

    def to_config(self) -> TimelineConfig:
        """Return the serialisable form of this runtime timeline."""
        return TimelineConfig(offset=self.offset, rate=self.rate)

    def to_experiment_time(self, local_time: float) -> float:
        """Experiment time for a moment on this input's own clock."""
        return to_experiment_time(local_time, self.offset, self.rate)

    def to_experiment_times(self, local_times: np.ndarray) -> np.ndarray:
        """Experiment times for an array of moments on this input's clock."""
        return self.offset + np.asarray(local_times, dtype=float) * self.rate

    def to_local_time(self, experiment_time: float) -> float:
        """This input's own clock at a moment of the experiment."""
        return to_local_time(experiment_time, self.offset, self.rate)

corrects_drift property

Whether this timeline carries a measured clock-rate difference.

drift_ppm property

How far this recording's clock runs from the experiment's, in ppm.

from_config(config) classmethod

Build runtime timeline state from its serialisable form.

Source code in src/body_eye_sync/experiment/timeline.py
@classmethod
def from_config(cls, config: TimelineConfig) -> Self:
    """Build runtime timeline state from its serialisable form."""
    return cls(offset=config.offset, rate=config.rate)

to_config()

Return the serialisable form of this runtime timeline.

Source code in src/body_eye_sync/experiment/timeline.py
def to_config(self) -> TimelineConfig:
    """Return the serialisable form of this runtime timeline."""
    return TimelineConfig(offset=self.offset, rate=self.rate)

to_experiment_time(local_time)

Experiment time for a moment on this input's own clock.

Source code in src/body_eye_sync/experiment/timeline.py
def to_experiment_time(self, local_time: float) -> float:
    """Experiment time for a moment on this input's own clock."""
    return to_experiment_time(local_time, self.offset, self.rate)

to_experiment_times(local_times)

Experiment times for an array of moments on this input's clock.

Source code in src/body_eye_sync/experiment/timeline.py
def to_experiment_times(self, local_times: np.ndarray) -> np.ndarray:
    """Experiment times for an array of moments on this input's clock."""
    return self.offset + np.asarray(local_times, dtype=float) * self.rate

to_local_time(experiment_time)

This input's own clock at a moment of the experiment.

Source code in src/body_eye_sync/experiment/timeline.py
def to_local_time(self, experiment_time: float) -> float:
    """This input's own clock at a moment of the experiment."""
    return to_local_time(experiment_time, self.offset, self.rate)

to_experiment_time(local_time, offset, rate=1.0)

Experiment time for a moment on one recording's own clock.

offset is where the recording starts and rate is how fast its clock runs against the experiment's. Always defined: every moment the recording's clock names did happen, whether or not content was captured for it.

Source code in src/body_eye_sync/experiment/timeline.py
def to_experiment_time(local_time: float, offset: float, rate: float = 1.0) -> float:
    """Experiment time for a moment on one recording's own clock.

    ``offset`` is where the recording starts and ``rate`` is how fast its clock
    runs against the experiment's. Always defined: every moment the recording's
    clock names did happen, whether or not content was captured for it.
    """
    return offset + local_time * rate

to_local_time(experiment_time, offset, rate=1.0)

Where a moment of the experiment sits on one recording's own clock.

Outside the recording's own duration the result is simply out of range; callers that stream a recording check that themselves.

Source code in src/body_eye_sync/experiment/timeline.py
def to_local_time(experiment_time: float, offset: float, rate: float = 1.0) -> float:
    """Where a moment of the experiment sits on one recording's own clock.

    Outside the recording's own duration the result is simply out of range;
    callers that stream a recording check that themselves.
    """
    return (experiment_time - offset) / rate

Video Results

body_eye_sync.experiment.video

Object tracking and vision model outputs for a video.

FixedVideo

Bases: Video

Video from a camera at a fixed position in the room.

Source code in src/body_eye_sync/experiment/video.py
class FixedVideo(Video):
    """Video from a camera at a fixed position in the room."""

GlassesVideo

Bases: Video

Video and gaze data from a participant's glasses-mounted camera.

Source code in src/body_eye_sync/experiment/video.py
class GlassesVideo(Video):
    """Video and gaze data from a participant's glasses-mounted camera."""

    def __init__(
        self,
        id: str = "",
        path: str | Path | None = None,
        gaze_path: str | Path | None = None,
        timeline: Timeline | None = None,
    ) -> None:
        super().__init__(id=id, path=path, timeline=timeline)
        self.gaze_path = Path(gaze_path) if gaze_path is not None else None

    def set_gaze(self, path: str | Path) -> None:
        """Set the gaze samples recorded with this video."""
        self.gaze_path = Path(path)

set_gaze(path)

Set the gaze samples recorded with this video.

Source code in src/body_eye_sync/experiment/video.py
def set_gaze(self, path: str | Path) -> None:
    """Set the gaze samples recorded with this video."""
    self.gaze_path = Path(path)

Video

A video input: its settings and the model outputs computed from it.

id names the input and its output directory, and timeline places the video's own clock on the experiment clock.

Completed results live in a single numeric :attr:data DataFrame. While a run is in progress, each frame's BoxMOT tracks array is accumulated and collapsed into that DataFrame once :meth:finish_object_tracking is called. Face detection runs as a later pass over those tracked boxes, accumulating per frame and folding its columns onto the matching rows in :meth:finish_face_detection. Body-pose detection follows the same pattern.

A camera also records audio, so the audio stages can run over this video's own track, with their results stored in :attr:speech and :attr:loudness.

Source code in src/body_eye_sync/experiment/video.py
class Video:
    """A video input: its settings and the model outputs computed from it.

    ``id`` names the input and its output directory, and ``timeline`` places
    the video's own clock on the experiment clock.

    Completed results live in a single numeric :attr:`data` DataFrame. While a
    run is in progress, each frame's BoxMOT ``tracks`` array is accumulated and
    collapsed into that DataFrame once :meth:`finish_object_tracking` is called.
    Face detection runs as a later pass over those tracked boxes, accumulating
    per frame and folding its columns onto the matching rows in
    :meth:`finish_face_detection`. Body-pose detection follows the same pattern.

    A camera also records audio, so the audio stages can run over this video's
    own track, with their results stored in :attr:`speech` and :attr:`loudness`.
    """

    #: The tracked boxes, this input's main result.
    _RESULTS_FILENAME: ClassVar[str] = "results.parquet"

    def __init__(
        self,
        id: str = "",
        path: str | Path | None = None,
        timeline: Timeline | None = None,
    ) -> None:
        self.id = id
        self.video_path = Path(path) if path is not None else None
        self.timeline = timeline if timeline is not None else Timeline()
        self.speech = Speech()
        self.loudness = Loudness()
        self._has_audio_track = False
        self._audio_track_path: Path | None = None
        # Persistent results.
        self._data: pd.DataFrame | None = None
        self._rows_by_frame: dict[int, np.ndarray] = {}
        self._body_embeddings: pd.DataFrame | None = None
        self._face_embeddings: pd.DataFrame | None = None
        # Per-pass scratch: accumulated while a pass runs, then collapsed into the
        # results above and reset. ``_tmp_`` marks them as transient.
        self._tmp_frames: list[tuple[int, np.ndarray]] = []
        self._tmp_face_frames: list[FaceFrameResult] = []
        self._tmp_pose_frames: list[PoseFrameResult] = []
        self._tmp_body_topk = TopK(0, _EMBEDDING_COLUMNS)
        self._tmp_face_topk = TopK(0, _EMBEDDING_COLUMNS)

    @property
    def path(self) -> Path | None:
        return self.video_path

    def has_audio_track(self) -> bool:
        """Whether this video carries sound"""
        if self.video_path is None:
            return False
        if self._audio_track_path != self.video_path:
            self._has_audio_track = has_audio_stream(self.video_path)
            self._audio_track_path = self.video_path
        return self._has_audio_track

    def begin_object_tracking(self, embeddings_per_track: int = 0) -> None:
        """Drop any previous model outputs.

        ``embeddings_per_track`` keeps that many best body-appearance (ReID)
        embeddings per tracklet, ranked by detection confidence, for later
        identity clustering; ``0`` keeps none.
        """
        self.clear()
        self._tmp_body_topk = TopK(embeddings_per_track, _EMBEDDING_COLUMNS)

    def add_object_tracking_frame(self, frame) -> None:
        """Accumulate a BoxMOT per-frame result, converting to 0-based indices"""
        tracks = np.asarray(frame.tracks)
        self._tmp_frames.append((frame.frame_idx - 1, tracks))
        self._collect_body_embeddings(frame.frame_idx - 1, tracks, frame)

    def _collect_body_embeddings(
        self, frame_index: int, tracks: np.ndarray, frame
    ) -> None:
        """Feed this frame's ReID embeddings (if any) into the per-track top-K."""
        embeddings = getattr(frame, "embeddings", None)
        if embeddings is None:
            return
        embeddings = np.asarray(embeddings)
        for row, vec in zip(tracks, embeddings):
            if not np.any(np.isfinite(vec)):
                continue  # predicted-only track with no detection this frame
            self._tmp_body_topk.add(int(row[4]), frame_index, float(row[5]), vec)

    def finish_object_tracking(self) -> None:
        """Collapse the streamed frames into the stored :attr:`data` DataFrame."""
        self.set_data(tracks_to_dataframe(self._tmp_frames))
        self._body_embeddings = self._tmp_body_topk.to_frame()

    def discard_object_tracking(self) -> None:
        """Drop a cancelled or failed run; its partial output is unusable."""
        self.clear()

    def set_data(self, data: pd.DataFrame) -> None:
        """Replace all results with a complete data DataFrame."""
        if "frame" not in data.columns:
            raise ValueError("results table has no 'frame' column")
        self._data = data
        self._rows_by_frame = data.groupby("frame").indices
        self._tmp_frames = []

    def all_boxes_by_frame(self) -> dict[int, list[BoundingBox]]:
        """Tracked person boxes grouped by frame, as later passes consume them."""
        if self._data is None:
            return {}
        return {
            int(frame): self.boxes_for_frame(int(frame))
            for frame in self._rows_by_frame
        }

    def begin_face_detection(self, embeddings_per_track: int = 0) -> None:
        """Drop any previous face columns so a fresh pass starts clean.

        ``embeddings_per_track`` keeps that many best face embeddings per
        tracklet, ranked by face score, for later identity clustering.
        """
        if self._data is not None:
            present = [c for c in FACE_COLUMNS if c in self._data.columns]
            if present:
                self.set_data(self._data.drop(columns=present))
        self._tmp_face_frames = []
        self._tmp_face_topk = TopK(embeddings_per_track, _EMBEDDING_COLUMNS)
        self._face_embeddings = None

    def add_face_detection_frame(self, result: FaceFrameResult) -> None:
        """Accumulate one frame's detected faces for the final merge."""
        self._tmp_face_frames.append(result)
        for face in result.faces:
            self._tmp_face_topk.add(
                face.box.track_id, result.frame_idx, face.score, face.embedding
            )

    def finish_face_detection(self) -> None:
        """Merge the streamed faces onto their ``(frame, track_id)`` rows."""
        if self._data is None:
            return
        faces = faces_to_dataframe(self._tmp_face_frames)
        self.set_data(self._data.merge(faces, on=["frame", "track_id"], how="left"))
        self._tmp_face_frames = []
        self._face_embeddings = self._tmp_face_topk.to_frame()

    def discard_face_detection(self) -> None:
        """Drop a cancelled or failed pass; the tracked boxes are left intact."""
        self._tmp_face_frames = []
        self._tmp_face_topk = TopK(0, _EMBEDDING_COLUMNS)
        self._face_embeddings = None

    def faces_for_frame(self, frame_index: int) -> list[FaceBox]:
        """Detected face boxes for frame ``frame_index`` (0-based)."""
        if self._data is None or "face_score" not in self._data.columns:
            return []
        positions = self._rows_by_frame.get(frame_index)
        if positions is None:
            return []
        rows = self._data.take(positions)
        rows = rows[rows["face_score"].notna()]
        return [face_box_from_row(r) for r in rows.itertuples(index=False)]

    def begin_body_pose_detection(self, embeddings_per_track: int = 0) -> None:
        """Drop any previous pose columns so a fresh pass starts clean.

        ``embeddings_per_track`` is accepted for a uniform ``begin_*`` signature
        across steps but ignored -- pose detection produces no embeddings.
        """
        if self._data is not None:
            present = [c for c in POSE_COLUMNS if c in self._data.columns]
            if present:
                self.set_data(self._data.drop(columns=present))
        self._tmp_pose_frames = []

    def add_body_pose_frame(self, result: PoseFrameResult) -> None:
        """Accumulate one frame's detected body poses for the final merge."""
        self._tmp_pose_frames.append(result)

    def finish_body_pose_detection(self) -> None:
        """Merge the streamed poses onto their ``(frame, track_id)`` rows."""
        if self._data is None:
            return
        poses = poses_to_dataframe(self._tmp_pose_frames)
        self.set_data(self._data.merge(poses, on=["frame", "track_id"], how="left"))
        self._tmp_pose_frames = []

    def discard_body_pose_detection(self) -> None:
        """Drop a cancelled or failed pass; the tracked boxes are left intact."""
        self._tmp_pose_frames = []

    def poses_for_frame(self, frame_index: int) -> list[BodyPose]:
        """Detected body poses for frame ``frame_index`` (0-based)."""
        if self._data is None or "pose_score" not in self._data.columns:
            return []
        positions = self._rows_by_frame.get(frame_index)
        if positions is None:
            return []
        rows = self._data.take(positions)
        rows = rows[rows["pose_score"].notna()]
        return [pose_from_row(r) for r in rows.itertuples(index=False)]

    @property
    def data(self) -> pd.DataFrame | None:
        """All tracked detections as a DataFrame, or ``None`` until complete."""
        return self._data

    @property
    def body_embeddings(self) -> pd.DataFrame | None:
        """Best-K body-appearance embeddings per tracklet, or ``None``."""
        return self._body_embeddings

    @property
    def face_embeddings(self) -> pd.DataFrame | None:
        """Best-K face embeddings per tracklet, or ``None``."""
        return self._face_embeddings

    def boxes_for_frame(self, frame_index: int) -> list[BoundingBox]:
        """Object bounding boxes for frame ``frame_index`` (0-based)."""
        if self._data is None:
            return []
        positions = self._rows_by_frame.get(frame_index)
        if positions is None:
            return []
        rows = self._data.take(positions)
        return [
            BoundingBox(r.x1, r.y1, r.x2, r.y2, int(r.track_id))
            for r in rows.itertuples(index=False)
        ]

    def clear(self) -> None:
        self._data = None
        self._rows_by_frame = {}
        self.speech.clear()
        self.loudness.clear()
        self._tmp_frames = []
        self._tmp_face_frames = []
        self._tmp_pose_frames = []
        self._tmp_body_topk = TopK(0, _EMBEDDING_COLUMNS)
        self._tmp_face_topk = TopK(0, _EMBEDDING_COLUMNS)
        self._body_embeddings = None
        self._face_embeddings = None

    def has_data(self) -> bool:
        """Whether this video has any completed pipeline results in memory."""
        return (
            self._data is not None
            or self.speech.data is not None
            or self.loudness.data is not None
        )

    def has_results(self, directory: str | Path) -> bool:
        """Whether ``directory`` already holds results for a video."""
        return (Path(directory) / self._RESULTS_FILENAME).exists()

    def save(self, directory: str | Path) -> None:
        """Write these results into ``directory``, one file per kind of result."""
        if not self.has_data():
            raise ValueError("no data to write; run the pipeline first")
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)
        if self._data is not None:
            import pyarrow as pa
            import pyarrow.parquet as pq

            table = pa.Table.from_pandas(self._data, preserve_index=False)
            pq.write_table(table, str(directory / self._RESULTS_FILENAME))
            for kind, embeddings in (
                ("body", self._body_embeddings),
                ("face", self._face_embeddings),
            ):
                embeddings_path = directory / _embeddings_filename(kind)
                if embeddings is None:
                    embeddings_path.unlink(missing_ok=True)
                else:
                    write_embeddings(embeddings_path, embeddings)
        self.speech.save(directory)
        self.loudness.save(directory)

    def load(self, directory: str | Path) -> None:
        """Load results written by :meth:`save`, if ``directory`` holds any."""
        directory = Path(directory)
        self.clear()
        self.speech.load(directory)
        self.loudness.load(directory)
        results_path = directory / self._RESULTS_FILENAME
        if not results_path.exists():
            return
        self.set_data(pd.read_parquet(results_path))
        body_path = directory / _embeddings_filename("body")
        if body_path.exists():
            self._body_embeddings = read_embeddings(body_path)
        face_path = directory / _embeddings_filename("face")
        if face_path.exists():
            self._face_embeddings = read_embeddings(face_path)

body_embeddings property

Best-K body-appearance embeddings per tracklet, or None.

data property

All tracked detections as a DataFrame, or None until complete.

face_embeddings property

Best-K face embeddings per tracklet, or None.

add_body_pose_frame(result)

Accumulate one frame's detected body poses for the final merge.

Source code in src/body_eye_sync/experiment/video.py
def add_body_pose_frame(self, result: PoseFrameResult) -> None:
    """Accumulate one frame's detected body poses for the final merge."""
    self._tmp_pose_frames.append(result)

add_face_detection_frame(result)

Accumulate one frame's detected faces for the final merge.

Source code in src/body_eye_sync/experiment/video.py
def add_face_detection_frame(self, result: FaceFrameResult) -> None:
    """Accumulate one frame's detected faces for the final merge."""
    self._tmp_face_frames.append(result)
    for face in result.faces:
        self._tmp_face_topk.add(
            face.box.track_id, result.frame_idx, face.score, face.embedding
        )

add_object_tracking_frame(frame)

Accumulate a BoxMOT per-frame result, converting to 0-based indices

Source code in src/body_eye_sync/experiment/video.py
def add_object_tracking_frame(self, frame) -> None:
    """Accumulate a BoxMOT per-frame result, converting to 0-based indices"""
    tracks = np.asarray(frame.tracks)
    self._tmp_frames.append((frame.frame_idx - 1, tracks))
    self._collect_body_embeddings(frame.frame_idx - 1, tracks, frame)

all_boxes_by_frame()

Tracked person boxes grouped by frame, as later passes consume them.

Source code in src/body_eye_sync/experiment/video.py
def all_boxes_by_frame(self) -> dict[int, list[BoundingBox]]:
    """Tracked person boxes grouped by frame, as later passes consume them."""
    if self._data is None:
        return {}
    return {
        int(frame): self.boxes_for_frame(int(frame))
        for frame in self._rows_by_frame
    }

begin_body_pose_detection(embeddings_per_track=0)

Drop any previous pose columns so a fresh pass starts clean.

embeddings_per_track is accepted for a uniform begin_* signature across steps but ignored -- pose detection produces no embeddings.

Source code in src/body_eye_sync/experiment/video.py
def begin_body_pose_detection(self, embeddings_per_track: int = 0) -> None:
    """Drop any previous pose columns so a fresh pass starts clean.

    ``embeddings_per_track`` is accepted for a uniform ``begin_*`` signature
    across steps but ignored -- pose detection produces no embeddings.
    """
    if self._data is not None:
        present = [c for c in POSE_COLUMNS if c in self._data.columns]
        if present:
            self.set_data(self._data.drop(columns=present))
    self._tmp_pose_frames = []

begin_face_detection(embeddings_per_track=0)

Drop any previous face columns so a fresh pass starts clean.

embeddings_per_track keeps that many best face embeddings per tracklet, ranked by face score, for later identity clustering.

Source code in src/body_eye_sync/experiment/video.py
def begin_face_detection(self, embeddings_per_track: int = 0) -> None:
    """Drop any previous face columns so a fresh pass starts clean.

    ``embeddings_per_track`` keeps that many best face embeddings per
    tracklet, ranked by face score, for later identity clustering.
    """
    if self._data is not None:
        present = [c for c in FACE_COLUMNS if c in self._data.columns]
        if present:
            self.set_data(self._data.drop(columns=present))
    self._tmp_face_frames = []
    self._tmp_face_topk = TopK(embeddings_per_track, _EMBEDDING_COLUMNS)
    self._face_embeddings = None

begin_object_tracking(embeddings_per_track=0)

Drop any previous model outputs.

embeddings_per_track keeps that many best body-appearance (ReID) embeddings per tracklet, ranked by detection confidence, for later identity clustering; 0 keeps none.

Source code in src/body_eye_sync/experiment/video.py
def begin_object_tracking(self, embeddings_per_track: int = 0) -> None:
    """Drop any previous model outputs.

    ``embeddings_per_track`` keeps that many best body-appearance (ReID)
    embeddings per tracklet, ranked by detection confidence, for later
    identity clustering; ``0`` keeps none.
    """
    self.clear()
    self._tmp_body_topk = TopK(embeddings_per_track, _EMBEDDING_COLUMNS)

boxes_for_frame(frame_index)

Object bounding boxes for frame frame_index (0-based).

Source code in src/body_eye_sync/experiment/video.py
def boxes_for_frame(self, frame_index: int) -> list[BoundingBox]:
    """Object bounding boxes for frame ``frame_index`` (0-based)."""
    if self._data is None:
        return []
    positions = self._rows_by_frame.get(frame_index)
    if positions is None:
        return []
    rows = self._data.take(positions)
    return [
        BoundingBox(r.x1, r.y1, r.x2, r.y2, int(r.track_id))
        for r in rows.itertuples(index=False)
    ]

discard_body_pose_detection()

Drop a cancelled or failed pass; the tracked boxes are left intact.

Source code in src/body_eye_sync/experiment/video.py
def discard_body_pose_detection(self) -> None:
    """Drop a cancelled or failed pass; the tracked boxes are left intact."""
    self._tmp_pose_frames = []

discard_face_detection()

Drop a cancelled or failed pass; the tracked boxes are left intact.

Source code in src/body_eye_sync/experiment/video.py
def discard_face_detection(self) -> None:
    """Drop a cancelled or failed pass; the tracked boxes are left intact."""
    self._tmp_face_frames = []
    self._tmp_face_topk = TopK(0, _EMBEDDING_COLUMNS)
    self._face_embeddings = None

discard_object_tracking()

Drop a cancelled or failed run; its partial output is unusable.

Source code in src/body_eye_sync/experiment/video.py
def discard_object_tracking(self) -> None:
    """Drop a cancelled or failed run; its partial output is unusable."""
    self.clear()

faces_for_frame(frame_index)

Detected face boxes for frame frame_index (0-based).

Source code in src/body_eye_sync/experiment/video.py
def faces_for_frame(self, frame_index: int) -> list[FaceBox]:
    """Detected face boxes for frame ``frame_index`` (0-based)."""
    if self._data is None or "face_score" not in self._data.columns:
        return []
    positions = self._rows_by_frame.get(frame_index)
    if positions is None:
        return []
    rows = self._data.take(positions)
    rows = rows[rows["face_score"].notna()]
    return [face_box_from_row(r) for r in rows.itertuples(index=False)]

finish_body_pose_detection()

Merge the streamed poses onto their (frame, track_id) rows.

Source code in src/body_eye_sync/experiment/video.py
def finish_body_pose_detection(self) -> None:
    """Merge the streamed poses onto their ``(frame, track_id)`` rows."""
    if self._data is None:
        return
    poses = poses_to_dataframe(self._tmp_pose_frames)
    self.set_data(self._data.merge(poses, on=["frame", "track_id"], how="left"))
    self._tmp_pose_frames = []

finish_face_detection()

Merge the streamed faces onto their (frame, track_id) rows.

Source code in src/body_eye_sync/experiment/video.py
def finish_face_detection(self) -> None:
    """Merge the streamed faces onto their ``(frame, track_id)`` rows."""
    if self._data is None:
        return
    faces = faces_to_dataframe(self._tmp_face_frames)
    self.set_data(self._data.merge(faces, on=["frame", "track_id"], how="left"))
    self._tmp_face_frames = []
    self._face_embeddings = self._tmp_face_topk.to_frame()

finish_object_tracking()

Collapse the streamed frames into the stored :attr:data DataFrame.

Source code in src/body_eye_sync/experiment/video.py
def finish_object_tracking(self) -> None:
    """Collapse the streamed frames into the stored :attr:`data` DataFrame."""
    self.set_data(tracks_to_dataframe(self._tmp_frames))
    self._body_embeddings = self._tmp_body_topk.to_frame()

has_audio_track()

Whether this video carries sound

Source code in src/body_eye_sync/experiment/video.py
def has_audio_track(self) -> bool:
    """Whether this video carries sound"""
    if self.video_path is None:
        return False
    if self._audio_track_path != self.video_path:
        self._has_audio_track = has_audio_stream(self.video_path)
        self._audio_track_path = self.video_path
    return self._has_audio_track

has_data()

Whether this video has any completed pipeline results in memory.

Source code in src/body_eye_sync/experiment/video.py
def has_data(self) -> bool:
    """Whether this video has any completed pipeline results in memory."""
    return (
        self._data is not None
        or self.speech.data is not None
        or self.loudness.data is not None
    )

has_results(directory)

Whether directory already holds results for a video.

Source code in src/body_eye_sync/experiment/video.py
def has_results(self, directory: str | Path) -> bool:
    """Whether ``directory`` already holds results for a video."""
    return (Path(directory) / self._RESULTS_FILENAME).exists()

load(directory)

Load results written by :meth:save, if directory holds any.

Source code in src/body_eye_sync/experiment/video.py
def load(self, directory: str | Path) -> None:
    """Load results written by :meth:`save`, if ``directory`` holds any."""
    directory = Path(directory)
    self.clear()
    self.speech.load(directory)
    self.loudness.load(directory)
    results_path = directory / self._RESULTS_FILENAME
    if not results_path.exists():
        return
    self.set_data(pd.read_parquet(results_path))
    body_path = directory / _embeddings_filename("body")
    if body_path.exists():
        self._body_embeddings = read_embeddings(body_path)
    face_path = directory / _embeddings_filename("face")
    if face_path.exists():
        self._face_embeddings = read_embeddings(face_path)

poses_for_frame(frame_index)

Detected body poses for frame frame_index (0-based).

Source code in src/body_eye_sync/experiment/video.py
def poses_for_frame(self, frame_index: int) -> list[BodyPose]:
    """Detected body poses for frame ``frame_index`` (0-based)."""
    if self._data is None or "pose_score" not in self._data.columns:
        return []
    positions = self._rows_by_frame.get(frame_index)
    if positions is None:
        return []
    rows = self._data.take(positions)
    rows = rows[rows["pose_score"].notna()]
    return [pose_from_row(r) for r in rows.itertuples(index=False)]

save(directory)

Write these results into directory, one file per kind of result.

Source code in src/body_eye_sync/experiment/video.py
def save(self, directory: str | Path) -> None:
    """Write these results into ``directory``, one file per kind of result."""
    if not self.has_data():
        raise ValueError("no data to write; run the pipeline first")
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    if self._data is not None:
        import pyarrow as pa
        import pyarrow.parquet as pq

        table = pa.Table.from_pandas(self._data, preserve_index=False)
        pq.write_table(table, str(directory / self._RESULTS_FILENAME))
        for kind, embeddings in (
            ("body", self._body_embeddings),
            ("face", self._face_embeddings),
        ):
            embeddings_path = directory / _embeddings_filename(kind)
            if embeddings is None:
                embeddings_path.unlink(missing_ok=True)
            else:
                write_embeddings(embeddings_path, embeddings)
    self.speech.save(directory)
    self.loudness.save(directory)

set_data(data)

Replace all results with a complete data DataFrame.

Source code in src/body_eye_sync/experiment/video.py
def set_data(self, data: pd.DataFrame) -> None:
    """Replace all results with a complete data DataFrame."""
    if "frame" not in data.columns:
        raise ValueError("results table has no 'frame' column")
    self._data = data
    self._rows_by_frame = data.groupby("frame").indices
    self._tmp_frames = []

Audio Results

body_eye_sync.experiment.audio

Model outputs for a separately recorded audio input.

Audio

An audio input: its settings and the model outputs computed from it.

Audio recorded on its own device, such as a directional microphone. The inputs carry their own audio separately - meaning embedded audio in video input files is played with the video itself, for example during the Alignment stage.

id names the input and its output directory, and timeline places the recording's own clock on the experiment clock. glasses_video is the glasses video worn by the participant this recording captures, when it is aimed at one.

Source code in src/body_eye_sync/experiment/audio.py
class Audio:
    """An audio input: its settings and the model outputs computed from it.

    Audio recorded on its own device, such as a directional microphone.  The inputs carry their own audio separately -
    meaning embedded audio in video input files is played with the video itself, for example during the Alignment stage.

    ``id`` names the input and its output directory, and ``timeline`` places
    the recording's own clock on the experiment clock.
    ``glasses_video`` is the glasses video worn by the participant this
    recording captures, when it is aimed at one.
    """

    def __init__(
        self,
        id: str = "",
        path: str | Path | None = None,
        glasses_video: GlassesVideo | None = None,
        timeline: Timeline | None = None,
    ) -> None:
        self.id = id
        self.audio_path = Path(path) if path is not None else None
        self.glasses_video = glasses_video
        self.timeline = timeline if timeline is not None else Timeline()
        self.speech = Speech()
        self.loudness = Loudness()

    @property
    def path(self) -> Path | None:
        return self.audio_path

    def has_audio_track(self) -> bool:
        """Whether this recording carries sound"""
        return self.audio_path is not None

    def clear(self) -> None:
        self.speech.clear()
        self.loudness.clear()

    def has_data(self) -> bool:
        """Whether this recording has any audio processing results in memory."""
        return self.speech.data is not None or self.loudness.data is not None

    def has_results(self, directory: str | Path) -> bool:
        """Whether ``directory`` already holds results for a recording."""
        return (Path(directory) / SEGMENTS_FILENAME).exists()

    def save(self, directory: str | Path) -> None:
        """Write these results into ``directory``, a file per kind of result."""
        if not self.has_data():
            raise ValueError("no data to write; run the pipeline first")
        self.speech.save(directory)
        self.loudness.save(directory)

    def load(self, directory: str | Path) -> None:
        """Load results written by :meth:`save`, if ``directory`` holds any.

        Replaces any current results. A directory with nothing in it leaves
        this recording empty rather than failing.
        """
        self.speech.load(directory)
        self.loudness.load(directory)

has_audio_track()

Whether this recording carries sound

Source code in src/body_eye_sync/experiment/audio.py
def has_audio_track(self) -> bool:
    """Whether this recording carries sound"""
    return self.audio_path is not None

has_data()

Whether this recording has any audio processing results in memory.

Source code in src/body_eye_sync/experiment/audio.py
def has_data(self) -> bool:
    """Whether this recording has any audio processing results in memory."""
    return self.speech.data is not None or self.loudness.data is not None

has_results(directory)

Whether directory already holds results for a recording.

Source code in src/body_eye_sync/experiment/audio.py
def has_results(self, directory: str | Path) -> bool:
    """Whether ``directory`` already holds results for a recording."""
    return (Path(directory) / SEGMENTS_FILENAME).exists()

load(directory)

Load results written by :meth:save, if directory holds any.

Replaces any current results. A directory with nothing in it leaves this recording empty rather than failing.

Source code in src/body_eye_sync/experiment/audio.py
def load(self, directory: str | Path) -> None:
    """Load results written by :meth:`save`, if ``directory`` holds any.

    Replaces any current results. A directory with nothing in it leaves
    this recording empty rather than failing.
    """
    self.speech.load(directory)
    self.loudness.load(directory)

save(directory)

Write these results into directory, a file per kind of result.

Source code in src/body_eye_sync/experiment/audio.py
def save(self, directory: str | Path) -> None:
    """Write these results into ``directory``, a file per kind of result."""
    if not self.has_data():
        raise ValueError("no data to write; run the pipeline first")
    self.speech.save(directory)
    self.loudness.save(directory)

Speech Results

body_eye_sync.experiment.speech

A transcription of what was said when in one recording.

Speech

The transcript computed from one recording's audio.

Source code in src/body_eye_sync/experiment/speech.py
class Speech:
    """The transcript computed from one recording's audio."""

    def __init__(self) -> None:
        # persistent results
        self._data: pd.DataFrame | None = None
        self._words: pd.DataFrame | None = None
        # temporary data while running a pass
        self._tmp_transcript: list[TranscriptSegment] = []

    def begin_transcription(self) -> None:
        """Drop any previous transcript so a fresh pass starts clean."""
        self.clear()

    def add_transcription_segment(self, segment: TranscriptSegment) -> None:
        """Accumulate one transcribed segment for the final table."""
        self._tmp_transcript.append(segment)

    def finish_transcription(self) -> None:
        """Collapse the accumulated segments into :attr:`data` and :attr:`words`."""
        self._data, self._words = transcript_to_dataframes(self._tmp_transcript)
        self._tmp_transcript = []

    def set_data(self, data: pd.DataFrame) -> None:
        """Replace the transcribed segments with a complete data DataFrame."""
        if "segment_id" not in data.columns:
            raise ValueError("transcript table has no 'segment_id' column")
        self._data = data
        self._tmp_transcript = []

    @property
    def data(self) -> pd.DataFrame | None:
        """The transcribed segments, or ``None`` until transcription has run."""
        return self._data

    @property
    def words(self) -> pd.DataFrame | None:
        """Per-word timings, or ``None`` until transcription has run."""
        return self._words

    def clear(self) -> None:
        self._data = None
        self._words = None
        self._tmp_transcript = []

    def save(self, directory: str | Path) -> None:
        """Write these results into ``directory``, one file per kind."""
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)
        for filename, table in (
            (SEGMENTS_FILENAME, self._data),
            (WORDS_FILENAME, self._words),
        ):
            path = directory / filename
            if table is None:
                path.unlink(missing_ok=True)
            else:
                _write_table(path, table)

    def load(self, directory: str | Path) -> None:
        """Load results written by :meth:`save`, if ``directory`` holds any."""
        directory = Path(directory)
        self.clear()
        segments_path = directory / SEGMENTS_FILENAME
        if not segments_path.exists():
            return
        self.set_data(pd.read_parquet(segments_path))
        words_path = directory / WORDS_FILENAME
        if words_path.exists():
            self._words = pd.read_parquet(words_path)

data property

The transcribed segments, or None until transcription has run.

words property

Per-word timings, or None until transcription has run.

add_transcription_segment(segment)

Accumulate one transcribed segment for the final table.

Source code in src/body_eye_sync/experiment/speech.py
def add_transcription_segment(self, segment: TranscriptSegment) -> None:
    """Accumulate one transcribed segment for the final table."""
    self._tmp_transcript.append(segment)

begin_transcription()

Drop any previous transcript so a fresh pass starts clean.

Source code in src/body_eye_sync/experiment/speech.py
def begin_transcription(self) -> None:
    """Drop any previous transcript so a fresh pass starts clean."""
    self.clear()

finish_transcription()

Collapse the accumulated segments into :attr:data and :attr:words.

Source code in src/body_eye_sync/experiment/speech.py
def finish_transcription(self) -> None:
    """Collapse the accumulated segments into :attr:`data` and :attr:`words`."""
    self._data, self._words = transcript_to_dataframes(self._tmp_transcript)
    self._tmp_transcript = []

load(directory)

Load results written by :meth:save, if directory holds any.

Source code in src/body_eye_sync/experiment/speech.py
def load(self, directory: str | Path) -> None:
    """Load results written by :meth:`save`, if ``directory`` holds any."""
    directory = Path(directory)
    self.clear()
    segments_path = directory / SEGMENTS_FILENAME
    if not segments_path.exists():
        return
    self.set_data(pd.read_parquet(segments_path))
    words_path = directory / WORDS_FILENAME
    if words_path.exists():
        self._words = pd.read_parquet(words_path)

save(directory)

Write these results into directory, one file per kind.

Source code in src/body_eye_sync/experiment/speech.py
def save(self, directory: str | Path) -> None:
    """Write these results into ``directory``, one file per kind."""
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    for filename, table in (
        (SEGMENTS_FILENAME, self._data),
        (WORDS_FILENAME, self._words),
    ):
        path = directory / filename
        if table is None:
            path.unlink(missing_ok=True)
        else:
            _write_table(path, table)

set_data(data)

Replace the transcribed segments with a complete data DataFrame.

Source code in src/body_eye_sync/experiment/speech.py
def set_data(self, data: pd.DataFrame) -> None:
    """Replace the transcribed segments with a complete data DataFrame."""
    if "segment_id" not in data.columns:
        raise ValueError("transcript table has no 'segment_id' column")
    self._data = data
    self._tmp_transcript = []

Loudness Results

body_eye_sync.experiment.loudness

How loud one recording is over time, computed from its audio.

Loudness

The loudness measured from one recording's audio.

Source code in src/body_eye_sync/experiment/loudness.py
class Loudness:
    """The loudness measured from one recording's audio."""

    def __init__(self) -> None:
        self._data: pd.DataFrame | None = None

    def measure(self, path: str | Path) -> None:
        """Measure a recording, replacing whatever was measured before."""
        self._data = measure_loudness(path)

    def set_data(self, data: pd.DataFrame) -> None:
        """Replace the measured loudness with a complete data DataFrame."""
        missing = [column for column in LOUDNESS_COLUMNS if column not in data.columns]
        if missing:
            raise ValueError(f"loudness table has no {missing[0]!r} column")
        self._data = data

    @property
    def data(self) -> pd.DataFrame | None:
        """The measured loudness, or ``None`` until the recording is measured."""
        return self._data

    @property
    def levels(self) -> np.ndarray:
        """The measured levels in dB, or nothing when this is unmeasured."""
        return self._values("level_db")

    @property
    def times(self) -> np.ndarray:
        """When each level was measured, on the recording's own clock."""
        return self._values("time")

    def _values(self, column: str) -> np.ndarray:
        if self._data is None:
            return np.empty(0)
        return self._data[column].to_numpy(dtype=float)

    def clear(self) -> None:
        self._data = None

    def save(self, directory: str | Path) -> None:
        """Write these results into ``directory``."""
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)
        path = directory / LOUDNESS_FILENAME
        if self._data is None:
            path.unlink(missing_ok=True)
        else:
            pq.write_table(
                pa.Table.from_pandas(self._data, preserve_index=False), str(path)
            )

    def load(self, directory: str | Path) -> None:
        """Load results written by :meth:`save`, if ``directory`` holds any."""
        self.clear()
        path = Path(directory) / LOUDNESS_FILENAME
        if path.exists():
            self.set_data(pd.read_parquet(path))

data property

The measured loudness, or None until the recording is measured.

levels property

The measured levels in dB, or nothing when this is unmeasured.

times property

When each level was measured, on the recording's own clock.

load(directory)

Load results written by :meth:save, if directory holds any.

Source code in src/body_eye_sync/experiment/loudness.py
def load(self, directory: str | Path) -> None:
    """Load results written by :meth:`save`, if ``directory`` holds any."""
    self.clear()
    path = Path(directory) / LOUDNESS_FILENAME
    if path.exists():
        self.set_data(pd.read_parquet(path))

measure(path)

Measure a recording, replacing whatever was measured before.

Source code in src/body_eye_sync/experiment/loudness.py
def measure(self, path: str | Path) -> None:
    """Measure a recording, replacing whatever was measured before."""
    self._data = measure_loudness(path)

save(directory)

Write these results into directory.

Source code in src/body_eye_sync/experiment/loudness.py
def save(self, directory: str | Path) -> None:
    """Write these results into ``directory``."""
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    path = directory / LOUDNESS_FILENAME
    if self._data is None:
        path.unlink(missing_ok=True)
    else:
        pq.write_table(
            pa.Table.from_pandas(self._data, preserve_index=False), str(path)
        )

set_data(data)

Replace the measured loudness with a complete data DataFrame.

Source code in src/body_eye_sync/experiment/loudness.py
def set_data(self, data: pd.DataFrame) -> None:
    """Replace the measured loudness with a complete data DataFrame."""
    missing = [column for column in LOUDNESS_COLUMNS if column not in data.columns]
    if missing:
        raise ValueError(f"loudness table has no {missing[0]!r} column")
    self._data = data

Embedding Storage

body_eye_sync.experiment.embeddings

Storage for the best-K embedding tables the pipeline stages collect.

TopK

Keeps the best-K embeddings per group as float16, ranked by score.

Source code in src/body_eye_sync/experiment/embeddings.py
class TopK:
    """Keeps the best-K embeddings per group as ``float16``, ranked by score."""

    def __init__(self, k: int, columns: list[str]) -> None:
        self._k = k
        self._columns = columns
        self._heaps: dict[int, list] = {}
        self._counter = itertools.count()

    def add(self, group: int, index: int, score: float, embedding) -> None:
        if self._k <= 0 or embedding is None:
            return
        vec = np.asarray(embedding, dtype=np.float16)
        # A per-group min-heap keyed by score keeps the K highest scoring; the
        # counter breaks score ties so the arrays are never compared.
        item = (float(score), next(self._counter), int(index), vec)
        heap = self._heaps.setdefault(int(group), [])
        if len(heap) < self._k:
            heapq.heappush(heap, item)
        elif item[0] > heap[0][0]:
            heapq.heapreplace(heap, item)

    def to_frame(self) -> pd.DataFrame | None:
        """Best-first table of the kept embeddings, or ``None`` if none were kept."""
        rows = []
        for group, heap in self._heaps.items():
            for score, _, index, vec in sorted(heap, key=lambda x: x[0], reverse=True):
                rows.append((group, index, score, vec))
        if not rows:
            return None
        frame = pd.DataFrame(rows, columns=self._columns)
        group_column, index_column, score_column = self._columns[:3]
        return frame.astype(
            {group_column: "int64", index_column: "int64", score_column: "float32"}
        )

to_frame()

Best-first table of the kept embeddings, or None if none were kept.

Source code in src/body_eye_sync/experiment/embeddings.py
def to_frame(self) -> pd.DataFrame | None:
    """Best-first table of the kept embeddings, or ``None`` if none were kept."""
    rows = []
    for group, heap in self._heaps.items():
        for score, _, index, vec in sorted(heap, key=lambda x: x[0], reverse=True):
            rows.append((group, index, score, vec))
    if not rows:
        return None
    frame = pd.DataFrame(rows, columns=self._columns)
    group_column, index_column, score_column = self._columns[:3]
    return frame.astype(
        {group_column: "int64", index_column: "int64", score_column: "float32"}
    )

read_embeddings(path)

Load an embeddings file back into a table of float16 vectors.

Source code in src/body_eye_sync/experiment/embeddings.py
def read_embeddings(path: str | Path) -> pd.DataFrame:
    """Load an embeddings file back into a table of ``float16`` vectors."""
    import pyarrow.parquet as pq

    return pq.read_table(str(path)).to_pandas()

write_embeddings(path, table)

Write a best-K embeddings table as fixed-size float16 vectors.

Source code in src/body_eye_sync/experiment/embeddings.py
def write_embeddings(path: str | Path, table: pd.DataFrame) -> None:
    """Write a best-K embeddings table as fixed-size ``float16`` vectors."""
    import pyarrow as pa
    import pyarrow.parquet as pq

    matrix = np.stack(table["embedding"].to_numpy())
    _, dim = matrix.shape
    values = pa.array(matrix.reshape(-1), type=pa.float16())
    columns = {
        name: pa.array(table[name].to_numpy())
        for name in table.columns
        if name != "embedding"
    }
    columns["embedding"] = pa.FixedSizeListArray.from_arrays(values, dim)
    pq.write_table(pa.table(columns), str(path))

Speech Turn Results

body_eye_sync.experiment.speech_turns

The experiment's speech turns: who spoke when, and what they said.

SpeechTurns

Every speech turn of an experiment, on the shared experiment clock.

Source code in src/body_eye_sync/experiment/speech_turns.py
class SpeechTurns:
    """Every speech turn of an experiment, on the shared experiment clock."""

    def __init__(self, data: pd.DataFrame | None = None) -> None:
        self._data: pd.DataFrame | None = None
        if data is not None:
            self.set_data(data)

    def set_data(self, data: pd.DataFrame) -> None:
        """Replace the turns, rejecting overlaps attributed to one speaker."""
        missing = [column for column in TURN_COLUMNS if column not in data.columns]
        if missing:
            raise ValueError(f"speech turns table is missing columns: {missing}")
        for speaker, spoken in data.groupby("speaker"):
            ordered = spoken.sort_values(["start", "end"])
            for previous, current in zip(
                ordered.itertuples(index=False),
                ordered.iloc[1:].itertuples(index=False),
            ):
                if current.start < previous.end:
                    raise ValueError(
                        f"speaker {speaker!r} has overlapping turns at "
                        f"{previous.start}-{previous.end} s and "
                        f"{current.start}-{current.end} s"
                    )
        self._data = data

    @property
    def data(self) -> pd.DataFrame | None:
        """The speech turns, or ``None`` until they have been worked out."""
        return self._data

    @property
    def speakers(self) -> list[str]:
        """The inputs speech was attributed to, in the order they are named."""
        if self._data is None:
            return []
        return sorted(self._data["speaker"].unique().tolist())

    def for_speaker(self, speaker: str) -> pd.DataFrame:
        """One speaker's turns, in the order they spoke them."""
        if self._data is None:
            return pd.DataFrame(columns=TURN_COLUMNS)
        return self._data[self._data["speaker"] == speaker]

    def has_data(self) -> bool:
        return self._data is not None

    def clear(self) -> None:
        self._data = None

    def save(self, directory: str | Path) -> None:
        """Write the turns into ``directory``, or remove them if there are none."""
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)
        path = directory / TURNS_FILENAME
        if self._data is None:
            path.unlink(missing_ok=True)
            return
        pq.write_table(
            pa.Table.from_pandas(self._data, preserve_index=False), str(path)
        )

    def load(self, directory: str | Path) -> None:
        """Load turns written by :meth:`save`, if ``directory`` holds any."""
        self.clear()
        path = Path(directory) / TURNS_FILENAME
        if path.exists():
            self.set_data(pd.read_parquet(path))

data property

The speech turns, or None until they have been worked out.

speakers property

The inputs speech was attributed to, in the order they are named.

for_speaker(speaker)

One speaker's turns, in the order they spoke them.

Source code in src/body_eye_sync/experiment/speech_turns.py
def for_speaker(self, speaker: str) -> pd.DataFrame:
    """One speaker's turns, in the order they spoke them."""
    if self._data is None:
        return pd.DataFrame(columns=TURN_COLUMNS)
    return self._data[self._data["speaker"] == speaker]

load(directory)

Load turns written by :meth:save, if directory holds any.

Source code in src/body_eye_sync/experiment/speech_turns.py
def load(self, directory: str | Path) -> None:
    """Load turns written by :meth:`save`, if ``directory`` holds any."""
    self.clear()
    path = Path(directory) / TURNS_FILENAME
    if path.exists():
        self.set_data(pd.read_parquet(path))

save(directory)

Write the turns into directory, or remove them if there are none.

Source code in src/body_eye_sync/experiment/speech_turns.py
def save(self, directory: str | Path) -> None:
    """Write the turns into ``directory``, or remove them if there are none."""
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    path = directory / TURNS_FILENAME
    if self._data is None:
        path.unlink(missing_ok=True)
        return
    pq.write_table(
        pa.Table.from_pandas(self._data, preserve_index=False), str(path)
    )

set_data(data)

Replace the turns, rejecting overlaps attributed to one speaker.

Source code in src/body_eye_sync/experiment/speech_turns.py
def set_data(self, data: pd.DataFrame) -> None:
    """Replace the turns, rejecting overlaps attributed to one speaker."""
    missing = [column for column in TURN_COLUMNS if column not in data.columns]
    if missing:
        raise ValueError(f"speech turns table is missing columns: {missing}")
    for speaker, spoken in data.groupby("speaker"):
        ordered = spoken.sort_values(["start", "end"])
        for previous, current in zip(
            ordered.itertuples(index=False),
            ordered.iloc[1:].itertuples(index=False),
        ):
            if current.start < previous.end:
                raise ValueError(
                    f"speaker {speaker!r} has overlapping turns at "
                    f"{previous.start}-{previous.end} s and "
                    f"{current.start}-{current.end} s"
                )
    self._data = data

Postprocessing Experiments

body_eye_sync.experiment.postprocess

Postprocess an experiment using the pipeline outputs.

attribute_experiment_speech(experiment, *, progress=None)

Work out the experiment's speech turns and store them on it.

Source code in src/body_eye_sync/experiment/postprocess.py
def attribute_experiment_speech(
    experiment: Experiment,
    *,
    progress: Progress | None = None,
) -> None:
    """Work out the experiment's speech turns and store them on it."""
    settings = experiment.pipeline.speech_post_processing

    inputs = {
        video.id: video
        for video in experiment.glasses_videos
        if video.path is not None and video.speech.data is not None
    }
    if len(inputs) < 2:
        logger.info(
            "cannot attribute speech: %d transcribed glasses recording(s), need 2",
            len(inputs),
        )
        experiment.speech_turns.clear()
        return

    timelines = {name: data.timeline for name, data in inputs.items()}
    levels = measure_levels(
        {name: data.loudness.data for name, data in inputs.items()},
        timelines,
        settings,
    )
    turns = attribute_segments(
        {name: data.speech.data for name, data in inputs.items()},
        levels,
        timelines,
        settings,
        words={name: data.speech.words for name, data in inputs.items()},
        progress=progress,
    )
    experiment.speech_turns.set_data(turns)
    if progress is not None:
        progress(1.0)
    logger.info(
        "attributed %d speech turns across %d wearers",
        len(turns),
        turns["speaker"].nunique() if not turns.empty else 0,
    )