Skip to content

AI Understanding

Analyze videos, transcribe audio, describe visual content, and track faces per shot.

For a single aggregate, serializable analysis object across multiple analyzers, see Video Analysis.

Local Model Support

Class Local Model Family
SceneVLM Ollama vision model
AudioToText Whisper
AudioClassifier AST
SemanticSceneDetector TransNetV2
FaceShotTracker / FaceSmoothingTracker OpenCV YuNet
ObjectDetector D-FINE (COCO)

AudioToText

Anti-hallucination knobs

Three Whisper decoder kwargs are surfaced for tuning on noisy or sparse-speech audio:

from videopython.ai import AudioToText

# Defaults: condition_on_previous_text=False (the cascading-hallucination fix),
# no_speech_threshold=0.6, logprob_threshold=-1.0.
transcriber = AudioToText()

# Tighter no-speech gate to drop more low-confidence windows on a film with
# heavy ambient music.
transcriber = AudioToText(no_speech_threshold=0.85)

# Restore Whisper's upstream default conditioning (e.g. for clean podcasts
# where cross-window context helps disambiguate homophones).
transcriber = AudioToText(condition_on_previous_text=True)

Brand-name vocabulary biasing

Bias Whisper's first-window decoder toward a caller-supplied list of brand names, product names, or proper nouns via the native initial_prompt channel. Recovers near-mishears (e.g. Klarna → "carna", InPost → "in post") on brand-monitoring inputs without any new model dependencies.

from videopython.ai import AudioToText

# Constructor default — applies to every transcribe() call on this instance.
transcriber = AudioToText(vocabulary=["Klarna", "Allegro", "InPost"])
result = transcriber.transcribe(video)

# Per-call override — useful when one transcriber serves multiple tenants.
result = transcriber.transcribe(video, vocabulary=["Pyszne", "Wolt"])

The list is normalized at construction (whitespace stripped, case-insensitive dedup, casing of the first occurrence preserved). Whisper reserves ~224 tokens for the prompt; longer lists are trimmed from the tail with a single WARNING log line naming the count dropped.

VideoDubber and LocalDubbingPipeline accept the same vocabulary kwarg; it threads through to the underlying transcriber. Within VideoAnalyzer, pass it via analyzer_params:

from videopython.ai import VideoAnalyzer
from videopython.ai.video_analysis import VideoAnalysisConfig

config = VideoAnalysisConfig(
    analyzer_params={"audio_to_text": {"vocabulary": ["Klarna", "Allegro"]}}
)
analysis = VideoAnalyzer(config=config).analyze_path("brand_review.mp4")

Recovers names Whisper almost heard correctly. It will not catch zero-prior names; an LLM correction pass would close that gap.

Per-segment confidence

TranscriptionSegment carries three optional confidence fields populated from the raw Whisper output: avg_logprob, no_speech_prob, and compression_ratio. They are None when not available (e.g. on the diarization-only path that builds segments from words without overlap match, or on transcripts loaded from formats that don't carry the metadata).

These signals feed the dubbing pipeline's transcript-quality gate (median avg_logprob is one of three reject flags) and the translator's confidence-aware translation prompt (segments below threshold get a low_confidence hint). They are also useful for downstream callers that want to drop low-quality segments before further processing.

result = AudioToText().transcribe(video)
for segment in result.segments:
    if segment.avg_logprob is not None and segment.avg_logprob < -1.0:
        print(f"low confidence: {segment.text!r}")

AudioToText

Bases: ManagedPredictor

Transcription service for audio and video using local Whisper models.

Uses openai-whisper for transcription (with word-level timestamps) and pyannote-audio for optional speaker diarization. By default, Silero VAD runs before Whisper to gate language detection on a 30s window built from voiced regions only — fixes Whisper's tendency to lock onto the wrong language when the file opens with silence, music, or non-vocal credits. Disable with enable_vad=False to reproduce pre-0.27 behaviour.

Three Whisper decoder kwargs are surfaced for anti-hallucination tuning:

  • condition_on_previous_text defaults to False (Whisper's own default is True). With conditioning on, a single hallucinated filler phrase cascades through the rest of the file because each window's decoder is primed by the previous window's decoded text. Turning it off is the most commonly recommended fix for that failure mode; the cost on clean audio is small (slightly less context for ambiguous homophones across sentence boundaries).
  • no_speech_threshold and logprob_threshold are forwarded with Whisper's documented defaults (0.6 and -1.0); raising no_speech_threshold biases toward dropping low-confidence windows instead of emitting filler.

vocabulary biases Whisper's first-window decoder toward a caller- supplied list of brand names, product names, or proper nouns via the native initial_prompt channel. Recovers near-mishears (e.g. Klarna → "carna") without new model deps; will not catch zero-prior names. Per-call override is available on :meth:transcribe.

Source code in src/videopython/ai/understanding/audio.py
class AudioToText(ManagedPredictor):
    """Transcription service for audio and video using local Whisper models.

    Uses openai-whisper for transcription (with word-level timestamps) and
    pyannote-audio for optional speaker diarization. By default, Silero VAD
    runs before Whisper to gate language detection on a 30s window built from
    voiced regions only — fixes Whisper's tendency to lock onto the wrong
    language when the file opens with silence, music, or non-vocal credits.
    Disable with ``enable_vad=False`` to reproduce pre-0.27 behaviour.

    Three Whisper decoder kwargs are surfaced for anti-hallucination tuning:

    - ``condition_on_previous_text`` defaults to ``False`` (Whisper's own
      default is ``True``). With conditioning on, a single hallucinated filler
      phrase cascades through the rest of the file because each window's
      decoder is primed by the previous window's decoded text. Turning it off
      is the most commonly recommended fix for that failure mode; the cost on
      clean audio is small (slightly less context for ambiguous homophones
      across sentence boundaries).
    - ``no_speech_threshold`` and ``logprob_threshold`` are forwarded with
      Whisper's documented defaults (``0.6`` and ``-1.0``); raising
      ``no_speech_threshold`` biases toward dropping low-confidence windows
      instead of emitting filler.

    ``vocabulary`` biases Whisper's first-window decoder toward a caller-
    supplied list of brand names, product names, or proper nouns via the
    native ``initial_prompt`` channel. Recovers near-mishears (e.g. Klarna
    → "carna") without new model deps; will not catch zero-prior names.
    Per-call override is available on :meth:`transcribe`.
    """

    PYANNOTE_DIARIZATION_MODEL = "pyannote/speaker-diarization-community-1"
    _model_attrs = ("_model", "_diarization_pipeline", "_vad_model")

    def __init__(
        self,
        model_name: Literal["tiny", "base", "small", "medium", "large", "turbo"] = "turbo",
        enable_diarization: bool = False,
        enable_vad: bool = True,
        condition_on_previous_text: bool = False,
        no_speech_threshold: float = 0.6,
        logprob_threshold: float | None = -1.0,
        vocabulary: list[str] | None = None,
        device: str | None = None,
    ):
        self.model_name = model_name
        self.enable_diarization = enable_diarization
        self.enable_vad = enable_vad
        self.condition_on_previous_text = condition_on_previous_text
        self.no_speech_threshold = no_speech_threshold
        self.logprob_threshold = logprob_threshold
        self.vocabulary = _normalize_vocabulary(vocabulary)
        self.device = select_device(device, mps_allowed=False)
        log_device_initialization(
            "AudioToText",
            requested_device=device,
            resolved_device=self.device,
        )
        self._model: Any = None
        self._diarization_pipeline: Any = None
        self._vad_model: Any = None

    def _transcribe_kwargs(self, language: str | None, vocabulary: list[str]) -> dict[str, Any]:
        """Kwargs threaded into ``whisper.Whisper.transcribe`` from both call sites.
        ``initial_prompt`` is omitted entirely on the no-vocab path."""
        kwargs: dict[str, Any] = {
            "word_timestamps": True,
            "language": language,
            "condition_on_previous_text": self.condition_on_previous_text,
            "no_speech_threshold": self.no_speech_threshold,
            "logprob_threshold": self.logprob_threshold,
        }
        prompt = _build_initial_prompt(vocabulary)
        if prompt is not None:
            kwargs["initial_prompt"] = prompt
        return kwargs

    def _init_local(self) -> None:
        """Initialize local Whisper model."""
        from videopython.ai._optional import require

        whisper = require("whisper", feature="AudioToText")

        # No revision pin: openai-whisper downloads weights by name from OpenAI's
        # own CDN, not via a HF from_pretrained repo, so there is no HF commit
        # SHA to pin (see videopython.ai._revisions module docstring).
        self._model = whisper.load_model(name=self.model_name, device=self.device)

    def _init_diarization(self) -> None:
        """Initialize pyannote speaker diarization pipeline."""
        import torch

        from videopython.ai._optional import require

        Pipeline = require("pyannote.audio", feature="AudioToText diarization").Pipeline

        self._diarization_pipeline = Pipeline.from_pretrained(
            self.PYANNOTE_DIARIZATION_MODEL, revision=pinned(self.PYANNOTE_DIARIZATION_MODEL)
        )
        self._diarization_pipeline.to(torch.device(self.device))

    def _init_vad(self) -> None:
        """Initialize Silero VAD model.

        The model is ~2 MB and CPU-fast (~5-15s for a 90 min movie); we keep
        it on CPU regardless of ``self.device`` since dispatch overhead would
        outweigh inference cost.
        """
        from videopython.ai._optional import require

        load_silero_vad = require("silero_vad", feature="AudioToText VAD").load_silero_vad

        self._vad_model = load_silero_vad()

    def _process_transcription_result(self, transcription_result: dict[str, Any]) -> Transcription:
        """Process raw transcription result into a Transcription object."""
        transcription_segments = []
        for segment in transcription_result["segments"]:
            transcription_words = [
                TranscriptionWord(word=word["word"], start=float(word["start"]), end=float(word["end"]))
                for word in segment.get("words", [])
            ]
            transcription_segment = TranscriptionSegment(
                start=segment["start"],
                end=segment["end"],
                text=segment["text"],
                words=transcription_words,
                avg_logprob=segment.get("avg_logprob"),
                no_speech_prob=segment.get("no_speech_prob"),
                compression_ratio=segment.get("compression_ratio"),
            )
            transcription_segments.append(transcription_segment)

        return Transcription(segments=transcription_segments, language=transcription_result.get("language"))

    @staticmethod
    def _assign_speakers_to_words(
        words: list[TranscriptionWord],
        diarization_result: Any,
    ) -> list[TranscriptionWord]:
        """Assign speaker labels to words based on diarization segment overlap.

        For each word, finds the diarization segment with the greatest time overlap
        and assigns that speaker. Words with no overlapping diarization segment get
        the nearest speaker by midpoint distance.
        """
        speaker_segments: list[tuple[float, float, str]] = []
        # pyannote-audio 4.x returns DiarizeOutput; use exclusive_speaker_diarization
        # (no overlapping turns) for cleaner word assignment.
        annotation = getattr(diarization_result, "exclusive_speaker_diarization", diarization_result)
        for turn, _, speaker in annotation.itertracks(yield_label=True):
            speaker_segments.append((turn.start, turn.end, speaker))

        if not speaker_segments:
            return words

        result = []
        for word in words:
            best_speaker: str | None = None
            best_overlap = 0.0

            for seg_start, seg_end, speaker in speaker_segments:
                overlap = max(0.0, min(word.end, seg_end) - max(word.start, seg_start))
                if overlap > best_overlap:
                    best_overlap = overlap
                    best_speaker = speaker

            if best_speaker is None:
                word_mid = (word.start + word.end) / 2.0
                best_dist = float("inf")
                for seg_start, seg_end, speaker in speaker_segments:
                    seg_mid = (seg_start + seg_end) / 2.0
                    dist = abs(word_mid - seg_mid)
                    if dist < best_dist:
                        best_dist = dist
                        best_speaker = speaker

            result.append(
                TranscriptionWord(
                    word=word.word,
                    start=word.start,
                    end=word.end,
                    speaker=best_speaker,
                )
            )
        return result

    def diarize_transcription(self, audio: Audio, transcription: Transcription) -> Transcription:
        """Attach speaker labels to a pre-computed transcription using pyannote.

        Useful when callers have a transcription (e.g. pre-computed and edited)
        but no speakers, and want per-speaker voice cloning in dubbing without
        re-running Whisper. Runs pyannote standalone on ``audio`` and overlays
        speakers onto the supplied transcription's words.

        Requires word-level timings: at least one segment must contain more
        than one word. Transcriptions loaded from SRT (one synthetic word per
        segment) will not produce useful speakers and are rejected.
        """
        import numpy as np
        import torch

        all_words: list[TranscriptionWord] = list(transcription.words)
        if not all_words:
            raise ValueError("Cannot diarize a transcription with no words.")

        if not any(len(seg.words) > 1 for seg in transcription.segments):
            raise ValueError(
                "Cannot diarize a transcription without word-level timings. "
                "Supplied transcription has at most one word per segment "
                "(e.g. loaded from SRT). Provide a transcription with "
                "word-level timings, or omit `transcription` to let the "
                "pipeline transcribe and diarize from scratch."
            )

        if self._diarization_pipeline is None:
            self._init_diarization()

        import whisper

        audio_mono = audio.to_mono().resample(whisper.audio.SAMPLE_RATE)
        waveform = torch.from_numpy(audio_mono.data.astype(np.float32)).unsqueeze(0)
        diarization_result = self._diarization_pipeline(
            {"waveform": waveform, "sample_rate": audio_mono.metadata.sample_rate}
        )

        all_words = self._assign_speakers_to_words(all_words, diarization_result)
        return Transcription(words=all_words, language=transcription.language)

    def _run_vad(self, audio_mono: Audio) -> list[tuple[float, float]]:
        """Return voiced spans in seconds using Silero VAD.

        Audio must already be mono at ``whisper.audio.SAMPLE_RATE`` (16 kHz),
        which is one of Silero's two supported rates.
        """
        import numpy as np
        import torch

        if self._vad_model is None:
            self._init_vad()

        from silero_vad import get_speech_timestamps

        waveform = torch.from_numpy(audio_mono.data.astype(np.float32))
        timestamps = get_speech_timestamps(
            waveform,
            self._vad_model,
            sampling_rate=audio_mono.metadata.sample_rate,
            return_seconds=True,
        )
        return [(float(ts["start"]), float(ts["end"])) for ts in timestamps]

    def _detect_language(self, audio_mono: Audio, voiced_spans: list[tuple[float, float]]) -> str:
        """Run Whisper language detection on a 30s window of voiced audio.

        Whisper's auto-detection only inspects the first 30s of input. When
        the file opens with silence/music/credits, that window contains no
        speech and detection picks the closest-looking thing (typically
        English). Concatenating voiced spans up to 30s and running
        ``model.detect_language()`` on the resulting mel fixes this.
        """
        import numpy as np
        import torch
        import whisper

        sample_rate = audio_mono.metadata.sample_rate
        chunks: list[np.ndarray] = []
        remaining = whisper.audio.N_SAMPLES
        for start, end in voiced_spans:
            if remaining <= 0:
                break
            chunk = audio_mono.data[int(start * sample_rate) : int(end * sample_rate)][:remaining]
            chunks.append(chunk)
            remaining -= len(chunk)

        voiced_audio = np.concatenate(chunks).astype(np.float32) if chunks else np.zeros(0, dtype=np.float32)
        padded = whisper.audio.pad_or_trim(torch.from_numpy(voiced_audio))
        mel = whisper.audio.log_mel_spectrogram(padded, n_mels=self._model.dims.n_mels).to(self._model.device)

        _, probs = self._model.detect_language(mel)
        return max(probs, key=probs.get)

    def _transcribe_with_diarization(
        self, audio_mono: Audio, language: str | None, vocabulary: list[str]
    ) -> Transcription:
        """Transcribe with word timestamps and assign speakers via pyannote."""
        import numpy as np
        import torch

        if self._diarization_pipeline is None:
            self._init_diarization()

        audio_data = audio_mono.data
        transcription_result = self._model.transcribe(audio=audio_data, **self._transcribe_kwargs(language, vocabulary))

        waveform = torch.from_numpy(audio_data.astype(np.float32)).unsqueeze(0)
        diarization_result = self._diarization_pipeline(
            {"waveform": waveform, "sample_rate": audio_mono.metadata.sample_rate}
        )

        transcription = self._process_transcription_result(transcription_result)

        # Capture original Whisper segments before flattening to words. The
        # diarization rebuild via Transcription(words=...) regroups by speaker,
        # which loses the per-segment confidence M1.3 plumbed through. We
        # re-attach by max-overlap match below so M2's confidence-aware
        # translation prompts have signal on the diarized path too.
        whisper_segments = transcription.segments

        all_words: list[TranscriptionWord] = []
        for seg in transcription.segments:
            all_words.extend(seg.words)

        if all_words:
            all_words = self._assign_speakers_to_words(all_words, diarization_result)

        rebuilt = Transcription(words=all_words, language=transcription.language)
        _attach_confidence_by_overlap(rebuilt.segments, whisper_segments)
        return rebuilt

    def _transcribe_local(self, audio: Audio, vocabulary: list[str]) -> Transcription:
        """Transcribe using local Whisper model.

        When ``enable_vad`` is True (default), Silero VAD locates voiced
        regions and a 30s voiced window is used for Whisper language
        detection -- avoiding the well-known failure where Whisper locks
        onto the wrong language because the first 30s of input is silence
        or music. The detected language is then passed into
        ``transcribe()`` so chunked decoding stays consistent. If VAD
        finds no speech, an empty Transcription is returned without
        invoking Whisper.
        """
        import whisper

        if self._model is None:
            self._init_local()

        audio_mono = audio.to_mono().resample(whisper.audio.SAMPLE_RATE)

        language: str | None = None
        if self.enable_vad:
            voiced_spans = self._run_vad(audio_mono)
            if not voiced_spans:
                return Transcription(segments=[])
            language = self._detect_language(audio_mono, voiced_spans)

        if self.enable_diarization:
            return self._transcribe_with_diarization(audio_mono, language, vocabulary)

        transcription_result = self._model.transcribe(
            audio=audio_mono.data, **self._transcribe_kwargs(language, vocabulary)
        )
        return self._process_transcription_result(transcription_result)

    def transcribe(self, media: Audio | Video, vocabulary: list[str] | None = None) -> Transcription:
        """Transcribe audio or video to text.

        ``vocabulary`` overrides the constructor default for this call only;
        a per-call list wins over the instance's vocabulary so one
        :class:`AudioToText` instance can serve multiple tenants. Pass
        ``None`` (the default) to use the constructor's list.
        """
        if isinstance(media, Video):
            if media.audio.is_silent:
                return Transcription(segments=[])
            audio = media.audio
        elif isinstance(media, Audio):
            if media.is_silent:
                return Transcription(segments=[])
            audio = media
        else:
            raise TypeError(f"Unsupported media type: {type(media)}. Expected Audio or Video.")

        effective_vocab = self.vocabulary if vocabulary is None else _normalize_vocabulary(vocabulary)
        return self._transcribe_local(audio, effective_vocab)

diarize_transcription

diarize_transcription(
    audio: Audio, transcription: Transcription
) -> Transcription

Attach speaker labels to a pre-computed transcription using pyannote.

Useful when callers have a transcription (e.g. pre-computed and edited) but no speakers, and want per-speaker voice cloning in dubbing without re-running Whisper. Runs pyannote standalone on audio and overlays speakers onto the supplied transcription's words.

Requires word-level timings: at least one segment must contain more than one word. Transcriptions loaded from SRT (one synthetic word per segment) will not produce useful speakers and are rejected.

Source code in src/videopython/ai/understanding/audio.py
def diarize_transcription(self, audio: Audio, transcription: Transcription) -> Transcription:
    """Attach speaker labels to a pre-computed transcription using pyannote.

    Useful when callers have a transcription (e.g. pre-computed and edited)
    but no speakers, and want per-speaker voice cloning in dubbing without
    re-running Whisper. Runs pyannote standalone on ``audio`` and overlays
    speakers onto the supplied transcription's words.

    Requires word-level timings: at least one segment must contain more
    than one word. Transcriptions loaded from SRT (one synthetic word per
    segment) will not produce useful speakers and are rejected.
    """
    import numpy as np
    import torch

    all_words: list[TranscriptionWord] = list(transcription.words)
    if not all_words:
        raise ValueError("Cannot diarize a transcription with no words.")

    if not any(len(seg.words) > 1 for seg in transcription.segments):
        raise ValueError(
            "Cannot diarize a transcription without word-level timings. "
            "Supplied transcription has at most one word per segment "
            "(e.g. loaded from SRT). Provide a transcription with "
            "word-level timings, or omit `transcription` to let the "
            "pipeline transcribe and diarize from scratch."
        )

    if self._diarization_pipeline is None:
        self._init_diarization()

    import whisper

    audio_mono = audio.to_mono().resample(whisper.audio.SAMPLE_RATE)
    waveform = torch.from_numpy(audio_mono.data.astype(np.float32)).unsqueeze(0)
    diarization_result = self._diarization_pipeline(
        {"waveform": waveform, "sample_rate": audio_mono.metadata.sample_rate}
    )

    all_words = self._assign_speakers_to_words(all_words, diarization_result)
    return Transcription(words=all_words, language=transcription.language)

transcribe

transcribe(
    media: Audio | Video,
    vocabulary: list[str] | None = None,
) -> Transcription

Transcribe audio or video to text.

vocabulary overrides the constructor default for this call only; a per-call list wins over the instance's vocabulary so one :class:AudioToText instance can serve multiple tenants. Pass None (the default) to use the constructor's list.

Source code in src/videopython/ai/understanding/audio.py
def transcribe(self, media: Audio | Video, vocabulary: list[str] | None = None) -> Transcription:
    """Transcribe audio or video to text.

    ``vocabulary`` overrides the constructor default for this call only;
    a per-call list wins over the instance's vocabulary so one
    :class:`AudioToText` instance can serve multiple tenants. Pass
    ``None`` (the default) to use the constructor's list.
    """
    if isinstance(media, Video):
        if media.audio.is_silent:
            return Transcription(segments=[])
        audio = media.audio
    elif isinstance(media, Audio):
        if media.is_silent:
            return Transcription(segments=[])
        audio = media
    else:
        raise TypeError(f"Unsupported media type: {type(media)}. Expected Audio or Video.")

    effective_vocab = self.vocabulary if vocabulary is None else _normalize_vocabulary(vocabulary)
    return self._transcribe_local(audio, effective_vocab)

AudioClassifier

Detect and classify sounds, music, and audio events with timestamps using Audio Spectrogram Transformer (AST), a state-of-the-art model achieving 0.485 mAP on AudioSet.

Basic Usage

from videopython.ai import AudioClassifier
from videopython.base import Video

classifier = AudioClassifier(confidence_threshold=0.3)
video = Video.from_path("video.mp4")

result = classifier.classify(video)

# Clip-level predictions (overall audio content)
for label, confidence in result.clip_predictions.items():
    print(f"{label}: {confidence:.2f}")

# Timestamped events
for event in result.events:
    print(f"{event.start:.1f}s - {event.end:.1f}s: {event.label} ({event.confidence:.2f})")

AudioClassifier

Bases: ManagedPredictor

Audio event and sound classification using AST.

Source code in src/videopython/ai/understanding/classification.py
class AudioClassifier(ManagedPredictor):
    """Audio event and sound classification using AST."""

    _model_attrs = ("_model", "_processor")
    AST_SAMPLE_RATE: int = 16000
    AST_CHUNK_SECONDS: float = 10.0
    AST_HOP_SECONDS: float = 5.0

    def __init__(
        self,
        model_name: str = "MIT/ast-finetuned-audioset-10-10-0.4593",
        confidence_threshold: float = 0.3,
        top_k: int = 10,
        device: str | None = None,
    ):
        self.model_name = model_name
        self.confidence_threshold = confidence_threshold
        self.top_k = top_k
        self.device = select_device(device, mps_allowed=True)
        log_device_initialization(
            "AudioClassifier",
            requested_device=device,
            resolved_device=self.device,
        )

        self._model: Any = None
        self._processor: Any = None
        self._labels: list[str] = []

    def _init_local(self) -> None:
        """Initialize local AST model from HuggingFace."""
        from videopython.ai._optional import require

        _transformers = require("transformers", feature="AudioClassifier")
        ASTFeatureExtractor = _transformers.ASTFeatureExtractor
        ASTForAudioClassification = _transformers.ASTForAudioClassification

        self._processor = ASTFeatureExtractor.from_pretrained(self.model_name, revision=pinned(self.model_name))
        self._model = ASTForAudioClassification.from_pretrained(self.model_name, revision=pinned(self.model_name))
        self._model.to(self.device)
        self._model.eval()

        self._labels = [self._model.config.id2label[i] for i in range(len(self._model.config.id2label))]

    def _merge_events(self, events: list[AudioEvent], gap_threshold: float = 0.5) -> list[AudioEvent]:
        """Merge consecutive events of the same class."""
        if not events:
            return []

        events_by_label: dict[str, list[AudioEvent]] = {}
        for event in events:
            if event.label not in events_by_label:
                events_by_label[event.label] = []
            events_by_label[event.label].append(event)

        merged = []
        for label, label_events in events_by_label.items():
            sorted_events = sorted(label_events, key=lambda e: e.start)
            current = sorted_events[0]

            for next_event in sorted_events[1:]:
                if next_event.start - current.end <= gap_threshold:
                    current = AudioEvent(
                        start=current.start,
                        end=next_event.end,
                        label=label,
                        confidence=max(current.confidence, next_event.confidence),
                    )
                else:
                    merged.append(current)
                    current = next_event

            merged.append(current)

        return sorted(merged, key=lambda e: e.start)

    def _classify_local(self, audio: Audio) -> AudioClassification:
        """Classify audio using local AST model with sliding window."""
        import numpy as np
        import torch

        if self._model is None:
            self._init_local()

        audio_processed = audio.to_mono().resample(self.AST_SAMPLE_RATE)
        audio_data = audio_processed.data.astype(np.float32)

        chunk_samples = int(self.AST_CHUNK_SECONDS * self.AST_SAMPLE_RATE)
        hop_samples = int(self.AST_HOP_SECONDS * self.AST_SAMPLE_RATE)
        total_samples = len(audio_data)

        all_chunk_probs = []
        chunk_times = []

        if total_samples <= chunk_samples:
            chunks = [(0, audio_data)]
        else:
            chunks = []
            start = 0
            while start < total_samples:
                end = min(start + chunk_samples, total_samples)
                chunk = audio_data[start:end]
                if len(chunk) < chunk_samples:
                    chunk = np.pad(chunk, (0, chunk_samples - len(chunk)))
                chunks.append((start, chunk))
                start += hop_samples

        for start_sample, chunk in chunks:
            start_time = start_sample / self.AST_SAMPLE_RATE

            inputs = self._processor(
                chunk,
                sampling_rate=self.AST_SAMPLE_RATE,
                return_tensors="pt",
            )
            inputs = {k: v.to(self.device) for k, v in inputs.items()}

            with torch.no_grad():
                outputs = self._model(**inputs)
                logits = outputs.logits[0]
                probs = torch.sigmoid(logits).cpu().numpy()

            all_chunk_probs.append(probs)
            chunk_times.append(start_time)

        chunk_probs_array = np.array(all_chunk_probs)

        events = []
        for start_time, probs in zip(chunk_times, chunk_probs_array):
            end_time = start_time + self.AST_CHUNK_SECONDS
            top_indices = np.argsort(probs)[-self.top_k :][::-1]

            for class_idx in top_indices:
                confidence = float(probs[class_idx])
                if confidence >= self.confidence_threshold:
                    label = self._labels[class_idx]
                    events.append(
                        AudioEvent(
                            start=start_time,
                            end=min(end_time, total_samples / self.AST_SAMPLE_RATE),
                            label=label,
                            confidence=confidence,
                        )
                    )

        merged_events = self._merge_events(events)

        clip_preds = np.mean(chunk_probs_array, axis=0)
        top_clip_indices = np.argsort(clip_preds)[-self.top_k :][::-1]
        clip_predictions = {
            self._labels[idx]: float(clip_preds[idx])
            for idx in top_clip_indices
            if clip_preds[idx] >= self.confidence_threshold
        }

        return AudioClassification(events=merged_events, clip_predictions=clip_predictions)

    def classify(self, media: Audio | Video) -> AudioClassification:
        """Classify audio events in audio or video."""
        if isinstance(media, Video):
            if media.audio.is_silent:
                return AudioClassification(events=[], clip_predictions={})
            audio = media.audio
        elif isinstance(media, Audio):
            if media.is_silent:
                return AudioClassification(events=[], clip_predictions={})
            audio = media
        else:
            raise TypeError(f"Unsupported media type: {type(media)}. Expected Audio or Video.")

        return self._classify_local(audio)

classify

classify(media: Audio | Video) -> AudioClassification

Classify audio events in audio or video.

Source code in src/videopython/ai/understanding/classification.py
def classify(self, media: Audio | Video) -> AudioClassification:
    """Classify audio events in audio or video."""
    if isinstance(media, Video):
        if media.audio.is_silent:
            return AudioClassification(events=[], clip_predictions={})
        audio = media.audio
    elif isinstance(media, Audio):
        if media.is_silent:
            return AudioClassification(events=[], clip_predictions={})
        audio = media
    else:
        raise TypeError(f"Unsupported media type: {type(media)}. Expected Audio or Video.")

    return self._classify_local(audio)

SceneVLM

SceneVLM describes scenes with a local Ollama vision model (model kwarg, an Ollama tag you have pulled; default qwen3.6:27b). It needs a running Ollama server and a vision-capable model that supports structured output.

analyze_scene() and analyze_frame() return a structured SceneDescription with three fields: a one-sentence caption, an open-list subjects, and a closed-enum shot_type. The schema is handed to Ollama's format, so the model returns valid JSON directly.

from videopython.ai import SceneVLM

vlm = SceneVLM(model="llava")  # any pulled vision model
description = vlm.analyze_frame(frame_array)

print(description.caption)     # "A man in a cap speaks into a microphone."
print(description.subjects)    # ["man", "microphone", "cap"]
print(description.shot_type)   # "medium"

SceneVLM.unload() clears the Ollama client for low_memory parity.

SceneVLM

Bases: ManagedPredictor

Generates structured scene descriptions with a local Ollama vision model.

The model must be vision-capable and support Ollama's structured-output format; ollama pull <model> first. options are extra Ollama generation options merged over temperature=0.

Source code in src/videopython/ai/understanding/image.py
class SceneVLM(ManagedPredictor):
    """Generates structured scene descriptions with a local Ollama vision model.

    The model must be vision-capable and support Ollama's structured-output
    ``format``; ``ollama pull <model>`` first. ``options`` are extra Ollama
    generation options merged over ``temperature=0``.
    """

    def __init__(
        self,
        model: str = DEFAULT_SCENE_VLM_MODEL,
        *,
        host: str | None = None,
        options: dict[str, Any] | None = None,
    ) -> None:
        self._client = OllamaStructuredClient(model=model, host=host, options=options)

    def analyze_frame(self, image: np.ndarray | Image.Image, prompt: str | None = None) -> SceneDescription:
        """Analyze one frame and return a structured scene description."""
        return self.analyze_scene([image], prompt=prompt)

    def analyze_scene(self, images: list[np.ndarray | Image.Image], prompt: str | None = None) -> SceneDescription:
        """Analyze a scene's frames and return a structured description."""
        if not images:
            raise ValueError("`images` must contain at least one frame")
        frames = [_to_rgb_array(image) for image in images]
        data = self._client.generate_json(
            system=_SYSTEM_PROMPT, text=prompt or _USER_PROMPT, schema=_SCENE_SCHEMA, images=frames
        )
        shot_type = data.get("shot_type")
        return SceneDescription(
            caption=str(data.get("caption", "")),
            subjects=[str(s) for s in data.get("subjects", [])],
            shot_type=shot_type if shot_type in _SHOT_TYPES else None,
        )

    def unload(self) -> None:
        self._client.unload()

analyze_frame

analyze_frame(
    image: ndarray | Image, prompt: str | None = None
) -> SceneDescription

Analyze one frame and return a structured scene description.

Source code in src/videopython/ai/understanding/image.py
def analyze_frame(self, image: np.ndarray | Image.Image, prompt: str | None = None) -> SceneDescription:
    """Analyze one frame and return a structured scene description."""
    return self.analyze_scene([image], prompt=prompt)

analyze_scene

analyze_scene(
    images: list[ndarray | Image], prompt: str | None = None
) -> SceneDescription

Analyze a scene's frames and return a structured description.

Source code in src/videopython/ai/understanding/image.py
def analyze_scene(self, images: list[np.ndarray | Image.Image], prompt: str | None = None) -> SceneDescription:
    """Analyze a scene's frames and return a structured description."""
    if not images:
        raise ValueError("`images` must contain at least one frame")
    frames = [_to_rgb_array(image) for image in images]
    data = self._client.generate_json(
        system=_SYSTEM_PROMPT, text=prompt or _USER_PROMPT, schema=_SCENE_SCHEMA, images=frames
    )
    shot_type = data.get("shot_type")
    return SceneDescription(
        caption=str(data.get("caption", "")),
        subjects=[str(s) for s in data.get("subjects", [])],
        shot_type=shot_type if shot_type in _SHOT_TYPES else None,
    )

SemanticSceneDetector

ML-based scene boundary detection using TransNetV2. More accurate than histogram-based detection, especially for gradual transitions like fades and dissolves.

from videopython.ai import SemanticSceneDetector

detector = SemanticSceneDetector(threshold=0.5, min_scene_length=1.0)
scenes = detector.detect_streaming("video.mp4")

for scene in scenes:
    print(f"Scene: {scene.start:.1f}s - {scene.end:.1f}s ({scene.duration:.1f}s)")

SemanticSceneDetector

Bases: ManagedPredictor

ML-based scene detection using TransNetV2.

TransNetV2 is a neural network specifically designed for shot boundary detection, providing more accurate scene boundaries than histogram-based methods, especially for gradual transitions.

Uses the transnetv2-pytorch package with pretrained weights.

Example

from videopython.ai.understanding import SemanticSceneDetector detector = SemanticSceneDetector() scenes = detector.detect_streaming("video.mp4") for scene in scenes: ... print(f"Scene: {scene.start:.2f}s - {scene.end:.2f}s")

Source code in src/videopython/ai/understanding/temporal.py
class SemanticSceneDetector(ManagedPredictor):
    """ML-based scene detection using TransNetV2.

    TransNetV2 is a neural network specifically designed for shot boundary
    detection, providing more accurate scene boundaries than histogram-based
    methods, especially for gradual transitions.

    Uses the transnetv2-pytorch package with pretrained weights.

    Example:
        >>> from videopython.ai.understanding import SemanticSceneDetector
        >>> detector = SemanticSceneDetector()
        >>> scenes = detector.detect_streaming("video.mp4")
        >>> for scene in scenes:
        ...     print(f"Scene: {scene.start:.2f}s - {scene.end:.2f}s")
    """

    def __init__(
        self,
        threshold: float = 0.5,
        min_scene_length: float = 0.5,
        device: str | None = None,
    ):
        """Initialize the semantic scene detector.

        Args:
            threshold: Confidence threshold for scene boundaries (0.0-1.0).
                Higher values = fewer, more confident boundaries.
            min_scene_length: Minimum scene duration in seconds.
            device: Device to run on ('cuda', 'mps', 'cpu', or None for auto).
                Note: MPS may have numerical inconsistencies; use 'cpu' for
                reproducible results.
        """
        if not 0.0 <= threshold <= 1.0:
            raise ValueError("threshold must be between 0.0 and 1.0")
        if min_scene_length < 0:
            raise ValueError("min_scene_length must be non-negative")

        self.threshold = threshold
        self.min_scene_length = min_scene_length
        self.device: str | None = device
        self._model: Any = None

    def _init_local(self) -> None:
        """Load the TransNetV2 model with pretrained weights."""
        if self._model is not None:
            return

        from videopython.ai._optional import require

        TransNetV2 = require("transnetv2_pytorch", feature="SemanticSceneDetector").TransNetV2

        requested_device = self.device
        device = select_device(self.device, mps_allowed=True)
        log_device_initialization(
            "SemanticSceneDetector",
            requested_device=requested_device,
            resolved_device=device,
        )
        self.device = device
        self._model = TransNetV2(device=device)
        self._model.eval()

    def detect(self, video: Video) -> list[SceneBoundary]:
        """Detect scenes in a video using ML-based boundary detection.

        Note: This method requires saving video to a temporary file for
        TransNetV2 processing. For better performance, use detect_streaming()
        with a file path directly.

        Args:
            video: Video object to analyze.

        Returns:
            List of SceneBoundary objects representing detected scenes.
        """
        import tempfile

        if len(video.frames) == 0:
            return []

        if len(video.frames) == 1:
            return [SceneBoundary(start=0.0, end=video.total_seconds, start_frame=0, end_frame=1)]

        # Save video to temp file for TransNetV2 processing
        with tempfile.NamedTemporaryFile(suffix=".mp4", delete=True) as tmp:
            video.save(tmp.name)
            return self.detect_streaming(tmp.name)

    def detect_streaming(self, path: str | Path) -> list[SceneBoundary]:
        """Detect scenes from a video file.

        Uses TransNetV2 with pretrained weights for accurate shot boundary
        detection.

        Args:
            path: Path to video file.

        Returns:
            List of SceneBoundary objects representing detected scenes.
        """
        self._init_local()

        # Use TransNetV2's detect_scenes which handles everything internally
        raw_scenes = self._model.detect_scenes(str(path), threshold=self.threshold)

        # Convert to SceneBoundary objects
        scenes = []
        for scene_data in raw_scenes:
            start_frame = scene_data["start_frame"]
            end_frame = scene_data["end_frame"]
            start_time = float(scene_data["start_time"])
            end_time = float(scene_data["end_time"])

            scenes.append(
                SceneBoundary(
                    start=start_time,
                    end=end_time,
                    start_frame=start_frame,
                    end_frame=end_frame,
                )
            )

        if self.min_scene_length > 0:
            scenes = self._merge_short_scenes(scenes)

        return scenes

    def _merge_short_scenes(self, scenes: list[SceneBoundary]) -> list[SceneBoundary]:
        """Merge scenes that are shorter than min_scene_length.

        Args:
            scenes: List of scenes to process.

        Returns:
            List of scenes with short scenes merged into adjacent ones.
        """
        if not scenes:
            return scenes

        merged = [scenes[0]]

        for scene in scenes[1:]:
            last_scene = merged[-1]

            if last_scene.duration < self.min_scene_length:
                merged[-1] = SceneBoundary(
                    start=last_scene.start,
                    end=scene.end,
                    start_frame=last_scene.start_frame,
                    end_frame=scene.end_frame,
                )
            else:
                merged.append(scene)

        if len(merged) > 1 and merged[-1].duration < self.min_scene_length:
            second_last = merged[-2]
            last = merged[-1]
            merged[-2] = SceneBoundary(
                start=second_last.start,
                end=last.end,
                start_frame=second_last.start_frame,
                end_frame=last.end_frame,
            )
            merged.pop()

        return merged

    @classmethod
    def detect_from_path(
        cls,
        path: str | Path,
        threshold: float = 0.5,
        min_scene_length: float = 0.5,
    ) -> list[SceneBoundary]:
        """Convenience method for one-shot scene detection.

        Args:
            path: Path to video file.
            threshold: Scene boundary threshold (0.0-1.0).
            min_scene_length: Minimum scene duration in seconds.

        Returns:
            List of SceneBoundary objects representing detected scenes.
        """
        detector = cls(threshold=threshold, min_scene_length=min_scene_length)
        return detector.detect_streaming(path)

__init__

__init__(
    threshold: float = 0.5,
    min_scene_length: float = 0.5,
    device: str | None = None,
)

Initialize the semantic scene detector.

Parameters:

Name Type Description Default
threshold float

Confidence threshold for scene boundaries (0.0-1.0). Higher values = fewer, more confident boundaries.

0.5
min_scene_length float

Minimum scene duration in seconds.

0.5
device str | None

Device to run on ('cuda', 'mps', 'cpu', or None for auto). Note: MPS may have numerical inconsistencies; use 'cpu' for reproducible results.

None
Source code in src/videopython/ai/understanding/temporal.py
def __init__(
    self,
    threshold: float = 0.5,
    min_scene_length: float = 0.5,
    device: str | None = None,
):
    """Initialize the semantic scene detector.

    Args:
        threshold: Confidence threshold for scene boundaries (0.0-1.0).
            Higher values = fewer, more confident boundaries.
        min_scene_length: Minimum scene duration in seconds.
        device: Device to run on ('cuda', 'mps', 'cpu', or None for auto).
            Note: MPS may have numerical inconsistencies; use 'cpu' for
            reproducible results.
    """
    if not 0.0 <= threshold <= 1.0:
        raise ValueError("threshold must be between 0.0 and 1.0")
    if min_scene_length < 0:
        raise ValueError("min_scene_length must be non-negative")

    self.threshold = threshold
    self.min_scene_length = min_scene_length
    self.device: str | None = device
    self._model: Any = None

detect

detect(video: Video) -> list[SceneBoundary]

Detect scenes in a video using ML-based boundary detection.

Note: This method requires saving video to a temporary file for TransNetV2 processing. For better performance, use detect_streaming() with a file path directly.

Parameters:

Name Type Description Default
video Video

Video object to analyze.

required

Returns:

Type Description
list[SceneBoundary]

List of SceneBoundary objects representing detected scenes.

Source code in src/videopython/ai/understanding/temporal.py
def detect(self, video: Video) -> list[SceneBoundary]:
    """Detect scenes in a video using ML-based boundary detection.

    Note: This method requires saving video to a temporary file for
    TransNetV2 processing. For better performance, use detect_streaming()
    with a file path directly.

    Args:
        video: Video object to analyze.

    Returns:
        List of SceneBoundary objects representing detected scenes.
    """
    import tempfile

    if len(video.frames) == 0:
        return []

    if len(video.frames) == 1:
        return [SceneBoundary(start=0.0, end=video.total_seconds, start_frame=0, end_frame=1)]

    # Save video to temp file for TransNetV2 processing
    with tempfile.NamedTemporaryFile(suffix=".mp4", delete=True) as tmp:
        video.save(tmp.name)
        return self.detect_streaming(tmp.name)

detect_streaming

detect_streaming(path: str | Path) -> list[SceneBoundary]

Detect scenes from a video file.

Uses TransNetV2 with pretrained weights for accurate shot boundary detection.

Parameters:

Name Type Description Default
path str | Path

Path to video file.

required

Returns:

Type Description
list[SceneBoundary]

List of SceneBoundary objects representing detected scenes.

Source code in src/videopython/ai/understanding/temporal.py
def detect_streaming(self, path: str | Path) -> list[SceneBoundary]:
    """Detect scenes from a video file.

    Uses TransNetV2 with pretrained weights for accurate shot boundary
    detection.

    Args:
        path: Path to video file.

    Returns:
        List of SceneBoundary objects representing detected scenes.
    """
    self._init_local()

    # Use TransNetV2's detect_scenes which handles everything internally
    raw_scenes = self._model.detect_scenes(str(path), threshold=self.threshold)

    # Convert to SceneBoundary objects
    scenes = []
    for scene_data in raw_scenes:
        start_frame = scene_data["start_frame"]
        end_frame = scene_data["end_frame"]
        start_time = float(scene_data["start_time"])
        end_time = float(scene_data["end_time"])

        scenes.append(
            SceneBoundary(
                start=start_time,
                end=end_time,
                start_frame=start_frame,
                end_frame=end_frame,
            )
        )

    if self.min_scene_length > 0:
        scenes = self._merge_short_scenes(scenes)

    return scenes

detect_from_path classmethod

detect_from_path(
    path: str | Path,
    threshold: float = 0.5,
    min_scene_length: float = 0.5,
) -> list[SceneBoundary]

Convenience method for one-shot scene detection.

Parameters:

Name Type Description Default
path str | Path

Path to video file.

required
threshold float

Scene boundary threshold (0.0-1.0).

0.5
min_scene_length float

Minimum scene duration in seconds.

0.5

Returns:

Type Description
list[SceneBoundary]

List of SceneBoundary objects representing detected scenes.

Source code in src/videopython/ai/understanding/temporal.py
@classmethod
def detect_from_path(
    cls,
    path: str | Path,
    threshold: float = 0.5,
    min_scene_length: float = 0.5,
) -> list[SceneBoundary]:
    """Convenience method for one-shot scene detection.

    Args:
        path: Path to video file.
        threshold: Scene boundary threshold (0.0-1.0).
        min_scene_length: Minimum scene duration in seconds.

    Returns:
        List of SceneBoundary objects representing detected scenes.
    """
    detector = cls(threshold=threshold, min_scene_length=min_scene_length)
    return detector.detect_streaming(path)

Face Tracking

Two YuNet-based face trackers share one detector, one per use case:

  • FaceShotTracker.track_shot(frames, frame_indices) returns a list of FaceTrack objects with stable ids within a shot, via IoU association — no embedding re-id, so a track does not survive across shot boundaries. This is the API the analyzer uses.
  • FaceSmoothingTracker.detect_and_track(frame, frame_index) / track_video(frames) are the single-subject smoothed-position APIs used by FaceTrackingCrop (see AI Transforms).
from videopython.ai import FaceShotTracker

tracker = FaceShotTracker()
tracks = tracker.track_shot(frames)

for track in tracks:
    print(f"track #{track.track_id}: {track.length} frames, "
          f"first frame {track.frame_indices[0]}")

FaceShotTracker

Bases: _FaceTrackerBase

Per-shot multi-track face association via IoU.

Detects faces on every input frame and stitches them into FaceTracks greedily by best IoU. Tracks do not survive across shot boundaries (IoU-only association; no embedding re-id). Used by the video-analysis pipeline to bind detections to subjects within one shot.

Source code in src/videopython/ai/understanding/faces.py
class FaceShotTracker(_FaceTrackerBase):
    """Per-shot multi-track face association via IoU.

    Detects faces on every input frame and stitches them into ``FaceTrack``s
    greedily by best IoU. Tracks do not survive across shot boundaries
    (IoU-only association; no embedding re-id). Used by the video-analysis
    pipeline to bind detections to subjects within one shot.
    """

    def __init__(
        self,
        min_face_size: int = 30,
        batch_size: int = 16,
        iou_match_threshold: float = DEFAULT_IOU_MATCH_THRESHOLD,
        max_missed_frames: int = DEFAULT_MAX_MISSED_FRAMES,
    ):
        """Initialize the per-shot tracker.

        Args:
            min_face_size: Minimum face size in pixels for detection.
            batch_size: Batch size for detection. Default 16.
            iou_match_threshold: Minimum IoU between consecutive detections to
                continue an existing track.
            max_missed_frames: Consecutive frames a track may go without a
                detection before it is closed.
        """
        super().__init__(min_face_size=min_face_size)
        self.batch_size = batch_size
        self.iou_match_threshold = iou_match_threshold
        self.max_missed_frames = max_missed_frames
        logger.info("FaceShotTracker initialized (min_face_size=%s)", self.min_face_size)

    def track_shot(
        self,
        frames: list[np.ndarray] | np.ndarray,
        frame_indices: list[int] | None = None,
    ) -> list[FaceTrack]:
        """Per-shot multi-track association via IoU.

        Detection is run on every input frame (caller is expected to have
        already chosen the sampling cadence -- the analysis pipeline
        passes one frame per scene-VLM sample, lip-sync passes every
        frame in the shot). Tracks are stitched together greedily by
        best IoU above ``iou_match_threshold``; tracks with no match for
        ``max_missed_frames`` consecutive frames are closed and won't
        accept future associations.

        Track ids are integers starting at 1 within this shot. They are
        **not** stable across shots — embedding re-id is deferred.

        Args:
            frames: Frames in the shot (list or stacked ndarray).
            frame_indices: Source-video frame indices. Defaults to
                ``range(len(frames))`` when omitted.

        Returns:
            List of ``FaceTrack`` objects, one per distinct subject
            tracked in the shot.
        """
        if isinstance(frames, np.ndarray):
            frame_list = [frames[i] for i in range(frames.shape[0])] if frames.ndim == 4 else [frames]
        else:
            frame_list = list(frames)

        if not frame_list:
            return []

        if frame_indices is None:
            frame_indices = list(range(len(frame_list)))
        if len(frame_indices) != len(frame_list):
            raise ValueError("frame_indices length must match frames length")

        if self._detector is None:
            self._init_detector()
            assert self._detector is not None

        per_frame_detections: list[list[DetectedFace]] = []
        for batch_start in range(0, len(frame_list), self.batch_size):
            batch = frame_list[batch_start : batch_start + self.batch_size]
            per_frame_detections.extend(self._detector.detect_batch(batch))

        active: list[_OpenTrack] = []
        finished: list[_OpenTrack] = []
        next_id = 1

        for relative_idx, faces in enumerate(per_frame_detections):
            absolute_idx = frame_indices[relative_idx]
            available = [face for face in faces if face.bounding_box is not None]
            assignments: dict[int, DetectedFace] = {}

            for track in active:
                best_face: DetectedFace | None = None
                best_iou = self.iou_match_threshold
                last_box = track.last_box
                if last_box is None:
                    continue
                for face in available:
                    if face in assignments.values() or face.bounding_box is None:
                        continue
                    iou = _bbox_iou(last_box, face.bounding_box)
                    if iou > best_iou:
                        best_iou = iou
                        best_face = face
                if best_face is not None:
                    assignments[track.track_id] = best_face

            for track in active:
                if track.track_id in assignments:
                    face = assignments[track.track_id]
                    assert face.bounding_box is not None
                    track.frame_indices.append(absolute_idx)
                    track.boxes.append(face.bounding_box)
                    track.confidences.append(face.confidence)
                    track.last_box = face.bounding_box
                    track.missed = 0
                else:
                    track.missed += 1

            for face in available:
                if face in assignments.values() or face.bounding_box is None:
                    continue
                track = _OpenTrack(track_id=next_id, last_box=face.bounding_box)
                next_id += 1
                track.frame_indices.append(absolute_idx)
                track.boxes.append(face.bounding_box)
                track.confidences.append(face.confidence)
                active.append(track)

            still_active: list[_OpenTrack] = []
            for track in active:
                if track.missed > self.max_missed_frames:
                    finished.append(track)
                else:
                    still_active.append(track)
            active = still_active

        finished.extend(active)

        return [
            FaceTrack(
                track_id=track.track_id,
                frame_indices=track.frame_indices,
                boxes=track.boxes,
                confidences=track.confidences,
            )
            for track in finished
            if track.frame_indices
        ]

__init__

__init__(
    min_face_size: int = 30,
    batch_size: int = 16,
    iou_match_threshold: float = DEFAULT_IOU_MATCH_THRESHOLD,
    max_missed_frames: int = DEFAULT_MAX_MISSED_FRAMES,
)

Initialize the per-shot tracker.

Parameters:

Name Type Description Default
min_face_size int

Minimum face size in pixels for detection.

30
batch_size int

Batch size for detection. Default 16.

16
iou_match_threshold float

Minimum IoU between consecutive detections to continue an existing track.

DEFAULT_IOU_MATCH_THRESHOLD
max_missed_frames int

Consecutive frames a track may go without a detection before it is closed.

DEFAULT_MAX_MISSED_FRAMES
Source code in src/videopython/ai/understanding/faces.py
def __init__(
    self,
    min_face_size: int = 30,
    batch_size: int = 16,
    iou_match_threshold: float = DEFAULT_IOU_MATCH_THRESHOLD,
    max_missed_frames: int = DEFAULT_MAX_MISSED_FRAMES,
):
    """Initialize the per-shot tracker.

    Args:
        min_face_size: Minimum face size in pixels for detection.
        batch_size: Batch size for detection. Default 16.
        iou_match_threshold: Minimum IoU between consecutive detections to
            continue an existing track.
        max_missed_frames: Consecutive frames a track may go without a
            detection before it is closed.
    """
    super().__init__(min_face_size=min_face_size)
    self.batch_size = batch_size
    self.iou_match_threshold = iou_match_threshold
    self.max_missed_frames = max_missed_frames
    logger.info("FaceShotTracker initialized (min_face_size=%s)", self.min_face_size)

track_shot

track_shot(
    frames: list[ndarray] | ndarray,
    frame_indices: list[int] | None = None,
) -> list[FaceTrack]

Per-shot multi-track association via IoU.

Detection is run on every input frame (caller is expected to have already chosen the sampling cadence -- the analysis pipeline passes one frame per scene-VLM sample, lip-sync passes every frame in the shot). Tracks are stitched together greedily by best IoU above iou_match_threshold; tracks with no match for max_missed_frames consecutive frames are closed and won't accept future associations.

Track ids are integers starting at 1 within this shot. They are not stable across shots — embedding re-id is deferred.

Parameters:

Name Type Description Default
frames list[ndarray] | ndarray

Frames in the shot (list or stacked ndarray).

required
frame_indices list[int] | None

Source-video frame indices. Defaults to range(len(frames)) when omitted.

None

Returns:

Type Description
list[FaceTrack]

List of FaceTrack objects, one per distinct subject

list[FaceTrack]

tracked in the shot.

Source code in src/videopython/ai/understanding/faces.py
def track_shot(
    self,
    frames: list[np.ndarray] | np.ndarray,
    frame_indices: list[int] | None = None,
) -> list[FaceTrack]:
    """Per-shot multi-track association via IoU.

    Detection is run on every input frame (caller is expected to have
    already chosen the sampling cadence -- the analysis pipeline
    passes one frame per scene-VLM sample, lip-sync passes every
    frame in the shot). Tracks are stitched together greedily by
    best IoU above ``iou_match_threshold``; tracks with no match for
    ``max_missed_frames`` consecutive frames are closed and won't
    accept future associations.

    Track ids are integers starting at 1 within this shot. They are
    **not** stable across shots — embedding re-id is deferred.

    Args:
        frames: Frames in the shot (list or stacked ndarray).
        frame_indices: Source-video frame indices. Defaults to
            ``range(len(frames))`` when omitted.

    Returns:
        List of ``FaceTrack`` objects, one per distinct subject
        tracked in the shot.
    """
    if isinstance(frames, np.ndarray):
        frame_list = [frames[i] for i in range(frames.shape[0])] if frames.ndim == 4 else [frames]
    else:
        frame_list = list(frames)

    if not frame_list:
        return []

    if frame_indices is None:
        frame_indices = list(range(len(frame_list)))
    if len(frame_indices) != len(frame_list):
        raise ValueError("frame_indices length must match frames length")

    if self._detector is None:
        self._init_detector()
        assert self._detector is not None

    per_frame_detections: list[list[DetectedFace]] = []
    for batch_start in range(0, len(frame_list), self.batch_size):
        batch = frame_list[batch_start : batch_start + self.batch_size]
        per_frame_detections.extend(self._detector.detect_batch(batch))

    active: list[_OpenTrack] = []
    finished: list[_OpenTrack] = []
    next_id = 1

    for relative_idx, faces in enumerate(per_frame_detections):
        absolute_idx = frame_indices[relative_idx]
        available = [face for face in faces if face.bounding_box is not None]
        assignments: dict[int, DetectedFace] = {}

        for track in active:
            best_face: DetectedFace | None = None
            best_iou = self.iou_match_threshold
            last_box = track.last_box
            if last_box is None:
                continue
            for face in available:
                if face in assignments.values() or face.bounding_box is None:
                    continue
                iou = _bbox_iou(last_box, face.bounding_box)
                if iou > best_iou:
                    best_iou = iou
                    best_face = face
            if best_face is not None:
                assignments[track.track_id] = best_face

        for track in active:
            if track.track_id in assignments:
                face = assignments[track.track_id]
                assert face.bounding_box is not None
                track.frame_indices.append(absolute_idx)
                track.boxes.append(face.bounding_box)
                track.confidences.append(face.confidence)
                track.last_box = face.bounding_box
                track.missed = 0
            else:
                track.missed += 1

        for face in available:
            if face in assignments.values() or face.bounding_box is None:
                continue
            track = _OpenTrack(track_id=next_id, last_box=face.bounding_box)
            next_id += 1
            track.frame_indices.append(absolute_idx)
            track.boxes.append(face.bounding_box)
            track.confidences.append(face.confidence)
            active.append(track)

        still_active: list[_OpenTrack] = []
        for track in active:
            if track.missed > self.max_missed_frames:
                finished.append(track)
            else:
                still_active.append(track)
        active = still_active

    finished.extend(active)

    return [
        FaceTrack(
            track_id=track.track_id,
            frame_indices=track.frame_indices,
            boxes=track.boxes,
            confidences=track.confidences,
        )
        for track in finished
        if track.frame_indices
    ]

FaceSmoothingTracker

Bases: _FaceTrackerBase

Single-subject face tracker with EMA position smoothing.

Selects one face per frame (selection_strategy) and returns a smoothed (cx, cy, w, h) tuple in normalized coords via detect_and_track / track_video. Used by FaceTrackingCrop to drive a follow-the-speaker crop.

Source code in src/videopython/ai/understanding/faces.py
class FaceSmoothingTracker(_FaceTrackerBase):
    """Single-subject face tracker with EMA position smoothing.

    Selects one face per frame (``selection_strategy``) and returns a smoothed
    ``(cx, cy, w, h)`` tuple in normalized coords via ``detect_and_track`` /
    ``track_video``. Used by ``FaceTrackingCrop`` to drive a follow-the-speaker
    crop.
    """

    def __init__(
        self,
        selection_strategy: Literal["largest", "centered", "index"] = "largest",
        face_index: int = 0,
        smoothing: float = 0.8,
        detection_interval: int = 3,
        min_face_size: int = 30,
        batch_size: int = 16,
    ):
        """Initialize the smoothing tracker.

        Args:
            selection_strategy: Which face to track — "largest" (biggest box),
                "centered" (closest to frame center), or "index" (``face_index``).
            face_index: Index of face to track when using the "index" strategy.
            smoothing: Exponential moving average factor (0-1). Higher = smoother.
            detection_interval: Run detection every N frames, hold position between.
            min_face_size: Minimum face size in pixels for detection.
            batch_size: Frames per detection batch in ``track_video``. Default 16.
        """
        super().__init__(min_face_size=min_face_size)
        self.selection_strategy = selection_strategy
        self.face_index = face_index
        self.smoothing = smoothing
        self.detection_interval = detection_interval
        self.batch_size = batch_size
        self._last_position: tuple[float, float] | None = None
        self._last_size: tuple[float, float] | None = None
        self._smoothed_position: tuple[float, float] | None = None
        self._smoothed_size: tuple[float, float] | None = None
        logger.info("FaceSmoothingTracker initialized (detection_interval=%s)", self.detection_interval)

    def _select_face(
        self,
        faces: list[DetectedFace],
        frame_width: int,
        frame_height: int,
    ) -> tuple[float, float, float, float] | None:
        """Select a face based on the configured strategy.

        Args:
            faces: List of DetectedFace objects.
            frame_width: Width of the frame.
            frame_height: Height of the frame.

        Returns:
            Tuple of (center_x, center_y, width, height) in normalized coords, or None.
        """
        faces_with_box = [(f, f.bounding_box) for f in faces if f.bounding_box is not None]
        if not faces_with_box:
            return None

        if self.selection_strategy == "largest":
            _, bbox = faces_with_box[0]
        elif self.selection_strategy == "centered":
            frame_center = (0.5, 0.5)
            _, bbox = min(
                faces_with_box,
                key=lambda fb: ((fb[1].center[0] - frame_center[0]) ** 2 + (fb[1].center[1] - frame_center[1]) ** 2),
            )
        elif self.selection_strategy == "index":
            idx = self.face_index if self.face_index < len(faces_with_box) else 0
            _, bbox = faces_with_box[idx]
        else:
            _, bbox = faces_with_box[0]

        return (bbox.center[0], bbox.center[1], bbox.width, bbox.height)

    def detect_and_track(
        self,
        frame: np.ndarray,
        frame_index: int,
    ) -> tuple[float, float, float, float] | None:
        """Detect face in frame and return smoothed position.

        Args:
            frame: Video frame as numpy array (H, W, 3).
            frame_index: Index of current frame.

        Returns:
            Tuple of (center_x, center_y, width, height) in normalized coords,
            or None if no face detected and no fallback available.
        """
        if self._detector is None:
            self._init_detector()
            assert self._detector is not None

        h, w = frame.shape[:2]

        if frame_index % self.detection_interval == 0:
            faces = self._detector.detect(frame)
            face_info = self._select_face(faces, w, h)
            if face_info is not None:
                self._last_position = (face_info[0], face_info[1])
                self._last_size = (face_info[2], face_info[3])
        elif self._last_position is not None and self._last_size is not None:
            face_info = (*self._last_position, *self._last_size)
        else:
            face_info = None

        return self._smooth(face_info)

    def _smooth(
        self,
        face_info: tuple[float, float, float, float] | None,
    ) -> tuple[float, float, float, float] | None:
        """Apply EMA smoothing, or replay the last smoothed value when no detection.

        Returns ``None`` when no detection has been seen yet.
        """
        if face_info is not None:
            cx, cy, fw, fh = face_info
            if self._smoothed_position is None:
                self._smoothed_position = (cx, cy)
                self._smoothed_size = (fw, fh)
            else:
                assert self._smoothed_size is not None
                alpha = 1 - self.smoothing
                self._smoothed_position = (
                    self._smoothed_position[0] * self.smoothing + cx * alpha,
                    self._smoothed_position[1] * self.smoothing + cy * alpha,
                )
                self._smoothed_size = (
                    self._smoothed_size[0] * self.smoothing + fw * alpha,
                    self._smoothed_size[1] * self.smoothing + fh * alpha,
                )
            return (*self._smoothed_position, *self._smoothed_size)

        if self._smoothed_position is not None and self._smoothed_size is not None:
            return (*self._smoothed_position, *self._smoothed_size)
        return None

    def reset(self) -> None:
        """Reset tracker state for a new video."""
        self._last_position = None
        self._last_size = None
        self._smoothed_position = None
        self._smoothed_size = None

    def track_video(
        self,
        frames: np.ndarray,
    ) -> list[tuple[float, float, float, float] | None]:
        """Track the face through a whole clip via batched per-frame detection.

        Detection runs on every frame (the YuNet detector is CPU-only), then each
        frame's selected face is EMA-smoothed.

        Args:
            frames: Video frames array of shape (N, H, W, 3).

        Returns:
            List of face positions (cx, cy, w, h) for each frame, or None where
            no face was detected and no fallback was available.
        """
        if self._detector is None:
            self._init_detector()
            assert self._detector is not None

        n_frames = len(frames)
        if n_frames == 0:
            return []

        h, w = frames[0].shape[:2]

        detections: list[list[DetectedFace]] = []
        for batch_start in range(0, n_frames, self.batch_size):
            batch = [frames[i] for i in range(batch_start, min(batch_start + self.batch_size, n_frames))]
            detections.extend(self._detector.detect_batch(batch))

        faces = [self._select_face(frame_faces, w, h) for frame_faces in detections]
        self.reset()
        return [self._smooth(face_info) for face_info in faces]

__init__

__init__(
    selection_strategy: Literal[
        "largest", "centered", "index"
    ] = "largest",
    face_index: int = 0,
    smoothing: float = 0.8,
    detection_interval: int = 3,
    min_face_size: int = 30,
    batch_size: int = 16,
)

Initialize the smoothing tracker.

Parameters:

Name Type Description Default
selection_strategy Literal['largest', 'centered', 'index']

Which face to track — "largest" (biggest box), "centered" (closest to frame center), or "index" (face_index).

'largest'
face_index int

Index of face to track when using the "index" strategy.

0
smoothing float

Exponential moving average factor (0-1). Higher = smoother.

0.8
detection_interval int

Run detection every N frames, hold position between.

3
min_face_size int

Minimum face size in pixels for detection.

30
batch_size int

Frames per detection batch in track_video. Default 16.

16
Source code in src/videopython/ai/understanding/faces.py
def __init__(
    self,
    selection_strategy: Literal["largest", "centered", "index"] = "largest",
    face_index: int = 0,
    smoothing: float = 0.8,
    detection_interval: int = 3,
    min_face_size: int = 30,
    batch_size: int = 16,
):
    """Initialize the smoothing tracker.

    Args:
        selection_strategy: Which face to track — "largest" (biggest box),
            "centered" (closest to frame center), or "index" (``face_index``).
        face_index: Index of face to track when using the "index" strategy.
        smoothing: Exponential moving average factor (0-1). Higher = smoother.
        detection_interval: Run detection every N frames, hold position between.
        min_face_size: Minimum face size in pixels for detection.
        batch_size: Frames per detection batch in ``track_video``. Default 16.
    """
    super().__init__(min_face_size=min_face_size)
    self.selection_strategy = selection_strategy
    self.face_index = face_index
    self.smoothing = smoothing
    self.detection_interval = detection_interval
    self.batch_size = batch_size
    self._last_position: tuple[float, float] | None = None
    self._last_size: tuple[float, float] | None = None
    self._smoothed_position: tuple[float, float] | None = None
    self._smoothed_size: tuple[float, float] | None = None
    logger.info("FaceSmoothingTracker initialized (detection_interval=%s)", self.detection_interval)

detect_and_track

detect_and_track(
    frame: ndarray, frame_index: int
) -> tuple[float, float, float, float] | None

Detect face in frame and return smoothed position.

Parameters:

Name Type Description Default
frame ndarray

Video frame as numpy array (H, W, 3).

required
frame_index int

Index of current frame.

required

Returns:

Type Description
tuple[float, float, float, float] | None

Tuple of (center_x, center_y, width, height) in normalized coords,

tuple[float, float, float, float] | None

or None if no face detected and no fallback available.

Source code in src/videopython/ai/understanding/faces.py
def detect_and_track(
    self,
    frame: np.ndarray,
    frame_index: int,
) -> tuple[float, float, float, float] | None:
    """Detect face in frame and return smoothed position.

    Args:
        frame: Video frame as numpy array (H, W, 3).
        frame_index: Index of current frame.

    Returns:
        Tuple of (center_x, center_y, width, height) in normalized coords,
        or None if no face detected and no fallback available.
    """
    if self._detector is None:
        self._init_detector()
        assert self._detector is not None

    h, w = frame.shape[:2]

    if frame_index % self.detection_interval == 0:
        faces = self._detector.detect(frame)
        face_info = self._select_face(faces, w, h)
        if face_info is not None:
            self._last_position = (face_info[0], face_info[1])
            self._last_size = (face_info[2], face_info[3])
    elif self._last_position is not None and self._last_size is not None:
        face_info = (*self._last_position, *self._last_size)
    else:
        face_info = None

    return self._smooth(face_info)

reset

reset() -> None

Reset tracker state for a new video.

Source code in src/videopython/ai/understanding/faces.py
def reset(self) -> None:
    """Reset tracker state for a new video."""
    self._last_position = None
    self._last_size = None
    self._smoothed_position = None
    self._smoothed_size = None

track_video

track_video(
    frames: ndarray,
) -> list[tuple[float, float, float, float] | None]

Track the face through a whole clip via batched per-frame detection.

Detection runs on every frame (the YuNet detector is CPU-only), then each frame's selected face is EMA-smoothed.

Parameters:

Name Type Description Default
frames ndarray

Video frames array of shape (N, H, W, 3).

required

Returns:

Type Description
list[tuple[float, float, float, float] | None]

List of face positions (cx, cy, w, h) for each frame, or None where

list[tuple[float, float, float, float] | None]

no face was detected and no fallback was available.

Source code in src/videopython/ai/understanding/faces.py
def track_video(
    self,
    frames: np.ndarray,
) -> list[tuple[float, float, float, float] | None]:
    """Track the face through a whole clip via batched per-frame detection.

    Detection runs on every frame (the YuNet detector is CPU-only), then each
    frame's selected face is EMA-smoothed.

    Args:
        frames: Video frames array of shape (N, H, W, 3).

    Returns:
        List of face positions (cx, cy, w, h) for each frame, or None where
        no face was detected and no fallback was available.
    """
    if self._detector is None:
        self._init_detector()
        assert self._detector is not None

    n_frames = len(frames)
    if n_frames == 0:
        return []

    h, w = frames[0].shape[:2]

    detections: list[list[DetectedFace]] = []
    for batch_start in range(0, n_frames, self.batch_size):
        batch = [frames[i] for i in range(batch_start, min(batch_start + self.batch_size, n_frames))]
        detections.extend(self._detector.detect_batch(batch))

    faces = [self._select_face(frame_faces, w, h) for frame_faces in detections]
    self.reset()
    return [self._smooth(face_info) for face_info in faces]

ObjectDetector

ObjectDetector runs a D-FINE COCO model and returns a list of DetectedObject per frame, with normalized bounding boxes sorted by confidence. It is the object-detection counterpart to the face trackers and powers ObjectDetectionOverlay.

The D-FINE weights (Apache-2.0) download from HuggingFace on first use; class names come from the model config. Detection is gated by confidence_threshold and optionally restricted to class_filter. D-FINE uses VOC-style COCO names, so class_filter must use the model's exact spellings (e.g. motorbike, tvmonitor).

from videopython.ai import ObjectDetector
from videopython.base import Video

video = Video.from_path("street.mp4")

detector = ObjectDetector(model_name="ustc-community/dfine-nano-coco", class_filter=("person", "car"))
for obj in detector.detect(video.frames[0]):
    print(f"{obj.label} {obj.confidence:.2f} @ {obj.bounding_box}")

# Batched detection over several frames.
per_frame = detector.detect_batch(video.frames[:16])

ObjectDetector

Bases: DetectorBase[DetectedObject]

Lazy D-FINE COCO object detector returning normalized detections.

The D-FINE weights (default ustc-community/dfine-nano-coco) download from HuggingFace on first real use; class names come from the model config. Detection is gated by confidence_threshold and optionally restricted to class_filter (COCO class names; YOLO-style spellings are normalized).

Source code in src/videopython/ai/understanding/objects.py
class ObjectDetector(DetectorBase[DetectedObject]):
    """Lazy D-FINE COCO object detector returning normalized detections.

    The D-FINE weights (default ``ustc-community/dfine-nano-coco``) download from
    HuggingFace on first real use; class names come from the model config.
    Detection is gated by ``confidence_threshold`` and optionally restricted to
    ``class_filter`` (COCO class names; YOLO-style spellings are normalized).
    """

    DEFAULT_CONFIDENCE_THRESHOLD = 0.5
    _FEATURE = "ObjectDetector"
    # Override the base sentinel/unload set: hold the model AND the processor.
    _model_attrs = ("_model", "_processor")

    def __init__(
        self,
        model_name: str = DEFAULT_MODEL,
        confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
        class_filter: tuple[str, ...] = (),
        backend: Backend = "auto",
    ):
        """Initialize the detector.

        Args:
            model_name: D-FINE COCO HuggingFace repo id (e.g.
                ``ustc-community/dfine-nano-coco``, ``...-small-coco``,
                ``...-medium-coco``, ``...-large-coco``). Downloaded on first use.
            confidence_threshold: Minimum detection confidence in ``[0, 1]``.
            class_filter: If non-empty, only these COCO class names are kept
                (D-FINE's VOC-style spelling, e.g. ``motorbike``/``tvmonitor``).
            backend: Detection device - ``"cpu"``, ``"gpu"``, or ``"auto"``.
        """
        super().__init__(backend=backend)
        self.model_name = model_name
        self.confidence_threshold = confidence_threshold
        self.class_filter = tuple(class_filter)
        self._model: Any = None
        self._processor: Any = None
        self._class_names: dict[int, str] = {}
        logger.info("ObjectDetector initialized with model=%s backend=%s", model_name, backend)

    def _load_model(self) -> None:
        from videopython.ai._optional import require

        tf = require("transformers", feature=self._FEATURE)
        revision = pinned(self.model_name)
        self._processor = tf.AutoImageProcessor.from_pretrained(self.model_name, revision=revision, use_fast=True)
        model = tf.DFineForObjectDetection.from_pretrained(self.model_name, revision=revision)
        model.eval()
        if self._resolve_device() == "cuda":
            model = model.to("cuda")
        self._model = model
        self._class_names = {int(k): v for k, v in model.config.id2label.items()}

    def _infer(self, images: list[np.ndarray]) -> list[list[DetectedObject]]:
        import torch

        device = self._resolve_device()
        inputs = self._processor(images=images, return_tensors="pt")
        if device == "cuda":
            inputs = inputs.to("cuda")
        with torch.no_grad():
            outputs = self._model(**inputs)
        # target_sizes is (height, width) per image; D-FINE letterboxes internally
        # so post-processing needs the original sizes to de-letterbox the boxes.
        target_sizes = torch.tensor([[img.shape[0], img.shape[1]] for img in images], device=device)
        results = self._processor.post_process_object_detection(
            outputs, target_sizes=target_sizes, threshold=self.confidence_threshold
        )
        return [self._parse(result, img.shape[1], img.shape[0]) for result, img in zip(results, images)]

    def _parse(self, result: dict[str, Any], img_w: int, img_h: int) -> list[DetectedObject]:
        detected: list[DetectedObject] = []
        scores = result["scores"].tolist()
        labels = result["labels"].tolist()
        boxes = result["boxes"].tolist()
        for score, label_id, (x1, y1, x2, y2) in zip(scores, labels, boxes):
            label = self._class_names.get(int(label_id), str(int(label_id)))
            if self.class_filter and label not in self.class_filter:
                continue
            # D-FINE boxes can sit slightly outside the frame; clamp before normalizing.
            x1 = min(max(x1, 0.0), img_w)
            x2 = min(max(x2, 0.0), img_w)
            y1 = min(max(y1, 0.0), img_h)
            y2 = min(max(y2, 0.0), img_h)
            detected.append(
                DetectedObject(
                    label=label,
                    confidence=float(score),
                    bounding_box=BoundingBox(
                        x=x1 / img_w,
                        y=y1 / img_h,
                        width=(x2 - x1) / img_w,
                        height=(y2 - y1) / img_h,
                    ),
                )
            )
        detected.sort(key=lambda d: d.confidence, reverse=True)
        return detected

__init__

__init__(
    model_name: str = DEFAULT_MODEL,
    confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
    class_filter: tuple[str, ...] = (),
    backend: Backend = "auto",
)

Initialize the detector.

Parameters:

Name Type Description Default
model_name str

D-FINE COCO HuggingFace repo id (e.g. ustc-community/dfine-nano-coco, ...-small-coco, ...-medium-coco, ...-large-coco). Downloaded on first use.

DEFAULT_MODEL
confidence_threshold float

Minimum detection confidence in [0, 1].

DEFAULT_CONFIDENCE_THRESHOLD
class_filter tuple[str, ...]

If non-empty, only these COCO class names are kept (D-FINE's VOC-style spelling, e.g. motorbike/tvmonitor).

()
backend Backend

Detection device - "cpu", "gpu", or "auto".

'auto'
Source code in src/videopython/ai/understanding/objects.py
def __init__(
    self,
    model_name: str = DEFAULT_MODEL,
    confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
    class_filter: tuple[str, ...] = (),
    backend: Backend = "auto",
):
    """Initialize the detector.

    Args:
        model_name: D-FINE COCO HuggingFace repo id (e.g.
            ``ustc-community/dfine-nano-coco``, ``...-small-coco``,
            ``...-medium-coco``, ``...-large-coco``). Downloaded on first use.
        confidence_threshold: Minimum detection confidence in ``[0, 1]``.
        class_filter: If non-empty, only these COCO class names are kept
            (D-FINE's VOC-style spelling, e.g. ``motorbike``/``tvmonitor``).
        backend: Detection device - ``"cpu"``, ``"gpu"``, or ``"auto"``.
    """
    super().__init__(backend=backend)
    self.model_name = model_name
    self.confidence_threshold = confidence_threshold
    self.class_filter = tuple(class_filter)
    self._model: Any = None
    self._processor: Any = None
    self._class_names: dict[int, str] = {}
    logger.info("ObjectDetector initialized with model=%s backend=%s", model_name, backend)

Scene Data Classes

These classes are used by scene and audio analyzers to represent analysis results:

SceneBoundary

SceneBoundary dataclass

Timing information for a detected scene.

A lightweight structure representing scene boundaries returned by scene detectors (e.g. videopython.ai.SemanticSceneDetector). This is a backbone type — higher-level scene analysis lives in orchestration packages.

Attributes:

Name Type Description
start float

Scene start time in seconds

end float

Scene end time in seconds

start_frame int

Index of the first frame in this scene

end_frame int

Index of the last frame in this scene (exclusive)

Source code in src/videopython/base/description.py
@dataclass
class SceneBoundary:
    """Timing information for a detected scene.

    A lightweight structure representing scene boundaries returned by
    scene detectors (e.g. ``videopython.ai.SemanticSceneDetector``). This
    is a backbone type — higher-level scene analysis lives in orchestration
    packages.

    Attributes:
        start: Scene start time in seconds
        end: Scene end time in seconds
        start_frame: Index of the first frame in this scene
        end_frame: Index of the last frame in this scene (exclusive)
    """

    start: float
    end: float
    start_frame: int
    end_frame: int

    @property
    def duration(self) -> float:
        """Duration of the scene in seconds."""
        return self.end - self.start

    @property
    def frame_count(self) -> int:
        """Number of frames in this scene."""
        return self.end_frame - self.start_frame

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "start": self.start,
            "end": self.end,
            "start_frame": self.start_frame,
            "end_frame": self.end_frame,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SceneBoundary":
        """Create SceneBoundary from dictionary."""
        return cls(
            start=data["start"],
            end=data["end"],
            start_frame=data["start_frame"],
            end_frame=data["end_frame"],
        )

duration property

duration: float

Duration of the scene in seconds.

frame_count property

frame_count: int

Number of frames in this scene.

to_dict

to_dict() -> dict[str, Any]

Convert to dictionary for JSON serialization.

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary for JSON serialization."""
    return {
        "start": self.start,
        "end": self.end,
        "start_frame": self.start_frame,
        "end_frame": self.end_frame,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> 'SceneBoundary'

Create SceneBoundary from dictionary.

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SceneBoundary":
    """Create SceneBoundary from dictionary."""
    return cls(
        start=data["start"],
        end=data["end"],
        start_frame=data["start_frame"],
        end_frame=data["end_frame"],
    )

SceneDescription

SceneDescription dataclass

Structured visual scene description from the SceneVLM.

The v1 schema is intentionally narrow (caption + subjects + shot_type). Wider schemas drop JSON parse rate on small models without eval data to defend the cost. Fields are added in v2 as parse-rate measurements justify them; closed enums first, open lists last.

Attributes:

Name Type Description
caption str

One-sentence summary of the scene.

subjects list[str]

Open list of named subjects visible in the frames.

shot_type str | None

Closed enum framing the camera distance, or None when JSON parsing fell back to raw text.

Source code in src/videopython/base/description.py
@dataclass
class SceneDescription:
    """Structured visual scene description from the SceneVLM.

    The v1 schema is intentionally narrow (caption + subjects + shot_type).
    Wider schemas drop JSON parse rate on small models without eval data
    to defend the cost. Fields are added in v2 as parse-rate measurements
    justify them; closed enums first, open lists last.

    Attributes:
        caption: One-sentence summary of the scene.
        subjects: Open list of named subjects visible in the frames.
        shot_type: Closed enum framing the camera distance, or None
            when JSON parsing fell back to raw text.
    """

    caption: str
    subjects: list[str] = field(default_factory=list)
    shot_type: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "caption": self.caption,
            "subjects": list(self.subjects),
            "shot_type": self.shot_type,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SceneDescription":
        return cls(
            caption=str(data["caption"]),
            subjects=[str(s) for s in data.get("subjects", [])],
            shot_type=data.get("shot_type"),
        )

BoundingBox

BoundingBox

Bases: BaseModel

A bounding box for detected objects or crop regions in an image.

Coordinates are normalized to [0, 1] relative to image dimensions. Promoted to a Pydantic model so it can be embedded directly into Operation fields (e.g. KenBurns.start_region) and validated / serialised as part of an op's JSON wire format.

Source code in src/videopython/base/description.py
class BoundingBox(BaseModel):
    """A bounding box for detected objects or crop regions in an image.

    Coordinates are normalized to ``[0, 1]`` relative to image dimensions.
    Promoted to a Pydantic model so it can be embedded directly into
    ``Operation`` fields (e.g. ``KenBurns.start_region``) and validated /
    serialised as part of an op's JSON wire format.
    """

    model_config = ConfigDict(extra="forbid", frozen=True)

    x: float = Field(description="Left edge of the box, 0=left of the image.")
    y: float = Field(description="Top edge of the box, 0=top of the image.")
    width: float = Field(description="Width of the box, normalized to image width.")
    height: float = Field(description="Height of the box, normalized to image height.")

    @property
    def center(self) -> tuple[float, float]:
        """Center point of the bounding box."""
        return (self.x + self.width / 2, self.y + self.height / 2)

    @property
    def area(self) -> float:
        """Area of the bounding box (normalized)."""
        return self.width * self.height

    def to_dict(self) -> dict[str, Any]:
        """Backwards-compat alias for ``model_dump()``."""
        return self.model_dump()

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> BoundingBox:
        """Backwards-compat alias for ``model_validate(data)``."""
        return cls.model_validate(data)

center property

center: tuple[float, float]

Center point of the bounding box.

area property

area: float

Area of the bounding box (normalized).

to_dict

to_dict() -> dict[str, Any]

Backwards-compat alias for model_dump().

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Backwards-compat alias for ``model_dump()``."""
    return self.model_dump()

from_dict classmethod

from_dict(data: dict[str, Any]) -> BoundingBox

Backwards-compat alias for model_validate(data).

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> BoundingBox:
    """Backwards-compat alias for ``model_validate(data)``."""
    return cls.model_validate(data)

DetectedObject

DetectedObject dataclass

An object detected in a video frame.

Attributes:

Name Type Description
label str

Name/class of the detected object (e.g., "person", "car", "dog")

confidence float

Detection confidence score between 0 and 1

bounding_box BoundingBox | None

Optional bounding box location of the object

Source code in src/videopython/base/description.py
@dataclass
class DetectedObject:
    """An object detected in a video frame.

    Attributes:
        label: Name/class of the detected object (e.g., "person", "car", "dog")
        confidence: Detection confidence score between 0 and 1
        bounding_box: Optional bounding box location of the object
    """

    label: str
    confidence: float
    bounding_box: BoundingBox | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "label": self.label,
            "confidence": self.confidence,
            "bounding_box": self.bounding_box.to_dict() if self.bounding_box else None,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DetectedObject:
        """Create DetectedObject from dictionary."""
        return cls(
            label=data["label"],
            confidence=data["confidence"],
            bounding_box=BoundingBox.from_dict(data["bounding_box"]) if data.get("bounding_box") else None,
        )

to_dict

to_dict() -> dict[str, Any]

Convert to dictionary for JSON serialization.

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary for JSON serialization."""
    return {
        "label": self.label,
        "confidence": self.confidence,
        "bounding_box": self.bounding_box.to_dict() if self.bounding_box else None,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> DetectedObject

Create DetectedObject from dictionary.

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DetectedObject:
    """Create DetectedObject from dictionary."""
    return cls(
        label=data["label"],
        confidence=data["confidence"],
        bounding_box=BoundingBox.from_dict(data["bounding_box"]) if data.get("bounding_box") else None,
    )

DetectedFace

DetectedFace dataclass

A face detected in a video frame.

Attributes:

Name Type Description
bounding_box BoundingBox | None

Bounding box location of the face (normalized 0-1 coordinates). May be None for cloud backends that only return face counts.

confidence float

Detection confidence score between 0 and 1

Source code in src/videopython/base/description.py
@dataclass
class DetectedFace:
    """A face detected in a video frame.

    Attributes:
        bounding_box: Bounding box location of the face (normalized 0-1 coordinates).
            May be None for cloud backends that only return face counts.
        confidence: Detection confidence score between 0 and 1
    """

    bounding_box: BoundingBox | None = None
    confidence: float = 1.0

    @property
    def center(self) -> tuple[float, float] | None:
        """Center point of the face bounding box, or None if no bounding box."""
        return self.bounding_box.center if self.bounding_box else None

    @property
    def area(self) -> float | None:
        """Area of the face bounding box (normalized), or None if no bounding box."""
        return self.bounding_box.area if self.bounding_box else None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "bounding_box": self.bounding_box.to_dict() if self.bounding_box else None,
            "confidence": self.confidence,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DetectedFace:
        """Create DetectedFace from dictionary."""
        return cls(
            bounding_box=BoundingBox.from_dict(data["bounding_box"]) if data.get("bounding_box") else None,
            confidence=data.get("confidence", 1.0),
        )

center property

center: tuple[float, float] | None

Center point of the face bounding box, or None if no bounding box.

area property

area: float | None

Area of the face bounding box (normalized), or None if no bounding box.

to_dict

to_dict() -> dict[str, Any]

Convert to dictionary for JSON serialization.

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary for JSON serialization."""
    return {
        "bounding_box": self.bounding_box.to_dict() if self.bounding_box else None,
        "confidence": self.confidence,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> DetectedFace

Create DetectedFace from dictionary.

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DetectedFace:
    """Create DetectedFace from dictionary."""
    return cls(
        bounding_box=BoundingBox.from_dict(data["bounding_box"]) if data.get("bounding_box") else None,
        confidence=data.get("confidence", 1.0),
    )

DetectedText

DetectedText dataclass

Text detected in a video frame.

Attributes:

Name Type Description
text str

OCR text content

confidence float

Detection confidence score between 0 and 1

bounding_box BoundingBox | None

Optional normalized bounding box for the text region

Source code in src/videopython/base/description.py
@dataclass
class DetectedText:
    """Text detected in a video frame.

    Attributes:
        text: OCR text content
        confidence: Detection confidence score between 0 and 1
        bounding_box: Optional normalized bounding box for the text region
    """

    text: str
    confidence: float
    bounding_box: BoundingBox | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "text": self.text,
            "confidence": self.confidence,
            "bounding_box": self.bounding_box.to_dict() if self.bounding_box else None,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "DetectedText":
        """Create DetectedText from dictionary."""
        return cls(
            text=data["text"],
            confidence=data["confidence"],
            bounding_box=BoundingBox.from_dict(data["bounding_box"]) if data.get("bounding_box") else None,
        )

to_dict

to_dict() -> dict[str, Any]

Convert to dictionary for JSON serialization.

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary for JSON serialization."""
    return {
        "text": self.text,
        "confidence": self.confidence,
        "bounding_box": self.bounding_box.to_dict() if self.bounding_box else None,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> 'DetectedText'

Create DetectedText from dictionary.

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "DetectedText":
    """Create DetectedText from dictionary."""
    return cls(
        text=data["text"],
        confidence=data["confidence"],
        bounding_box=BoundingBox.from_dict(data["bounding_box"]) if data.get("bounding_box") else None,
    )

FaceTrack

FaceTrack dataclass

A face tracked across consecutive frames within a single shot.

Tracks are produced by IoU association — no embedding re-id, so a track does not survive across shot/scene boundaries. frame_indices and boxes are parallel lists of equal length.

Attributes:

Name Type Description
track_id int

Stable id within the shot the track was produced in. Not globally unique across scenes.

frame_indices list[int]

Source-video frame indices for each detection.

boxes list[BoundingBox]

Per-frame bounding boxes (normalized 0-1 coords).

confidences list[float]

Per-frame detection confidence in [0, 1].

Source code in src/videopython/base/description.py
@dataclass
class FaceTrack:
    """A face tracked across consecutive frames within a single shot.

    Tracks are produced by IoU association — no embedding re-id, so a
    track does not survive across shot/scene boundaries. ``frame_indices``
    and ``boxes`` are parallel lists of equal length.

    Attributes:
        track_id: Stable id within the shot the track was produced in.
            Not globally unique across scenes.
        frame_indices: Source-video frame indices for each detection.
        boxes: Per-frame bounding boxes (normalized 0-1 coords).
        confidences: Per-frame detection confidence in [0, 1].
    """

    track_id: int
    frame_indices: list[int]
    boxes: list[BoundingBox]
    confidences: list[float] = field(default_factory=list)

    @property
    def length(self) -> int:
        """Number of frames in this track."""
        return len(self.frame_indices)

    def to_dict(self) -> dict[str, Any]:
        return {
            "track_id": self.track_id,
            "frame_indices": list(self.frame_indices),
            "boxes": [box.to_dict() for box in self.boxes],
            "confidences": list(self.confidences),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "FaceTrack":
        return cls(
            track_id=int(data["track_id"]),
            frame_indices=[int(i) for i in data.get("frame_indices", [])],
            boxes=[BoundingBox.from_dict(b) for b in data.get("boxes", [])],
            confidences=[float(c) for c in data.get("confidences", [])],
        )

length property

length: int

Number of frames in this track.

AudioEvent

AudioEvent dataclass

A detected audio event with timestamp.

Attributes:

Name Type Description
start float

Start time in seconds

end float

End time in seconds

label str

Name of the detected sound (e.g., "Music", "Speech", "Dog bark")

confidence float

Detection confidence score between 0 and 1

Source code in src/videopython/base/description.py
@dataclass
class AudioEvent:
    """A detected audio event with timestamp.

    Attributes:
        start: Start time in seconds
        end: End time in seconds
        label: Name of the detected sound (e.g., "Music", "Speech", "Dog bark")
        confidence: Detection confidence score between 0 and 1
    """

    start: float
    end: float
    label: str
    confidence: float

    @property
    def duration(self) -> float:
        """Duration of the audio event in seconds."""
        return self.end - self.start

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "start": self.start,
            "end": self.end,
            "label": self.label,
            "confidence": self.confidence,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> AudioEvent:
        """Create AudioEvent from dictionary."""
        return cls(
            start=data["start"],
            end=data["end"],
            label=data["label"],
            confidence=data["confidence"],
        )

duration property

duration: float

Duration of the audio event in seconds.

to_dict

to_dict() -> dict[str, Any]

Convert to dictionary for JSON serialization.

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary for JSON serialization."""
    return {
        "start": self.start,
        "end": self.end,
        "label": self.label,
        "confidence": self.confidence,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> AudioEvent

Create AudioEvent from dictionary.

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AudioEvent:
    """Create AudioEvent from dictionary."""
    return cls(
        start=data["start"],
        end=data["end"],
        label=data["label"],
        confidence=data["confidence"],
    )

AudioClassification

AudioClassification dataclass

Complete audio classification results.

Attributes:

Name Type Description
events list[AudioEvent]

List of detected audio events with timestamps

clip_predictions dict[str, float]

Overall class probabilities for the entire audio clip

Source code in src/videopython/base/description.py
@dataclass
class AudioClassification:
    """Complete audio classification results.

    Attributes:
        events: List of detected audio events with timestamps
        clip_predictions: Overall class probabilities for the entire audio clip
    """

    events: list[AudioEvent]
    clip_predictions: dict[str, float] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "events": [event.to_dict() for event in self.events],
            "clip_predictions": self.clip_predictions,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "AudioClassification":
        """Create AudioClassification from dictionary."""
        return cls(
            events=[AudioEvent.from_dict(event) for event in data.get("events", [])],
            clip_predictions={k: float(v) for k, v in data.get("clip_predictions", {}).items()},
        )

to_dict

to_dict() -> dict[str, Any]

Convert to dictionary for JSON serialization.

Source code in src/videopython/base/description.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary for JSON serialization."""
    return {
        "events": [event.to_dict() for event in self.events],
        "clip_predictions": self.clip_predictions,
    }

from_dict classmethod

from_dict(data: dict[str, Any]) -> 'AudioClassification'

Create AudioClassification from dictionary.

Source code in src/videopython/base/description.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AudioClassification":
    """Create AudioClassification from dictionary."""
    return cls(
        events=[AudioEvent.from_dict(event) for event in data.get("events", [])],
        clip_predictions={k: float(v) for k, v in data.get("clip_predictions", {}).items()},
    )