ꘇ perception.read
Turn one landmark into a number you can wire
- channels
- value
- observations
perception.read is the last mile of the perception chain, and it's the node that makes the whole thing useful: it takes a named feature out of a running MediaPipe stream and hands it to you as a GENERETI_CHOP, a FLOAT, and a DAT. One wrist's Y position becomes a FLOAT you can convert to an input and wire into a rotation, a blur amount, a synth cutoff - without writing a line of code.
It's part of ꘇ Genereti's Perception category (languel/genereti, by Liuboto, MIT, super-alpha). Worth being clear about the architecture, because it's unusual: the inference runs in your browser, via the MediaPipe Tasks Vision WASM runtime, not in Python. The pack ships no mediapipe pip dependency. The KB's MediaPipe panel covers why people reach for it - Apache 2.0, unlike InsightFace's non-commercial pretrained models - but here it's used for a completely different job: interactive landmarks rather than detection for generation pipelines.
Inputs
Three, all required, and they're all about which number to read:
stream(defaultbody) - the name of a live global stream published bymediapipe.stream. Streams are named, and one provider owns each name in the active workflow, so this is a string you type, not a dropdown.feature(defaultpose.nose.x) - the feature path. The named set is broad:pose.nose.x,pose.left_wrist.yand the rest of the official 33 pose landmarks,hands.0.index_tip.xand friends (hand indices are per-frame detections, not stable track IDs), face vertices likeface.1.x, blendshapes likeface.blend.jawOpen, plus aggregate ones -<task>.count,segment.coverage,segment.mean_confidence. Coordinates are unmirrored, normalized image coordinates with x right and y down.max_age_ms(default 500, range 50–10000) - how stale a result can be before this reader rejects it. This is the field people ignore and then complain about.
Outputs, and the one you must check
channels (GENERETI_CHOP) contains value, valid and age_ms. value (FLOAT) is the scalar, ready for a normal parameter socket. observations (DAT) carries the complete structured result.
The trap is right here: missing or stale values report valid = 0 and a zero scalar. Zero is not "the nose is at the top-left corner," it's "I don't have a fresh answer." The pack's own documentation says consumers should check validity rather than treat zero as a detection, and it's telling the truth - a graph that blindly wires value into a position will yank your element to the origin every time somebody leaves the frame.
The practical fix is a CHOP-side gate: chop.logic to threshold valid (or select it and multiply), or read the whole channels block and do the check in chop.expression. Either way, valid is the signal to branch on.
Why the age limit exists
The perception service is deliberately bounded: one worker inference plus one newest waiting frame per provider, no FIFO backlog. Subscribers reject old source revisions, paused producers, and results older than their max_age_ms. The intent is that your interaction uses current data or nothing - no queue of stale frames being replayed in a burst after a stall. Decisions are made against source time, not wall-clock, so a slow inference doesn't stretch a "hold" condition indefinitely. When you set max_age_ms low you're trading smoothness for freshness; 500 ms is a sane middle.
Live only, and Queue is honest about it
The node raises in Queue: "Global perception streams are browser Live state; capture a snapshot for Queue." That's the design, not a bug - the stream lives in the active browser workflow tab. If you need a queued run to use perception data, you capture the image (the provider's clean image output, or a capture node) and run detection on that.
Install
Manager → search "Genereti" → install ꘇ Genereti → restart ComfyUI → refresh the browser page. Manual:
git clone https://github.com/languel/genereti.git custom_nodes/genereti
./.venv/bin/python -m pip install -r custom_nodes/genereti/requirements.txt
No Python MediaPipe dependency - the runtime and models download on request from the perception panel, verified by SHA-256, into models/genereti/browser-perception, and the routes are loopback-only. Runtime plus one task model is roughly 22.6 MB of shared runtime plus 4–14 MB depending on task. ComfyUI 0.38.2+, Python 3.11+, a modern browser.
Gotchas
Nothing to read if nothing is running. This node consumes; it never starts capture or inference. You need a provider with an explicitly started stream (↓ download, ↑ load, ▶ start).
The stream name must match exactly. No autocomplete, no fuzzy match. A typo gives you a permanently invalid reading rather than an error, which is precisely why valid matters.
Hand indices are per-frame. hands.0 means "the first hand detected this frame," not "the person's left hand." The docs are explicit that hand-classifier handedness scores don't establish body-side identity - so don't build a two-hander interaction on index 0 and 1 without calibration.
Inputs (3)
| Name | Type | Default | Description |
|---|---|---|---|
| stream | STRING | body | — |
| feature | STRING | pose.nose.x | — |
| max_age_ms | FLOAT | 500.0050–10000 | — |
Outputs (3)
| Name | Type | Description |
|---|---|---|
| channels | GENERETI_CHOP | — |
| value | FLOAT | — |
| observations | GENERETI_DAT | — |