From 0efeacd53811f8ea7fe0e7f5d73c0cd652a603c6 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 03:29:01 -0300 Subject: [PATCH 01/14] add a depth map panel The node showed a colour image only, so debugging a depth pipeline meant either trusting the numbers or writing a throwaway viewer. It now keeps one panel per frame stream -- image and depth -- and tiles them side by side, scaled to a common height. Wiring one is a plain viewer; wiring both shows them together. bbox and text stay overlays rather than panels. The contract follows what dora-zed already publishes: float32 [H, W] in millimetres with 0 for missing. It is sent there as a raw byte slice, so the payload arrives as UInt8 despite the Float32 contract and is reinterpreted rather than converted. Auto-ranging the colormap over the valid pixels' full extent, rather than over a percentile of them, is deliberate and was not the first attempt: trimming the tails collapsed on the scene the node exists for. A small object in front of a large wall is a few percent of the pixels, so both tails landed on the wall and the object clipped away. Rendering a frame and looking at it found that; every test passed either way. Verified with dora-cli 1.0.1: validate passes with both contracts injected, the dataflow runs both nodes to a clean exit, and a composed frame was rendered and inspected. --- AGENTS.md | 21 ++++-- README.md | 53 +++++++++----- dataflow.yml | 2 + dora-node.yml | 23 ++++++- dora_image_view/depth.py | 126 ++++++++++++++++++++++++++++++++++ dora_image_view/main.py | 43 ++++++++---- dora_image_view/view.py | 68 +++++++++++++++--- examples/dora-node.yml | 8 ++- examples/source.py | 28 ++++++-- tests/test_dora_image_view.py | 95 ++++++++++++++++++++++++- 10 files changed, 417 insertions(+), 50 deletions(-) create mode 100644 dora_image_view/depth.py diff --git a/AGENTS.md b/AGENTS.md index d2ff4b2..17ac4f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to AI coding assistants when working with code in th ## What this is -A dora-rs Python node that displays a video stream with overlays: it keeps a current frame, bbox set and text string, and every `image` event redraws all three into an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say. +A dora-rs Python node that displays a video stream with overlays: it keeps one panel per frame stream (`image`, `depth`) plus a bbox set and text string, and every frame event redraws the lot as one side-by-side composite in an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say. Everything the example dataflow needs is in this checkout. Do not reintroduce a dependency on another repository — the example must keep running with no camera, no detector and no network. @@ -25,9 +25,10 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. - **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. -- **`dora_image_view/view.py`** — `View` (the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded, which is what makes the node safe in a pipeline test. +- **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one. +- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. Two things are deliberate: `as_float32()` **reinterprets** a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`; and the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. -- **`examples/source.py`** — The synthetic source node: an animated frame, the box around the square in it, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. +- **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. - **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. - **`examples/dora-node.yml`** — Manifest for the source node, and not optional. `dora validate` matches a manifest to a `path:` node by entrypoint; with no manifest beside `examples/source.py`, the root `dora-node.yml` is tried against it, does not match its entrypoint, and warns — which `strict_types: true` turns into a failure. Declaring the source's types here rather than as `output_types:` in `dataflow.yml` is also what makes them *checked*: an inline annotation shadows the manifest, so the manifest would be injected but never compared. - **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. Both nodes are `path:` nodes with a manifest, so both contracts are injected and compared against each other; `bbox` is annotated on neither side, matching the manifests. @@ -41,7 +42,19 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless ## A caveat on typed optional inputs -`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. +`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map at all: `depth` is typed, so the example has to drive it. + +## Checking what it draws + +The gates prove the node does not crash, not that the picture is right — the colormap-ranging defect found while adding depth rendered as a plausible flat panel and passed every test. Compose a frame offline and look at it: + +```python +view = View(headless=True) +view.panels["depth"] = depth.colorize(depth.to_millimetres(storage, metadata)) +cv2.imwrite("/tmp/frame.png", view.compose()) +``` + +`compose()` is public for exactly this: it returns the composite without needing a window. ## Rules diff --git a/README.md b/README.md index f16d00e..34f9fee 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # dora-image-view -Dora node that overlays bounding boxes, labels and text on an image input and displays -it in an OpenCV window. A display sink for debugging a vision pipeline: it produces no -outputs. +Dora node that displays a colour image and a depth map side by side in one OpenCV +window, with bounding box and text overlays. A display sink for debugging a +vision pipeline: it produces no outputs. Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot) from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`. @@ -19,7 +19,8 @@ nodes: build: pip install . path: dora-image-view inputs: - image: camera/image + image: camera/image_bgr + depth: camera/depth bbox: detector/bbox text: detector/text ``` @@ -27,9 +28,11 @@ nodes: Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own managed environment. -Wire `image` from any node publishing one of the encodings below, and `bbox` from any -detector publishing the struct below. Both inputs are optional: an `image` alone is a -plain viewer. +Every input is optional, and each of `image` and `depth` fills one panel of the window, +tiled left to right in that order. Wiring one of them is a plain viewer; +wiring several shows them side by side, scaled to a common height. `bbox` and `text` +are overlays rather than panels — boxes are drawn on the `image` panel, in its own +pixels, and the caption on the composite. ### Runnable example @@ -72,10 +75,11 @@ $ dora validate dataflow.yml All type annotations OK. ``` -`image` is `std/core/v1/UInt8` and `text` is `std/core/v1/String`; the payloads are raw -buffers described by their metadata, so the URN names the element type rather than a -struct. `bbox` is deliberately left untyped — senders disagree on whether its numeric -fields are 32- or 64-bit, and the node reads either. +`image` is `std/core/v1/UInt8`, `depth` is `std/core/v1/Float32` and `text` is +`std/core/v1/String`; the payloads are raw buffers described by their +metadata, so the URN names the element type rather than a struct. `bbox` is deliberately +left untyped — senders disagree on whether its numeric fields are 32- or 64-bit, and the +node reads either. ## Behavior @@ -86,8 +90,9 @@ of that state: bbox (green rectangle plus `label, conf` text), draws the stored text at the top left, resizes to `VIEW_WIDTH`x`VIEW_HEIGHT` if both are set, and shows it in a window titled `Dora Node: dora-image-view`. Press `q` in the window to quit. +- A **`depth`** event colour-maps the distances onto the depth panel and redraws. - A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next - `image` redraw. + redraw. Setting `CI=true` suppresses the display: the node still decodes every input but opens no window. That is what makes it safe to run in a pipeline test. @@ -97,6 +102,7 @@ no window. That is what makes it safe to run in a pipeline test. | Input | Payload | Metadata | |---|---|---| | `image` | Flat `UInt8` array | `width`, `height`, `encoding` | +| `depth` | Flat `Float32` array, `[H, W]` | `shape`, `units` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `text` | Arrow array of size 1 | — | @@ -106,6 +112,11 @@ encodings — the rest carry their own dimensions. An unsupported encoding raise Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`). +`depth` is in millimetres unless `units` is `m`. In a depth map, `0` +and any non-finite value mean missing depth and are drawn black. A payload sent as a +raw byte slice — which is what a C++ node does — arrives as `UInt8` despite the +`Float32` contract, and is reinterpreted rather than converted. + ```python node.send_output("image", pa.array(frame.ravel()), {"width": 640, "height": 480, "encoding": "bgr8"}) @@ -127,11 +138,21 @@ None — it is a display sink. | Variable | Default | Meaning | |---|---|---| -| `VIEW_WIDTH` | image width | Resize the displayed frame to this width, in pixels. | -| `VIEW_HEIGHT` | image height | Resize the displayed frame to this height, in pixels. | +| `VIEW_WIDTH` | composite width | Resize the displayed composite to this width, in pixels. | +| `VIEW_HEIGHT` | composite height | Resize the displayed composite to this height, in pixels. | +| `VIEW_DEPTH_MIN_MM` | auto | Pin the near end of the depth colour range, in millimetres. | +| `VIEW_DEPTH_MAX_MM` | auto | Pin the far end of the depth colour range, in millimetres. | -Resizing only happens when both are set. Both are also available as `--view-width` and -`--view-height`; the environment variable wins when both are given. +Resizing only happens when both `VIEW_WIDTH` and `VIEW_HEIGHT` are set. Both are also +available as `--view-width` and `--view-height`; the environment variable wins when both +are given. + +Each end of the depth range is auto-ranged over the valid pixels of the current frame +unless it is pinned, so pinning one end leaves the other automatic. Auto-ranging makes +an unconfigured sensor legible at the cost of colours that shift from frame to frame; +pin both ends to compare frames against each other. The range covers the full extent of +the valid pixels rather than a percentile of them, so a small near object against a +large wall stays visible. `CI=true` suppresses the display window; it is not a configuration knob. diff --git a/dataflow.yml b/dataflow.yml index cbded38..22731c8 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -19,6 +19,7 @@ nodes: tick: dora/timer/millis/33 outputs: - image + - depth - bbox - text @@ -27,5 +28,6 @@ nodes: build: pip install . inputs: image: source/image + depth: source/depth bbox: source/bbox text: source/text diff --git a/dora-node.yml b/dora-node.yml index 67ae067..f9dc7bf 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -7,9 +7,9 @@ apiVersion: 1 name: dora-image-view namespace: liria -description: Overlays bounding boxes, labels, and text on an image input and displays it in an OpenCV window +description: Displays an image and a depth map side by side in an OpenCV window, with bounding box and text overlays categories: [visualization, debug] -keywords: [opencv, view, bbox, bounding-box, image, visualization, object-detection] +keywords: [opencv, view, bbox, bounding-box, image, depth, visualization, object-detection] runtime: python entrypoint: dora-image-view @@ -41,10 +41,26 @@ inputs: description: >- Text string to overlay at the top-left corner. Stored and drawn on the next image redraw. + depth: + type: std/core/v1/Float32 + required: false + description: >- + Depth map as a flat Float32 buffer, described by the metadata + {shape: [height, width], units}. Millimetres unless `units` says `m`; + 0 and non-finite values are missing depth and draw black. Colour-mapped + into its own panel, which receiving this input redraws. outputs: {} env: + VIEW_DEPTH_MIN_MM: + type: float + description: >- + Pin the near end of the depth colour range, in millimetres. Each end is + auto-ranged over the valid pixels of the current frame when it is unset. + VIEW_DEPTH_MAX_MM: + type: float + description: Pin the far end of the depth colour range, in millimetres. VIEW_WIDTH: type: int description: Resize the displayed frame to this width (pixels). Defaults to the image input width. @@ -59,6 +75,7 @@ example: | build: pip install . path: dora-image-view inputs: - image: camera/image + image: camera/image_bgr + depth: camera/depth bbox: detector/bbox text: detector/text diff --git a/dora_image_view/depth.py b/dora_image_view/depth.py new file mode 100644 index 0000000..86733ce --- /dev/null +++ b/dora_image_view/depth.py @@ -0,0 +1,126 @@ +"""Decoding and colouring of the `depth` input. + +A depth map is a single-channel distance image, not a picture: it is turned into +something displayable by mapping a distance range onto a colormap. Decoding +normalises to float32 millimetres with 0 for missing, so the colouring only ever +sees one representation. +""" + +import os + +import cv2 +import numpy as np + +# Perceptually uniform and readable in both directions, unlike JET. +COLORMAP = cv2.COLORMAP_TURBO + +MISSING = 0.0 + + +def to_millimetres(storage, metadata: dict) -> np.ndarray: + """Decode one `depth` payload into a float32 array of millimetres. + + Missing depth is 0. Non-finite values are normalised to 0 as well, so + everything downstream only has to test for it. + """ + depth = as_float32(storage) + height, width = _hw(metadata, depth.size) + + if depth.size < height * width: + raise RuntimeError( + f"depth payload holds {depth.size} values, " + f"expected at least {height * width} for the declared shape", + ) + + depth = depth[: height * width].reshape(height, width).copy() + depth[~np.isfinite(depth)] = MISSING + + if units(metadata) == "m": + depth *= 1000.0 + + return depth + + +def colorize(depth_mm: np.ndarray) -> np.ndarray: + """Map millimetres onto the colormap, leaving missing depth black.""" + low, high = resolve_range(depth_mm) + + normalised = np.clip((depth_mm - low) / (high - low), 0.0, 1.0) + frame = cv2.applyColorMap((normalised * 255).astype(np.uint8), COLORMAP) + frame[depth_mm <= MISSING] = 0 + return frame + + +def resolve_range(depth_mm: np.ndarray) -> tuple[float, float]: + """Pick the millimetre range to map onto the colormap. + + Each end is taken from the environment if it is set there and auto-ranged + over the valid pixels otherwise, so pinning one end does not force the + other. Auto-ranging re-reads the frame every time, which is what makes an + unconfigured sensor legible and also what makes the colours shift between + frames. + + The auto range is the full extent of the valid pixels, not a percentile of + them. Trimming the tails reads as the safer choice but breaks the scene + this viewer exists for: a small object in front of a large flat wall is a + few percent of the pixels, so both tails land on the wall, the range + collapses, and the object -- the thing being looked at -- clips to one + colour with everything else. + """ + low = _env("VIEW_DEPTH_MIN_MM") + high = _env("VIEW_DEPTH_MAX_MM") + + if low is None or high is None: + valid = depth_mm[depth_mm > MISSING] + auto_low, auto_high = ( + (float(valid.min()), float(valid.max())) if valid.size else (0.0, 1.0) + ) + low = auto_low if low is None else low + high = auto_high if high is None else high + + # An empty or single-valued frame would otherwise divide by zero. + if high <= low: + high = low + 1.0 + + return float(low), float(high) + + +def as_float32(storage) -> np.ndarray: + """Read the payload as float32, whether it arrived as floats or as bytes. + + A node that sends the map as a raw byte slice -- dora-zed does -- produces a + UInt8 arrow array even though its manifest declares Float32, so the buffer + has to be reinterpreted rather than converted. + """ + buffer = storage.to_numpy() + + if buffer.dtype == np.uint8: + return buffer.view(np.float32) + + return buffer.astype(np.float32, copy=False) + + +def _hw(metadata: dict, size: int) -> tuple[int, int]: + shape = metadata.get("shape") + + if shape is not None and len(shape) >= 2: + return int(shape[0]), int(shape[1]) + + height, width = metadata.get("height"), metadata.get("width") + if height and width: + return int(height), int(width) + + raise RuntimeError( + "depth needs a `shape` [height, width] or `height`/`width` metadata; " + f"got a payload of {size} values and neither", + ) + + +def units(metadata: dict) -> str: + """Read the distance unit the sender declared, defaulting to millimetres.""" + return str(metadata.get("units", "mm")).lower() + + +def _env(name: str) -> float | None: + value = os.getenv(name) + return float(value) if value else None diff --git a/dora_image_view/main.py b/dora_image_view/main.py index dbc984f..fb5f0c9 100644 --- a/dora_image_view/main.py +++ b/dora_image_view/main.py @@ -1,15 +1,16 @@ """A dora display sink: overlays bounding boxes and text on an image input. -An `image` event decodes the frame and immediately redraws. `bbox` and `text` -events only update stored state, which the next `image` event draws. +`image` and `depth` each fill one panel of the window and redraw. `bbox` and +`text` events only update stored state, which the next redraw draws. """ import argparse import os +import numpy as np from dora import Node -from dora_image_view import bbox, decode +from dora_image_view import bbox, decode, depth from dora_image_view.view import View @@ -36,14 +37,12 @@ def main() -> None: event_id = event["id"] metadata = event["metadata"] - if event_id == "image": - frame = decode.to_bgr( - event["value"], - metadata.get("encoding", "bgr8").lower(), - metadata.get("width"), - metadata.get("height"), - ) - if view.show(frame): + panel = _decode_panel(event_id, event["value"], metadata) + + if panel is not None: + name, frame = panel + view.panels[name] = frame + if view.show(): break elif event_id == "bbox": view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower()) @@ -53,9 +52,29 @@ def main() -> None: print(f"ignoring unexpected input `{event_id}`") +def _decode_panel(event_id: str, value, metadata: dict) -> tuple[str, np.ndarray] | None: + """Decode a frame-bearing input into the panel name and frame it fills. + + Returns None for an input that carries no frame, which is what tells the + event loop to treat it as overlay state instead. + """ + if event_id == "image": + return "image", decode.to_bgr( + value, + metadata.get("encoding", "bgr8").lower(), + metadata.get("width"), + metadata.get("height"), + ) + + if event_id == "depth": + return "depth", depth.colorize(depth.to_millimetres(value, metadata)) + + return None + + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="OpenCV plotter: draws text and bounding boxes on an image.", + description="OpenCV viewer: image and depth, with bbox and text overlays.", ) parser.add_argument( "--name", diff --git a/dora_image_view/view.py b/dora_image_view/view.py index dd66d14..bfed187 100644 --- a/dora_image_view/view.py +++ b/dora_image_view/view.py @@ -1,4 +1,4 @@ -"""The display state: the frame, the overlays drawn on it, and the window.""" +"""The display state: the panels, the overlays drawn on them, and the window.""" from dataclasses import dataclass, field @@ -11,6 +11,10 @@ WHITE = (255, 255, 255) FONT = cv2.FONT_HERSHEY_SIMPLEX FONT_SCALE = 0.5 +# Left to right in the composite. Fixed rather than insertion-ordered so a panel +# does not move when a stream drops a frame. +PANELS = ("image", "depth") + @dataclass class Bboxes: @@ -23,9 +27,11 @@ class Bboxes: @dataclass class View: - """Overlay state, plus the window it is displayed in. + """Panel state, plus the window the panels are displayed in. - `headless` suppresses the window; the node still decodes every input. + Each panel holds the most recent frame of one stream, as BGR. They are + tiled left to right, so wiring only `depth` shows only depth. `headless` + suppresses the window; the node still decodes every input. """ width: int | None = None @@ -33,13 +39,17 @@ class View: headless: bool = False bboxes: Bboxes = field(default_factory=Bboxes) text: str = "" + panels: dict[str, np.ndarray] = field(default_factory=dict) - def show(self, frame: np.ndarray) -> bool: - """Draw the overlays on `frame`, resize it, and display it. + def show(self) -> bool: + """Draw the overlays, tile the panels, and display them. Returns whether the viewer asked to quit by pressing `q`. """ - self._draw(frame) + frame = self.compose() + + if frame is None: + return False if self.width and self.height: frame = cv2.resize(frame, (self.width, self.height)) @@ -50,7 +60,42 @@ class View: cv2.imshow(WINDOW, frame) return cv2.waitKey(1) & 0xFF == ord("q") - def _draw(self, frame: np.ndarray) -> None: + def compose(self) -> np.ndarray | None: + """Tile the populated panels into one frame, tallest panel setting the height.""" + panels = [(name, self.panels[name]) for name in PANELS if name in self.panels] + + if not panels: + return None + + # The boxes are in image pixels, so they are drawn before any scaling. + if "image" in self.panels: + self._draw_bboxes(self.panels["image"]) + + tallest = max(panel.shape[0] for _, panel in panels) + scaled = [self._label(_scale(panel, tallest), name) for name, panel in panels] + + frame = scaled[0] if len(scaled) == 1 else cv2.hconcat(scaled) + + # On the composite, not on a panel: the caption describes the frame as a + # whole, and a panel it was drawn on might not be the leftmost one. + cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1) + return frame + + def _label(self, panel: np.ndarray, name: str) -> np.ndarray: + """Name a panel, so two side by side are not read as one picture.""" + if len(self.panels) > 1: + cv2.putText( + panel, + name, + (10, panel.shape[0] - 10), + FONT, + FONT_SCALE, + WHITE, + 1, + ) + return panel + + def _draw_bboxes(self, frame: np.ndarray) -> None: for corners, conf, label in zip( self.bboxes.corners, self.bboxes.conf, @@ -69,4 +114,11 @@ class View: 1, ) - cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1) + +def _scale(panel: np.ndarray, height: int) -> np.ndarray: + """Match a panel to the composite height, keeping its aspect ratio.""" + if panel.shape[0] == height: + return panel + + width = max(round(panel.shape[1] * height / panel.shape[0]), 1) + return cv2.resize(panel, (width, height)) diff --git a/examples/dora-node.yml b/examples/dora-node.yml index 689e872..93439cf 100644 --- a/examples/dora-node.yml +++ b/examples/dora-node.yml @@ -12,7 +12,7 @@ apiVersion: 1 name: source namespace: liria -description: Synthetic image, bbox and text source for the dora-image-view example +description: Synthetic image, depth, bbox and text source for the dora-image-view example categories: [debug] keywords: [example, test, synthetic] @@ -39,6 +39,12 @@ outputs: text: type: std/core/v1/String description: A fixed caption. + depth: + type: std/core/v1/Float32 + description: >- + Depth of the same scene in millimetres, [height, width]: a flat wall with + the square standing in front of it. + env: SOURCE_WIDTH: diff --git a/examples/source.py b/examples/source.py index 6a8f799..704b741 100644 --- a/examples/source.py +++ b/examples/source.py @@ -1,8 +1,11 @@ """Synthetic source node: this repository's stand-in for a camera and a detector. -Emits an animated frame, the box around the square moving in it, and a caption -- -the three inputs dora-image-view draws -- so the example dataflow needs no camera, no -detector and no network. +Emits an animated frame, a depth map of the same scene, the box around the square +moving in it, and a caption -- every input dora-image-view draws -- so the example +dataflow needs no camera, no detector and no network. + +The depth map describes the same scene as the image: a flat back wall with the square +standing in front of it. """ import os @@ -21,9 +24,12 @@ BOX = 96 SPEED = 6 CAPTION = "dora-image-view synthetic source" +WALL_MM = 2000.0 +SQUARE_MM = 800.0 + def main() -> None: - """Send one image, bbox and text per tick.""" + """Send one image, depth map, bbox and text per tick.""" node = Node("source") sent = 0 @@ -46,6 +52,12 @@ def main() -> None: }]), {"format": "xyxy"}, ) + depth = render_depth(corners) + node.send_output( + "depth", + pa.array(depth.ravel()), + {"shape": [HEIGHT, WIDTH], "dtype": "float32", "layout": "HW", "units": "mm"}, + ) node.send_output("text", pa.array([CAPTION])) sent += 1 @@ -69,5 +81,13 @@ def render(index: int) -> tuple[np.ndarray, np.ndarray]: return frame, corners +def render_depth(corners: np.ndarray) -> np.ndarray: + """Depth of the scene in `render`: a flat wall with the square in front of it.""" + depth = np.full((HEIGHT, WIDTH), WALL_MM, dtype=np.float32) + left, top, right, bottom = (int(value) for value in corners) + depth[top:bottom, left:right] = SQUARE_MM + return depth + + if __name__ == "__main__": main() diff --git a/tests/test_dora_image_view.py b/tests/test_dora_image_view.py index 429db69..1fe81be 100644 --- a/tests/test_dora_image_view.py +++ b/tests/test_dora_image_view.py @@ -7,7 +7,7 @@ import numpy as np import pytest from PIL import Image -from dora_image_view import bbox, decode +from dora_image_view import bbox, decode, depth from dora_image_view.view import Bboxes, View WIDTH, HEIGHT = 12, 8 @@ -130,6 +130,97 @@ def test_headless_show_draws_without_a_window(): labels=np.array(["cat"]), ) frame = np.zeros((32, 32, 3), dtype=np.uint8) + view.panels["image"] = frame - assert view.show(frame) is False + assert view.show() is False assert frame.any(), "the overlays should have marked the frame" + + +def test_show_with_no_panels_is_a_no_op(): + assert View(headless=True).show() is False + + +def test_panels_are_tiled_left_to_right_at_a_common_height(): + view = View(headless=True) + view.panels["image"] = np.zeros((20, 10, 3), dtype=np.uint8) + # Half the height and half the width: scaling to the common height should + # double both, so it contributes 20 columns, not 10. + view.panels["depth"] = np.zeros((10, 10, 3), dtype=np.uint8) + + composite = view.compose() + + assert composite.shape[0] == 20 + assert composite.shape[1] == 10 + 20 + + +def test_depth_decodes_millimetres_and_reshapes(): + values = np.array([100.0, 200.0, 300.0, 400.0], dtype=np.float32) + + decoded = depth.to_millimetres(FakeStorage(values), {"shape": [2, 2]}) + + np.testing.assert_allclose(decoded, [[100.0, 200.0], [300.0, 400.0]]) + + +def test_depth_reinterprets_a_float32_payload_sent_as_bytes(): + values = np.array([1500.0, 2500.0], dtype=np.float32) + + decoded = depth.to_millimetres( + FakeStorage(values.view(np.uint8)), + {"shape": [1, 2], "dtype": "float32"}, + ) + + np.testing.assert_allclose(decoded, [[1500.0, 2500.0]]) + + +def test_depth_converts_metres_to_millimetres(): + decoded = depth.to_millimetres( + FakeStorage(np.array([1.5, 2.0], dtype=np.float32)), + {"shape": [1, 2], "units": "m"}, + ) + + np.testing.assert_allclose(decoded, [[1500.0, 2000.0]]) + + +def test_depth_normalises_non_finite_values_to_missing(): + values = np.array([np.inf, np.nan, 500.0, 0.0], dtype=np.float32) + + decoded = depth.to_millimetres(FakeStorage(values), {"shape": [2, 2]}) + + np.testing.assert_allclose(decoded, [[0.0, 0.0], [500.0, 0.0]]) + + +def test_depth_without_a_shape_is_an_error(): + with pytest.raises(RuntimeError, match="shape"): + depth.to_millimetres(FakeStorage(np.zeros(4, dtype=np.float32)), {}) + + +def test_colorize_leaves_missing_depth_black_and_colours_the_rest(): + depth_mm = np.array([[0.0, 500.0], [1000.0, 1500.0]], dtype=np.float32) + + frame = depth.colorize(depth_mm) + + assert frame.shape == (2, 2, 3) + np.testing.assert_array_equal(frame[0, 0], [0, 0, 0]) + assert frame[1, 1].any(), "valid depth should be coloured" + + +def test_depth_range_is_pinned_by_the_environment(monkeypatch): + monkeypatch.setenv("VIEW_DEPTH_MIN_MM", "200") + monkeypatch.setenv("VIEW_DEPTH_MAX_MM", "600") + + assert depth.resolve_range(np.array([[1000.0]], dtype=np.float32)) == (200.0, 600.0) + + +def test_one_pinned_end_leaves_the_other_auto_ranged(monkeypatch): + monkeypatch.setenv("VIEW_DEPTH_MIN_MM", "0") + + low, high = depth.resolve_range(np.array([[100.0, 900.0]], dtype=np.float32)) + + assert low == 0.0 + assert high > 0.0 + + +def test_depth_range_of_an_empty_frame_does_not_divide_by_zero(): + low, high = depth.resolve_range(np.zeros((2, 2), dtype=np.float32)) + + assert high > low -- 2.54.0 From 5a0f13d4dbe77766204d68281c3bea6c68ad02c2 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:09:07 -0300 Subject: [PATCH 02/14] add camera_info input for the pinhole model Nothing in a dataflow carries fx, fy, cx and cy except the camera, and every pixel of a depth map needs them to become a 3D point. The node now accepts them so whatever computes that next has them to hand. They are read from two places because dora-zed publishes them twice: the camera_info message, and the metadata of every frame. The per-frame copy is not redundant -- camera_info goes out once per camera session, so a viewer started after the camera would otherwise never see it, and reading frame metadata means wiring the input is optional even for a camera that publishes it. A nine-element row-major K is accepted alongside the compact [fx, fy, cx, cy], since that is how ROS spells the same model in sensor_msgs/CameraInfo. The model is drawn under the caption rather than only stored. A depth map read through the wrong intrinsics looks entirely plausible, and this is where that becomes visible. as_float32 moves to a payload module: camera_info arrives as a raw byte slice for the same reason the depth map does, and the reinterpretation is now shared rather than written twice. Verified with dora-cli 1.0.1: validate passes with four contracts injected on each side, the dataflow runs to a clean exit, and a composed frame was rendered and inspected with the model drawn on it. --- AGENTS.md | 8 ++-- README.md | 28 +++++++++-- dataflow.yml | 2 + dora-node.yml | 16 ++++++- dora_image_view/depth.py | 17 +------ dora_image_view/intrinsics.py | 89 +++++++++++++++++++++++++++++++++++ dora_image_view/main.py | 14 ++++-- dora_image_view/payload.py | 18 +++++++ dora_image_view/view.py | 10 ++++ examples/dora-node.yml | 7 ++- examples/source.py | 31 ++++++++++-- tests/test_dora_image_view.py | 61 +++++++++++++++++++++++- 12 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 dora_image_view/intrinsics.py create mode 100644 dora_image_view/payload.py diff --git a/AGENTS.md b/AGENTS.md index 17ac4f7..04319fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to AI coding assistants when working with code in th ## What this is -A dora-rs Python node that displays a video stream with overlays: it keeps one panel per frame stream (`image`, `depth`) plus a bbox set and text string, and every frame event redraws the lot as one side-by-side composite in an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say. +A dora-rs Python node that displays a video stream with overlays: it keeps one panel per frame stream (`image`, `depth`) plus a bbox set, a text string and the camera model, and every frame event redraws the lot as one side-by-side composite in an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say. Everything the example dataflow needs is in this checkout. Do not reintroduce a dependency on another repository — the example must keep running with no camera, no detector and no network. @@ -26,7 +26,9 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. - **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. - **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one. -- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. Two things are deliberate: `as_float32()` **reinterprets** a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`; and the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. +- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. +- **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. +- **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible. - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. - **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. - **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. @@ -42,7 +44,7 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless ## A caveat on typed optional inputs -`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map at all: `depth` is typed, so the example has to drive it. +`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map and a camera_info at all: both are typed, so the example has to drive them. ## Checking what it draws diff --git a/README.md b/README.md index 34f9fee..c54d76a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # dora-image-view Dora node that displays a colour image and a depth map side by side in one OpenCV -window, with bounding box and text overlays. A display sink for debugging a +window, with bounding box, text and camera intrinsics overlays. A display sink for debugging a vision pipeline: it produces no outputs. Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot) @@ -21,6 +21,7 @@ nodes: inputs: image: camera/image_bgr depth: camera/depth + camera_info: camera/camera_info bbox: detector/bbox text: detector/text ``` @@ -30,7 +31,7 @@ managed environment. Every input is optional, and each of `image` and `depth` fills one panel of the window, tiled left to right in that order. Wiring one of them is a plain viewer; -wiring several shows them side by side, scaled to a common height. `bbox` and `text` +wiring several shows them side by side, scaled to a common height. `bbox`, `text` and `camera_info` are overlays rather than panels — boxes are drawn on the `image` panel, in its own pixels, and the caption on the composite. @@ -75,8 +76,8 @@ $ dora validate dataflow.yml All type annotations OK. ``` -`image` is `std/core/v1/UInt8`, `depth` is `std/core/v1/Float32` and `text` is -`std/core/v1/String`; the payloads are raw buffers described by their +`image` is `std/core/v1/UInt8`, `depth` and `camera_info` are `std/core/v1/Float32` and +`text` is `std/core/v1/String`; the payloads are raw buffers described by their metadata, so the URN names the element type rather than a struct. `bbox` is deliberately left untyped — senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. @@ -91,6 +92,7 @@ of that state: resizes to `VIEW_WIDTH`x`VIEW_HEIGHT` if both are set, and shows it in a window titled `Dora Node: dora-image-view`. Press `q` in the window to quit. - A **`depth`** event colour-maps the distances onto the depth panel and redraws. +- A **`camera_info`** event stores the pinhole model and draws it under the caption. - A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next redraw. @@ -103,6 +105,7 @@ no window. That is what makes it safe to run in a pipeline test. |---|---|---| | `image` | Flat `UInt8` array | `width`, `height`, `encoding` | | `depth` | Flat `Float32` array, `[H, W]` | `shape`, `units` | +| `camera_info` | Flat `Float32` array, `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `text` | Arrow array of size 1 | — | @@ -117,6 +120,23 @@ and any non-finite value mean missing depth and are drawn black. A payload sent raw byte slice — which is what a C++ node does — arrives as `UInt8` despite the `Float32` contract, and is reinterpreted rather than converted. +### Camera intrinsics + +`camera_info` carries the pinhole model the depth map is interpreted through, either as +the four values `[fx, fy, cx, cy]` or as a nine-element row-major `K`, which is how ROS +spells the same model in `sensor_msgs/CameraInfo`. Distortion coefficients are read from +the `distortion` metadata. + +The node displays the model and holds it. It needs none of it to draw a panel; it is +there for what reads it next — turning a depth map into a point cloud needs `fx`, `fy`, +`cx` and `cy` for every pixel, and nothing else in a dataflow carries them. + +Intrinsics are also picked up from the metadata of any `image` or `depth` frame that +carries `fx`, `fy`, `cx` and `cy`, so **wiring `camera_info` is optional even for a +camera that publishes it**. That matters because a camera typically publishes +`camera_info` once per session: a viewer started after the camera would otherwise never +see it. + ```python node.send_output("image", pa.array(frame.ravel()), {"width": 640, "height": 480, "encoding": "bgr8"}) diff --git a/dataflow.yml b/dataflow.yml index 22731c8..c94f3d4 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -20,6 +20,7 @@ nodes: outputs: - image - depth + - camera_info - bbox - text @@ -29,5 +30,6 @@ nodes: inputs: image: source/image depth: source/depth + camera_info: source/camera_info bbox: source/bbox text: source/text diff --git a/dora-node.yml b/dora-node.yml index f9dc7bf..0c29cad 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -7,9 +7,9 @@ apiVersion: 1 name: dora-image-view namespace: liria -description: Displays an image and a depth map side by side in an OpenCV window, with bounding box and text overlays +description: Displays an image and a depth map side by side in an OpenCV window, with bounding box, text and camera intrinsics overlays categories: [visualization, debug] -keywords: [opencv, view, bbox, bounding-box, image, depth, visualization, object-detection] +keywords: [opencv, view, bbox, bounding-box, image, depth, intrinsics, camera-info, visualization, object-detection] runtime: python entrypoint: dora-image-view @@ -41,6 +41,17 @@ inputs: description: >- Text string to overlay at the top-left corner. Stored and drawn on the next image redraw. + camera_info: + type: std/core/v1/Float32 + required: false + description: >- + The pinhole model the depth map is interpreted through: [fx, fy, cx, cy], + or a nine-element row-major K as ROS spells it in sensor_msgs/CameraInfo. + Distortion coefficients are read from the `distortion` metadata. Stored + and displayed, and held for readers that turn depth into 3D. A sender + that also puts fx/fy/cx/cy in each frame's metadata does not need to wire + this input -- they are picked up from there, which is what covers a + camera that published its intrinsics before this node started. depth: type: std/core/v1/Float32 required: false @@ -77,5 +88,6 @@ example: | inputs: image: camera/image_bgr depth: camera/depth + camera_info: camera/camera_info bbox: detector/bbox text: detector/text diff --git a/dora_image_view/depth.py b/dora_image_view/depth.py index 86733ce..a75aeed 100644 --- a/dora_image_view/depth.py +++ b/dora_image_view/depth.py @@ -11,6 +11,8 @@ import os import cv2 import numpy as np +from dora_image_view.payload import as_float32 + # Perceptually uniform and readable in both directions, unlike JET. COLORMAP = cv2.COLORMAP_TURBO @@ -85,21 +87,6 @@ def resolve_range(depth_mm: np.ndarray) -> tuple[float, float]: return float(low), float(high) -def as_float32(storage) -> np.ndarray: - """Read the payload as float32, whether it arrived as floats or as bytes. - - A node that sends the map as a raw byte slice -- dora-zed does -- produces a - UInt8 arrow array even though its manifest declares Float32, so the buffer - has to be reinterpreted rather than converted. - """ - buffer = storage.to_numpy() - - if buffer.dtype == np.uint8: - return buffer.view(np.float32) - - return buffer.astype(np.float32, copy=False) - - def _hw(metadata: dict, size: int) -> tuple[int, int]: shape = metadata.get("shape") diff --git a/dora_image_view/intrinsics.py b/dora_image_view/intrinsics.py new file mode 100644 index 0000000..81868fb --- /dev/null +++ b/dora_image_view/intrinsics.py @@ -0,0 +1,89 @@ +"""The pinhole camera model a depth map is interpreted through. + +The node does not need intrinsics to draw anything -- it displays them, and +holds them for whatever reads them next. Back-projecting a depth map into a +point cloud is the obvious such reader: every pixel needs fx, fy, cx and cy to +become an XYZ point, and nothing else in a dataflow carries them. + +Two senders are handled because dora-zed publishes the same numbers twice: once +per camera session on `camera_info`, and again in the metadata of every frame. +The per-frame copy is what makes a viewer that started after the camera did +still end up with intrinsics. +""" + +from dataclasses import dataclass + +from dora_image_view.payload import as_float32 + +# [fx, fy, cx, cy], the four numbers dora-zed publishes. Also accepted is a +# nine-element row-major K, which is how ROS spells the same model in +# sensor_msgs/CameraInfo. +COMPACT = 4 +K_MATRIX = 9 + +# Indices of fx, fy, cx, cy in a row-major K: +# [fx, 0, cx, +# 0, fy, cy, +# 0, 0, 1] +K_INDICES = (0, 4, 2, 5) + + +@dataclass(frozen=True) +class Intrinsics: + """A pinhole model: focal lengths and principal point, both in pixels.""" + + fx: float + fy: float + cx: float + cy: float + distortion: tuple[float, ...] = () + model: str = "" + + def __str__(self) -> str: + """Render for the overlay, short enough to fit across a panel.""" + return f"fx {self.fx:.1f} fy {self.fy:.1f} cx {self.cx:.1f} cy {self.cy:.1f}" + + +def parse(storage, metadata: dict) -> Intrinsics: + """Read one `camera_info` payload. + + Accepts the four-element [fx, fy, cx, cy] dora-zed sends and the + nine-element row-major K that ROS does. + """ + values = as_float32(storage) + + if values.size >= K_MATRIX: + fx, fy, cx, cy = (float(values[index]) for index in K_INDICES) + elif values.size >= COMPACT: + fx, fy, cx, cy = (float(value) for value in values[:COMPACT]) + else: + raise RuntimeError( + f"camera_info holds {values.size} values, " + f"expected {COMPACT} as [fx, fy, cx, cy] or {K_MATRIX} as K", + ) + + return Intrinsics(fx, fy, cx, cy, _distortion(metadata), _model(metadata)) + + +def from_metadata(metadata: dict) -> Intrinsics | None: + """Read intrinsics a sender attached to a frame, or None if it attached none.""" + try: + fx, fy, cx, cy = ( + float(metadata["fx"]), + float(metadata["fy"]), + float(metadata["cx"]), + float(metadata["cy"]), + ) + except (KeyError, TypeError, ValueError): + return None + + return Intrinsics(fx, fy, cx, cy, _distortion(metadata), _model(metadata)) + + +def _distortion(metadata: dict) -> tuple[float, ...]: + values = metadata.get("distortion") or () + return tuple(float(value) for value in values) + + +def _model(metadata: dict) -> str: + return str(metadata.get("distortion_model", "")) diff --git a/dora_image_view/main.py b/dora_image_view/main.py index fb5f0c9..3b052ed 100644 --- a/dora_image_view/main.py +++ b/dora_image_view/main.py @@ -1,7 +1,8 @@ """A dora display sink: overlays bounding boxes and text on an image input. -`image` and `depth` each fill one panel of the window and redraw. `bbox` and -`text` events only update stored state, which the next redraw draws. +`image` and `depth` each fill one panel of the window and redraw. `bbox`, +`text` and `camera_info` events only update stored state, which the next redraw +draws. """ import argparse @@ -10,7 +11,7 @@ import os import numpy as np from dora import Node -from dora_image_view import bbox, decode, depth +from dora_image_view import bbox, decode, depth, intrinsics from dora_image_view.view import View @@ -37,6 +38,11 @@ def main() -> None: event_id = event["id"] metadata = event["metadata"] + # A sender that puts intrinsics on every frame keeps them current even + # if the camera_info message itself was published before this node + # started, which is when dora-zed sends it. + view.intrinsics = intrinsics.from_metadata(metadata) or view.intrinsics + panel = _decode_panel(event_id, event["value"], metadata) if panel is not None: @@ -44,6 +50,8 @@ def main() -> None: view.panels[name] = frame if view.show(): break + elif event_id == "camera_info": + view.intrinsics = intrinsics.parse(event["value"], metadata) elif event_id == "bbox": view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower()) elif event_id == "text": diff --git a/dora_image_view/payload.py b/dora_image_view/payload.py new file mode 100644 index 0000000..2c44b3e --- /dev/null +++ b/dora_image_view/payload.py @@ -0,0 +1,18 @@ +"""Reading raw dora payloads, independently of what they mean.""" + +import numpy as np + + +def as_float32(storage) -> np.ndarray: + """Read a payload as float32, whether it arrived as floats or as bytes. + + A node that sends its payload as a raw byte slice -- which is what a C++ + node does -- produces a UInt8 arrow array even though its manifest declares + Float32, so the buffer has to be reinterpreted rather than converted. + """ + buffer = storage.to_numpy() + + if buffer.dtype == np.uint8: + return buffer.view(np.float32) + + return buffer.astype(np.float32, copy=False) diff --git a/dora_image_view/view.py b/dora_image_view/view.py index bfed187..3b5fb13 100644 --- a/dora_image_view/view.py +++ b/dora_image_view/view.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field import cv2 import numpy as np +from dora_image_view.intrinsics import Intrinsics + WINDOW = "Dora Node: dora-image-view" GREEN = (0, 255, 0) WHITE = (255, 255, 255) @@ -40,6 +42,7 @@ class View: bboxes: Bboxes = field(default_factory=Bboxes) text: str = "" panels: dict[str, np.ndarray] = field(default_factory=dict) + intrinsics: Intrinsics | None = None def show(self) -> bool: """Draw the overlays, tile the panels, and display them. @@ -79,6 +82,13 @@ class View: # On the composite, not on a panel: the caption describes the frame as a # whole, and a panel it was drawn on might not be the leftmost one. cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1) + + # Showing the camera model is the point of receiving it here: a depth + # map read through the wrong intrinsics looks entirely plausible, and + # this is where that becomes visible. + if self.intrinsics is not None: + cv2.putText(frame, str(self.intrinsics), (20, 40), FONT, FONT_SCALE, WHITE, 1) + return frame def _label(self, panel: np.ndarray, name: str) -> np.ndarray: diff --git a/examples/dora-node.yml b/examples/dora-node.yml index 93439cf..d1ad921 100644 --- a/examples/dora-node.yml +++ b/examples/dora-node.yml @@ -12,7 +12,7 @@ apiVersion: 1 name: source namespace: liria -description: Synthetic image, depth, bbox and text source for the dora-image-view example +description: Synthetic image, depth, camera_info, bbox and text source for the dora-image-view example categories: [debug] keywords: [example, test, synthetic] @@ -39,6 +39,11 @@ outputs: text: type: std/core/v1/String description: A fixed caption. + camera_info: + type: std/core/v1/Float32 + description: >- + [fx, fy, cx, cy] for the frames above. Published once, on the first tick, + the way a real camera publishes it once per session. depth: type: std/core/v1/Float32 description: >- diff --git a/examples/source.py b/examples/source.py index 704b741..75935f7 100644 --- a/examples/source.py +++ b/examples/source.py @@ -5,7 +5,9 @@ moving in it, and a caption -- every input dora-image-view draws -- so the examp dataflow needs no camera, no detector and no network. The depth map describes the same scene as the image: a flat back wall with the square -standing in front of it. +standing in front of it. `camera_info` is published once, on the first tick, the way a +real camera publishes it once per session -- which is exactly the case the viewer has +to cope with by reading intrinsics from frame metadata as well. """ import os @@ -27,9 +29,17 @@ CAPTION = "dora-image-view synthetic source" WALL_MM = 2000.0 SQUARE_MM = 800.0 +# A plausible pinhole for a frame this size. +FX = FY = float(WIDTH) +CX, CY = WIDTH / 2, HEIGHT / 2 +DISTORTION = [0.0, 0.0, 0.0, 0.0, 0.0] +# Attached to every frame as well as published once, which is what dora-zed does +# and what lets a viewer that started late still learn the camera model. +INTRINSICS_METADATA = {"fx": FX, "fy": FY, "cx": CX, "cy": CY, "distortion": DISTORTION} + def main() -> None: - """Send one image, depth map, bbox and text per tick.""" + """Send one image, depth map, bbox and text per tick, and camera_info once.""" node = Node("source") sent = 0 @@ -37,11 +47,18 @@ def main() -> None: if event["type"] != "INPUT": continue + if sent == 0: + node.send_output( + "camera_info", + pa.array(np.array([FX, FY, CX, CY], dtype=np.float32)), + {"shape": [4], "dtype": "float32", "layout": "C", "distortion": DISTORTION}, + ) + frame, corners = render(sent) node.send_output( "image", pa.array(frame.ravel()), - {"width": WIDTH, "height": HEIGHT, "encoding": "bgr8"}, + {"width": WIDTH, "height": HEIGHT, "encoding": "bgr8", **INTRINSICS_METADATA}, ) node.send_output( "bbox", @@ -56,7 +73,13 @@ def main() -> None: node.send_output( "depth", pa.array(depth.ravel()), - {"shape": [HEIGHT, WIDTH], "dtype": "float32", "layout": "HW", "units": "mm"}, + { + "shape": [HEIGHT, WIDTH], + "dtype": "float32", + "layout": "HW", + "units": "mm", + **INTRINSICS_METADATA, + }, ) node.send_output("text", pa.array([CAPTION])) diff --git a/tests/test_dora_image_view.py b/tests/test_dora_image_view.py index 1fe81be..e12e9c9 100644 --- a/tests/test_dora_image_view.py +++ b/tests/test_dora_image_view.py @@ -7,7 +7,7 @@ import numpy as np import pytest from PIL import Image -from dora_image_view import bbox, decode, depth +from dora_image_view import bbox, decode, depth, intrinsics from dora_image_view.view import Bboxes, View WIDTH, HEIGHT = 12, 8 @@ -224,3 +224,62 @@ def test_depth_range_of_an_empty_frame_does_not_divide_by_zero(): low, high = depth.resolve_range(np.zeros((2, 2), dtype=np.float32)) assert high > low + + +def test_camera_info_reads_the_compact_four_value_form(): + values = np.array([600.0, 601.0, 320.0, 240.0], dtype=np.float32) + + model = intrinsics.parse(FakeStorage(values), {"distortion": [0.1, 0.2]}) + + assert (model.fx, model.fy, model.cx, model.cy) == (600.0, 601.0, 320.0, 240.0) + assert model.distortion == (0.1, 0.2) + + +def test_camera_info_reads_a_ros_k_matrix(): + # Row-major K: [fx, 0, cx, 0, fy, cy, 0, 0, 1]. + k = np.array( + [600.0, 0.0, 320.0, 0.0, 601.0, 240.0, 0.0, 0.0, 1.0], + dtype=np.float32, + ) + + model = intrinsics.parse(FakeStorage(k), {}) + + assert (model.fx, model.fy, model.cx, model.cy) == (600.0, 601.0, 320.0, 240.0) + + +def test_camera_info_reinterprets_a_payload_sent_as_bytes(): + values = np.array([600.0, 601.0, 320.0, 240.0], dtype=np.float32) + + model = intrinsics.parse(FakeStorage(values.view(np.uint8)), {}) + + assert model.fx == 600.0 + assert model.cy == 240.0 + + +def test_camera_info_that_is_too_short_is_an_error(): + with pytest.raises(RuntimeError, match="camera_info"): + intrinsics.parse(FakeStorage(np.zeros(3, dtype=np.float32)), {}) + + +def test_intrinsics_are_read_from_frame_metadata(): + model = intrinsics.from_metadata( + {"fx": 600.0, "fy": 601.0, "cx": 320.0, "cy": 240.0}, + ) + + assert model is not None + assert (model.fx, model.cy) == (600.0, 240.0) + + +def test_frame_metadata_without_intrinsics_yields_none(): + assert intrinsics.from_metadata({"width": 640, "height": 480}) is None + + +def test_the_camera_model_is_drawn_on_the_composite(): + view = View(headless=True) + view.panels["image"] = np.zeros((40, 80, 3), dtype=np.uint8) + + without = view.compose().copy() + view.intrinsics = intrinsics.Intrinsics(600.0, 601.0, 320.0, 240.0) + with_model = view.compose() + + assert not np.array_equal(without, with_model), "the model should be drawn" -- 2.54.0 From 1c7a9c816d550346121ca01be9a03fc00cb9dcc4 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:17:33 -0300 Subject: [PATCH 03/14] docs: cite the manifest-next-to-a-path-node rule The comment on examples/dora-node.yml read as an apology for a workaround. It is the documented mechanism: a manifest describes exactly one node, and one sitting next to a plain `path:` node is picked up automatically, which is how a node gets contract validation without being published. --- AGENTS.md | 2 +- examples/dora-node.yml | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 04319fb..cbe5614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. - **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. - **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. -- **`examples/dora-node.yml`** — Manifest for the source node, and not optional. `dora validate` matches a manifest to a `path:` node by entrypoint; with no manifest beside `examples/source.py`, the root `dora-node.yml` is tried against it, does not match its entrypoint, and warns — which `strict_types: true` turns into a failure. Declaring the source's types here rather than as `output_types:` in `dataflow.yml` is also what makes them *checked*: an inline annotation shadows the manifest, so the manifest would be injected but never compared. +- **`examples/dora-node.yml`** — Manifest for the source node, and not optional. A manifest describes exactly one node (the schema is `NodeManifest`, singular), so the second node in this repository gets a second manifest; a `dora-node.yml` next to a plain `path:` node is [the documented way](https://dora-rs.ai/dora/hub/node-manifest) to type a node without publishing it. `dora validate` matches a manifest to a `path:` node by entrypoint; with no manifest beside `examples/source.py`, the root `dora-node.yml` is tried against it, does not match its entrypoint, and warns — which `strict_types: true` turns into a failure. Declaring the source's types here rather than as `output_types:` in `dataflow.yml` is also what makes them *checked*: an inline annotation shadows the manifest, so the manifest would be injected but never compared. - **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. Both nodes are `path:` nodes with a manifest, so both contracts are injected and compared against each other; `bbox` is annotated on neither side, matching the manifests. - **`.gitea/workflows/validate.yml`** — CI: lints, runs the unit tests, validates the dataflow and runs it headless. Needs no camera and no display. One job on purpose — the gates themselves take about a second, and are dwarfed by preparing the runner. diff --git a/examples/dora-node.yml b/examples/dora-node.yml index d1ad921..354c903 100644 --- a/examples/dora-node.yml +++ b/examples/dora-node.yml @@ -1,10 +1,14 @@ # Manifest for the example source node. # -# Its reason to exist is as much mechanical as documentary: `dora validate` -# matches a node manifest to a `path:` node by entrypoint, and without a -# manifest here the repository-root dora-node.yml -- whose entrypoint is -# `dora-image-view` -- gets tried against `examples/source.py`, does not match, -# and warns. Under `strict_types: true` that warning fails the run. +# A manifest describes exactly one node, so a second node in this repository +# gets a second manifest. This is the documented way to type a node you have +# not published: "a dora-node.yml sitting next to a plain `path:` node is +# picked up automatically" -- https://dora-rs.ai/dora/hub/node-manifest +# +# It is not optional here. `dora validate` matches a manifest to a `path:` node +# by entrypoint; with no manifest beside examples/source.py the repository-root +# one is tried against it, does not match its `dora-image-view` entrypoint, and +# warns -- which `strict_types: true` turns into a failure. # # Declaring the types here rather than as `output_types:` in dataflow.yml is # what makes them checked: an inline annotation shadows the manifest, so the @@ -50,7 +54,6 @@ outputs: Depth of the same scene in millimetres, [height, width]: a flat wall with the square standing in front of it. - env: SOURCE_WIDTH: type: int -- 2.54.0 From 92abe8a446b34b194bb5e7ab23c92456223fc669 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:21:36 -0300 Subject: [PATCH 04/14] refactor: type the example source inline instead of shipping a manifest A dora-node.yml describes exactly one node and is the unit `dora hub publish` operates on, which expects it beside pyproject.toml. This repository ships one node, so it should carry one manifest. examples/dora-node.yml described a test fixture that nobody installs and that has no package of its own -- a manifest for something that is not a node. The source's outputs are now annotated with `output_types:` in the dataflow, which is the documented way to type an output without a manifest: "only annotated outputs are type-checked". It runs as `path: python` + `args:` because `dora validate` matches a manifest to a `path:` node by entrypoint, so a path node would be tried against the root manifest, fail to match `dora-image-view`, and warn -- fatal under strict_types. Through the interpreter there is nothing to match. The type gate is unchanged in strength: the viewer's contract is still injected and compared against the source's annotations, and a deliberate Float32 -> String swap still fails validate. The cost is that the fixture no longer gets a managed venv of its own and runs against the project environment. That applies to the fixture only; the shipped node still uses path: + build:, which is what a consumer exercises. This supersedes the approach in "fix strict-type validation by giving the example source a manifest" and the doc commit that cited the manifest-next-to-a-path-node rule as justification. --- AGENTS.md | 9 ++++-- README.md | 13 +++++---- dataflow.yml | 23 ++++++++++++++- examples/dora-node.yml | 66 ------------------------------------------ 4 files changed, 36 insertions(+), 75 deletions(-) delete mode 100644 examples/dora-node.yml diff --git a/AGENTS.md b/AGENTS.md index cbe5614..c03eb2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,11 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. - **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. - **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. -- **`examples/dora-node.yml`** — Manifest for the source node, and not optional. A manifest describes exactly one node (the schema is `NodeManifest`, singular), so the second node in this repository gets a second manifest; a `dora-node.yml` next to a plain `path:` node is [the documented way](https://dora-rs.ai/dora/hub/node-manifest) to type a node without publishing it. `dora validate` matches a manifest to a `path:` node by entrypoint; with no manifest beside `examples/source.py`, the root `dora-node.yml` is tried against it, does not match its entrypoint, and warns — which `strict_types: true` turns into a failure. Declaring the source's types here rather than as `output_types:` in `dataflow.yml` is also what makes them *checked*: an inline annotation shadows the manifest, so the manifest would be injected but never compared. -- **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. Both nodes are `path:` nodes with a manifest, so both contracts are injected and compared against each other; `bbox` is annotated on neither side, matching the manifests. +- **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. The viewer is a `path:` node, so its manifest is injected; the source is typed inline with `output_types:` and the two are compared against each other. `bbox` is annotated on neither side, matching the manifest. + + **One manifest, on purpose.** A `dora-node.yml` describes exactly one node (the schema is `NodeManifest`, singular) and is the unit `dora hub publish` operates on — it expects one beside `pyproject.toml`. This repository ships one node, so it has one manifest. The example source is a test fixture and gets none. + + That is why the source runs as `path: python` + `args:` rather than `path: examples/source.py`. `dora validate` matches a manifest to a `path:` node **by entrypoint**, so a path node here would be tried against the root manifest, would fail to match its `dora-image-view` entrypoint, and would warn — and `strict_types: true` makes that warning fatal. Going through the interpreter leaves nothing to match. The cost is that the fixture gets no managed venv of its own (`.dora/python-envs/` stays empty for it) and runs against the project environment instead; the shipped node still uses `path:` + `build:`, which is the mechanism a consumer actually uses. Do not "fix" this back into a path node without re-reading this paragraph. - **`.gitea/workflows/validate.yml`** — CI: lints, runs the unit tests, validates the dataflow and runs it headless. Needs no camera and no display. One job on purpose — the gates themselves take about a second, and are dwarfed by preparing the runner. ## Why CI installs libgl1 @@ -44,7 +47,7 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless ## A caveat on typed optional inputs -`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map and a camera_info at all: both are typed, so the example has to drive them. +`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map and a camera_info at all: both are typed, so the example has to drive them, and a new output it sends needs a matching entry in the dataflow's `output_types:`. ## Checking what it draws diff --git a/README.md b/README.md index c54d76a..0ec34b0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ dora run dataflow.yml --uv A window opens with a square bouncing across a gradient, boxed and labelled. Press `q` to quit. -It builds both nodes from this checkout (`path:` + `build:`) rather than from a tag, +It builds the viewer from this checkout (`path:` + `build:`) rather than from a tag, which is what lets `dora validate` pick up the manifest described below. Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running @@ -66,16 +66,19 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv [`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source package is shared between node repositories, so it is the only contract a consumer has, and it is what makes a wiring mistake fail at build time instead of at runtime. The -example source has a manifest of its own, so `dora validate` checks the two against -each other: +example source is typed inline in the dataflow with `output_types:`, so `dora validate` +checks the two against each other: ``` $ dora validate dataflow.yml - node "source": injected 2 contract type(s) from .../examples/dora-node.yml - node "view": injected 2 contract type(s) from .../dora-node.yml + node "view": injected 4 contract type(s) from .../dora-node.yml All type annotations OK. ``` +There is one manifest because this repository ships one node. The example source is a +test fixture rather than something anyone installs, so it has none, and runs through +the interpreter to keep `dora validate` from matching the viewer's manifest against it. + `image` is `std/core/v1/UInt8`, `depth` and `camera_info` are `std/core/v1/Float32` and `text` is `std/core/v1/String`; the payloads are raw buffers described by their metadata, so the URN names the element type rather than a struct. `bbox` is deliberately diff --git a/dataflow.yml b/dataflow.yml index c94f3d4..b6331fd 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -11,7 +11,21 @@ strict_types: true nodes: - id: source - path: examples/source.py + # Run through the interpreter rather than as `path: examples/source.py`. + # + # A dora-node.yml describes exactly one node and is what gets published + # (`dora hub publish` expects it beside pyproject.toml), and this + # repository ships one node: dora-image-view. The example source is a test + # fixture, not a node anyone installs, so it gets no manifest. + # + # That has a mechanical consequence. `dora validate` matches a manifest to + # a `path:` node by entrypoint, so a path node here would be tried against + # the root manifest, would not match its `dora-image-view` entrypoint, and + # would warn -- which strict_types turns into a failure. Going through the + # interpreter leaves nothing for it to match, and `output_types` below is + # the documented way to type an output without one. + path: python + args: examples/source.py # The source needs dora-rs, numpy and pyarrow; installing the project gives # it exactly the versions pinned in uv.lock. build: pip install . @@ -23,6 +37,13 @@ nodes: - camera_info - bbox - text + # bbox is deliberately absent: it is untyped on the viewer's side too, + # because senders disagree on 32- vs 64-bit numeric fields. + output_types: + image: std/core/v1/UInt8 + text: std/core/v1/String + depth: std/core/v1/Float32 + camera_info: std/core/v1/Float32 - id: view path: dora-image-view diff --git a/examples/dora-node.yml b/examples/dora-node.yml deleted file mode 100644 index 354c903..0000000 --- a/examples/dora-node.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Manifest for the example source node. -# -# A manifest describes exactly one node, so a second node in this repository -# gets a second manifest. This is the documented way to type a node you have -# not published: "a dora-node.yml sitting next to a plain `path:` node is -# picked up automatically" -- https://dora-rs.ai/dora/hub/node-manifest -# -# It is not optional here. `dora validate` matches a manifest to a `path:` node -# by entrypoint; with no manifest beside examples/source.py the repository-root -# one is tried against it, does not match its `dora-image-view` entrypoint, and -# warns -- which `strict_types: true` turns into a failure. -# -# Declaring the types here rather than as `output_types:` in dataflow.yml is -# what makes them checked: an inline annotation shadows the manifest, so the -# manifest would be injected but never compared against dora-image-view's. -apiVersion: 1 -name: source -namespace: liria -description: Synthetic image, depth, camera_info, bbox and text source for the dora-image-view example -categories: [debug] -keywords: [example, test, synthetic] - -runtime: python -entrypoint: source.py -build: pip install . -dora: ">=1.0" - -inputs: - tick: - required: true - description: Timer tick; one frame is emitted per tick. - -outputs: - image: - type: std/core/v1/UInt8 - description: >- - Animated bgr8 frame as a flat UInt8 buffer, with metadata - {width, height, encoding}. - bbox: - description: >- - The box around the moving square: an Arrow struct {bbox, conf, labels} - with metadata {format: xyxy}. Untyped to match the dora-image-view input. - text: - type: std/core/v1/String - description: A fixed caption. - camera_info: - type: std/core/v1/Float32 - description: >- - [fx, fy, cx, cy] for the frames above. Published once, on the first tick, - the way a real camera publishes it once per session. - depth: - type: std/core/v1/Float32 - description: >- - Depth of the same scene in millimetres, [height, width]: a flat wall with - the square standing in front of it. - -env: - SOURCE_WIDTH: - type: int - description: Frame width in pixels. Defaults to 640. - SOURCE_HEIGHT: - type: int - description: Frame height in pixels. Defaults to 480. - SOURCE_FRAMES: - type: int - description: Stop after this many frames. 0, the default, runs until the viewer quits. -- 2.54.0 From a6fa3ce32bacd032e8c65a41a8797e8f1e902f0e Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:25:18 -0300 Subject: [PATCH 05/14] accept std/media/v1/Image payloads dora's typed image is an Arrow struct of width, height, encoding and data -- it carries inside the payload what this node has been reading out of message metadata. The decoder now takes either shape: a struct describes itself and its metadata is ignored, a flat UInt8 buffer is described by width/height/ encoding beside it, and everything after unpacking is common. The declared contract stays std/core/v1/UInt8, deliberately. Type compatibility is directional -- Image satisfies a Bytes input, but a std/core/v1/UInt8 output wired into an std/media/v1/Image input is a validate error -- and dora-zed declares image_bgr as std/core/v1/UInt8. Flipping this manifest now would break the camera. Accepting both shapes first is what lets the producers move before the contract does. output_framing is not part of this. It selects the wire format, raw or arrow-ipc, and is not required for images: a struct payload was verified to arrive identically under both framings, and validate does not ask for arrow-ipc when an output is annotated std/media/v1/Image. It earns its keep when the wire must be self-describing, such as crossing languages or recording. --- AGENTS.md | 8 +++- README.md | 14 ++++++- dora_image_view/decode.py | 53 ++++++++++++++++++++++--- dora_image_view/main.py | 7 +--- tests/test_dora_image_view.py | 75 ++++++++++++++++++++++++++++++----- 5 files changed, 133 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c03eb2a..4ae4065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,9 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless ## Architecture - **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. -- **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. +- **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `unpack()` accepts two payload shapes — the `std/media/v1/Image` struct (`width`, `height`, `encoding`, `data`), and the flat `UInt8` buffer described by message metadata that a `std/core/v1/UInt8` producer sends. A struct describes itself, so its metadata is ignored; everything after unpacking is common. + + **The declared type is still `std/core/v1/UInt8` and the reason is interop.** Type compatibility is directional: `Image` satisfies a `Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input is a validate error. dora-zed declares `image_bgr` as `std/core/v1/UInt8`, so flipping this manifest to `Image` breaks the camera until dora-zed is migrated. Accepting both shapes first is what makes that migration orderable: producers move, then the contract. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. - **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one. - **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. - **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. @@ -45,6 +47,10 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless `opencv-python-headless` needs none of them, but it also has no `imshow`, which is this node's whole purpose. Removing the step therefore means making the GUI wheel an optional extra — headless by default, `pip install .[gui]` for a real window — and that is a change to what a consumer installs, not just to CI. It has not been done. +## On `output_framing` + +`output_framing: {image: arrow-ipc}` sets a per-output wire format — `raw` (the default, plain Arrow buffer layout) or `arrow-ipc` (self-describing: schema plus record batches). It is *not* required for images: a struct payload was verified to arrive identically under both framings between Python nodes, and `dora validate` does not ask for it when an output is annotated `std/media/v1/Image`. Reach for `arrow-ipc` when the wire has to be self-describing — crossing languages, or recording — not because a payload is an image. + ## A caveat on typed optional inputs `dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map and a camera_info at all: both are typed, so the example has to drive them, and a new output it sends needs a matching entry in the dataflow's `output_types:`. diff --git a/README.md b/README.md index 0ec34b0..b9bb0df 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,24 @@ no window. That is what makes it safe to run in a pipeline test. | Input | Payload | Metadata | |---|---|---| -| `image` | Flat `UInt8` array | `width`, `height`, `encoding` | +| `image` | `std/media/v1/Image` struct, **or** a flat `UInt8` array | none for the struct; `width`, `height`, `encoding` for the flat form | | `depth` | Flat `Float32` array, `[H, W]` | `shape`, `units` | | `camera_info` | Flat `Float32` array, `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `text` | Arrow array of size 1 | — | +Two image payload shapes are accepted. The typed one is `std/media/v1/Image`, an Arrow +struct of `width: UInt32`, `height: UInt32`, `encoding: Utf8` and `data: LargeBinary`, +which carries inside the payload what the flat form carries beside it; when it is +received the message metadata is not consulted at all. The flat form is a raw `UInt8` +buffer described by `width`, `height` and `encoding` metadata, which is what a node +declaring `std/core/v1/UInt8` sends. + +The declared contract is still `std/core/v1/UInt8`, so a producer sending the struct +should keep declaring that until every producer in the dataflow has moved: dora's type +compatibility is directional, and a `std/core/v1/UInt8` output wired into an +`std/media/v1/Image` input fails `dora validate`. + Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png` (encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw encodings — the rest carry their own dimensions. An unsupported encoding raises. diff --git a/dora_image_view/decode.py b/dora_image_view/decode.py index df95e80..a1745b4 100644 --- a/dora_image_view/decode.py +++ b/dora_image_view/decode.py @@ -1,4 +1,12 @@ -"""Decoding of the `image` input into a BGR frame that can be drawn on.""" +"""Decoding of the `image` input into a BGR frame that can be drawn on. + +Two payload shapes are accepted, because dora has both in the field. The typed +one is `std/media/v1/Image`: an Arrow struct carrying width, height, encoding +and data together, so the frame is self-describing. The older one is a flat +UInt8 buffer whose dimensions and encoding travel beside it in the message +metadata, which is what a node declaring `std/core/v1/UInt8` sends -- dora-zed +among them. Everything after unpacking is identical. +""" import io @@ -14,13 +22,18 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"}) SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"} -def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.ndarray: +# The fields of std/media/v1/Image. A payload carrying them describes itself and +# its metadata is not consulted. +IMAGE_FIELDS = ("width", "height", "encoding", "data") + + +def to_bgr(storage, metadata: dict) -> np.ndarray: """Decode one `image` payload into a writable BGR array. - `width` and `height` come from the message metadata and are only required by - the raw encodings, which carry no dimensions of their own. + Accepts either an `std/media/v1/Image` struct or a flat buffer described by + the message metadata. """ - buffer = storage.to_numpy().astype(np.uint8) + buffer, encoding, width, height = unpack(storage, metadata) if encoding == "bgr8": # Copy so the frame can be annotated: to_numpy() may be a view on the @@ -53,6 +66,36 @@ def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np. raise RuntimeError(f"unsupported image encoding: {encoding}") +def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | None]: + """Split a payload of either shape into buffer, encoding, width and height.""" + if _is_image_struct(storage): + # One image per message: the struct is a single-row array. + row = storage[0].as_py() + return ( + np.frombuffer(row["data"], dtype=np.uint8), + str(row["encoding"]).lower(), + row["width"], + row["height"], + ) + + return ( + storage.to_numpy().astype(np.uint8), + str(metadata.get("encoding", "bgr8")).lower(), + metadata.get("width"), + metadata.get("height"), + ) + + +def _is_image_struct(storage) -> bool: + """Whether the payload is an Arrow struct with the std/media/v1/Image fields.""" + fields = getattr(getattr(storage, "type", None), "fields", None) + + if fields is None: + return False + + return all(name in {field.name for field in fields} for name in IMAGE_FIELDS) + + def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str): width, height = _dimensions(width, height, encoding) return _take(buffer, width * height * 3, encoding).reshape(height, width, 3) diff --git a/dora_image_view/main.py b/dora_image_view/main.py index 3b052ed..1335f35 100644 --- a/dora_image_view/main.py +++ b/dora_image_view/main.py @@ -67,12 +67,7 @@ def _decode_panel(event_id: str, value, metadata: dict) -> tuple[str, np.ndarray event loop to treat it as overlay state instead. """ if event_id == "image": - return "image", decode.to_bgr( - value, - metadata.get("encoding", "bgr8").lower(), - metadata.get("width"), - metadata.get("height"), - ) + return "image", decode.to_bgr(value, metadata) if event_id == "depth": return "depth", depth.colorize(depth.to_millimetres(value, metadata)) diff --git a/tests/test_dora_image_view.py b/tests/test_dora_image_view.py index e12e9c9..fb15416 100644 --- a/tests/test_dora_image_view.py +++ b/tests/test_dora_image_view.py @@ -4,6 +4,7 @@ import io import cv2 import numpy as np +import pyarrow as pa import pytest from PIL import Image @@ -13,6 +14,11 @@ from dora_image_view.view import Bboxes, View WIDTH, HEIGHT = 12, 8 +def _meta(encoding: str, width: int | None = None, height: int | None = None) -> dict: + """Build message metadata as a node sending a flat buffer attaches it.""" + return {"encoding": encoding, "width": width, "height": height} + + class FakeStorage: """Stands in for the Arrow array dora hands to the node.""" @@ -30,7 +36,7 @@ def frame() -> np.ndarray: def test_bgr8_round_trips_unchanged(frame): - decoded = decode.to_bgr(FakeStorage(frame.ravel()), "bgr8", WIDTH, HEIGHT) + decoded = decode.to_bgr(FakeStorage(frame.ravel()), _meta("bgr8", WIDTH, HEIGHT)) np.testing.assert_array_equal(decoded, frame) @@ -38,7 +44,7 @@ def test_bgr8_round_trips_unchanged(frame): def test_rgb8_round_trips_unchanged(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - decoded = decode.to_bgr(FakeStorage(rgb.ravel()), "rgb8", WIDTH, HEIGHT) + decoded = decode.to_bgr(FakeStorage(rgb.ravel()), _meta("rgb8", WIDTH, HEIGHT)) np.testing.assert_array_equal(decoded, frame) @@ -53,7 +59,7 @@ def test_self_describing_encodings_need_no_dimensions(frame, suffix, encoding): ok, buffer = cv2.imencode(suffix, frame) assert ok - decoded = decode.to_bgr(FakeStorage(buffer.ravel()), encoding, None, None) + decoded = decode.to_bgr(FakeStorage(buffer.ravel()), _meta(encoding)) assert decoded.shape == (HEIGHT, WIDTH, 3) @@ -61,7 +67,7 @@ def test_self_describing_encodings_need_no_dimensions(frame, suffix, encoding): def test_yuv420_is_decoded(frame): planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420) - decoded = decode.to_bgr(FakeStorage(planes.ravel()), "yuv420", WIDTH, HEIGHT) + decoded = decode.to_bgr(FakeStorage(planes.ravel()), _meta("yuv420", WIDTH, HEIGHT)) assert decoded.shape == (HEIGHT, WIDTH, 3) @@ -71,7 +77,7 @@ def test_avif_is_decoded(frame): Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).save(buffer, format="AVIF") payload = np.frombuffer(buffer.getvalue(), dtype=np.uint8) - decoded = decode.to_bgr(FakeStorage(payload), "avif", None, None) + decoded = decode.to_bgr(FakeStorage(payload), _meta("avif")) assert decoded.shape == (HEIGHT, WIDTH, 3) @@ -79,7 +85,7 @@ def test_avif_is_decoded(frame): def test_decoded_frame_is_writable(): storage = FakeStorage(np.arange(2 * 3 * 3, dtype=np.uint8)) - decoded = decode.to_bgr(storage, "bgr8", width=3, height=2) + decoded = decode.to_bgr(storage, _meta("bgr8", 3, 2)) # The frame is annotated in place, so it must not be a read-only view. decoded[0, 0] = 0 @@ -88,24 +94,22 @@ def test_decoded_frame_is_writable(): def test_raw_encodings_need_dimensions(): storage = FakeStorage(np.zeros(18, dtype=np.uint8)) with pytest.raises(RuntimeError, match="width"): - decode.to_bgr(storage, "bgr8", width=None, height=None) + decode.to_bgr(storage, _meta("bgr8")) def test_short_payload_is_rejected(): storage = FakeStorage(np.zeros(4, dtype=np.uint8)) with pytest.raises(RuntimeError, match="expected at least"): - decode.to_bgr(storage, "bgr8", width=3, height=2) + decode.to_bgr(storage, _meta("bgr8", 3, 2)) def test_unsupported_encoding_is_rejected(): storage = FakeStorage(np.zeros(4, dtype=np.uint8)) with pytest.raises(RuntimeError, match="unsupported image encoding"): - decode.to_bgr(storage, "tiff", width=None, height=None) + decode.to_bgr(storage, _meta("tiff")) def test_xywh_is_converted_to_corners(): - import pyarrow as pa - value = pa.array( [ { @@ -283,3 +287,52 @@ def test_the_camera_model_is_drawn_on_the_composite(): with_model = view.compose() assert not np.array_equal(without, with_model), "the model should be drawn" + + +def _image_struct(frame: np.ndarray, encoding: str = "bgr8"): + """Build a payload shaped like std/media/v1/Image.""" + return pa.array( + [{ + "width": frame.shape[1], + "height": frame.shape[0], + "encoding": encoding, + "data": frame.tobytes(), + }], + type=pa.struct([ + ("width", pa.uint32()), + ("height", pa.uint32()), + ("encoding", pa.utf8()), + ("data", pa.large_binary()), + ]), + ) + + +def test_image_struct_is_decoded(frame): + # No metadata at all: the struct describes itself. + decoded = decode.to_bgr(_image_struct(frame), {}) + + np.testing.assert_array_equal(decoded, frame) + + +def test_image_struct_wins_over_message_metadata(frame): + # Metadata disagreeing with the struct must not be consulted. + decoded = decode.to_bgr(_image_struct(frame), _meta("rgb8", 1, 1)) + + np.testing.assert_array_equal(decoded, frame) + + +def test_image_struct_carrying_an_encoded_frame_is_decoded(frame): + ok, encoded = cv2.imencode(".png", frame) + assert ok + payload = np.frombuffer(encoded.tobytes(), dtype=np.uint8).reshape(1, -1) + + decoded = decode.to_bgr(_image_struct(payload, "png"), {}) + + np.testing.assert_array_equal(decoded, frame) + + +def test_a_decoded_image_struct_frame_is_writable(frame): + decoded = decode.to_bgr(_image_struct(frame), {}) + decoded[0, 0] = (1, 2, 3) + + assert tuple(decoded[0, 0]) == (1, 2, 3) -- 2.54.0 From 8a7bda586763fdb1f03f38405f2fa8f9ffdaa333 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:27:40 -0300 Subject: [PATCH 06/14] use std/media/v1/Image for image and depth Both inputs now declare dora's Arrow struct {width, height, encoding, data} rather than a flat buffer plus loose metadata. The payload describes itself, so nothing about its shape has to be agreed out of band, and the frame travels as one Arrow value instead of a buffer whose meaning lives in a sidecar dict. Depth uses the same type. dora's registry has no depth type, and a depth map is an image whose pixels are distances -- the reading ROS takes as well -- so it arrives with a single-channel encoding: 32FC1/float32 or 16UC1/mono16/uint16. `units` stays in message metadata because the struct has no field for it. decode.IMAGE_TYPE is the single definition of the struct; the example source and the tests build payloads from it rather than respelling the schema. The older shapes are still decoded -- a flat UInt8 buffer with width/height/encoding for image, a flat Float32 buffer with shape/units for depth -- so a producer's payload can move before its manifest does. That is only half a migration path: type compatibility is directional, so a producer still declaring std/core/v1/UInt8 fails validate against an std/media/v1/Image input regardless of what it sends, and has to update its manifest too. Verified: 41 tests, validate clean with 4 contracts injected, the dataflow runs to a clean exit, and a composite rendered through the struct path is identical to the one rendered before the migration. --- AGENTS.md | 6 +++-- README.md | 32 +++++++++++++---------- dataflow.yml | 4 +-- dora-node.yml | 25 ++++++++++-------- dora_image_view/decode.py | 18 +++++++++---- dora_image_view/depth.py | 37 +++++++++++++++++++++++++-- examples/source.py | 36 ++++++++++++++++---------- tests/test_dora_image_view.py | 48 ++++++++++++++++++++++++++++++----- 8 files changed, 153 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ae4065..2f2cd02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,9 +26,11 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. - **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `unpack()` accepts two payload shapes — the `std/media/v1/Image` struct (`width`, `height`, `encoding`, `data`), and the flat `UInt8` buffer described by message metadata that a `std/core/v1/UInt8` producer sends. A struct describes itself, so its metadata is ignored; everything after unpacking is common. - **The declared type is still `std/core/v1/UInt8` and the reason is interop.** Type compatibility is directional: `Image` satisfies a `Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input is a validate error. dora-zed declares `image_bgr` as `std/core/v1/UInt8`, so flipping this manifest to `Image` breaks the camera until dora-zed is migrated. Accepting both shapes first is what makes that migration orderable: producers move, then the contract. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. + `IMAGE_TYPE` is the one definition of that struct; the example source and the tests build payloads from it rather than respelling the schema. + + **Interop cost of the contract being `Image`.** Type compatibility is directional: `Image` satisfies a `Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input is a validate error. A producer still declaring `std/core/v1/UInt8` — dora-zed's `image_bgr` at time of writing — has to change its manifest, not just its payload, before it can feed this node. The decoder accepting the flat form does not rescue it: the failure is at validate, before anything runs. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. - **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one. -- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. +- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. Depth arrives as `std/media/v1/Image` with a single-channel encoding (`DEPTH_DTYPES`) because dora's registry has no depth type and a depth map *is* an image of distances — the same reading ROS takes. `units` stays in message metadata: the struct has no field for it. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. - **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. - **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible. - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. diff --git a/README.md b/README.md index b9bb0df..e785a5b 100644 --- a/README.md +++ b/README.md @@ -106,25 +106,31 @@ no window. That is what makes it safe to run in a pipeline test. | Input | Payload | Metadata | |---|---|---| -| `image` | `std/media/v1/Image` struct, **or** a flat `UInt8` array | none for the struct; `width`, `height`, `encoding` for the flat form | -| `depth` | Flat `Float32` array, `[H, W]` | `shape`, `units` | +| `image` | `std/media/v1/Image` struct | none — the struct describes itself | +| `depth` | `std/media/v1/Image` struct, one channel | `units` (defaults to `mm`) | | `camera_info` | Flat `Float32` array, `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `text` | Arrow array of size 1 | — | -Two image payload shapes are accepted. The typed one is `std/media/v1/Image`, an Arrow -struct of `width: UInt32`, `height: UInt32`, `encoding: Utf8` and `data: LargeBinary`, -which carries inside the payload what the flat form carries beside it; when it is -received the message metadata is not consulted at all. The flat form is a raw `UInt8` -buffer described by `width`, `height` and `encoding` metadata, which is what a node -declaring `std/core/v1/UInt8` sends. +`image` and `depth` are both `std/media/v1/Image`: an Arrow struct of `width: UInt32`, +`height: UInt32`, `encoding: Utf8` and `data: LargeBinary`. The payload describes +itself, so nothing about its shape has to travel as message metadata. -The declared contract is still `std/core/v1/UInt8`, so a producer sending the struct -should keep declaring that until every producer in the dataflow has moved: dora's type -compatibility is directional, and a `std/core/v1/UInt8` output wired into an -`std/media/v1/Image` input fails `dora validate`. +Depth is an image whose pixels are distances — dora's type registry has no depth type, +and this is how ROS spells it too. Its encodings are `32FC1`/`float32` and +`16UC1`/`mono16`/`uint16`, one channel per pixel, and `units` metadata still switches +millimetres to metres. -Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png` +Older payloads are still decoded, so a producer can move at its own pace: a flat `UInt8` +buffer with `width`/`height`/`encoding` metadata for `image`, and a flat `Float32` +buffer with `shape`/`units` metadata for `depth`. + +**Migrating a producer.** dora's type compatibility is directional — `Image` satisfies a +`Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input +is a `dora validate` error. A producer still declaring `std/core/v1/UInt8` or +`std/core/v1/Float32` therefore has to change its manifest as well as its payload. + +Supported `image` `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png` (encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw encodings — the rest carry their own dimensions. An unsupported encoding raises. diff --git a/dataflow.yml b/dataflow.yml index b6331fd..28ade4b 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -40,9 +40,9 @@ nodes: # bbox is deliberately absent: it is untyped on the viewer's side too, # because senders disagree on 32- vs 64-bit numeric fields. output_types: - image: std/core/v1/UInt8 + image: std/media/v1/Image text: std/core/v1/String - depth: std/core/v1/Float32 + depth: std/media/v1/Image camera_info: std/core/v1/Float32 - id: view diff --git a/dora-node.yml b/dora-node.yml index 0c29cad..beb1a55 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -21,13 +21,14 @@ dora: ">=1.0" inputs: image: - type: std/core/v1/UInt8 + type: std/media/v1/Image required: false description: >- - Base image to draw on: a flat UInt8 buffer described by the metadata - {width, height, encoding}. Supported encodings: bgr8, rgb8, - jpeg/jpg/jpe/bmp/webp/png (encoded bytes), yuv420, avif. Receiving this - input triggers a redraw and display. + Base image to draw on: an Arrow struct {width, height, encoding, data}. + Supported encodings: bgr8, rgb8, jpeg/jpg/jpe/bmp/webp/png (encoded + bytes), yuv420, avif. Receiving this input triggers a redraw and display. + A flat UInt8 buffer described by {width, height, encoding} metadata is + still decoded, for producers that have not moved to the struct. bbox: required: false description: >- @@ -53,13 +54,17 @@ inputs: this input -- they are picked up from there, which is what covers a camera that published its intrinsics before this node started. depth: - type: std/core/v1/Float32 + type: std/media/v1/Image required: false description: >- - Depth map as a flat Float32 buffer, described by the metadata - {shape: [height, width], units}. Millimetres unless `units` says `m`; - 0 and non-finite values are missing depth and draw black. Colour-mapped - into its own panel, which receiving this input redraws. + Depth map as an Arrow struct {width, height, encoding, data}, one channel + per pixel. Supported encodings: 32FC1/float32 and 16UC1/mono16/uint16 -- + dora has no depth type, and a depth map is an image whose pixels are + distances, which is how ROS spells it too. Millimetres unless the `units` + metadata says `m`; 0 and non-finite values are missing depth and draw + black. Colour-mapped into its own panel, which receiving this input + redraws. A flat Float32 buffer with {shape, units} metadata is still + decoded, for producers that have not moved to the struct. outputs: {} diff --git a/dora_image_view/decode.py b/dora_image_view/decode.py index a1745b4..f491a8d 100644 --- a/dora_image_view/decode.py +++ b/dora_image_view/decode.py @@ -13,6 +13,7 @@ import io import cv2 import numpy as np import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow +import pyarrow as pa from PIL import Image # Encodings that carry their own dimensions, so `width`/`height` metadata is not @@ -22,9 +23,16 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"}) SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"} -# The fields of std/media/v1/Image. A payload carrying them describes itself and -# its metadata is not consulted. -IMAGE_FIELDS = ("width", "height", "encoding", "data") +# std/media/v1/Image. A payload carrying these fields describes itself, so its +# message metadata is not consulted. Exported as an Arrow type so a producer -- +# the example source, the tests -- builds it from one definition. +IMAGE_TYPE = pa.struct([ + ("width", pa.uint32()), + ("height", pa.uint32()), + ("encoding", pa.utf8()), + ("data", pa.large_binary()), +]) +IMAGE_FIELDS = tuple(field.name for field in IMAGE_TYPE) def to_bgr(storage, metadata: dict) -> np.ndarray: @@ -68,7 +76,7 @@ def to_bgr(storage, metadata: dict) -> np.ndarray: def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | None]: """Split a payload of either shape into buffer, encoding, width and height.""" - if _is_image_struct(storage): + if is_image_struct(storage): # One image per message: the struct is a single-row array. row = storage[0].as_py() return ( @@ -86,7 +94,7 @@ def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | ) -def _is_image_struct(storage) -> bool: +def is_image_struct(storage) -> bool: """Whether the payload is an Arrow struct with the std/media/v1/Image fields.""" fields = getattr(getattr(storage, "type", None), "fields", None) diff --git a/dora_image_view/depth.py b/dora_image_view/depth.py index a75aeed..380c138 100644 --- a/dora_image_view/depth.py +++ b/dora_image_view/depth.py @@ -11,6 +11,7 @@ import os import cv2 import numpy as np +from dora_image_view.decode import is_image_struct from dora_image_view.payload import as_float32 # Perceptually uniform and readable in both directions, unlike JET. @@ -18,6 +19,18 @@ COLORMAP = cv2.COLORMAP_TURBO MISSING = 0.0 +# Single-channel depth encodings and the pixel type each names. The ROS spellings +# are the ones senders use; a depth map is an image whose pixels are distances, +# which is why it arrives as std/media/v1/Image rather than a type of its own -- +# dora's registry has no depth type. +DEPTH_DTYPES = { + "32fc1": np.float32, + "float32": np.float32, + "16uc1": np.uint16, + "mono16": np.uint16, + "uint16": np.uint16, +} + def to_millimetres(storage, metadata: dict) -> np.ndarray: """Decode one `depth` payload into a float32 array of millimetres. @@ -25,8 +38,7 @@ def to_millimetres(storage, metadata: dict) -> np.ndarray: Missing depth is 0. Non-finite values are normalised to 0 as well, so everything downstream only has to test for it. """ - depth = as_float32(storage) - height, width = _hw(metadata, depth.size) + depth, height, width = _values(storage, metadata) if depth.size < height * width: raise RuntimeError( @@ -87,6 +99,27 @@ def resolve_range(depth_mm: np.ndarray) -> tuple[float, float]: return float(low), float(high) +def _values(storage, metadata: dict) -> tuple[np.ndarray, int, int]: + """Read the payload of either shape into float32 values plus its shape.""" + if is_image_struct(storage): + row = storage[0].as_py() + encoding = str(row["encoding"]).lower() + dtype = DEPTH_DTYPES.get(encoding) + + if dtype is None: + raise RuntimeError( + f"unsupported depth encoding: {encoding}; " + f"expected one of {', '.join(sorted(DEPTH_DTYPES))}", + ) + + values = np.frombuffer(row["data"], dtype=dtype).astype(np.float32) + return values, int(row["height"]), int(row["width"]) + + values = as_float32(storage) + height, width = _hw(metadata, values.size) + return values, height, width + + def _hw(metadata: dict, size: int) -> tuple[int, int]: shape = metadata.get("shape") diff --git a/examples/source.py b/examples/source.py index 75935f7..7dde418 100644 --- a/examples/source.py +++ b/examples/source.py @@ -16,6 +16,8 @@ import numpy as np import pyarrow as pa from dora import Node +from dora_image_view.decode import IMAGE_TYPE + WIDTH = int(os.getenv("SOURCE_WIDTH", "640")) HEIGHT = int(os.getenv("SOURCE_HEIGHT", "480")) # 0 runs until the viewer quits; a positive count is what lets CI run the @@ -55,11 +57,7 @@ def main() -> None: ) frame, corners = render(sent) - node.send_output( - "image", - pa.array(frame.ravel()), - {"width": WIDTH, "height": HEIGHT, "encoding": "bgr8", **INTRINSICS_METADATA}, - ) + node.send_output("image", image_struct(frame, "bgr8"), INTRINSICS_METADATA) node.send_output( "bbox", pa.array([{ @@ -72,14 +70,8 @@ def main() -> None: depth = render_depth(corners) node.send_output( "depth", - pa.array(depth.ravel()), - { - "shape": [HEIGHT, WIDTH], - "dtype": "float32", - "layout": "HW", - "units": "mm", - **INTRINSICS_METADATA, - }, + image_struct(depth, "32FC1"), + {"units": "mm", **INTRINSICS_METADATA}, ) node.send_output("text", pa.array([CAPTION])) @@ -88,6 +80,24 @@ def main() -> None: break +def image_struct(frame: np.ndarray, encoding: str) -> pa.Array: + """Pack a frame as std/media/v1/Image. + + The struct carries the width, height and encoding a receiver needs, so none + of it has to travel as message metadata. `frame` may be any dtype: the + encoding names how to read the bytes back. + """ + return pa.array( + [{ + "width": frame.shape[1], + "height": frame.shape[0], + "encoding": encoding, + "data": frame.tobytes(), + }], + type=IMAGE_TYPE, + ) + + def render(index: int) -> tuple[np.ndarray, np.ndarray]: """Draw frame `index` and return it with the xyxy corners of the square in it.""" frame = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8) diff --git a/tests/test_dora_image_view.py b/tests/test_dora_image_view.py index fb15416..b45a510 100644 --- a/tests/test_dora_image_view.py +++ b/tests/test_dora_image_view.py @@ -298,12 +298,7 @@ def _image_struct(frame: np.ndarray, encoding: str = "bgr8"): "encoding": encoding, "data": frame.tobytes(), }], - type=pa.struct([ - ("width", pa.uint32()), - ("height", pa.uint32()), - ("encoding", pa.utf8()), - ("data", pa.large_binary()), - ]), + type=decode.IMAGE_TYPE, ) @@ -336,3 +331,44 @@ def test_a_decoded_image_struct_frame_is_writable(frame): decoded[0, 0] = (1, 2, 3) assert tuple(decoded[0, 0]) == (1, 2, 3) + + +def test_depth_is_decoded_from_an_image_struct(): + values = np.array([[100.0, 200.0], [300.0, 400.0]], dtype=np.float32) + + # No shape metadata: the struct carries its own dimensions. + decoded = depth.to_millimetres(_image_struct(values, "32FC1"), {}) + + np.testing.assert_allclose(decoded, values) + + +def test_depth_image_struct_reads_a_uint16_encoding(): + values = np.array([[1500, 2500]], dtype=np.uint16) + + decoded = depth.to_millimetres(_image_struct(values, "16UC1"), {}) + + np.testing.assert_allclose(decoded, [[1500.0, 2500.0]]) + + +def test_depth_image_struct_still_honours_the_units_metadata(): + values = np.array([[1.5, 2.0]], dtype=np.float32) + + decoded = depth.to_millimetres(_image_struct(values, "32FC1"), {"units": "m"}) + + np.testing.assert_allclose(decoded, [[1500.0, 2000.0]]) + + +def test_depth_image_struct_with_an_unsupported_encoding_is_rejected(): + values = np.zeros((1, 2), dtype=np.float32) + + with pytest.raises(RuntimeError, match="unsupported depth encoding"): + depth.to_millimetres(_image_struct(values, "rgb8"), {}) + + +def test_a_decoded_depth_struct_is_writable(): + values = np.array([[100.0, 0.0]], dtype=np.float32) + + decoded = depth.to_millimetres(_image_struct(values, "32FC1"), {}) + decoded[0, 0] = 42.0 + + assert decoded[0, 0] == 42.0 -- 2.54.0 From 2b70d0786871e16ac3079687c011d770c786ad6d Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:30:03 -0300 Subject: [PATCH 07/14] docs: correct the example's description and say what it is for The README still described the source as emitting "the three inputs this node draws". It emits five outputs -- the depth map and the intrinsics were added without the prose following. Also records that the example is the CI gate as well as the demo, and that the second role is what has been shaping it: it publishes depth and camera_info because those inputs are typed and strict validation fails on a typed input the dataflow does not wire. Worth knowing before adding the next output. --- AGENTS.md | 2 +- README.md | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f2cd02..e2dc6af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. - **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible. - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. -- **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. +- **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It is both the demo and the CI gate, and the second role has been shaping it: it publishes depth and camera_info because those inputs are *typed*, and `dora validate --strict` fails on a typed input the dataflow does not wire. Before adding an output here, know which of the two reasons you are adding it for. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. - **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. - **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. The viewer is a `path:` node, so its manifest is injected; the source is typed inline with `output_types:` and the two are compared against each other. `bbox` is annotated on neither side, matching the manifest. diff --git a/README.md b/README.md index e785a5b..40e034f 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ pixels, and the caption on the composite. ### Runnable example [`dataflow.yml`](dataflow.yml) is self-contained. [`examples/source.py`](examples/source.py) -is a synthetic source node that emits an animated frame, the box around the square -moving in it, and a caption — the three inputs this node draws — so the example needs -no camera, no detector and no network: +is a synthetic source node that emits an animated frame, a depth map of the same scene, +the camera intrinsics, the box around the square moving in the frame, and a caption — +every input this node draws — so the example needs no camera, no detector and no +network: ```bash dora run dataflow.yml --uv @@ -52,6 +53,12 @@ to quit. It builds the viewer from this checkout (`path:` + `build:`) rather than from a tag, which is what lets `dora validate` pick up the manifest described below. +This example is also the end-to-end gate: CI runs the same dataflow headless on every +push, which is what keeps it from rotting. It earns its place twice over — it is the +only worked example of the *producing* side of these contracts, which is the fiddly +half of wiring this node. The realistic wiring, against a real camera, is the block at +the top of this file. + Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running until you quit; that plus `CI=true` is how the same dataflow runs headless as a test: -- 2.54.0 From 7d9ebe8b385ccebacb11b014f83bcbade7974ce1 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 05:31:39 -0300 Subject: [PATCH 08/14] move the synthetic source into tests It is a test fixture, so it lives in tests/. It was only ever an example by placement: CI runs it as the end-to-end gate, SOURCE_FRAMES exists so CI can bound it, and it publishes depth and camera_info because those inputs are typed and strict validation fails on a typed input the dataflow does not wire -- none of which is pedagogy. The worked example of wiring this node is the README's usage block, against a real camera. It stays a dora node rather than a pytest case because what it exercises is the wiring and the event loop, which only a running dataflow covers. pytest does not collect it: the filename does not start with `test_`. dataflow.yml stays at the repository root rather than moving beside it. A node's working directory is the directory of the dataflow file, and both nodes build with `pip install .`, which needs pyproject.toml in that directory. --- AGENTS.md | 8 ++++---- README.md | 16 +++++++--------- dataflow.yml | 15 ++++++++++----- {examples => tests}/source.py | 13 +++++++++---- 4 files changed, 30 insertions(+), 22 deletions(-) rename {examples => tests}/source.py (86%) diff --git a/AGENTS.md b/AGENTS.md index e2dc6af..1823adf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,13 +34,13 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless - **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. - **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible. - **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. -- **`examples/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the box around the square, and a caption. It is both the demo and the CI gate, and the second role has been shaping it: it publishes depth and camera_info because those inputs are *typed*, and `dora validate --strict` fails on a typed input the dataflow does not wire. Before adding an output here, know which of the two reasons you are adding it for. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. +- **`tests/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the intrinsics, the box around the square, and a caption. A **test fixture**, not an example — it lives in `tests/` because that is what it is, and it publishes depth and camera_info because those inputs are *typed* and `dora validate --strict` fails on a typed input the dataflow does not wire. It is a dora node rather than a pytest case because it exists to exercise the wiring and the event loop, which only a running dataflow covers; pytest does not collect it, since the filename does not start with `test_`. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. - **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. -- **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. The viewer is a `path:` node, so its manifest is injected; the source is typed inline with `output_types:` and the two are compared against each other. `bbox` is annotated on neither side, matching the manifest. +- **`dataflow.yml`** — The end-to-end test, with `strict_types: true`. It sits at the repository root, not beside the fixture, because a node's working directory is the dataflow file's directory and both nodes build with `pip install .`, which needs `pyproject.toml` there. The viewer is a `path:` node, so its manifest is injected; the source is typed inline with `output_types:` and the two are compared against each other. `bbox` is annotated on neither side, matching the manifest. **One manifest, on purpose.** A `dora-node.yml` describes exactly one node (the schema is `NodeManifest`, singular) and is the unit `dora hub publish` operates on — it expects one beside `pyproject.toml`. This repository ships one node, so it has one manifest. The example source is a test fixture and gets none. - That is why the source runs as `path: python` + `args:` rather than `path: examples/source.py`. `dora validate` matches a manifest to a `path:` node **by entrypoint**, so a path node here would be tried against the root manifest, would fail to match its `dora-image-view` entrypoint, and would warn — and `strict_types: true` makes that warning fatal. Going through the interpreter leaves nothing to match. The cost is that the fixture gets no managed venv of its own (`.dora/python-envs/` stays empty for it) and runs against the project environment instead; the shipped node still uses `path:` + `build:`, which is the mechanism a consumer actually uses. Do not "fix" this back into a path node without re-reading this paragraph. + That is why the source runs as `path: python` + `args:` rather than `path: tests/source.py`. `dora validate` matches a manifest to a `path:` node **by entrypoint**, so a path node here would be tried against the root manifest, would fail to match its `dora-image-view` entrypoint, and would warn — and `strict_types: true` makes that warning fatal. Going through the interpreter leaves nothing to match. The cost is that the fixture gets no managed venv of its own (`.dora/python-envs/` stays empty for it) and runs against the project environment instead; the shipped node still uses `path:` + `build:`, which is the mechanism a consumer actually uses. Do not "fix" this back into a path node without re-reading this paragraph. - **`.gitea/workflows/validate.yml`** — CI: lints, runs the unit tests, validates the dataflow and runs it headless. Needs no camera and no display. One job on purpose — the gates themselves take about a second, and are dwarfed by preparing the runner. ## Why CI installs libgl1 @@ -55,7 +55,7 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless ## A caveat on typed optional inputs -`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `examples/source.py` publishes a depth map and a camera_info at all: both are typed, so the example has to drive them, and a new output it sends needs a matching entry in the dataflow's `output_types:`. +`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `tests/source.py` publishes a depth map and a camera_info at all: both are typed, so the fixture has to drive them, and a new output it sends needs a matching entry in the dataflow's `output_types:`. ## Checking what it draws diff --git a/README.md b/README.md index 40e034f..567fd1f 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,14 @@ wiring several shows them side by side, scaled to a common height. `bbox`, `text are overlays rather than panels — boxes are drawn on the `image` panel, in its own pixels, and the caption on the composite. -### Runnable example +### End-to-end test -[`dataflow.yml`](dataflow.yml) is self-contained. [`examples/source.py`](examples/source.py) +[`dataflow.yml`](dataflow.yml) is self-contained. [`tests/source.py`](tests/source.py) is a synthetic source node that emits an animated frame, a depth map of the same scene, the camera intrinsics, the box around the square moving in the frame, and a caption — -every input this node draws — so the example needs no camera, no detector and no -network: +every input this node draws — so it needs no camera, no detector and no network. It is +a test fixture rather than a worked example; the realistic wiring is the block at the +top of this file. Run it to watch the node work: ```bash dora run dataflow.yml --uv @@ -53,11 +54,8 @@ to quit. It builds the viewer from this checkout (`path:` + `build:`) rather than from a tag, which is what lets `dora validate` pick up the manifest described below. -This example is also the end-to-end gate: CI runs the same dataflow headless on every -push, which is what keeps it from rotting. It earns its place twice over — it is the -only worked example of the *producing* side of these contracts, which is the fiddly -half of wiring this node. The realistic wiring, against a real camera, is the block at -the top of this file. +CI runs the same dataflow headless on every push, which is what makes it a gate rather +than a demo that rots. Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running until you quit; that plus `CI=true` is how the same dataflow runs headless as a test: diff --git a/dataflow.yml b/dataflow.yml index 28ade4b..4d09992 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -1,6 +1,11 @@ -# The repository's own example, and its end-to-end test: a synthetic source -# feeding the viewer. Everything it needs is in this checkout -- no camera, no -# detector, no network -- so it is also what CI runs. +# The end-to-end test: a synthetic source feeding the viewer. Everything it +# needs is in this checkout -- no camera, no detector, no network -- which is +# what lets CI run it rather than only validate it. +# +# It lives at the repository root rather than beside tests/source.py because a +# node's working directory is the directory of the dataflow file, and both +# nodes build with `pip install .`, which needs pyproject.toml in that +# directory. # # Both nodes are built from the checkout with `path:` + `build:`, which is what # lets `dora validate` pick up the typed contract in dora-node.yml. A consumer @@ -11,7 +16,7 @@ strict_types: true nodes: - id: source - # Run through the interpreter rather than as `path: examples/source.py`. + # Run through the interpreter rather than as `path: tests/source.py`. # # A dora-node.yml describes exactly one node and is what gets published # (`dora hub publish` expects it beside pyproject.toml), and this @@ -25,7 +30,7 @@ nodes: # interpreter leaves nothing for it to match, and `output_types` below is # the documented way to type an output without one. path: python - args: examples/source.py + args: tests/source.py # The source needs dora-rs, numpy and pyarrow; installing the project gives # it exactly the versions pinned in uv.lock. build: pip install . diff --git a/examples/source.py b/tests/source.py similarity index 86% rename from examples/source.py rename to tests/source.py index 7dde418..50db001 100644 --- a/examples/source.py +++ b/tests/source.py @@ -1,8 +1,13 @@ -"""Synthetic source node: this repository's stand-in for a camera and a detector. +"""Synthetic source node: the test's stand-in for a camera and a detector. -Emits an animated frame, a depth map of the same scene, the box around the square -moving in it, and a caption -- every input dora-image-view draws -- so the example -dataflow needs no camera, no detector and no network. +Emits an animated frame, a depth map of the same scene, the intrinsics, the box +around the square moving in it, and a caption -- every input dora-image-view draws -- +so dataflow.yml needs no camera, no detector and no network. + +This is a test fixture, not an example of how to wire the node; see the README for +that. It is a dora node rather than a pytest case because what it exists to exercise +is the wiring and the event loop, which only a running dataflow covers. pytest does +not collect it: the filename does not start with `test_`. The depth map describes the same scene as the image: a flat back wall with the square standing in front of it. `camera_info` is published once, on the first tick, the way a -- 2.54.0 From 093715e78f16347874412ce38001700eb5f93bd2 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 06:03:40 -0300 Subject: [PATCH 09/14] add markdown linting and cut AGENTS.md back to rules AGENTS.md was a per-module tour of the code, which restated what the code says and had already drifted: it described the example as emitting three inputs when it emits five. It is now the conventions an agent cannot infer -- dora version, uv, ruff, numpy docstrings, the platform requirement, act, and what not to do -- and nothing else. The gotchas it carried are explained at their point of use in the code instead. pymarkdown lints the markdown, configured in pyproject.toml and run in CI beside ruff. It caught a fenced block with no language and an over-long table row. ruff's pydocstyle convention moves to numpy. GEMINI.md is removed. --- .gitea/workflows/validate.yml | 2 + AGENTS.md | 89 +++-------- GEMINI.md | 3 - README.md | 4 +- dataflow.yml | 5 + pyproject.toml | 13 +- uv.lock | 289 ++++++++++++++++++++++++++++++++++ 7 files changed, 329 insertions(+), 76 deletions(-) delete mode 100644 GEMINI.md diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 477d6f7..aae5dce 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -69,6 +69,8 @@ jobs: - run: uv run ruff check . + - run: uv run pymarkdown scan *.md + - run: uv run pytest -q # strict_types: true in the dataflow turns a type warning into a non-zero diff --git a/AGENTS.md b/AGENTS.md index 1823adf..64f637a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,74 +1,25 @@ # AGENTS.md -This file provides guidance to AI coding assistants when working with code in this repository. +Rules for AI coding assistants working in this repository. -## What this is +- **dora >= 1.0.** Verified against `dora-cli 1.0.1`, which CI pins in + `.gitea/workflows/validate.yml`. +- **`uv` for everything:** `uv sync --group dev`, `uv run pytest`, + `uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient + environment. +- **`ruff` is the linter and the formatter.** `uv run ruff check .` must be clean. +- **Markdown is linted too.** `uv run pymarkdown scan *.md` must be clean. Its line + length is set in `pyproject.toml`. +- **numpy-style docstrings.** +- **Dependencies must ship prebuilt wheels for linux-x86_64, linux-aarch64 and + macos-arm64.** `uv sync` is the whole install: no system packages, no compiler. +- **Run the workflow locally with `act` before asking for a commit.** It lives in + `.gitea/workflows/`, which is not where `act` looks by default: -A dora-rs Python node that displays a video stream with overlays: it keeps one panel per frame stream (`image`, `depth`) plus a bbox set, a text string and the camera model, and every frame event redraws the lot as one side-by-side composite in an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say. + ```bash + act pull_request -W .gitea/workflows/validate.yml \ + -P ubuntu-latest=catthehacker/ubuntu:act-latest + ``` -Everything the example dataflow needs is in this checkout. Do not reintroduce a dependency on another repository — the example must keep running with no camera, no detector and no network. - -**It is Python on purpose.** The node was first written in Rust against the `opencv` crate; that crate generates its bindings from the OpenCV headers installed on the build machine, so it needs `libopencv-dev` and `libclang-dev` on every machine and every architecture and does not cross-compile. Every dependency here ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64, so `uv sync` is the whole install. Do not reintroduce a dependency that has to be compiled from source. - -## Commands - -```bash -uv sync --group dev # deps plus pytest and ruff -uv run pytest && uv run ruff check . # no camera/dataflow/display needed -dora run dataflow.yml --uv # the example, in a window -CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless -``` - -`dora build --uv` / `dora run --uv` rewrites each manifest's `build: pip install .` to `uv pip install .` and gives the node its own managed venv under `.dora/python-envs//`, so what it imports at runtime is what was installed at build time. - -## Architecture - -- **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. -- **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `unpack()` accepts two payload shapes — the `std/media/v1/Image` struct (`width`, `height`, `encoding`, `data`), and the flat `UInt8` buffer described by message metadata that a `std/core/v1/UInt8` producer sends. A struct describes itself, so its metadata is ignored; everything after unpacking is common. - - `IMAGE_TYPE` is the one definition of that struct; the example source and the tests build payloads from it rather than respelling the schema. - - **Interop cost of the contract being `Image`.** Type compatibility is directional: `Image` satisfies a `Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input is a validate error. A producer still declaring `std/core/v1/UInt8` — dora-zed's `image_bgr` at time of writing — has to change its manifest, not just its payload, before it can feed this node. The decoder accepting the flat form does not rescue it: the failure is at validate, before anything runs. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. -- **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one. -- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. Depth arrives as `std/media/v1/Image` with a single-channel encoding (`DEPTH_DTYPES`) because dora's registry has no depth type and a depth map *is* an image of distances — the same reading ROS takes. `units` stays in message metadata: the struct has no field for it. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. -- **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. -- **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible. -- **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. -- **`tests/source.py`** — The synthetic source node: an animated frame, a depth map of the same scene (a flat wall with the square in front of it), the intrinsics, the box around the square, and a caption. A **test fixture**, not an example — it lives in `tests/` because that is what it is, and it publishes depth and camera_info because those inputs are *typed* and `dora validate --strict` fails on a typed input the dataflow does not wire. It is a dora node rather than a pytest case because it exists to exercise the wiring and the event loop, which only a running dataflow covers; pytest does not collect it, since the filename does not start with `test_`. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout. -- **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either. -- **`dataflow.yml`** — The end-to-end test, with `strict_types: true`. It sits at the repository root, not beside the fixture, because a node's working directory is the dataflow file's directory and both nodes build with `pip install .`, which needs `pyproject.toml` there. The viewer is a `path:` node, so its manifest is injected; the source is typed inline with `output_types:` and the two are compared against each other. `bbox` is annotated on neither side, matching the manifest. - - **One manifest, on purpose.** A `dora-node.yml` describes exactly one node (the schema is `NodeManifest`, singular) and is the unit `dora hub publish` operates on — it expects one beside `pyproject.toml`. This repository ships one node, so it has one manifest. The example source is a test fixture and gets none. - - That is why the source runs as `path: python` + `args:` rather than `path: tests/source.py`. `dora validate` matches a manifest to a `path:` node **by entrypoint**, so a path node here would be tried against the root manifest, would fail to match its `dora-image-view` entrypoint, and would warn — and `strict_types: true` makes that warning fatal. Going through the interpreter leaves nothing to match. The cost is that the fixture gets no managed venv of its own (`.dora/python-envs/` stays empty for it) and runs against the project environment instead; the shipped node still uses `path:` + `build:`, which is the mechanism a consumer actually uses. Do not "fix" this back into a path node without re-reading this paragraph. -- **`.gitea/workflows/validate.yml`** — CI: lints, runs the unit tests, validates the dataflow and runs it headless. Needs no camera and no display. One job on purpose — the gates themselves take about a second, and are dwarfed by preparing the runner. - -## Why CI installs libgl1 - -`opencv-python` links against libGL at import time, so `import cv2` fails on the bare runner image and CI installs the shared libraries first. At roughly sixteen seconds it is the most expensive step in the job, several times everything else combined. - -`opencv-python-headless` needs none of them, but it also has no `imshow`, which is this node's whole purpose. Removing the step therefore means making the GUI wheel an optional extra — headless by default, `pip install .[gui]` for a real window — and that is a change to what a consumer installs, not just to CI. It has not been done. - -## On `output_framing` - -`output_framing: {image: arrow-ipc}` sets a per-output wire format — `raw` (the default, plain Arrow buffer layout) or `arrow-ipc` (self-describing: schema plus record batches). It is *not* required for images: a struct payload was verified to arrive identically under both framings between Python nodes, and `dora validate` does not ask for it when an output is annotated `std/media/v1/Image`. Reach for `arrow-ipc` when the wire has to be self-describing — crossing languages, or recording — not because a payload is an image. - -## A caveat on typed optional inputs - -`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped. This is why `tests/source.py` publishes a depth map and a camera_info at all: both are typed, so the fixture has to drive them, and a new output it sends needs a matching entry in the dataflow's `output_types:`. - -## Checking what it draws - -The gates prove the node does not crash, not that the picture is right — the colormap-ranging defect found while adding depth rendered as a plausible flat panel and passed every test. Compose a frame offline and look at it: - -```python -view = View(headless=True) -view.panels["depth"] = depth.colorize(depth.to_millimetres(storage, metadata)) -cv2.imwrite("/tmp/frame.png", view.compose()) -``` - -`compose()` is public for exactly this: it returns the composite without needing a window. - -## Rules - -- Do not mention AI tools in commit messages or documentation. +- **Do not commit or push.** Leave changes in the working tree and wait to be asked. +- **Do not mention AI tools in commit messages or documentation.** diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 6c114ed..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,3 +0,0 @@ -# GEMINI.md - -See [AGENTS.md](AGENTS.md) for repository guidance. diff --git a/README.md b/README.md index 567fd1f..bd7dc9a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ and it is what makes a wiring mistake fail at build time instead of at runtime. example source is typed inline in the dataflow with `output_types:`, so `dora validate` checks the two against each other: -``` +```text $ dora validate dataflow.yml node "view": injected 4 contract type(s) from .../dora-node.yml All type annotations OK. @@ -113,7 +113,7 @@ no window. That is what makes it safe to run in a pipeline test. |---|---|---| | `image` | `std/media/v1/Image` struct | none — the struct describes itself | | `depth` | `std/media/v1/Image` struct, one channel | `units` (defaults to `mm`) | -| `camera_info` | Flat `Float32` array, `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` | +| `camera_info` | `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `text` | Arrow array of size 1 | — | diff --git a/dataflow.yml b/dataflow.yml index 4d09992..5099a51 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -12,6 +12,11 @@ # wires the viewer from a tag instead -- see README.md. # # Run it with `--uv` so every node gets its own managed environment. +# strict_types makes a type warning a non-zero exit. It also means a *typed* +# manifest input must be wired below: `dora validate --strict` warns +# `input_types key "..." not found in inputs list` for any it leaves unwired, so +# a new typed input on the node has to be driven from tests/source.py with a +# matching entry in `output_types` here, or left untyped. strict_types: true nodes: diff --git a/pyproject.toml b/pyproject.toml index 90bdd7d..5dda844 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,11 @@ dependencies = [ dora-image-view = "dora_image_view.main:main" [dependency-groups] -dev = ["pytest >= 8.1", "ruff >= 0.9"] +dev = [ + "pymarkdownlnt>=0.9.40", + "pytest >= 8.1", + "ruff >= 0.9", +] [build-system] requires = ["hatchling"] @@ -37,8 +41,13 @@ packages = ["dora_image_view"] extend-select = ["D", "I", "N", "RET", "UP"] [tool.ruff.lint.pydocstyle] -convention = "pep257" +convention = "numpy" [tool.ruff.lint.per-file-ignores] # Test names are the documentation here; a docstring would only repeat them. "tests/*" = ["D1"] + +[tool.pymarkdown] +# The prose here wraps near ruff's 88; 100 leaves room for a long link or table +# row without inviting unwrapped paragraphs. +plugins.md013.line_length = 100 diff --git a/uv.lock b/uv.lock index caf38c9..0631786 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,34 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "application-file-scanner" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-walk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/22/e872546d298103527380955f51191ff87cd178e6aac62bb87de73c3e074f/application_file_scanner-0.6.4.tar.gz", hash = "sha256:581c48c5017345747be7f49507da84fec36d1f7b4f67003e9fbaf2f0bc6a3f66", size = 28540, upload-time = "2026-01-19T23:32:15.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/83/e9a5a14c1b6c20ad44abcdb7bb2f34d860aa4f4ee64c526247066b6c42fb/application_file_scanner-0.6.4-py3-none-any.whl", hash = "sha256:49c211c60f1932812477facc38701d58037b07b031fdb6a6061fdee3fd53e35f", size = 15445, upload-time = "2026-01-19T23:32:14.4Z" }, +] + +[[package]] +name = "application-properties" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjson5" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/bb/321da0e373416080801d05b8dd9c0a8da77fe305c697adf8c025470ce170/application_properties-0.9.3.tar.gz", hash = "sha256:7dc7d8f23d11e539427e7b8e3afa70353e159c16f703bad6dd082cc6cdfeeaa8", size = 43304, upload-time = "2026-06-02T03:52:07.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/72/3ac1bda73e9c95444b1e13a0de73e847048a776e5ac5b3977c37f04167f9/application_properties-0.9.3-py3-none-any.whl", hash = "sha256:32dbd62b3fb657ec1c7fdc352590463c2ffd14950ffd258a5c091e8798d8aec0", size = 24595, upload-time = "2026-06-02T03:52:06.416Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -15,6 +43,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "columnar" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "toolz" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/0d/a0b2fd781050d29c9df64ac6df30b5f18b775724b79779f56fc5a8298fe9/Columnar-1.4.1.tar.gz", hash = "sha256:c3cb57273333b2ff9cfaafc86f09307419330c97faa88dcfe23df05e6fbb9c72", size = 11386, upload-time = "2021-12-27T21:58:56.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/00/a17a5657bf090b9dffdb310ac273c553a38f9252f60224da9fe62d9b60e9/Columnar-1.4.1-py3-none-any.whl", hash = "sha256:8efb692a7e6ca07dcc8f4ea889960421331a5dffa8e5af81f0a67ad8ea1fc798", size = 11845, upload-time = "2021-12-27T21:58:54.388Z" }, +] + [[package]] name = "dora-image-view" version = "0.1.0" @@ -31,6 +72,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pymarkdownlnt" }, { name = "pytest" }, { name = "ruff" }, ] @@ -47,6 +89,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pymarkdownlnt", specifier = ">=0.9.40" }, { name = "pytest", specifier = ">=8.1" }, { name = "ruff", specifier = ">=0.9" }, ] @@ -427,6 +470,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-walk" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/b5/e2f3fab1e11d4089b1c3dfd72175fdb2408ff8028e01bdb0d308923609bb/py_walk-0.3.3.tar.gz", hash = "sha256:a1b28d6079f27203fa3098b69a98572675b3ff5bd02286c43e6dacd66615f879", size = 1815727, upload-time = "2024-10-26T14:30:39.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/38/56b67abdbf6797475dfe2f62d391b4a6ead851c76acbaf07e118e53651b6/py_walk-0.3.3-py3-none-any.whl", hash = "sha256:238fc018165138021ce0bfd9c351cdc473d3120ccc5534df35611b92608c94d5", size = 14537, upload-time = "2024-10-26T14:30:38.06Z" }, +] + [[package]] name = "pyarrow" version = "25.0.1" @@ -479,6 +534,150 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjson5" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/9a/3db19560e968d6e85b2a4ddf4b949c6ebf9dd1dcfb5a9f37736f8adeb927/pyjson5-2.0.1.tar.gz", hash = "sha256:a5b0e322e847b198a50d8a1ef16d6b2b19129644dc018d76773e81ef1487ca39", size = 352242, upload-time = "2026-05-15T16:12:54.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/75/bd6b2297beef9730e7f265679de7105fbc9a49dd85dff28563851b9c679a/pyjson5-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1727cebec0ca73597188057bb826cd96c8503ced9b22a9cc73501f2e68d91ea0", size = 297438, upload-time = "2026-05-15T16:05:33.756Z" }, + { url = "https://files.pythonhosted.org/packages/ed/18/663ab5ad9aff987889440a2cb6f0415d334850444aba759211aa3eec10aa/pyjson5-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4833d46e8dc4bcba54db038305c8a7455d05cf8533d52a2297abff634cf69fa3", size = 155300, upload-time = "2026-05-15T16:05:35.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/9f68629021052044cf46bb89652a442f14505f3087d786ac4b18268235b9/pyjson5-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d73d09a5cad4e3325bd6737697d649a4594c4fde8df8eb784ed795dea845435f", size = 151680, upload-time = "2026-05-15T16:05:36.26Z" }, + { url = "https://files.pythonhosted.org/packages/ac/06/21da19b96b3215d266bf4662147d5ef70f65daf9bd4e82305ae61584908a/pyjson5-2.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac66d237c78b9ecda45506225c7eed648d2e6750532e15e1ac2052acf3e2d444", size = 189073, upload-time = "2026-05-15T16:05:37.691Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5b/48ec81dcee70f8d188ac04499207e3c8a893ec306a1ba4db5c38988f98c1/pyjson5-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1c0fa8af1cac361b2bbb030be942ead42bf872f279bc8e2666a8d5d6e7e7ee", size = 171985, upload-time = "2026-05-15T16:05:39.852Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/d79ac6cd317b3488e38f225c2cd8315fa54145faf939295b853bae70fe3c/pyjson5-2.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2e3ade7f34e1ad2b270fdaa6a11c31c7cb5f07379a422e9a714791d345e4bf", size = 169481, upload-time = "2026-05-15T16:05:41.751Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/e15cc07bf375a59d102536b233171eec621d1b8e9412596c0ef656828baf/pyjson5-2.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:55780f15c6d00a8d7847c7291c1a09492beab61460cf1b4725177b189cfaab65", size = 191706, upload-time = "2026-05-15T16:05:43.54Z" }, + { url = "https://files.pythonhosted.org/packages/4f/64/3c1c2b350071ccbfcc3a8e4eae66af960cc3193008cf47846cc1c775aebb/pyjson5-2.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f80ec4e18b2a8d42832f843b48cf06e77240c8e9cb046ea7e10752e8135ce87", size = 195462, upload-time = "2026-05-15T16:05:45.164Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/e0774939571faad2609fce9865ffd7a30b648f828fc2ca5acc364411f0ce/pyjson5-2.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94a645be2b846a6a1155296047176099b6ce987030fb7723208d364cb1b73eed", size = 183993, upload-time = "2026-05-15T16:05:46.952Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a2/ce59cab1733bed125193765f8d55ff316f151703eeb5c264b63200c4b26a/pyjson5-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c34ddc5a211ecebcfadeb9c698abd047245bac83ab7340811b77fd41aab76e4", size = 182419, upload-time = "2026-05-15T16:05:48.351Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/cf77e9193cbff64a8488fa005c62544d102fcc7f8d233932b8c026adf00a/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3614a579966cc22bc4e83599a786bd875e2c1de4baf71898763623f670df69a2", size = 1153689, upload-time = "2026-05-15T16:05:50.295Z" }, + { url = "https://files.pythonhosted.org/packages/27/d3/fb75865e713871e0fbb2c6c46a1569365f8778c59601939432dcf540b9d4/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1925fa0b53bf722d6d263e1eeafd6c009dd4dc975bc481e03786497b2c25eb08", size = 1013811, upload-time = "2026-05-15T16:05:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/7f/678c7a3ca28439558f42e78560d5e8096918e5fa1995edfc23702cb10a90/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:497b4d61617db4498a20b47827939034d9234c695d44df7dc907ec455068070e", size = 1327361, upload-time = "2026-05-15T16:05:54.035Z" }, + { url = "https://files.pythonhosted.org/packages/bd/86/f4b57480f00d546031f6ec6dda17780f996cceb1adf02fed586d2d985aa7/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d8ca0d3e4fc92dd20ae76d067ae692cac35161f83f0712c5b20d8f7ba73730b9", size = 1251975, upload-time = "2026-05-15T16:05:56.082Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/27be9cdb773cdeb334d423d57cc68be30c280df3d3b163c68b7a963d4e50/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6899d9e22660862fb9baf3eb5ac9b2887eb478b18d14f95de94feffed5ce0558", size = 1188190, upload-time = "2026-05-15T16:05:57.841Z" }, + { url = "https://files.pythonhosted.org/packages/cf/59/c9672530a00d433e5411440940d2454d39b19fde29c69eab064af136a157/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4671741493245604579cd9a9c044fd12341a0a61cf0fda8fb844812e29a1a488", size = 1367103, upload-time = "2026-05-15T16:05:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/b3/14/356b2a67f1d6d062df8345c98b1337556f75c18ca0f9f32921d2a28e1946/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f5ae05506fc86bbe5716ec102cd023d20d2051f43f4eb3a741b557616c3245d", size = 1217145, upload-time = "2026-05-15T16:06:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/dbf1f6f20cd54be3588b890648f46c1a0db111c7c988d3325e31c14f3510/pyjson5-2.0.1-cp311-cp311-win32.whl", hash = "sha256:90ef60fe98b6d0eaef3a6cdc81a6b1c6acb135a061bc695c00f3b6fdb86f3f90", size = 114798, upload-time = "2026-05-15T16:06:03.289Z" }, + { url = "https://files.pythonhosted.org/packages/48/87/933712d67031be67cf7c0298d655026b46b30539144f9f12f9084d80b03b/pyjson5-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9f5d62dfe22810a3ad2252c1b0d7f89b0673255263410d999d88bfd6f46b0ca8", size = 134709, upload-time = "2026-05-15T16:06:04.683Z" }, + { url = "https://files.pythonhosted.org/packages/c4/77/ce42988444b3e567d2b949970f96949e61aadbe535ceacb12afc2570b553/pyjson5-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:25ee8af17c19d0a31adc3672adc9c92429130e171e6fbe374468519892fd218b", size = 117230, upload-time = "2026-05-15T16:06:06.173Z" }, + { url = "https://files.pythonhosted.org/packages/68/41/be622b742fe4749fefa9d8a923b1cf5e51a1e8e4b4930fa8ea8c531b243d/pyjson5-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62394660d54e76ff8a968c8194b252be23d184f05a00238d4def9624454ea6", size = 302636, upload-time = "2026-05-15T16:06:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e2/50378c3bec9164c1bb983ca15ed59f8be498981b790a90d1410a1016ffe2/pyjson5-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:58dfa4cf5c327e14c530b16c23e812c021fe53b19e6f9cdfce257d8c02244895", size = 158588, upload-time = "2026-05-15T16:06:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/5de0f82c144b6fc2fa2e3fde5517257733a91d24734b5d2415b7e991e8fd/pyjson5-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07cb90a31f962a6de9e49592a306046ee015da73510c6d215e9481a2a21c9ac2", size = 153741, upload-time = "2026-05-15T16:06:11.114Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ac/174ca02e5f3ed8245dc48247e534880bbec1528c0c4eaa768fafa9417dda/pyjson5-2.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ef4026a90a68cd2bcb64d89ca064a1615b60a61c7694714e2b8ef30434f365b5", size = 185450, upload-time = "2026-05-15T16:06:12.883Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/bfc553176b7e109b72f23697ab1fd1a77d7fd5c6626d6e23936f13d5884e/pyjson5-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04b4929a7adf0e0720da92ac54c92d408e8057158be3ccc34da17c7767a9af78", size = 163331, upload-time = "2026-05-15T16:06:14.314Z" }, + { url = "https://files.pythonhosted.org/packages/b3/05/b0e67106f78c8ea5ffcf6ce0b8421f38a04d5f37ea88ac70123199755bcb/pyjson5-2.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e889b039d6bffda9a6b8d822073bbdb4a60166af22f075e7faa0c03c2dfcf17b", size = 166429, upload-time = "2026-05-15T16:06:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/81/3c/2366f8ecf053434470eafe1f120f2e235d13015fc50d7f06e55e39b550ab/pyjson5-2.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a08e99d0a33463728fb016f5eec680504499c4be280dac016de2da70ec55b0db", size = 180499, upload-time = "2026-05-15T16:06:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/76/65/9a70876d4dd09ce17678024266406b94b8f1edf9db6c2e3bf76cfa653a13/pyjson5-2.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:035c094feb6a9d4312183f9f34228d976102eefb971e9e3dd972d8c7900a466a", size = 187469, upload-time = "2026-05-15T16:06:19.212Z" }, + { url = "https://files.pythonhosted.org/packages/5b/43/0c0bcbf9a9ef7fdcd49dc0992a3a206244c2c2baa7b409932464cf26f1a5/pyjson5-2.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d178025a5317db44663dc87aab90f34674991b3da40dbedc3c108e7b03b6466", size = 176032, upload-time = "2026-05-15T16:06:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/82/2c/32213674010c44db265d45e977a431cbde971c45342b483f7af337811403/pyjson5-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:381ca5b3172bccf9c29c5823a0173ab9118d579d0f5bb4fca20b803daa6e72d5", size = 171477, upload-time = "2026-05-15T16:06:23.017Z" }, + { url = "https://files.pythonhosted.org/packages/f1/49/da5417d592ac75a23d1ca2dfda846391ccf137edd89054b780866c592b65/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e158caf0dd7fbf7afe12f92fcc9cba26bceac4b38615c087fd592e9e3240d9f", size = 1145571, upload-time = "2026-05-15T16:06:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/1f0c326b3a3267f28b7b9e2ac07121386ec20ee8f9dd49955af4faa17ba7/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c8010e9a02b6a0719234f8d074639f9051afe579ff0f74d3367af1d8b4c7d839", size = 1010388, upload-time = "2026-05-15T16:06:26.574Z" }, + { url = "https://files.pythonhosted.org/packages/de/c8/76f2739a8715061755f12f4d2795f6c4079aeadd166c56d0992521442d5b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3543e067d1d8ab6cb1cd41add38a42eaf82e9a7e802e8a08f1acda9f22199b00", size = 1323111, upload-time = "2026-05-15T16:06:28.805Z" }, + { url = "https://files.pythonhosted.org/packages/50/7a/683e1586fa076fb344aec23a647d67f427847b6bffb1001d56e86aa8da33/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e496938e7e0defc33bfbd0ff581d4ffdc428c598cc5d679232f34df27f7330a", size = 1240671, upload-time = "2026-05-15T16:06:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8e/6638b0b22344b23cae1271b0211668be54b3f76183bd8d4f89a2edd8e562/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2badbc101908123905a38789cc552b99ab896a7ec0ce4fa6ee2bb7b613c85c1b", size = 1178013, upload-time = "2026-05-15T16:06:32.212Z" }, + { url = "https://files.pythonhosted.org/packages/7a/21/d44540d3cdc670e5c6e665471361f557ef8c7d56964cd433524208b3707b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1b0df4898e2e046fa1e9b5df436fe11d9b681b1e5fc9877508d982d2059aaf1", size = 1354907, upload-time = "2026-05-15T16:06:33.922Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/fceedd2709760f5c85e75b508002af9b4c2da8af20a80e62b2d0e2358a15/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fcc96c429888f2293f672e33d01da8a0847cd5116a773c9a1903b8bc2f12bc1", size = 1208847, upload-time = "2026-05-15T16:06:35.883Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3a/d2f062b5801b3034ecb16ffb0801d7b4a1d6abae4cd4615482dbf62f1ed8/pyjson5-2.0.1-cp312-cp312-win32.whl", hash = "sha256:48c97e2f7f02171948f8a7ed6c9b2b2faea00653a3348d9e810238a688f07b6c", size = 115562, upload-time = "2026-05-15T16:09:24.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/13/2265cf16720defdc87672b68ff5edf83820bd76e59e32f8384c9cb1ae619/pyjson5-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8dea9976eea0aa7af8b5ad49df3fa831d6bda08ffd7bb11409d6a81961491219", size = 136176, upload-time = "2026-05-15T16:09:25.931Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/a8947e646ce642555ac22b31f44b7168c81b0e364d0f2711d571955229b6/pyjson5-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:264ab65fb7d68a763567c2302151f1f074bf053c8479939aa8e75f5f9518fe31", size = 116752, upload-time = "2026-05-15T16:09:27.274Z" }, + { url = "https://files.pythonhosted.org/packages/93/2c/0619f89a9576f335ba63eb851e73ba507480a8c7f28a2e7de2501ed3d303/pyjson5-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8a661d292801b38434d5288bf7329f9b50b3a693b1d2941c7595175842692eba", size = 301821, upload-time = "2026-05-15T16:09:28.721Z" }, + { url = "https://files.pythonhosted.org/packages/91/31/7824913ec71e7421d6a57bc06228f3e2d946d8e8f738f898572dded0dc57/pyjson5-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cb5c1c066038ce6e1922d9d5be23f5cd14d5b5a3c96e6358aa5d0e379014a93", size = 158201, upload-time = "2026-05-15T16:09:30.261Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7d/4ddb249563a838425242342d2cf67976ccc292a831b543a92fa1a8a83b11/pyjson5-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7417eab751817fa5f070e41975d9df4aec3532d21389073318161f63cd96d636", size = 153244, upload-time = "2026-05-15T16:09:32.04Z" }, + { url = "https://files.pythonhosted.org/packages/02/39/7622416ac0570d9ce377447bd5b2ec9383c1282e63cc6d0b65779f1336fa/pyjson5-2.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cc4802093b4ab9039367774988486fd04754cf416c32aebf94a94bafd8b8a478", size = 185568, upload-time = "2026-05-15T16:09:33.719Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/2f317da231477b77481020d79c73bbe10625e4925ceeae77603726ef1763/pyjson5-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc3181274ed19ecb2315cf85a499bf83002554f42c72453666c616059d665a7b", size = 163169, upload-time = "2026-05-15T16:09:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/02/b3/20023c3cfe2f2c2523007d7031b5b7ad7ccd8856d2665547683cebaa6f8e/pyjson5-2.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5398306b8aa253e620af9ed8085767de9cd28d6846da45e535e80fcffa9f33d7", size = 166514, upload-time = "2026-05-15T16:09:36.383Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1c/b863e502153477b2845a54b688b72436457aca5107b300fca95e98184551/pyjson5-2.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b97b592287d52a6ec5af84dca94aab02b185f72f8ddb6fc99c58efb8891be5cd", size = 179907, upload-time = "2026-05-15T16:09:38.166Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/413fcb76632e5f6fa89a2ec83976755b5bb4696c6d9eca9ff3c70cd717b6/pyjson5-2.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4efbf5e910a3aa6f330a92d1ae940dda5ed662976ed5056526baaf2e1821f95d", size = 187171, upload-time = "2026-05-15T16:09:39.624Z" }, + { url = "https://files.pythonhosted.org/packages/d6/11/66151b819407ab589aef36582038257f1ef42dc065e3423b3d8274f4fcd2/pyjson5-2.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e042f9b869e7f21d5f53a2638e5d0cf1daaba2342127c8777c502daf972602e", size = 175564, upload-time = "2026-05-15T16:09:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/f2/98/deb70690dab994474ea527cba91f43ae55928bcff498c441e812b60fc1e2/pyjson5-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26074370d7a6dd38b6e8c28a2f10c790fc6dec938ec22ec3de6c93c97d195f10", size = 171530, upload-time = "2026-05-15T16:09:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/a9/be/969752a3a052d00698b6e1dd926c627d7a6475a7c8dd390db148363544f8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cb163a495096716a7a01d052b60bda0308ecfaeddcb2d50a247ba9d707e3e40", size = 1145510, upload-time = "2026-05-15T16:09:45.005Z" }, + { url = "https://files.pythonhosted.org/packages/d0/be/4c5c92cdda5a911ee4450a74281782335e7ce6715638308322beb96be639/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5fdd66bdddc2d53421ab7427bedcd2ca9b2f1b4b3b464381aa60d305c94be38c", size = 1010114, upload-time = "2026-05-15T16:09:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/89/1e/72283bc505d77dcdab7eaeb0020cf0fd79a05d2991e886d1fcfa73e88ae1/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4ad222a3eff1cc93f9b70894c5f3d2ff5e46531e88feaa4818e329d5f3ae4307", size = 1322915, upload-time = "2026-05-15T16:09:48.885Z" }, + { url = "https://files.pythonhosted.org/packages/06/b4/94a09e744a6bb6e76108b61a28a7cc5ecab1b8105cacde7548359b3b74a3/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:63f5cf25113dd1bf3bfeda211d99413e58ec6b9036ff34f7b69f85f23695e9c4", size = 1240126, upload-time = "2026-05-15T16:09:50.638Z" }, + { url = "https://files.pythonhosted.org/packages/af/29/4549380cae425ee112f3c154606c35f5211c12ddf415f079c2b23f2d493b/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b0001eecce9080e6c170e978786501e8931e2075a3d15f5260e1bdd6a45e8976", size = 1178022, upload-time = "2026-05-15T16:09:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/5a8a869e855645858e45dbd077204cec3a85b2f2e669bc3c8cefe9c4a70e/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40371c73cc83ed81914028786a6cc5950bdf300ae82443df83a5dfa80c582fca", size = 1355161, upload-time = "2026-05-15T16:09:55.177Z" }, + { url = "https://files.pythonhosted.org/packages/82/32/827066cd946447648275a892f494f4572d78e6a79d877fa6b6c14af599d8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9dca542eeb6e8fbbc1fe3373bfd18ae6250f1aedc78f39242ac12c8837091b8", size = 1208864, upload-time = "2026-05-15T16:09:56.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cc/a9bc12aff47d8bfd3074a21e3fb056ec0028fac28f8ec7e0fe4449d050f5/pyjson5-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d30c8b2c91d530be475a8fab6dde8c3bcd3e89b999c9e0f05aec2bf2c24b0638", size = 115461, upload-time = "2026-05-15T16:10:30.711Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c7/fa3fc956fbc3fa6250c8b99ef75a8f52b691780fca1bfb368340283bd898/pyjson5-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:67f8f5b8d3e3b2ca5f618c928729986aa00fb588c476c0d0d3a151633ff41e0d", size = 135836, upload-time = "2026-05-15T16:10:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/e457b20d1e36a766d3ccd09c418b7ef41acabb679ff2248bb1cd38247ce6/pyjson5-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:bdf30ce5b1242f63254d936b30af75cae237fab0ee71cc2544163c82b6bc9201", size = 116652, upload-time = "2026-05-15T16:10:33.39Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4c/6266789e615576b62d2db6de26a3b4b8f4cc7a8ff23fe24e48363bca1682/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:954c14d40022fa40e0d36e0ebd337e9e90fd71145592d25a0e2868344e3daca0", size = 320487, upload-time = "2026-05-15T16:09:58.38Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/c4a563034805e20b274fe4db963e1d480001931d88fe70b7d082033b7dcb/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f57529b98d21e8b76f8bc51af9d3c3057dd04a92287fd21ef7ec55255ca6f5c5", size = 166879, upload-time = "2026-05-15T16:09:59.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/5e6ec3580379794e9a99a402465f74586e93e93814974172202d1254fa89/pyjson5-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:545b655ef0b59f39fc29e2b63b4dadd45e447ad77bad2fdca859ff4db69e21f5", size = 162235, upload-time = "2026-05-15T16:10:01.147Z" }, + { url = "https://files.pythonhosted.org/packages/5b/48/b2d0e868ef8375eb0696ddc74f71028fc4fcd26cdddaeb34485214f2dc87/pyjson5-2.0.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b519de49dd4cbf254179749e5c6bfe9bd8fd1f97e7755bff2e17cc6f79f85aa5", size = 182162, upload-time = "2026-05-15T16:10:02.523Z" }, + { url = "https://files.pythonhosted.org/packages/ce/18/fed02a3d68c3badb87394e8420b72d7cb165ade208c1e02561637409aa99/pyjson5-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de48fbd0466c114c17f408be7bcbc5b3aedeb29ff5f73187022915e6d5eb7817", size = 169487, upload-time = "2026-05-15T16:10:03.876Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/dc897332dda24e949828616e8e74d7937b587a56789f3aca148fcadddde2/pyjson5-2.0.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fe6516ff9ab94368c18ff8ac0ebdb53fac9e047da093d43e67d5e7cd98cdd0bf", size = 164128, upload-time = "2026-05-15T16:10:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/39/09/90104baeab58adfe63bda804174ef1806b1cabb1692e047fabc2f4aa1799/pyjson5-2.0.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea7aadacac7ac6661117b708175f6beff75e132322c08bbf3794d8ec4ba572b4", size = 184306, upload-time = "2026-05-15T16:10:06.986Z" }, + { url = "https://files.pythonhosted.org/packages/89/73/613efc3cdab5cf9ec399e6c5a0463b672e5f5cbd693a3a4d82cbdda19476/pyjson5-2.0.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5c339bcac113dfc30a1f88f23af273d67bb6169abee1684c0dd27daa71a32f9", size = 192239, upload-time = "2026-05-15T16:10:08.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/17b97e02b37ab9353fc9713e56470be3d2539501ed18d6c676cfbcd0a57c/pyjson5-2.0.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:075de0f1e5e0ffee174a8941b5d9d8af632772a6e5eebe92fb0899db7f6ab7fa", size = 177652, upload-time = "2026-05-15T16:10:10.19Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/a05331ba88fd1aa09ca418548c6b06692e34b2a64b109bb363e01ecafdeb/pyjson5-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c463c9bf508c1d316191898a3c0bfc03dd3de67bdc02f480b0742ac5c859eaeb", size = 174860, upload-time = "2026-05-15T16:10:11.976Z" }, + { url = "https://files.pythonhosted.org/packages/2e/00/5d253751f4d27b7e63fdfd765041593fa588395f45b9cea496878d964448/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:54427c8cad0c1a516bad5b4457167cde1e7ef7ee563e28a944f59b3c7413b6a0", size = 1151556, upload-time = "2026-05-15T16:10:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f7/a30eed477295e92dfd59553063cf82bf510a7052efc7720c9c50d0082fdb/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca04839cecd364cb53de7ebcd81fd5915193707d968730ea1c8d4ff5c9c2f1a8", size = 1012330, upload-time = "2026-05-15T16:10:15.171Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/80818fad90a99336f8bfc686d76d0b2d7c8bb51e8de5d031620fa1a2d7a1/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13a2de8b14aeb576b1623c8af419510bf871b8d9cc2308db41a46bcc94d00f2f", size = 1320970, upload-time = "2026-05-15T16:10:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3f/090eb3e0971d067defb58bbfd738f74a09be495c6f4f00d5d76a10755bca/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8a689f5252e9791bfbc8fa916999202556a1e8c7b24dfd72de1ad84c493e10a", size = 1244775, upload-time = "2026-05-15T16:10:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/61/38/e7546ad733affe51a5462bad21bceb4fd659baf24930bd141627ce56279b/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc0c2c80fd9d1e4c26f8b6c91a54d38b48648b93ae3f09355856c75fda3e460", size = 1182149, upload-time = "2026-05-15T16:10:21.696Z" }, + { url = "https://files.pythonhosted.org/packages/90/2c/016647580d8a82ee53b4cb9ddb96deb4b157ccea9624bba61de4aa2cd25c/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:13d78b3e2c60b81bd7e6b00e6ab24a664ba1fd64b5d86baa65c4cdfae1fff7ae", size = 1358579, upload-time = "2026-05-15T16:10:23.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/45/8b84ff7a0c4d8c12a76e097c67af62abe986a18d1ab2540764dd6eaa0253/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a7b640dbeabbd7d975170f793f90a7500cc5d510aeae9364a1bc558bcc5336ed", size = 1211015, upload-time = "2026-05-15T16:10:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/cf/48/d8f34de7a7319f5966bdfd10f133e689ba01f138d2040bf792f1ca8b18e1/pyjson5-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:46e7c5525034fde8abf3aaf100ed7660c1b618ccd3205544ed7c99e4c55cf201", size = 131660, upload-time = "2026-05-15T16:10:26.747Z" }, + { url = "https://files.pythonhosted.org/packages/06/aa/0bd437252134115a846ffc061078c85f8e8c325c86abcaa862c134d3c57e/pyjson5-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:207c00e7f9641e0358bdc848a1c0610f26f2e8bd51d73bd64e5a0ab3b725f1d8", size = 157717, upload-time = "2026-05-15T16:10:28.053Z" }, + { url = "https://files.pythonhosted.org/packages/24/61/7849b04a0dc78f73905189400ac99c45de8eec2ec451ce9c6990fb3588e5/pyjson5-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4917d5b6186bfa48ec4d85326e2a09769a5f7844963307ab2710429e1f743264", size = 126281, upload-time = "2026-05-15T16:10:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/70aa1cb1fb0a3ac4c9b8cd405c0c85ba935c53fdfae6271b8eae364b92d1/pyjson5-2.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:573fecd7cad4e24d232053f9ebf331c80fe1b0ed6e2064f7614797a7f691e7ca", size = 303706, upload-time = "2026-05-15T16:10:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/caf44bf3d33b9502dc4b8ed5d0c7a8af8fbe33001e82414c0407f39f18bd/pyjson5-2.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8ade508a046a5407a322c098e3b7e6033216b158a7746eb8962b7a4cdbf9248", size = 158915, upload-time = "2026-05-15T16:10:36.496Z" }, + { url = "https://files.pythonhosted.org/packages/3f/94/f2bab1ce2eac8f77e16dcd0a1ba39de20733a84b9961c3504af0dd68bceb/pyjson5-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4635e349bb1d1f1e854dc790f14be31b6cb198d6453848e8d86818104074f805", size = 154199, upload-time = "2026-05-15T16:10:37.902Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/d94573ca37486af3d6a72f2582844d7d490d8e55639e0cfba371ce91442f/pyjson5-2.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d274697f81f143abac12ce256a06b61635c30d0f8cc11eefae7182655dac9a5", size = 186060, upload-time = "2026-05-15T16:10:39.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/c42e697def319b286fdcb912939c044cc94bd1cfc7338b6dbb566f817f29/pyjson5-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4e2242e55a2651fd8696cfd67ec8e04f54f4ed6c089cceea2b63763a7516d8", size = 165796, upload-time = "2026-05-15T16:10:40.967Z" }, + { url = "https://files.pythonhosted.org/packages/3f/dd/f22a5f0e619ef22b8e32520a91bd92f815d50f9ec2d67cfe5974eab9476c/pyjson5-2.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af855c680feaa39cae4a44914ee2863eeae1549f37ce58069c747b3d4803999a", size = 165262, upload-time = "2026-05-15T16:10:42.391Z" }, + { url = "https://files.pythonhosted.org/packages/99/8b/90e22ecb12d51ccdc68325b6b051002e6103cc4600a335d9ed13828acdd9/pyjson5-2.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb769d90516da904e6cf0ba58f3d5830f4a5804486b5e85c5ca42ba3146d187a", size = 181608, upload-time = "2026-05-15T16:10:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ab/237f9036eed73e08cf8861466d3d1182ef1c88506bd29955740dcadb24ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6484c14e07aa46abeb3cb2ff0204a765260763ad7cf3f87f601f26b20ed607f3", size = 189582, upload-time = "2026-05-15T16:10:45.367Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3d/3df8d5f003910a9291e5f04fa178f626e3c8553a5986dc62589ca74195ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc03673adb544324500d79b2acfe2ced038998b2aceb564d335de9cc238cbdb0", size = 176321, upload-time = "2026-05-15T16:10:47.134Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d9/a458a54f780bafec1b44ee3c27cb3007a83fb7bd4f1c17ac6bf519fd6151/pyjson5-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d714daf784bec2e14fc06c0c26e4a373a6157eaeddaed2d5a7b7b6ae2aabc33", size = 171956, upload-time = "2026-05-15T16:10:49.174Z" }, + { url = "https://files.pythonhosted.org/packages/78/d8/1010e0147c8862dc0881cf3656364d3d87a5e336d290fe4cb0b92223e14c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cf3ded356730e08b5941a16db6525efc0de26f35468ab09ad573056c4efc6e5", size = 1147679, upload-time = "2026-05-15T16:10:51.005Z" }, + { url = "https://files.pythonhosted.org/packages/19/2f/91c8d8cdb4a2e4bbe8f14b9b57d717988e14830f2ed4a4f08d503cb5edde/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cc00d4669fb4170c28b2c9ed3cd1c2af9f21727c55d6866030cc154f31f68747", size = 1008216, upload-time = "2026-05-15T16:10:53.252Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d7/25c3f8693660a35be7bb4eb71b0d10d9a6981e986723a4881d607f0c6462/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a77a91c2e019476345c03cde105da44100daaabc3fa56f82f2bd9eb2ebbcb698", size = 1323856, upload-time = "2026-05-15T16:10:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/82f0f83556ff68ec0ca1129ba9afeb14149cd421dc74823cdf10c209c95c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9ae54e5d717982cb7a082c700ab1f7f965c6aedea6fcb003ec4bdeac4f02bf52", size = 1241860, upload-time = "2026-05-15T16:10:57.635Z" }, + { url = "https://files.pythonhosted.org/packages/c1/01/2c4695fc06a0f3f29041a875d3bb24f904c9ae7b3d8dbfa1f8db17ce995c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ce4f5b67b3a6fab623a31d83416b51a5b2c97cb172194f214078484bbb4783a6", size = 1178443, upload-time = "2026-05-15T16:10:59.398Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ae/c10f534037b096aca21b90017ac9d41f791572dc3ec31fcd92db8ddcd256/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f8d2e61f7bed0b40cfe5f62375581bf56d7b5b9707c9d74747b5931149d7d376", size = 1357739, upload-time = "2026-05-15T16:11:01.189Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f0/655224f78ede087aa6a295c4745423e0104dd31f67928675b1a4cea72a0a/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2930140edd1a64eb42689993c5ae317bca2e6bc785b5dc5053bd5e9d184c98ad", size = 1209542, upload-time = "2026-05-15T16:11:03.364Z" }, + { url = "https://files.pythonhosted.org/packages/73/52/a7b2a26625136fcd0f92e17beaaae51e04923cc2e28502db354d4de061b4/pyjson5-2.0.1-cp314-cp314-win32.whl", hash = "sha256:51733f91dda897239ca10928f363ce6f4b5eadaf797e74477f6c1c3222c185a7", size = 118342, upload-time = "2026-05-15T16:11:40.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/79db426fff3a41610076989d4060cc18a6508ebd3c7877155f6709eea102/pyjson5-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:698c73aacff49ea35bbbf7f700a97785626201ea7aa2e1f1e0a4fe23788c3be2", size = 138408, upload-time = "2026-05-15T16:11:41.757Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/385da14c05412bca15e86551dde91959686c8bddf7e78722bdc627fa6815/pyjson5-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d447e2e5756f89abfd0ad82d1438075bcbc701c1cc9279b43d24825aaac67356", size = 120905, upload-time = "2026-05-15T16:11:43.146Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/cd69739556d304d3ea48064ea7d47e5ff7321b0032d7ad78864bceaa0cae/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5603be4f0cb9685d7c2cbd408ddec21b33f415252bee00ac7969f20b5789a1d1", size = 321150, upload-time = "2026-05-15T16:11:05.07Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/6d98268ed07a2cac1e79634fcd3dc280d2503ade5f3c465f7aa095951444/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87267520a256b3f2dba7f2995b745e8b3f2dd72bbf8436010a5191f7809f8ab6", size = 167540, upload-time = "2026-05-15T16:11:06.822Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c6/8855a9462bdbc8306cba62793e23cc2952dfcbcbcab8e0413f570c891361/pyjson5-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77e58307b74019d90aff9f2781bcffbbea3315b15fc814f8fa9b8d334b956ff4", size = 162533, upload-time = "2026-05-15T16:11:08.218Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/1092f68538ee71ed8027393ab5e71aa9c4a114e493e30a55e46e34c2ffb9/pyjson5-2.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd1712038837342dea73ed526883e53c6e85a158242b6f574b9ad9cdd3199c7f", size = 184165, upload-time = "2026-05-15T16:11:09.639Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/bb9f42a4047284ea59064c134c88dfac3b7f5273e40fe548d6a086cea452/pyjson5-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dba36677c77aed5d680827c65478933705ca156a34838f1250ea191ffc27662", size = 170075, upload-time = "2026-05-15T16:11:11.105Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/0379aa5184e986d0f9af2919712c148d81928ce7d2d7950407e271b1df3b/pyjson5-2.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:04356d0c09e58907f60675d33859f269473479b90add5026231ef2fadba5207e", size = 164100, upload-time = "2026-05-15T16:11:12.909Z" }, + { url = "https://files.pythonhosted.org/packages/15/d5/2087af69695c28c7fe796afc73dec7f0b0b9bc123ec329e45928d58d4705/pyjson5-2.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b331a0fbe2ae26f4ccb88045ebaecc00aeeeab23ff858f9a2223cedd969ec6d6", size = 184674, upload-time = "2026-05-15T16:11:14.448Z" }, + { url = "https://files.pythonhosted.org/packages/2a/06/cf1b2744e07f1689cb0ac663e162a060b81cd358474754fe9f3bf02d5398/pyjson5-2.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ca55878d923ba5764254ad1c020c22b33e6161b3555d1f457b8060f2b3c6c17", size = 193047, upload-time = "2026-05-15T16:11:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/e6/a6b0deb6a3f393907c6d9115a7ac8e27557109204b56b05d3b57c51fc2ca/pyjson5-2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35e18d11e4c2034b78a664affe18c153a8efbf8872cf6bed7f12dc4fb819d440", size = 178712, upload-time = "2026-05-15T16:11:17.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/48/9e5c16daba56dfdb481baab85d1197e14ae3f403902fc23cdca14d14351b/pyjson5-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b710a7489b6e134890eaa1fdb285e9644a1e730c1cfcf1cd90a87859b87a84a9", size = 175642, upload-time = "2026-05-15T16:11:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/c3/32/f07d4dbd8cf733f9e3f3e9c9abedfbc23da1e5cfa7fed46aebae07ca6f25/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:08920c0dd6aba3fb6bc6c849e6f05731c5fb2716cd651de424ff60cdd12eae65", size = 1152219, upload-time = "2026-05-15T16:11:20.54Z" }, + { url = "https://files.pythonhosted.org/packages/01/9d/0ddbb89d381bc27f3740850d5d664c1f6f76620ad879ff5eb2506a4dfd6c/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e166dadb3275025cff5ee8602131372a3ea171c6727b3fc6e44697348e3972c5", size = 1013305, upload-time = "2026-05-15T16:11:22.702Z" }, + { url = "https://files.pythonhosted.org/packages/bb/93/0f44886391dd2249ad9cf98c2deb3f26d1a115dd5a215629af7455765968/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea3c2b4e7b8e209e7f59bd6a925d79b69cd0a4ffa12f2df0c6af55514c8b2227", size = 1322840, upload-time = "2026-05-15T16:11:24.918Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/c478dc24dfcfc07e407ff8669df265ecc51fed5e7d47e51a01f17fd1c53f/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b710fc8ce00c984c8865131f05a8536163e1d3caffb9d081647879eca7424670", size = 1245221, upload-time = "2026-05-15T16:11:26.873Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/5fef8c47c5b7052da79af6425a021040402a9a09b37c5687d91f98696a27/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9784d79275d1b06d95f2920c3f260ce5d3f6671f65a1fae1464f83fbfc834a0", size = 1182991, upload-time = "2026-05-15T16:11:29.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/33/4d3a9d3159cdff1f1886d39f1991e6c8d5927f9b606cfc9743b5bef98c2a/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0ef86c53d0e14991b5b0fe58f225e3d8faec437aa0a70da4762525b6bc2fca10", size = 1359104, upload-time = "2026-05-15T16:11:31.651Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/20eb16c52453aea0af68a697f4058378c9ff871bd2f0bea28eb76ffe12f8/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d9faccf5a9e86f14104eec31c13428ef8baeb867182ce107e0c68fcc5c62477", size = 1212847, upload-time = "2026-05-15T16:11:33.767Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/69bbe93275eafb7802a5b29230cc5c28f9b924c68d15b3ab2d43cfe19825/pyjson5-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:2df497af7ab03cf82d9278a4707a83d0ae4e6d727f919a883b5ccec3f7d92650", size = 139137, upload-time = "2026-05-15T16:11:35.487Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/b865ca6e0ae6887bc2b6b17cd123470ae43ac0fa04a2362d77d4b9f5bd43/pyjson5-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b31cf4f4a4f01800812865af3b02f6700cefb9377e4d9a9c6b78fdcf41fd973", size = 169412, upload-time = "2026-05-15T16:11:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/68/55/02c734459fef0a955ab3e7e745df415d5f9fc873a4c288765f60f22fbc13/pyjson5-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:15f0d8baea89d35c6c01c60b944c778a79cac10f77f71b727f1b244e2c7a8ccb", size = 129061, upload-time = "2026-05-15T16:11:38.712Z" }, +] + +[[package]] +name = "pymarkdownlnt" +version = "0.9.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "application-file-scanner" }, + { name = "application-properties" }, + { name = "columnar" }, + { name = "py-walk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/ea/a784da505745c5eb19b7b6be62a0cee4c25c42ba72e010413a7ebaa40ca7/pymarkdownlnt-0.9.40.tar.gz", hash = "sha256:9e6b1137b9f4b496da10f838bc30f66e4c182a0bf53d93d3afce65a541ded091", size = 467022, upload-time = "2026-09-13T20:29:33.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/1e/e2d60317291cedff329cfe08dff7d5a2aefe51ef5f336997e1f2e8a08a94/pymarkdownlnt-0.9.40-py3-none-any.whl", hash = "sha256:de92072911fcb1e5476a7d453078d51457329f5e5cfd78c37d29efc363500262", size = 549389, upload-time = "2026-09-13T20:29:31.653Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -574,3 +773,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] + +[[package]] +name = "sly" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702, upload-time = "2022-10-25T14:35:30.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864, upload-time = "2022-10-25T14:35:28.054Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +] -- 2.54.0 From c1dba8998d6b1516bdf008ab0845d71be1346144 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 06:09:33 -0300 Subject: [PATCH 10/14] build with uv rather than pip The build command was `pip install .`, which is what the dora docs use because `--uv` rewrites it. Spelling it `uv pip install .` means the dataflow never installs through pip at all, whether or not `--uv` was passed. The cost is that a node then needs a virtualenv to install into, so dataflows must be run with `--uv`; AGENTS.md says so. Note that the PR #3 branch still carries `pip install .`; landing this branch on top of it is what corrects that. --- AGENTS.md | 3 ++- README.md | 2 +- dataflow.yml | 6 +++--- dora-node.yml | 10 +++++----- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64f637a..68886b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,8 @@ Rules for AI coding assistants working in this repository. `.gitea/workflows/validate.yml`. - **`uv` for everything:** `uv sync --group dev`, `uv run pytest`, `uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient - environment. + environment. A node's `build:` is `uv pip install .`, so dataflows must be run + with `--uv`. - **`ruff` is the linter and the formatter.** `uv run ruff check .` must be clean. - **Markdown is linted too.** `uv run pymarkdown scan *.md` must be clean. Its line length is set in `pyproject.toml`. diff --git a/README.md b/README.md index bd7dc9a..d7f4ee3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ nodes: - id: view git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git tag: v0.1.0 - build: pip install . + build: uv pip install . path: dora-image-view inputs: image: camera/image_bgr diff --git a/dataflow.yml b/dataflow.yml index 5099a51..bf33145 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -4,7 +4,7 @@ # # It lives at the repository root rather than beside tests/source.py because a # node's working directory is the directory of the dataflow file, and both -# nodes build with `pip install .`, which needs pyproject.toml in that +# nodes build with `uv pip install .`, which needs pyproject.toml in that # directory. # # Both nodes are built from the checkout with `path:` + `build:`, which is what @@ -38,7 +38,7 @@ nodes: args: tests/source.py # The source needs dora-rs, numpy and pyarrow; installing the project gives # it exactly the versions pinned in uv.lock. - build: pip install . + build: uv pip install . inputs: tick: dora/timer/millis/33 outputs: @@ -57,7 +57,7 @@ nodes: - id: view path: dora-image-view - build: pip install . + build: uv pip install . inputs: image: source/image depth: source/depth diff --git a/dora-node.yml b/dora-node.yml index beb1a55..68fbdc1 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -13,10 +13,10 @@ keywords: [opencv, view, bbox, bounding-box, image, depth, intrinsics, camera-in runtime: python entrypoint: dora-image-view -# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and -# gives the node its own managed venv under `.dora/python-envs/`, so the -# dependencies it sees at runtime are the ones installed here. -build: pip install . +# Run the dataflow with --uv, which gives the node its own managed venv under +# `.dora/python-envs/`, so what it imports at runtime is what was installed +# here. Without --uv this needs a virtualenv to install into. +build: uv pip install . dora: ">=1.0" inputs: @@ -88,7 +88,7 @@ example: | - id: view git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git tag: v0.1.0 - build: pip install . + build: uv pip install . path: dora-image-view inputs: image: camera/image_bgr -- 2.54.0 From c9715a34d55c12a609346051d0d22a85110c2132 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 06:14:00 -0300 Subject: [PATCH 11/14] move the workflow to .github/workflows act looks in .github/workflows by default, and running it locally before a commit is one of this repository's rules; keeping the workflow where act cannot see it meant every invocation needed -W. Gitea Actions reads both locations, so only one of them may exist or a push fires the run twice. --- {.gitea => .github}/workflows/validate.yml | 0 AGENTS.md | 12 +++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) rename {.gitea => .github}/workflows/validate.yml (100%) diff --git a/.gitea/workflows/validate.yml b/.github/workflows/validate.yml similarity index 100% rename from .gitea/workflows/validate.yml rename to .github/workflows/validate.yml diff --git a/AGENTS.md b/AGENTS.md index 68886b5..2b03ccc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Rules for AI coding assistants working in this repository. - **dora >= 1.0.** Verified against `dora-cli 1.0.1`, which CI pins in - `.gitea/workflows/validate.yml`. + `.github/workflows/validate.yml`. - **`uv` for everything:** `uv sync --group dev`, `uv run pytest`, `uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient environment. A node's `build:` is `uv pip install .`, so dataflows must be run @@ -14,13 +14,15 @@ Rules for AI coding assistants working in this repository. - **numpy-style docstrings.** - **Dependencies must ship prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64.** `uv sync` is the whole install: no system packages, no compiler. -- **Run the workflow locally with `act` before asking for a commit.** It lives in - `.gitea/workflows/`, which is not where `act` looks by default: +- **Run the workflow locally with `act` before asking for a commit:** ```bash - act pull_request -W .gitea/workflows/validate.yml \ - -P ubuntu-latest=catthehacker/ubuntu:act-latest + act pull_request -P ubuntu-latest=catthehacker/ubuntu:act-latest ``` + Workflows live in `.github/workflows/`, not `.gitea/workflows/`, so that `act` + finds them with no `-W`. Gitea Actions reads both locations; keep only this one, + or a run fires twice. + - **Do not commit or push.** Leave changes in the working tree and wait to be asked. - **Do not mention AI tools in commit messages or documentation.** -- 2.54.0 From 363bf006beee72c50ee0805d905b15c87b380229 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 06:20:14 -0300 Subject: [PATCH 12/14] lint markdown recursively `scan *.md` only matched the repository root, so a file added under any subdirectory would have stopped being checked without the CI step noticing. Scanning recursively follows the files instead of the layout, and --respect-gitignore keeps it off .venv and .dora. --- .github/workflows/validate.yml | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index aae5dce..a0f1b4e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -69,7 +69,7 @@ jobs: - run: uv run ruff check . - - run: uv run pymarkdown scan *.md + - run: uv run pymarkdown scan -r --respect-gitignore . - run: uv run pytest -q diff --git a/AGENTS.md b/AGENTS.md index 2b03ccc..7d3153e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Rules for AI coding assistants working in this repository. environment. A node's `build:` is `uv pip install .`, so dataflows must be run with `--uv`. - **`ruff` is the linter and the formatter.** `uv run ruff check .` must be clean. -- **Markdown is linted too.** `uv run pymarkdown scan *.md` must be clean. Its line +- **Markdown is linted too.** `uv run pymarkdown scan -r --respect-gitignore .` must be clean. Its line length is set in `pyproject.toml`. - **numpy-style docstrings.** - **Dependencies must ship prebuilt wheels for linux-x86_64, linux-aarch64 and -- 2.54.0 From 15e0b9155a4e7a27a6c67437234d196da3509cb7 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 06:26:10 -0300 Subject: [PATCH 13/14] add a worked example wiring dora-webcam into this node The only dataflow here drove the viewer from a test fixture, so nothing showed how to consume a real producer. dataflow_webcam.yml wires dora-webcam v0.1.0 into it: the camera publishes std/media/v1/Image and this node draws it, with depth, camera_info, text and bbox simply unwired, which is what every input being optional buys. The dependency points one way only. A consumer naming its producer is an arrow; dora-webcam names no consumer, so there is no cycle. Two things are deliberate. The camera's output type is repeated in the dataflow because a `git:` node's manifest is not read at validate time, only at build, so without it the two contracts are never compared -- a deliberate mismatch is caught with the annotation and passes silently without it. And the file carries no strict_types: a camera publishes an image and nothing else, and strict validation warns about every typed input a dataflow leaves unwired. Interoperability was verified directly, feeding dora-webcam's packed payload into this node's decoder across bgr8, rgb8, jpeg, png and yuv420: the struct types are identical and the lossless encodings round-trip exactly. The dataflow validates but was not run: with dora-cli 1.0.1 a `git:` node pointing at a private repository does not build over either transport, which the file and the README record. --- README.md | 25 +++++++++++++++++++++++++ dataflow_webcam.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 dataflow_webcam.yml diff --git a/README.md b/README.md index d7f4ee3..9c63c1d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,31 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv `SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size. +### With a real camera + +[`dataflow_webcam.yml`](dataflow_webcam.yml) wires +[dora-webcam](https://gitea.liria.cl/liria/dora-webcam) into this node, and is the +worked example of consuming something that is not a test fixture: + +```bash +dora run dataflow_webcam.yml --uv +``` + +The camera publishes `std/media/v1/Image` and this node draws it. `depth`, +`camera_info`, `text` and `bbox` are simply unwired — every input is optional, so a +plain viewer is the result. + +It carries no `strict_types`, deliberately. A camera publishes an image and nothing +else, and `dora validate --strict` warns about every *typed* input this node declares +that a dataflow leaves unwired. The strictness belongs to `dataflow.yml`, which drives +all of them. + +**Known limitation (dora-cli 1.0.1).** A `git:` node pointing at a private repository +does not build: over `ssh://` on a non-standard port the connection fails, and over +`https://` libgit2 reports `remote authentication required but no callback set`. The +dataflow validates and the wiring is the form a consumer should use, but running it +needs that fixed or the repository made public. + ### Node manifest [`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source diff --git a/dataflow_webcam.yml b/dataflow_webcam.yml new file mode 100644 index 0000000..4c6604c --- /dev/null +++ b/dataflow_webcam.yml @@ -0,0 +1,40 @@ +# A real camera into the viewer, and the worked example of wiring this node to +# something that is not a test fixture. +# +# Not run by CI: it needs a camera and it fetches dora-webcam over the network. +# dataflow.yml is the gate, and needs neither. +# +# Known limitation, dora-cli 1.0.1: a `git:` node pointing at a *private* +# repository does not build. Over ssh:// on a non-standard port the connection +# fails outright, and over https:// libgit2 reports "remote authentication +# required but no callback set" -- dora has no credential callback, so neither +# transport authenticates. This file validates, and the wiring below is the form +# a consumer should use; running it needs that fixed, or the repository public. +# +# `strict_types` is deliberately absent. A camera publishes an image and nothing +# else, and `dora validate --strict` warns about every *typed* input this node +# declares that a dataflow leaves unwired -- depth, camera_info and text here. +# The wiring is correct; the strictness is aimed at the self-contained dataflow, +# which drives all of them. + +nodes: + - id: camera + git: ssh://git@gitea.liria.cl:5001/liria/dora-webcam.git + tag: v0.1.0 + build: uv pip install . + path: dora-webcam + inputs: + tick: dora/timer/millis/33 + outputs: + - image + # dora-webcam's own manifest says this, but a `git:` node's manifest is not + # read at validate time -- only at build. Repeating it here is what makes + # the two contracts checked against each other now rather than at runtime. + output_types: + image: std/media/v1/Image + + - id: view + path: dora-image-view + build: uv pip install . + inputs: + image: camera/image -- 2.54.0 From f7171de2af43233fc8542e62d39089543c617dc2 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 06:29:37 -0300 Subject: [PATCH 14/14] fetch git nodes over https ssh:// cannot authenticate under dora-cli 1.0.1: it fetches through libgit2 with no credential callback, so the only transport that can work is anonymous https. The documented URLs move accordingly, and the repository is now dora-image-view rather than dora-plot. The note about what a `git:` node needs is corrected while here. The cause is not that a repository is private but that the fetch is anonymous: on Gitea a repository inside a private organization is internal and answers an anonymous clone with 401 even when the repository itself is marked public. The organization has to be public too. --- README.md | 13 +++++++------ dataflow_webcam.yml | 13 ++++++------- dora-node.yml | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9c63c1d..ff3ba1a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Add to your dataflow: ```yaml nodes: - id: view - git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git + git: https://gitea.liria.cl/liria/dora-image-view.git tag: v0.1.0 build: uv pip install . path: dora-image-view @@ -85,11 +85,12 @@ else, and `dora validate --strict` warns about every *typed* input this node dec that a dataflow leaves unwired. The strictness belongs to `dataflow.yml`, which drives all of them. -**Known limitation (dora-cli 1.0.1).** A `git:` node pointing at a private repository -does not build: over `ssh://` on a non-standard port the connection fails, and over -`https://` libgit2 reports `remote authentication required but no callback set`. The -dataflow validates and the wiring is the form a consumer should use, but running it -needs that fixed or the repository made public. +**A `git:` node needs anonymous read access.** dora-cli 1.0.1 fetches through libgit2 +with no credential callback, so a repository that asks for credentials fails with +`remote authentication required but no callback set`, and `ssh://` cannot authenticate +at all. Note that marking a repository public is not sufficient on Gitea: a repository +inside a **private organization** is treated as internal and still answers anonymous +clones with `401`. The organization has to be public too. ### Node manifest diff --git a/dataflow_webcam.yml b/dataflow_webcam.yml index 4c6604c..728907f 100644 --- a/dataflow_webcam.yml +++ b/dataflow_webcam.yml @@ -4,12 +4,11 @@ # Not run by CI: it needs a camera and it fetches dora-webcam over the network. # dataflow.yml is the gate, and needs neither. # -# Known limitation, dora-cli 1.0.1: a `git:` node pointing at a *private* -# repository does not build. Over ssh:// on a non-standard port the connection -# fails outright, and over https:// libgit2 reports "remote authentication -# required but no callback set" -- dora has no credential callback, so neither -# transport authenticates. This file validates, and the wiring below is the form -# a consumer should use; running it needs that fixed, or the repository public. +# This needs anonymous read access to the camera's repository. dora-cli 1.0.1 +# fetches through libgit2 with no credential callback, so anything that asks for +# credentials fails with "remote authentication required but no callback set". +# On Gitea, marking the repository public is not enough: a repository inside a +# private organization is internal and still answers anonymous clones with 401. # # `strict_types` is deliberately absent. A camera publishes an image and nothing # else, and `dora validate --strict` warns about every *typed* input this node @@ -19,7 +18,7 @@ nodes: - id: camera - git: ssh://git@gitea.liria.cl:5001/liria/dora-webcam.git + git: https://gitea.liria.cl/liria/dora-webcam.git tag: v0.1.0 build: uv pip install . path: dora-webcam diff --git a/dora-node.yml b/dora-node.yml index 68fbdc1..c98a765 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -86,7 +86,7 @@ env: example: | - id: view - git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git + git: https://gitea.liria.cl/liria/dora-image-view.git tag: v0.1.0 build: uv pip install . path: dora-image-view -- 2.54.0