Spaces:
Running
Running
File size: 10,186 Bytes
549c3f0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | """The perception loop β where seeing and hearing become identity (CHT-025β¦027).
The identity runtime answers "who is here?" from a stream of claims, but it makes
none itself: on the robot the claims come from a camera and a microphone, and
this is the object that turns one into the other. It closes the two paths the
runtime's docstring describes only in the abstract::
frame β detect β FaceObservation β face provider β claim β runtime.observe
window β segment β SpeechSegment β voice provider β claim β runtime.observe
and, on a fixed cadence, calls ``runtime.tick`` so that presence *decays* when
observation stops β because leaving is the absence of observations, and without a
tick past the staleness horizon the room would never empty (see
``IdentityRuntime.tick``).
The control-loop discipline it follows
--------------------------------------
The reference conversation app runs one thread that owns its control surface and
paces itself off a monotonic clock; the production roadmap holds every subsystem
to that pattern (Β§"Non-negotiables"). This loop obeys it:
- **Single owner.** One thread runs :meth:`PerceptionLoop.run`. It is the sole
mutator of the runtime β it threads each immutable ``RuntimeStep.runtime``
forward and no one else touches it. Other threads learn what happened through
the ``on_step`` callback, which fires on the loop thread.
- **Monotonic cadence.** The tick interval is measured against ``time.monotonic``,
never wall time, so a clock adjustment cannot make presence decay early or
stall. The wall clock is used only for the ``now`` stamped on claims, where a
real timestamp is what a replay wants.
- **Stop-event driven.** :meth:`run` loops until a ``threading.Event`` is set and
exits promptly even mid-wait, so shutting the robot down releases the loop at
once rather than after one more poll.
Every seam is injected
----------------------
Hardware sits behind :class:`CameraSource` / :class:`MicSource`; detection,
segmentation, and identification behind their existing Protocols; and both the
wall clock and the monotonic clock are constructor arguments. So the same object
a robot drives with real devices and real clocks, a test drives with synthetic
frames, a fake runtime, and a scripted clock β the loop's behaviour is a function
of its inputs and replays exactly, which is the whole reason the runtime was kept
clock-free beneath it.
"""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Final
from chittios_core.identity.face.detector import FaceDetectorProtocol, FaceObservation
from chittios_core.identity.runtime import IdentityRuntime, RuntimeStep
from chittios_core.identity.types import IdentityProvider
from chittios_core.identity.voice.segmenter import SegmenterProtocol, SpeechSegment
from chittios_core.perception.sources import CameraSource, MicSource
#: How often presence is aged, in seconds. Well below ``fusion.decay``'s 300 s
#: staleness horizon so the room empties promptly after someone leaves rather
#: than a horizon-length afterward, yet coarse enough that ticking costs nothing
#: against the single-digit-Hz claim rate the runtime is built for.
DEFAULT_TICK_INTERVAL_S: Final = 1.0
#: How long the loop waits between polling cycles when it is otherwise idle.
#: 20 Hz is far faster than frames or utterances arrive, so a present face is
#: never missed for want of looking, while the wait keeps a quiet loop off the
#: CPU. It is also the upper bound on how long a stop takes to be noticed.
DEFAULT_POLL_INTERVAL_S: Final = 0.05
def _wall_clock() -> datetime:
"""The default source of the ``now`` stamped on claims and ticks."""
return datetime.now(UTC)
@dataclass(frozen=True, slots=True)
class FacePipeline:
"""The three seams that turn a frame into a face claim.
Grouped so the loop takes one face argument and one voice argument instead of
six loose collaborators, and so a caller wires a modality as a unit.
Attributes:
source: Where frames come from.
detector: Locates faces in a frame; returns them largest-first.
provider: Identifies one detected face, emitting the Β§6.2 claim.
"""
source: CameraSource
detector: FaceDetectorProtocol
provider: IdentityProvider[FaceObservation]
@dataclass(frozen=True, slots=True)
class VoicePipeline:
"""The three seams that turn an audio window into voice claims.
Attributes:
source: Where audio windows come from.
segmenter: Cuts a window into utterances, in the order spoken.
provider: Identifies the speaker of one utterance, emitting the claim.
"""
source: MicSource
segmenter: SegmenterProtocol
provider: IdentityProvider[SpeechSegment]
class PerceptionLoop:
"""Drives camera and microphone into an :class:`IdentityRuntime`.
Construct with the two modality pipelines and a runtime, then hand
:meth:`run` a stop event on a dedicated thread::
loop = PerceptionLoop(face_pipeline, voice_pipeline, runtime, on_step=react)
stop = threading.Event()
threading.Thread(target=loop.run, args=(stop,), daemon=True).start()
...
stop.set() # released promptly
Args:
face: The frame β claim pipeline.
voice: The window β claim pipeline.
runtime: The identity runtime to drive. Owned exclusively by the loop
thread once :meth:`run` is entered; read the live one via
:attr:`runtime`.
tick_interval_s: How often presence is aged, measured monotonically.
poll_interval_s: How long to wait between polling cycles when idle, and
the upper bound on stop latency.
on_step: Called with every :class:`RuntimeStep` the runtime produces β
each observation and each tick β on the loop thread. This is how a
caller reacts to greetings, an authorization change, or a
re-enrollment prompt. ``None`` discards the steps.
clock: The wall clock stamped on claims and ticks. Injected for
deterministic replay; defaults to ``datetime.now(UTC)``.
monotonic: The monotonic clock the tick cadence is measured against.
Injected for deterministic tests; defaults to ``time.monotonic``.
"""
def __init__(
self,
face: FacePipeline,
voice: VoicePipeline,
runtime: IdentityRuntime,
*,
tick_interval_s: float = DEFAULT_TICK_INTERVAL_S,
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
on_step: Callable[[RuntimeStep], None] | None = None,
clock: Callable[[], datetime] = _wall_clock,
monotonic: Callable[[], float] = time.monotonic,
) -> None:
self._face = face
self._voice = voice
self._runtime = runtime
self._tick_interval_s = tick_interval_s
self._poll_interval_s = poll_interval_s
self._on_step = on_step
self._clock = clock
self._monotonic = monotonic
self._next_tick_at = 0.0
@property
def runtime(self) -> IdentityRuntime:
"""The current runtime β the latest state the loop has threaded to."""
return self._runtime
def run(self, stop: threading.Event) -> None:
"""Poll both modalities and age presence until ``stop`` is set.
Returns when the event is set. Each cycle observes whatever the camera
and microphone offer, ages presence if the cadence is due, then waits β
interruptibly, so a stop set during the wait returns at once.
"""
# Anchor the first tick a full interval out rather than firing one
# immediately on an empty room.
self._next_tick_at = self._monotonic() + self._tick_interval_s
while not stop.is_set():
self._observe_faces()
self._observe_speech()
self._maybe_tick(self._monotonic())
# Event.wait is the interruptible pace: it returns True the moment
# the event is set, so shutdown never waits out a full interval.
if stop.wait(self._poll_interval_s):
break
# ββ modality paths βββββββββββββββββββββββββββββββββββββββββββββββββββ
def _observe_faces(self) -> None:
"""Fold a claim for every face currently in view.
Every detected face is observed, not just the nearest: presence is
per-person, and only a claim per face lets the room hold everyone who is
actually there. All faces in one frame share its capture instant.
"""
frame = self._face.source.read()
if frame is None:
return
now = self._clock()
for face in self._face.detector.detect(frame):
observation = FaceObservation(frame_bgr=frame, face=face)
claim = self._face.provider.identify(observation, observed_at=now)
self._fold(self._runtime.observe(claim, now=now))
def _observe_speech(self) -> None:
"""Fold a claim for every utterance in the latest audio window."""
window = self._voice.source.read()
if window is None:
return
now = self._clock()
for segment in self._voice.segmenter.segment(window):
claim = self._voice.provider.identify(segment, observed_at=now)
self._fold(self._runtime.observe(claim, now=now))
def _maybe_tick(self, now_monotonic: float) -> None:
"""Age presence if a tick interval has elapsed since the last one."""
if now_monotonic < self._next_tick_at:
return
self._next_tick_at = now_monotonic + self._tick_interval_s
self._fold(self._runtime.tick(self._clock()))
def _fold(self, step: RuntimeStep) -> None:
"""Thread the new runtime forward and surface the step to the caller."""
self._runtime = step.runtime
if self._on_step is not None:
self._on_step(step)
|