Skip to content

Perception and Memory

This lane runs the perception → memory → action contract on deterministic synthetic data — no camera, no model. A SyntheticColorCamera paints a moving red and blue block; a ColorDetector turns pixels into typed Detection2D; a BeliefTracker smooths and remembers them; a selector turns belief into a PointTarget2D. Every payload is a standard retriever.types.perception type, so the same graph accepts a real detector later without rewiring.

The scene is deterministic, so the output is reproducible. The detector is plain NumPy thresholding that returns a typed DetectionBatch — a Flow[Image2D, DetectionBatch].

import numpy as np
from retriever.flow import Flow
from retriever.types.perception import BBox2D, Detection2D, DetectionBatch, Image2D

class SyntheticColorCamera(Flow[None, Image2D]):
    """One red and one blue block moving over time."""
    def reset(self):
        self.frame_index = 0; self.t_sim = 0.0
    def step(self, _):
        self.frame_index += 1
        image = np.zeros((self.height, self.width, 3), dtype=np.uint8)
        # ... paint a red block and a blue block at time-varying positions ...
        return Image2D(data=image, encoding="rgb8", header=..., frame_index=self.frame_index)

class ColorDetector(Flow[Image2D, DetectionBatch]):
    MIN_PIXELS = 20
    def step(self, frame: Image2D) -> DetectionBatch:
        image = frame.data
        red_mask  = (image[..., 0] > 180) & (image[..., 1] < 100) & (image[..., 2] < 100)
        blue_mask = (image[..., 2] > 180) & (image[..., 0] < 100) & (image[..., 1] < 100)
        detections: list[Detection2D] = []
        for label, mask in (("red", red_mask), ("blue", blue_mask)):
            stats = _mask_stats(mask)                 # pixel_count, cx, cy, bbox
            if stats is None or stats[0] < self.MIN_PIXELS:
                continue
            pixel_count, cx, cy, bbox = stats
            detections.append(Detection2D(
                label=label, confidence=min(0.99, pixel_count / 180.0),
                bbox=bbox, centroid_x=cx, centroid_y=cy, pixel_count=pixel_count))
        return DetectionBatch(detections=tuple(detections),
                              header=frame.header, frame_index=frame.frame_index)

Clocks say when each Flow runs; sync= says which record it consumes. Every payload carries a frame_index, so the detector and printer wake once per frame:

from retriever.flow import Latest, Pipeline, Rate, Trigger

pipe = Pipeline("advanced_perception_detection")
with pipe:
    camera   = SyntheticColorCamera(dt=0.1) @ Rate(hz=10)
    detector = ColorDetector()              @ Trigger("frame_index")  # wakes per new frame
    printer  = DetectionPrinter()           @ Trigger("frame_index")
    pipe.connect(camera, detector, sync=Latest())
    pipe.connect(detector, printer, sync=Latest())
pixi run -e golden-retriever demo-perception-detection-flow

Real output — each frame yields two typed detections with sub-pixel centroids that track the moving blocks:

[frame=01] detections=['red@(28.5,34.5) c=0.80', 'blue@(59.5,41.5) c=0.56']
[frame=02] detections=['red@(38.5,34.5) c=0.80', 'blue@(57.5,43.5) c=0.56']
[frame=03] detections=['red@(47.5,33.5) c=0.80', 'blue@(53.5,46.5) c=0.56']
[frame=04] detections=['red@(54.5,32.5) c=0.80', 'blue@(49.5,47.5) c=0.56']
[frame=05] detections=['red@(59.5,31.5) c=0.80', 'blue@(43.5,48.5) c=0.56']
[frame=06] detections=['red@(62.5,30.5) c=0.80', 'blue@(37.5,49.5) c=0.56']

BeliefTracker is a Flow[DetectionBatch, SceneBelief] that holds state across steps. It smooths each object’s position with an EMA (alpha), counts how many times it has been seen, and — via hold_steps — keeps an object in the belief for a few frames after it drops out of detection.

from examples.advanced.memory_examples.types import ObjectBelief, SceneBelief

class BeliefTracker(Flow[DetectionBatch, SceneBelief]):
    def reset(self):
        self._memory: dict[str, ObjectBelief] = {}
    def step(self, batch: DetectionBatch) -> SceneBelief:
        updated: dict[str, ObjectBelief] = {}
        seen = {d.label for d in batch.detections}
        for det in batch.detections:                 # smooth toward the new observation
            prev = self._memory.get(det.label)
            x = (det.centroid_x or 0.0) / (self.image_width - 1.0)
            y = (det.centroid_y or 0.0) / (self.image_height - 1.0)
            if prev is not None:
                x = (1 - self.alpha) * prev.x_norm + self.alpha * x
                y = (1 - self.alpha) * prev.y_norm + self.alpha * y
            updated[det.label] = ObjectBelief(label=det.label, x_norm=x, y_norm=y,
                confidence=det.confidence or 0.0,
                seen_count=(prev.seen_count + 1) if prev else 1,
                last_frame_index=batch.frame_index, missing_steps=0)
        for label, prev in self._memory.items():     # hold a missing object briefly
            if label in seen or prev.missing_steps + 1 > self.hold_steps:
                continue
            updated[label] = replace_missing(prev)   # keep position, decay confidence
        self._memory = updated
        return SceneBelief(frame_index=batch.frame_index,
                           objects=tuple(sorted(updated.values(), key=lambda o: o.label)))

SceneBelief is an example-local @io payload built from standard perception types — the pattern for defining your own record when the standard set does not cover it.

pipe = Pipeline("advanced_memory_belief")
with pipe:
    camera   = SyntheticColorCamera(dt=0.1) @ Rate(hz=10)
    detector = ColorDetector()              @ Trigger("frame_index")
    belief   = BeliefTracker()              @ Trigger("frame_index")
    printer  = BeliefPrinter()              @ Trigger("frame_index")
    pipe.connect(camera, detector, sync=Latest())
    pipe.connect(detector, belief, sync=Latest())
    pipe.connect(belief, printer, sync=Latest())
pixi run -e golden-retriever demo-memory-belief-flow

Real output — normalized positions, and seen climbing as the belief accumulates evidence across frames:

[frame=01] belief=['blue@(0.63,0.58) c=0.56 seen=1 miss=0', 'red@(0.30,0.49) c=0.80 seen=1 miss=0']
[frame=02] belief=['blue@(0.62,0.59) c=0.56 seen=2 miss=0', 'red@(0.34,0.49) c=0.80 seen=2 miss=0']
[frame=03] belief=['blue@(0.60,0.62) c=0.56 seen=3 miss=0', 'red@(0.39,0.48) c=0.80 seen=3 miss=0']
[frame=04] belief=['blue@(0.57,0.63) c=0.56 seen=4 miss=0', 'red@(0.46,0.47) c=0.80 seen=4 miss=0']
[frame=05] belief=['blue@(0.53,0.65) c=0.56 seen=5 miss=0', 'red@(0.52,0.46) c=0.80 seen=5 miss=0']
[frame=06] belief=['blue@(0.48,0.67) c=0.56 seen=6 miss=0', 'red@(0.57,0.45) c=0.80 seen=6 miss=0']

The last stage turns detections into a PointTarget2D for a controller. Because the input is typed, the selector is a few lines and knows nothing about clocks or wiring.

from retriever.types.perception import PointTarget2D

class PointToLabel(Flow[DetectionBatch, PointTarget2D]):
    def step(self, batch: DetectionBatch) -> PointTarget2D:
        for det in batch.detections:
            if det.label == self.target_label:
                return PointTarget2D(frame_index=batch.frame_index, label=det.label,
                    x_norm=det.centroid_x / (self.image_width - 1.0),
                    y_norm=det.centroid_y / (self.image_height - 1.0),
                    confidence=det.confidence)
        return PointTarget2D(frame_index=batch.frame_index)   # nothing to point at
pixi run -e golden-retriever demo-perception-pointing-flow
[frame=01] target=red x_norm=0.30 y_norm=0.49 confidence=0.80
[frame=02] target=red x_norm=0.41 y_norm=0.49 confidence=0.80
[frame=03] target=red x_norm=0.50 y_norm=0.47 confidence=0.80
[frame=04] target=red x_norm=0.57 y_norm=0.46 confidence=0.80
[frame=05] target=red x_norm=0.63 y_norm=0.44 confidence=0.80
[frame=06] target=red x_norm=0.66 y_norm=0.43 confidence=0.80

SelectBeliefTarget is the same shape but reads the belief instead of the raw batch, so pointing stays on target even when a detection is briefly missing — the memory-backed variant of this stage.

Image2D, Detection2D, DetectionBatch, and PointTarget2D are standard payloads — the same records a real camera, a learned detector, and a controller would exchange. Because each stage is an isolated Flow over a typed stream, you can:

  • swap ColorDetector for a model-backed detector without touching the graph,
  • carry any payload’s memory in local Flow state (BeliefTracker._memory),
  • step the whole pipeline in-process and breakpoint inside any step().

The model-backed siblings keep the identical graph shape, running a real detector behind a mock backend:

pixi run demo-gemini-detection-flow      # --backend mock
pixi run demo-belief-from-real-detections # --backend mock

Source: examples/advanced/perception_examples/ and examples/advanced/memory_examples/ (common.py, detection_flow.py, belief_from_detections.py, pointing_flow.py).