Docs / Contact / sdks/micropython/vision

MicroPython Client: Vision

tendrl.vision turns a camera into a sensor.

Instead of uploading video, the device runs a detector on each frame, reduces it to a state, and publishes a small message only when that state changes:

json

{"state": "empty", "prev": "full", "conf": 0.81}

While the scene is stable, nothing is sent at all. A couple of hundred bytes cross the network when reality changes, not a video feed. That is what makes a camera cheap enough to leave running forever, and it means the footage never leaves the device.

OpenMV only

Vision requires the OpenMV csi or sensor module (and ml for the model detectors). It will not run on an ESP32. See Supported boards.

Install

OpenMV firmware doesn't include `mip`

Standard MicroPython (ESP32, Pico W, …) ships the on-device mip installer, but OpenMV firmware does not, and vision only runs on OpenMV. So the usual on-board import mip / mip.install(...) won't work here, and neither will install_script.py (it imports mip). Install the vision add-on one of these mip-free ways instead:

Provision from Tendrl (recommended). In the Contact Device tab, or the standalone tendrl-dev-mcp console, pick the vision tier. The SDK is pushed to the board over serial as precompiled .mpy (into /flash/lib/tendrl), no mip involved. See Getting started.

Host-side mpremote. mpremote runs mip on your computer and copies the files to the board over USB, so the board itself needs neither mip nor Wi-Fi:

bash

mpremote mip install https://app.tendrl.com/api/public/sdk/v1/latest/mpy/package-vision.json

OpenMV IDE. Copy the tendrl/ package onto the board from the editor's file browser.

Vision is an add-on: it layers on top of either the full or the minimal SDK install.

Quick start

python

from tendrl import Client
from tendrl.vision import Camera, Watch, roi_brightness

client = Client(mode="sync")
client.start()
time.sleep(5)

cam = Camera()  # RGB565, ready for detection and clips

watch = Watch(
    client,
    detect=roi_brightness(roi=(120, 80, 60, 90), threshold=0.45,
                          states=("full", "empty")),
    debounce=5,          # consecutive agreeing frames before a change is believed
    cooldown_s=30,       # minimum gap between published changes
    tags=["coffee"],     # routes to a flow / Strand workflow
)

while True:
    watch.update(cam.snapshot())   # publishes ONLY on a real transition
    time.sleep_ms(500)

The tags are the whole integration story: a flow or a Strand workflow keyed on coffee picks the message up and turns it into a Slack post, a log entry, or a page.

Detectors

A detector is a function detect(img) -> (state, confidence). Each one owns its own state labels. A detector may return (None, 0.0) to mean "no verdict this frame"; Watch ignores those.

Every detector takes a states argument: a 2-tuple (negative, positive). states[0] is the resting / "not happening" state; states[1] is the fired state (above the threshold, a blob present, motion detected, a person seen). So for motion you'd write states=("still", "motion"): resting first, fired second. Get this order backwards and your labels are inverted.

Detector Use it for
roi_brightness(roi, threshold, states) coffee pot empty, tank level, shelf stocked, bin full
color_blob(thresholds, roi, min_pixels, states) indicator lamps, status LEDs, signal towers
motion(threshold, scale, states) frame-difference motion
model_detect(path, min_conf, postprocess, states) any TFLite / FOMO / YOLO model
person(min_conf) people, OpenMV's built-in model
face(min_conf) faces, OpenMV's built-in MediaPipe model
palm(min_conf) hands, OpenMV's built-in MediaPipe model
hands(min_conf) hands, confirmed by the 21-joint landmark model
Reach for the classical detectors first

roi_brightness and color_blob need no model, no training data, and no heap for weights. They cover a large share of real "tell me when this changes" problems, they run on weaker boards, and they work the moment you point the camera. Use a model only when the thing you care about genuinely needs one.

Budgeting heap for the person detector

person() uses OpenMV's YOLO LC model, which ships in flash (/rom/yolo_lc_192.tflite). Its weights are read in place rather than copied into the heap, which is what lets it run on boards that could never fit a custom model. But loading the model still allocates a tensor arena on the heap.

Measured on an OpenMV RT1062:

Heap to load the model (arena) ~275 KB
Heap per inference 0 bytes
Time per inference ~310 ms

(The model reports its own arena as model.ram: ~270 KB here.)

So budget two things for person detection, not one: this ~275 KB arena and the camera framebuffer (VGA windowed to 400×400 is another ~280 KB), call it ~550 KB free before you start. If you are tight on memory, shrink the framesize first; the arena is fixed, so on a very small board a smaller model may be the only lever left.

python

from tendrl.vision import Camera, Watch, person

cam = Camera(framesize="VGA", window=(400, 400))
watch = Watch(client, detect=person(min_conf=0.4), debounce=2, tags=["person-detected"])

Faces and hands (MediaPipe)

Three more models ship in OpenMV flash, from Google's MediaPipe family. Same economics as person() (weights read in place, tensor arena on the heap), and all three want a square input, so build the camera as Camera(framesize="VGA", window=(400, 400)).

Each has a state form for Watch and a geometry form for drawing:

Detector Geometry form Returns
face(min_conf) face_boxes(min_conf) box + 6 keypoints: eyes, nose, mouth, ears
palm(min_conf) palm_boxes(min_conf) box + 7 keypoints: wrist, four knuckles, two thumb joints
hands(min_conf) hand_landmarks(min_conf) box + 21 joints + "left"/"right"

face is a 128×128 model and the cheapest of the three. palm (192×192) and hand_landmarks (224×224) want an NPU to run in real time.

These models are not on every OpenMV board

They are firmware assets, so which ones exist depends on the build your board is running, not on the SDK. Measured on the two boards to hand:

Board yolo_lc_192 (person) blazeface_front_128 (face) palm_detection_full_192 hand_landmarks_full_224
OpenMV AE3
OpenMV H7 Plus

The H7 Plus ships a different set (person_detect, fomo_face_detection, yolo_v5_224_nano), so these detectors raise rather than run slowly there. Check what your board actually carries with os.listdir("/rom"); the Device tab's vision playground does this for you and greys out any detector whose model is missing.

`hand_landmarks` runs two models in series

Palm detection finds each hand, then the landmark model runs again on a 3× crop around each hand. That's one inference per hand on top of the palm pass, with both tensor arenas resident at once. If you only need "is there a hand" or where it is, palm() is one model and one inference.

Draw the results with draw_boxes, draw_keypoints, and draw_skeleton; all three write into the pixels, so the overlay survives JPEG compression and shows up in a stream, a clip, or a preview.frame():

python

from tendrl.vision import Camera, HAND_LINES, draw_boxes, draw_skeleton, hand_landmarks
from tendrl import preview

cam = Camera(framesize="VGA", window=(400, 400))
detect = hand_landmarks(min_conf=0.4)

while True:
    img = cam.snapshot()
    for x, y, w, h, score, kps, label in detect(img):
        draw_boxes(img, [(x, y, w, h)], label=label)
        draw_skeleton(img, kps, HAND_LINES)
    preview.frame(img)          # draw FIRST: this compresses img in place

Joint order follows the MediaPipe convention: 0 is the wrist, then four joints per digit running base → tip (thumb 1-4, index 5-8, middle 9-12, ring 13-16, pinky 17-20), so 4/8/12/16/20 are the fingertips. PALM_LINES and HAND_LINES are the connection tables for draw_skeleton.

Measured on an OpenMV AE3 (which has an NPU):

Arena Inference
face ~390 KB ~14 ms
palm ~870 KB ~44 ms
hand_landmarks ~1.8 MB palm + one landmark pass per hand

When the model isn't there

The in-flash models come from OpenMV firmware, not from the Tendrl SDK, so which ones a board has varies by board and firmware version. Asking for one that isn't installed raises a VisionError naming the file:

code

VisionError: /rom/blazeface_front_128.tflite is not in this firmware's flash.
List /rom to see which models this board ships, or update OpenMV firmware.

To see what your board actually has:

python

print([f for f in os.listdir('/rom') if f.endswith('.tflite')])

Presence isn't sufficiency: a model that exists still has to fit in free heap alongside the camera framebuffer. Check gc.mem_free() if a load fails on a small board.

Picking a threshold

roi_brightness works by watching a rectangle of the image (the "ROI", or region of interest) and asking one question: is it lighter or darker than a cutoff? That cutoff is the threshold. Two things to pick, and this section walks through both.

First, some vocabulary:

Now find the threshold: you measure it, you don't guess it. Run this on the device with the camera aimed at your scene:

python

from tendrl.vision import Camera, roi_level

cam = Camera()
ROI = (120, 80, 60, 90)   # the box to watch; adjust to cover your thing

while True:
    print("%.3f" % roi_level(cam.snapshot(), ROI))
    time.sleep_ms(500)

It prints the box's brightness twice a second. Now change the thing you care about and watch the two numbers it settles at. For a coffee pot:

code

0.31   0.30   0.31    ← pot is FULL: dark coffee fills the box, low number
0.30   0.62   0.63    ← you pour it out; empty glass is bright, number jumps up
0.62   0.61   0.62    ← pot is EMPTY: settles high

You saw it settle around 0.31 when full and 0.62 when empty. Pick a threshold halfway between, 0.45, so full sits comfortably below it and empty comfortably above:

python

roi_brightness(roi=(120, 80, 60, 90), threshold=0.45, states=("full", "empty"))

The bigger the gap between your two numbers, the more reliable the detector. If they're too close (say 0.48 vs 0.52), move or resize the ROI to a spot where the change is more dramatic, or improve the lighting, then re-measure.

Detecting a colour (color_blob)

color_blob fires when a patch of a specific colour appears: a lit red lamp, a green status LED, an amber stack light. It takes:

python

from tendrl.vision import Camera, Watch, color_blob

# A red LAB range from the OpenMV Threshold Editor:
RED = [(15, 60, 30, 90, 10, 60)]

watch = Watch(
    client,
    detect=color_blob(RED, roi=(200, 40, 40, 40), min_pixels=40,
                      states=("off", "lit")),
    debounce=3, tags=["fault-lamp"],
)

Self-illuminated targets (lamps, LEDs) are the easy case: they glow at the camera, so ambient light barely matters.

The other detectors, briefly

Camera modes

Camera wraps the OpenMV sensor. Its constructor:

python

Camera(mode="detect", framesize="QVGA", quality=90, window=None, skip_ms=2000)
Argument What it does
mode "detect" (RGB565, the default) or "stream" (JPEG). See the table below.
framesize Resolution: "QQVGA" (160×120), "QVGA" (320×240), "VGA" (640×480), etc. Bigger = more detail but more heap and bandwidth.
quality JPEG quality 1–100, used only in STREAM mode.
window Optional (width, height) crop of the sensor's centre, in pixels, e.g. (400, 400) for a square. Cropping shrinks the framebuffer (less heap) and is how the person example keeps a square aspect for the model. None = full frame.
skip_ms Milliseconds to let auto-exposure/gain settle after (re)configuring the sensor, before the first usable frame. Default 2000.

The two modes configure the sensor for different pixel formats. Camera makes that explicit rather than letting you discover it the hard way.

Mode Pixel format Capture method Feeds
DETECT (default) RGB565 cam.snapshot()image.Image detectors, send_clip_burst(), your own img.compress()
STREAM JPEG cam.jpeg()bytes start_streaming(capture_frame_func=cam.jpeg)
python

cam.set_mode("stream")
client.start_streaming(capture_frame_func=cam.jpeg)   # detection is stopped

cam.jpeg() returns hardware-encoded JPEG when the sensor has an encoder, and otherwise compresses in software, so STREAM mode works on sensors without hardware JPEG (like the OpenMV AE3's PAG7936) at some CPU cost. cam.hw_jpeg records which path was chosen.

Streaming frames you have detected on

You can stream frames you ran a detector on; you just don't do it from STREAM mode. Stay in DETECT, draw onto the RGB565 frame, and compress it yourself inside the capture function. start_streaming accepts any callable that returns JPEG bytes, and the draw helpers write into the pixels, so the overlay survives compression:

python

from tendrl.vision import Camera, face_boxes, draw_boxes, draw_keypoints

cam = Camera(mode="detect", framesize="QVGA")
detect = face_boxes(min_conf=0.5)

def annotated_jpeg():
    img = cam.snapshot()
    dets = detect(img)
    draw_boxes(img, dets, label="face")
    for d in dets:
        draw_keypoints(img, d[5])
    return img.compress(quality=70).bytearray()

client.start_streaming(capture_frame_func=annotated_jpeg, target_fps=10)

Measured on an OpenMV AE3 at QVGA: one snapshot → detect → draw → compress pass:

Detector Per frame Sustained
face_boxes 43–50 ms 20–23 fps
hand_landmarks (two models) 76 ms 13 fps
What you actually cannot do

Run a detector on a hardware-JPEG frame. cam.jpeg() raises in DETECT mode, and in STREAM mode on a sensor that has an encoder the frame carries no readable pixels: img.get_pixel() raises ValueError: Expected an uncompressed image (measured on an OpenMV H7 Plus, hw_jpeg=True). On a sensor without an encoder the frame stays RGB565 in both modes (measured on an AE3, hw_jpeg=False), so the distinction is nominal there, but the pattern above is the portable one, and it is the same code on every board.

If you want video of an event rather than a continuous annotated feed, send_clip_burst() from DETECT mode is cheaper: the same draw calls work before it, so the boxes end up in the clip. See Motion clips.

Watch

A raw detector flips on noise: one stray reflection, a passing hand, a single bad exposure. Watch is what turns a detector into something you can leave running.

python

Watch(client, detect, debounce=3, cooldown_s=0, tags=None,
      on_change=None, entity="", publish=True, emit_initial=True, debug=False)
Parameter Behavior
debounce Consecutive frames that must agree before a change is believed.
cooldown_s Minimum seconds between publishes. A transition that comes due mid-cooldown is deferred, not dropped: it fires as soon as the cooldown expires and the state still holds.
tags Attached to the published message; this is what routes it.
on_change Your function, run after each publish when the state changes. Upload a clip, escalate a frame, drive a GPIO. Signature and arguments explained below.
emit_initial Publish the first confirmed state on boot (default True). Set False to stay silent until something actually changes.
publish Set False to run on_change only and send nothing.
entity Which entity the message is addressed to (default "" = this device itself). Passed through to client.publish.
debug Print each transition to the console (old -> new), handy while tuning.

watch.update(img) returns the current confirmed state. watch.reset() forgets it, so the next confirmation publishes again.

The on_change callback

If you pass an on_change function, Watch calls it each time the state changes, right after it publishes. Your function always receives three positional arguments: (new state, previous state, image):

python

def on_change(state, prev, img):
    ...
Argument What it is
state The new state that just took effect (e.g. "empty", "person"), the same value that was just published.
prev The state it was before this change (e.g. "full", "clear"). None on the very first change.
img The single camera frame (image.Image) from the moment of the transition. Save it, run extra checks, or send it on its own with send_file.
`img` is one frame; a clip is many

To record a video clip you don't pass img; you pass a capture function like cam.snapshot to send_clip_burst, which calls it repeatedly to grab the burst (that's what the example below does). Use img only when you want that one still, e.g. client.send_file(data=img.compress().bytearray(), filename="moment.jpg", tags=["snapshot"]) (a plain file needs a dest or tags to route it; see File transfer).

Why this matters for your bill

Every file upload is malware-scanned and costs one Surface scan, charged to the sender (see File transfer). Video is expensive; a state change is not. The local detector's real job is deciding which rare moments are worth the bytes, which is exactly what debounce and cooldown_s control.

Sending a clip on a change

This uses the on_change callback (see The on_change callback above): Watch calls it after each transition with (state, prev, img): the new state, the state before it, and the single frame from that moment. Here we record a clip only when a person arrives (state == "person"), not when they leave.

python

from tendrl.vision import Camera, Watch, person

cam = Camera(framesize="VGA", window=(400, 400))

def on_change(state, prev, img):   # state = new state, prev = state before, img = the frame
    if state != "person":
        return                      # departures publish, but don't need video
    cam.lock_exposure(True)         # stop auto-exposure pumping mid-clip
    try:
        # send_clip_burst takes a capture FUNCTION (cam.snapshot); it calls it
        # repeatedly to record the burst. Not the single `img` above.
        client.send_clip_burst(cam.snapshot, duration_s=3, fps=5,
                               tags=["person-detected"])
    finally:
        cam.lock_exposure(False)

watch = Watch(client, detect=person(), debounce=2, cooldown_s=30,
              tags=["person-detected"], on_change=on_change)

The clip lands in the Contact clip gallery, transcoded to MP4 with playback controls.

Previewing frames while you build

When you're tuning a detector or a custom capture loop, you usually just want to see what the camera sees, without standing up a full stream. tendrl.preview prints frames and short clips in the marker format the Contact Device tab and the standalone tendrl-dev-mcp console recognise, so while your script is Run from the Files tab (or the Terminal), the frames render live in the browser. Run the same script headless and those are just harmless print lines that nothing consumes.

python

from tendrl.vision import Camera
from tendrl import preview

cam = Camera()   # RGB565, detection and preview

while True:
    img = cam.snapshot()
    # ... run your detector on img here ...
    preview.frame(img)          # -> live view; the stream panel opens itself

preview.frame(img, quality=80) JPEG-compresses the frame in place (the AE3 has no hardware encoder, and the non-destructive path doesn't render there), so run any detection on the frame before you call it. To assemble a short playable clip in the console's Clips view instead:

python

preview.clip([f1, f2, f3], meta={"label": "dog", "conf": 0.92})

preview ships in the Full (default) install and the Vision add-on, so from tendrl import preview works wherever you're already running detection. It's a development aid: for a real video feed to the dashboard use streaming, and to store an event use motion clips.

Examples

Example What it shows
vision_state_watch.py Start here. Camera + roi_brightness + Watch, with three presets (coffee pot, indicator lamp, tank level), the same code re-tasked to three problems.
vision_person_alert.py The in-flash person model, publishing on arrival/departure and uploading a clip.
person_clip_example.py The lower-level version: raw csi + ml + send_clip_burst, no Watch.
vision_face_preview.py In-flash face detection, boxes + facial keypoints drawn live in the dev UI.
vision_palm_preview.py In-flash hand detection with a 7-joint skeleton. Wants an NPU (AE3 / N6).
vision_hand_landmarks_preview.py Full 21-joint hand skeletons, left/right labelled, two models in series. Wants an NPU.

Requirements & limits