From e2debe0581835ea96996b8d0f12af5d14815d450 Mon Sep 17 00:00:00 2001 From: fondef Date: Mon, 14 Sep 2026 16:08:48 -0500 Subject: [PATCH 1/5] refactor: rename the node from opencv-plot to dora-plot The repository is dora-plot, but the package, the entrypoint, the manifest name and the window title were all opencv-plot. Consumers wire the node by `path:`, so the split name was visible in every dataflow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018WLVZCFFcCH1EpTXGfS9aD --- AGENTS.md | 8 +-- README.md | 6 +- dataflow.yml | 2 +- dora-node.yml | 6 +- {opencv_plot => dora_plot}/__init__.py | 0 {opencv_plot => dora_plot}/bbox.py | 2 +- {opencv_plot => dora_plot}/decode.py | 0 {opencv_plot => dora_plot}/main.py | 6 +- {opencv_plot => dora_plot}/plot.py | 2 +- pyproject.toml | 6 +- ...{test_opencv_plot.py => test_dora_plot.py} | 4 +- uv.lock | 72 +++++++++---------- 12 files changed, 57 insertions(+), 57 deletions(-) rename {opencv_plot => dora_plot}/__init__.py (100%) rename {opencv_plot => dora_plot}/bbox.py (97%) rename {opencv_plot => dora_plot}/decode.py (100%) rename {opencv_plot => dora_plot}/main.py (95%) rename {opencv_plot => dora_plot}/plot.py (98%) rename tests/{test_opencv_plot.py => test_dora_plot.py} (97%) diff --git a/AGENTS.md b/AGENTS.md index 9bfd6fa..5540eb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,10 @@ uv run ruff check . ## Architecture -- **`opencv_plot/main.py`** — Argument parsing and the dora event loop, nothing else. `image` decodes and redraws; `bbox` and `text` only store state. The env var wins over the CLI flag for `PLOT_WIDTH`/`PLOT_HEIGHT`, matching the upstream node. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. -- **`opencv_plot/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `width`/`height` are only consulted for the raw encodings (`bgr8`, `rgb8`, `yuv420`); the rest carry their own dimensions, so a missing dimension is an error only where it actually matters. 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. -- **`opencv_plot/plot.py`** — `Plot` (the overlay state and the window) and `Bboxes`. `show()` draws, resizes and displays, and returns whether `q` was pressed. Resizing only happens when *both* `PLOT_WIDTH` and `PLOT_HEIGHT` are set. `headless` (from `CI=true`) skips only the window: every input is still decoded, which is what makes the node safe in a pipeline test. -- **`opencv_plot/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. +- **`dora_plot/main.py`** — Argument parsing and the dora event loop, nothing else. `image` decodes and redraws; `bbox` and `text` only store state. The env var wins over the CLI flag for `PLOT_WIDTH`/`PLOT_HEIGHT`, matching the upstream node. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. +- **`dora_plot/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `width`/`height` are only consulted for the raw encodings (`bgr8`, `rgb8`, `yuv420`); the rest carry their own dimensions, so a missing dimension is an error only where it actually matters. 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_plot/plot.py`** — `Plot` (the overlay state and the window) and `Bboxes`. `show()` draws, resizes and displays, and returns whether `q` was pressed. Resizing only happens when *both* `PLOT_WIDTH` and `PLOT_HEIGHT` are set. `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_plot/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. - **`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 dataflow builds from the checkout. `image` and `text` carry type URNs naming the element type (`std/core/v1/UInt8`, `.../String`) 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`** — Example dataflow: a webcam and a one-shot caption feeding the viewer, with `strict_types: true`. Note the `output_types:` on the two `git:`-sourced nodes — their own manifests are not injected, so without those annotations strict validation fails on an untyped upstream. `bbox` is left unwired on purpose; every bbox source is a detector, and wiring one would put a model download in front of a webcam preview. - **`.gitea/workflows/validate.yml`** — CI gate running `dora validate` on the dataflow on push and pull request. Needs no camera and no display. diff --git a/README.md b/README.md index 2f7e0e2..24017a7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ nodes: git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git tag: v0.1.0 build: pip install . - path: opencv-plot + path: dora-plot inputs: image: camera/image bbox: detector/bbox @@ -55,7 +55,7 @@ the camera and the viewer: - bbox - id: plot - path: opencv-plot + path: dora-plot build: pip install . inputs: image: camera/image @@ -91,7 +91,7 @@ of that state: - An **`image`** event decodes the frame and immediately redraws: it draws every stored bbox (green rectangle plus `label, conf` text), draws the stored text at the top left, resizes to `PLOT_WIDTH`x`PLOT_HEIGHT` if both are set, and shows it in a window titled - `Dora Node: opencv-plot`. Press `q` in the window to quit. + `Dora Node: dora-plot`. Press `q` in the window to quit. - A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next `image` redraw. diff --git a/dataflow.yml b/dataflow.yml index 8c4df7f..38bf7f5 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -34,7 +34,7 @@ nodes: data: std/core/v1/String - id: plot - path: opencv-plot + path: dora-plot build: pip install . inputs: image: camera/image diff --git a/dora-node.yml b/dora-node.yml index e7a6582..c47000d 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -5,14 +5,14 @@ # dataflow against it without running the node, so CI needs neither OpenCV nor # a display. apiVersion: 1 -name: opencv-plot +name: dora-plot namespace: liria description: Overlays bounding boxes, labels, and text on an image input and displays it in an OpenCV window categories: [visualization, debug] keywords: [opencv, plot, bbox, bounding-box, image, visualization, object-detection] runtime: python -entrypoint: opencv-plot +entrypoint: dora-plot # `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. @@ -57,7 +57,7 @@ example: | git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git tag: v0.1.0 build: pip install . - path: opencv-plot + path: dora-plot inputs: image: camera/image bbox: detector/bbox diff --git a/opencv_plot/__init__.py b/dora_plot/__init__.py similarity index 100% rename from opencv_plot/__init__.py rename to dora_plot/__init__.py diff --git a/opencv_plot/bbox.py b/dora_plot/bbox.py similarity index 97% rename from opencv_plot/bbox.py rename to dora_plot/bbox.py index d5d1c18..d6ea2a3 100644 --- a/opencv_plot/bbox.py +++ b/dora_plot/bbox.py @@ -2,7 +2,7 @@ import numpy as np -from opencv_plot.plot import Bboxes +from dora_plot.plot import Bboxes def parse(value, bbox_format: str) -> Bboxes: diff --git a/opencv_plot/decode.py b/dora_plot/decode.py similarity index 100% rename from opencv_plot/decode.py rename to dora_plot/decode.py diff --git a/opencv_plot/main.py b/dora_plot/main.py similarity index 95% rename from opencv_plot/main.py rename to dora_plot/main.py index b68de3d..3cc414b 100644 --- a/opencv_plot/main.py +++ b/dora_plot/main.py @@ -9,8 +9,8 @@ import os from dora import Node -from opencv_plot import bbox, decode -from opencv_plot.plot import Plot +from dora_plot import bbox, decode +from dora_plot.plot import Plot def main() -> None: @@ -59,7 +59,7 @@ def _parse_args() -> argparse.Namespace: ) parser.add_argument( "--name", - default="opencv-plot", + default="dora-plot", help="The name of the node in the dataflow, used when running as a dynamic node.", ) parser.add_argument( diff --git a/opencv_plot/plot.py b/dora_plot/plot.py similarity index 98% rename from opencv_plot/plot.py rename to dora_plot/plot.py index 67e0dc3..2063bd8 100644 --- a/opencv_plot/plot.py +++ b/dora_plot/plot.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field import cv2 import numpy as np -WINDOW = "Dora Node: opencv-plot" +WINDOW = "Dora Node: dora-plot" GREEN = (0, 255, 0) WHITE = (255, 255, 255) FONT = cv2.FONT_HERSHEY_SIMPLEX diff --git a/pyproject.toml b/pyproject.toml index f14d922..a02a52e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "opencv-plot" +name = "dora-plot" version = "0.1.0" description = "Dora node that overlays bounding boxes and text on an image and displays it in an OpenCV window" readme = "README.md" @@ -21,7 +21,7 @@ dependencies = [ ] [project.scripts] -opencv-plot = "opencv_plot.main:main" +dora-plot = "dora_plot.main:main" [dependency-groups] dev = ["pytest >= 8.1", "ruff >= 0.9"] @@ -31,7 +31,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["opencv_plot"] +packages = ["dora_plot"] [tool.ruff.lint] extend-select = ["D", "I", "N", "RET", "UP"] diff --git a/tests/test_opencv_plot.py b/tests/test_dora_plot.py similarity index 97% rename from tests/test_opencv_plot.py rename to tests/test_dora_plot.py index 948d32d..17c290e 100644 --- a/tests/test_opencv_plot.py +++ b/tests/test_dora_plot.py @@ -7,8 +7,8 @@ import numpy as np import pytest from PIL import Image -from opencv_plot import bbox, decode -from opencv_plot.plot import Bboxes, Plot +from dora_plot import bbox, decode +from dora_plot.plot import Bboxes, Plot WIDTH, HEIGHT = 12, 8 diff --git a/uv.lock b/uv.lock index e4652fe..00d0b62 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,42 @@ 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 = "dora-plot" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dora-rs" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pillow-avif-plugin" }, + { name = "pyarrow" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "dora-rs", specifier = ">=1.0.1" }, + { name = "numpy", specifier = ">=2.0" }, + { name = "opencv-python", specifier = ">=4.9" }, + { name = "pillow", specifier = ">=10.4" }, + { name = "pillow-avif-plugin", specifier = ">=1.4" }, + { name = "pyarrow", specifier = ">=17.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.1" }, + { name = "ruff", specifier = ">=0.9" }, +] + [[package]] name = "dora-rs" version = "1.0.1" @@ -206,42 +242,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, ] -[[package]] -name = "opencv-plot" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "dora-rs" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "opencv-python" }, - { name = "pillow" }, - { name = "pillow-avif-plugin" }, - { name = "pyarrow" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "dora-rs", specifier = ">=1.0.1" }, - { name = "numpy", specifier = ">=2.0" }, - { name = "opencv-python", specifier = ">=4.9" }, - { name = "pillow", specifier = ">=10.4" }, - { name = "pillow-avif-plugin", specifier = ">=1.4" }, - { name = "pyarrow", specifier = ">=17.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.1" }, - { name = "ruff", specifier = ">=0.9" }, -] - [[package]] name = "opencv-python" version = "5.0.0.93" From 22bd1e9e89ed000a93fc71ecaa17b15fbcf75217 Mon Sep 17 00:00:00 2001 From: fondef Date: Mon, 14 Sep 2026 16:09:01 -0500 Subject: [PATCH 2/5] add an in-repo synthetic source so the example needs no network The example dataflow pulled opencv-video-capture and pyarrow-sender from dora-hub, needed a webcam, and left `bbox` unwired because every bbox source is a detector. examples/source.py emits an animated frame, the box around the square in it, and a caption, so the dataflow exercises all three inputs with nothing outside this checkout. SOURCE_FRAMES bounds the run, which lets CI run the dataflow headless rather than only validate it. Drops the dora-zed and dora-yolo wirings from the README and trims AGENTS.md to what the code and README do not already say. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018WLVZCFFcCH1EpTXGfS9aD --- .gitea/workflows/validate.yml | 30 ++++++++++---- AGENTS.md | 44 ++++++++------------- README.md | 48 ++++++++++------------- dataflow.yml | 45 +++++++++------------ examples/source.py | 73 +++++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 89 deletions(-) create mode 100644 examples/source.py diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 9b99aee..fa68938 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -1,5 +1,6 @@ -# Two gates, neither of which needs a camera or a display: the dataflow still -# matches the typed contract in dora-node.yml, and the node's own tests pass. +# Two gates, neither of which needs a camera or a display: the example dataflow +# still matches the typed contract in dora-node.yml and still runs end to end, +# and the node's own unit tests pass. name: validate # A pull request would otherwise run this twice on the same commit: once for @@ -14,11 +15,22 @@ env: DORA_TARGET: x86_64-unknown-linux-gnu jobs: - validate: + dataflow: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # The opencv-python wheel links against libGL at import time, so cv2 + # cannot even be imported on the bare runner image. + - name: Install the OpenCV shared libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install the dora CLI run: | curl -fsSL -o dora-cli.tar.gz \ @@ -36,13 +48,19 @@ jobs: - name: Validate the dataflow run: dora validate dataflow.yml + # CI=true is already set by the runner, which keeps the viewer from trying + # to open a window; SOURCE_FRAMES bounds the source so the run ends on its + # own. Everything the dataflow needs is in the checkout. + - name: Run the dataflow headless + env: + SOURCE_FRAMES: 30 + run: dora run dataflow.yml --uv + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # The opencv-python wheel links against libGL at import time, so cv2 - # cannot even be imported on the bare runner image. - name: Install the OpenCV shared libraries run: | sudo apt-get update @@ -59,6 +77,4 @@ jobs: - run: uv run ruff check . - # CI=true is already set by the runner, which is what keeps the node from - # trying to open a window on a headless machine. - run: uv run pytest -q diff --git a/AGENTS.md b/AGENTS.md index 5540eb3..7df0012 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,49 +4,37 @@ 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. +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. -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`. +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. -## Build +## Commands ```bash -uv sync # runtime deps -uv sync --group dev # plus pytest and ruff +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 the 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. - -**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ (`dora-rs` 1.0.1 requires it), `uv`, and a display — or `CI=true`. - -## Run - -```bash -dora run dataflow.yml --uv -``` - -## Test - -```bash -uv run pytest # 14 tests, no camera/dataflow/display needed -uv run ruff check . -``` +`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_plot/main.py`** — Argument parsing and the dora event loop, nothing else. `image` decodes and redraws; `bbox` and `text` only store state. The env var wins over the CLI flag for `PLOT_WIDTH`/`PLOT_HEIGHT`, matching the upstream node. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. -- **`dora_plot/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `width`/`height` are only consulted for the raw encodings (`bgr8`, `rgb8`, `yuv420`); the rest carry their own dimensions, so a missing dimension is an error only where it actually matters. 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_plot/plot.py`** — `Plot` (the overlay state and the window) and `Bboxes`. `show()` draws, resizes and displays, and returns whether `q` was pressed. Resizing only happens when *both* `PLOT_WIDTH` and `PLOT_HEIGHT` are set. `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_plot/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `PLOT_WIDTH`/`PLOT_HEIGHT`, matching the upstream node. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. +- **`dora_plot/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_plot/plot.py`** — `Plot` (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_plot/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. -- **`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 dataflow builds from the checkout. `image` and `text` carry type URNs naming the element type (`std/core/v1/UInt8`, `.../String`) 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`** — Example dataflow: a webcam and a one-shot caption feeding the viewer, with `strict_types: true`. Note the `output_types:` on the two `git:`-sourced nodes — their own manifests are not injected, so without those annotations strict validation fails on an untyped upstream. `bbox` is left unwired on purpose; every bbox source is a detector, and wiring one would put a model download in front of a webcam preview. -- **`.gitea/workflows/validate.yml`** — CI gate running `dora validate` on the dataflow on push and pull request. Needs no camera and no display. +- **`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. +- **`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`. `source` carries `output_types:` because it has no manifest of its own; `bbox` is annotated on neither side, matching the manifest. +- **`.gitea/workflows/validate.yml`** — CI: validates and runs the dataflow headless, and runs the unit tests. Needs no camera and no display. ## 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. That is why `dataflow.yml` wires `text` from `pyarrow-sender` — dropping it would fail CI. 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. ## Rules diff --git a/README.md b/README.md index 24017a7..b3d2207 100644 --- a/README.md +++ b/README.md @@ -27,44 +27,35 @@ 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. + ### Runnable example -[`dataflow.yml`](dataflow.yml) wires a webcam and a fixed caption from dora-hub into -the viewer: +[`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: ```bash dora run dataflow.yml --uv ``` -It builds the node from this checkout (`path:` + `build:`) rather than from a tag, +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, which is what lets `dora validate` pick up the manifest described below. -`bbox` is left unwired there, because every bbox source is a detector and pulling one -in would put a model download in front of a webcam preview. To see boxes, add -[`dora-yolo`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/dora-yolo) between -the camera and the viewer: +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: -```yaml - - id: detector - git: https://github.com/dora-rs/dora-hub.git - build: pip install ./node-hub/dora-yolo - path: dora-yolo - inputs: - image: camera/image - outputs: - - bbox - - - id: plot - path: dora-plot - build: pip install . - inputs: - image: camera/image - bbox: detector/bbox +```bash +CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv ``` -To display a [dora-zed](https://gitea.liria.cl/liria/dora-zed) camera instead, wire -`image: zed-camera/image_bgr` — that node publishes `bgr8` with the `width`, `height` -and `encoding` metadata this one expects. +`SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size. ### Node manifest @@ -160,5 +151,6 @@ display to show the window on (or `CI=true` to run without one). uv run pytest ``` -The tests cover decoding, the bbox format conversion and the headless draw path, so they -need neither a camera, a dataflow nor a display. +Unit tests over decoding, the bbox format conversion and the headless draw path; they +need neither a camera, a dataflow nor a display. The headless dataflow run above covers +the rest — the manifest, the wiring and the event loop. diff --git a/dataflow.yml b/dataflow.yml index 38bf7f5..0d40ac0 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -1,41 +1,34 @@ -# The repository's own example: a webcam and a fixed caption feeding the viewer. -# The plot node is built from this checkout with `path:` + `build:`, so -# `dora validate` picks up the typed contract in dora-node.yml next to it. +# 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. # -# Run it with `--uv` so every Python node gets its own managed environment. -# A consumer wires the node from a tag instead — see README.md. +# 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 +# wires the viewer from a tag instead -- see README.md. # -# `bbox` is left unwired: every bbox source is a detector, and pulling one in -# would put a model download in the way of a webcam preview. README.md shows -# the wiring. +# Run it with `--uv` so every node gets its own managed environment. strict_types: true nodes: - - id: camera - git: https://github.com/dora-rs/dora-hub.git - build: pip install ./node-hub/opencv-video-capture - path: opencv-video-capture + - id: source + path: 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 . inputs: - tick: dora/timer/millis/100 + tick: dora/timer/millis/33 outputs: - image + - bbox + - text output_types: image: std/core/v1/UInt8 - - - id: caption - git: https://github.com/dora-rs/dora-hub.git - build: pip install ./node-hub/pyarrow-sender - path: pyarrow-sender - env: - DATA: "'dora-plot'" - outputs: - - data - output_types: - data: std/core/v1/String + text: std/core/v1/String - id: plot path: dora-plot build: pip install . inputs: - image: camera/image - text: caption/data + image: source/image + bbox: source/bbox + text: source/text diff --git a/examples/source.py b/examples/source.py new file mode 100644 index 0000000..711c2a1 --- /dev/null +++ b/examples/source.py @@ -0,0 +1,73 @@ +"""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-plot draws -- so the example dataflow needs no camera, no +detector and no network. +""" + +import os + +import numpy as np +import pyarrow as pa +from dora import Node + +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 +# dataflow to completion instead of killing it on a timeout. +FRAMES = int(os.getenv("SOURCE_FRAMES", "0")) + +BOX = 96 +SPEED = 6 +CAPTION = "dora-plot synthetic source" + + +def main() -> None: + """Send one image, bbox and text per tick.""" + node = Node("source") + sent = 0 + + for event in node: + if event["type"] != "INPUT": + continue + + frame, corners = render(sent) + node.send_output( + "image", + pa.array(frame.ravel()), + {"width": WIDTH, "height": HEIGHT, "encoding": "bgr8"}, + ) + node.send_output( + "bbox", + pa.array([{ + "bbox": corners, + "conf": np.array([0.99]), + "labels": np.array(["square"]), + }]), + {"format": "xyxy"}, + ) + node.send_output("text", pa.array([CAPTION])) + + sent += 1 + if FRAMES and sent >= FRAMES: + break + + +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) + frame[:, :, 0] = np.linspace(40, 140, HEIGHT, dtype=np.uint8)[:, None] + + travel = WIDTH - BOX + # A triangle wave over `index`: the square starts at the left edge, runs to + # the right edge, and comes back. + left = travel - abs((index * SPEED) % (2 * travel) - travel) + top = (HEIGHT - BOX) // 2 + frame[top : top + BOX, left : left + BOX] = (60, 200, 255) + + corners = np.array([left, top, left + BOX, top + BOX], dtype=np.float64) + return frame, corners + + +if __name__ == "__main__": + main() From 00dc4c7b711222091652c33cdd6adaebe444d8a9 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 03:22:27 -0300 Subject: [PATCH 3/5] rename node to dora-image-view The repository, the Python package, the entrypoint and the manifest name all now agree. `plot` was carried over from the upstream opencv-plot node and no longer described anything here, so the module, the class and the environment variables follow the node: dora_plot/ -> dora_image_view/ dora_plot/plot.py -> dora_image_view/view.py Plot -> View PLOT_WIDTH/HEIGHT -> VIEW_WIDTH/HEIGHT --plot-width/-height -> --view-width/-height Breaking for anyone wiring `path: dora-plot` or setting PLOT_WIDTH; nothing is tagged yet. --- AGENTS.md | 19 ++++++++---- README.md | 31 +++++++++++-------- dora-node.yml | 16 +++++----- {dora_plot => dora_image_view}/__init__.py | 0 {dora_plot => dora_image_view}/bbox.py | 2 +- {dora_plot => dora_image_view}/decode.py | 0 {dora_plot => dora_image_view}/main.py | 26 ++++++++-------- dora_plot/plot.py => dora_image_view/view.py | 4 +-- examples/source.py | 4 +-- pyproject.toml | 6 ++-- ...t_dora_plot.py => test_dora_image_view.py} | 10 +++--- uv.lock | 2 +- 12 files changed, 66 insertions(+), 54 deletions(-) rename {dora_plot => dora_image_view}/__init__.py (100%) rename {dora_plot => dora_image_view}/bbox.py (96%) rename {dora_plot => dora_image_view}/decode.py (100%) rename {dora_plot => dora_image_view}/main.py (81%) rename dora_plot/plot.py => dora_image_view/view.py (97%) rename tests/{test_dora_plot.py => test_dora_image_view.py} (94%) diff --git a/AGENTS.md b/AGENTS.md index 7df0012..d2ff4b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,14 +23,21 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless ## Architecture -- **`dora_plot/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `PLOT_WIDTH`/`PLOT_HEIGHT`, matching the upstream node. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. -- **`dora_plot/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_plot/plot.py`** — `Plot` (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_plot/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. +- **`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/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. - **`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`. `source` carries `output_types:` because it has no manifest of its own; `bbox` is annotated on neither side, matching the manifest. -- **`.gitea/workflows/validate.yml`** — CI: validates and runs the dataflow headless, and runs the unit tests. Needs no camera and no display. +- **`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. +- **`.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. ## A caveat on typed optional inputs diff --git a/README.md b/README.md index b3d2207..f16d00e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dora-plot +# 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 @@ -13,11 +13,11 @@ Add to your dataflow: ```yaml nodes: - - id: plot - git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git + - id: view + git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git tag: v0.1.0 build: pip install . - path: dora-plot + path: dora-image-view inputs: image: camera/image bbox: detector/bbox @@ -61,11 +61,14 @@ 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: +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: ``` $ dora validate dataflow.yml - node "plot": injected 2 contract type(s) from .../dora-node.yml + node "source": injected 2 contract type(s) from .../examples/dora-node.yml + node "view": injected 2 contract type(s) from .../dora-node.yml All type annotations OK. ``` @@ -81,8 +84,8 @@ of that state: - An **`image`** event decodes the frame and immediately redraws: it draws every stored bbox (green rectangle plus `label, conf` text), draws the stored text at the top left, - resizes to `PLOT_WIDTH`x`PLOT_HEIGHT` if both are set, and shows it in a window titled - `Dora Node: dora-plot`. Press `q` in the window to quit. + 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 **`bbox`** or **`text`** event only updates stored state; it is drawn on the next `image` redraw. @@ -124,11 +127,11 @@ None — it is a display sink. | Variable | Default | Meaning | |---|---|---| -| `PLOT_WIDTH` | image width | Resize the displayed frame to this width, in pixels. | -| `PLOT_HEIGHT` | image height | Resize the displayed frame to this height, in pixels. | +| `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. | -Resizing only happens when both are set. Both are also available as `--plot-width` and -`--plot-height`; the environment variable wins when both are given. +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. `CI=true` suppresses the display window; it is not a configuration knob. @@ -143,7 +146,9 @@ so the node installs with no system packages and no compiler on any of them — AVIF support, which comes from `pillow-avif-plugin` rather than OpenCV. **Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a -display to show the window on (or `CI=true` to run without one). +display to show the window on (or `CI=true` to run without one). On a bare Linux image +`opencv-python` also needs the libGL shared libraries (`libgl1`, `libglib2.0-0`), which +it links at import time. ## Test diff --git a/dora-node.yml b/dora-node.yml index c47000d..67ae067 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -5,14 +5,14 @@ # dataflow against it without running the node, so CI needs neither OpenCV nor # a display. apiVersion: 1 -name: dora-plot +name: dora-image-view namespace: liria description: Overlays bounding boxes, labels, and text on an image input and displays it in an OpenCV window categories: [visualization, debug] -keywords: [opencv, plot, bbox, bounding-box, image, visualization, object-detection] +keywords: [opencv, view, bbox, bounding-box, image, visualization, object-detection] runtime: python -entrypoint: dora-plot +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. @@ -45,19 +45,19 @@ inputs: outputs: {} env: - PLOT_WIDTH: + VIEW_WIDTH: type: int description: Resize the displayed frame to this width (pixels). Defaults to the image input width. - PLOT_HEIGHT: + VIEW_HEIGHT: type: int description: Resize the displayed frame to this height (pixels). Defaults to the image input height. example: | - - id: plot - git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git + - id: view + git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git tag: v0.1.0 build: pip install . - path: dora-plot + path: dora-image-view inputs: image: camera/image bbox: detector/bbox diff --git a/dora_plot/__init__.py b/dora_image_view/__init__.py similarity index 100% rename from dora_plot/__init__.py rename to dora_image_view/__init__.py diff --git a/dora_plot/bbox.py b/dora_image_view/bbox.py similarity index 96% rename from dora_plot/bbox.py rename to dora_image_view/bbox.py index d6ea2a3..3f6716c 100644 --- a/dora_plot/bbox.py +++ b/dora_image_view/bbox.py @@ -2,7 +2,7 @@ import numpy as np -from dora_plot.plot import Bboxes +from dora_image_view.view import Bboxes def parse(value, bbox_format: str) -> Bboxes: diff --git a/dora_plot/decode.py b/dora_image_view/decode.py similarity index 100% rename from dora_plot/decode.py rename to dora_image_view/decode.py diff --git a/dora_plot/main.py b/dora_image_view/main.py similarity index 81% rename from dora_plot/main.py rename to dora_image_view/main.py index 3cc414b..dbc984f 100644 --- a/dora_plot/main.py +++ b/dora_image_view/main.py @@ -9,17 +9,17 @@ import os from dora import Node -from dora_plot import bbox, decode -from dora_plot.plot import Plot +from dora_image_view import bbox, decode +from dora_image_view.view import View def main() -> None: """Connect to the dataflow and display every frame that arrives.""" args = _parse_args() - plot = Plot( - width=_size("PLOT_WIDTH", args.plot_width), - height=_size("PLOT_HEIGHT", args.plot_height), + view = View( + width=_size("VIEW_WIDTH", args.view_width), + height=_size("VIEW_HEIGHT", args.view_height), headless=os.getenv("CI") == "true", ) @@ -43,12 +43,12 @@ def main() -> None: metadata.get("width"), metadata.get("height"), ) - if plot.show(frame): + if view.show(frame): break elif event_id == "bbox": - plot.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower()) + view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower()) elif event_id == "text": - plot.text = event["value"][0].as_py() + view.text = event["value"][0].as_py() else: print(f"ignoring unexpected input `{event_id}`") @@ -59,18 +59,18 @@ def _parse_args() -> argparse.Namespace: ) parser.add_argument( "--name", - default="dora-plot", + default="dora-image-view", help="The name of the node in the dataflow, used when running as a dynamic node.", ) parser.add_argument( - "--plot-width", + "--view-width", type=int, - help="Resize the displayed frame to this width. Overridden by PLOT_WIDTH.", + help="Resize the displayed frame to this width. Overridden by VIEW_WIDTH.", ) parser.add_argument( - "--plot-height", + "--view-height", type=int, - help="Resize the displayed frame to this height. Overridden by PLOT_HEIGHT.", + help="Resize the displayed frame to this height. Overridden by VIEW_HEIGHT.", ) return parser.parse_args() diff --git a/dora_plot/plot.py b/dora_image_view/view.py similarity index 97% rename from dora_plot/plot.py rename to dora_image_view/view.py index 2063bd8..dd66d14 100644 --- a/dora_plot/plot.py +++ b/dora_image_view/view.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field import cv2 import numpy as np -WINDOW = "Dora Node: dora-plot" +WINDOW = "Dora Node: dora-image-view" GREEN = (0, 255, 0) WHITE = (255, 255, 255) FONT = cv2.FONT_HERSHEY_SIMPLEX @@ -22,7 +22,7 @@ class Bboxes: @dataclass -class Plot: +class View: """Overlay state, plus the window it is displayed in. `headless` suppresses the window; the node still decodes every input. diff --git a/examples/source.py b/examples/source.py index 711c2a1..6a8f799 100644 --- a/examples/source.py +++ b/examples/source.py @@ -1,7 +1,7 @@ """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-plot draws -- so the example dataflow needs no camera, no +the three inputs dora-image-view draws -- so the example dataflow needs no camera, no detector and no network. """ @@ -19,7 +19,7 @@ FRAMES = int(os.getenv("SOURCE_FRAMES", "0")) BOX = 96 SPEED = 6 -CAPTION = "dora-plot synthetic source" +CAPTION = "dora-image-view synthetic source" def main() -> None: diff --git a/pyproject.toml b/pyproject.toml index a02a52e..90bdd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "dora-plot" +name = "dora-image-view" version = "0.1.0" description = "Dora node that overlays bounding boxes and text on an image and displays it in an OpenCV window" readme = "README.md" @@ -21,7 +21,7 @@ dependencies = [ ] [project.scripts] -dora-plot = "dora_plot.main:main" +dora-image-view = "dora_image_view.main:main" [dependency-groups] dev = ["pytest >= 8.1", "ruff >= 0.9"] @@ -31,7 +31,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["dora_plot"] +packages = ["dora_image_view"] [tool.ruff.lint] extend-select = ["D", "I", "N", "RET", "UP"] diff --git a/tests/test_dora_plot.py b/tests/test_dora_image_view.py similarity index 94% rename from tests/test_dora_plot.py rename to tests/test_dora_image_view.py index 17c290e..429db69 100644 --- a/tests/test_dora_plot.py +++ b/tests/test_dora_image_view.py @@ -7,8 +7,8 @@ import numpy as np import pytest from PIL import Image -from dora_plot import bbox, decode -from dora_plot.plot import Bboxes, Plot +from dora_image_view import bbox, decode +from dora_image_view.view import Bboxes, View WIDTH, HEIGHT = 12, 8 @@ -123,13 +123,13 @@ def test_xywh_is_converted_to_corners(): def test_headless_show_draws_without_a_window(): - plot = Plot(headless=True, text="hello") - plot.bboxes = Bboxes( + view = View(headless=True, text="hello") + view.bboxes = Bboxes( corners=np.array([[1.0, 1.0, 5.0, 5.0]]), conf=np.array([0.9]), labels=np.array(["cat"]), ) frame = np.zeros((32, 32, 3), dtype=np.uint8) - assert plot.show(frame) is False + assert view.show(frame) is False assert frame.any(), "the overlays should have marked the frame" diff --git a/uv.lock b/uv.lock index 00d0b62..caf38c9 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ wheels = [ ] [[package]] -name = "dora-plot" +name = "dora-image-view" version = "0.1.0" source = { editable = "." } dependencies = [ From b9d9843fa16d128cc614625dff4962f3dc83fa38 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 03:22:27 -0300 Subject: [PATCH 4/5] fix strict-type validation by giving the example source a manifest `dora validate` matches a node manifest to a `path:` node by entrypoint. With no manifest beside examples/source.py the root dora-node.yml was tried against it, did not match its entrypoint, and warned -- which `strict_types: true` turns into a non-zero exit, failing CI. Declaring the source's output types in the new manifest 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 against the viewer's. Verified with dora-cli 1.0.1: validate passes with both contracts injected, and `CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv` runs both nodes to a clean exit. --- dataflow.yml | 7 ++---- examples/dora-node.yml | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 examples/dora-node.yml diff --git a/dataflow.yml b/dataflow.yml index 0d40ac0..cbded38 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -21,12 +21,9 @@ nodes: - image - bbox - text - output_types: - image: std/core/v1/UInt8 - text: std/core/v1/String - - id: plot - path: dora-plot + - id: view + path: dora-image-view build: pip install . inputs: image: source/image diff --git a/examples/dora-node.yml b/examples/dora-node.yml new file mode 100644 index 0000000..689e872 --- /dev/null +++ b/examples/dora-node.yml @@ -0,0 +1,52 @@ +# 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. +# +# 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, 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. + +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. From 407395ab88838b6b6b16f805f950e51a64017107 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Tue, 15 Sep 2026 03:22:27 -0300 Subject: [PATCH 5/5] refactor CI into a single job and cache the dora CLI The two jobs each paid for a checkout, an apt-get update and an OpenCV shared library install -- about twenty seconds apiece -- to run gates that together take about a second. Merging them pays that once. The dora CLI is a static binary pinned to DORA_VERSION, so it caches exactly. --- .gitea/workflows/validate.yml | 65 +++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index fa68938..477d6f7 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -1,6 +1,11 @@ -# Two gates, neither of which needs a camera or a display: the example dataflow -# still matches the typed contract in dora-node.yml and still runs end to end, -# and the node's own unit tests pass. +# Three gates, none of which needs a camera or a display: the example dataflow +# still matches the typed contracts in the two dora-node.yml files, it still +# runs end to end, and the node's own unit tests pass. +# +# All three share one job on purpose. The work itself is trivial -- the unit +# tests take under a second -- and is dwarfed by preparing the runner, so +# splitting the gates across jobs mostly buys a second checkout and a second +# OpenCV install. A step that fails is still named in the log. name: validate # A pull request would otherwise run this twice on the same commit: once for @@ -15,13 +20,15 @@ env: DORA_TARGET: x86_64-unknown-linux-gnu jobs: - dataflow: + validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # The opencv-python wheel links against libGL at import time, so cv2 - # cannot even be imported on the bare runner image. + # cannot even be imported on the bare runner image. This is the most + # expensive step in the job by a wide margin; see AGENTS.md for the + # headless-wheel alternative that would remove it. - name: Install the OpenCV shared libraries run: | sudo apt-get update @@ -31,20 +38,43 @@ jobs: with: enable-cache: true + # The CLI is a single static binary pinned to DORA_VERSION, so the cache + # hit is exact and the download only happens when the version changes. + - name: Cache the dora CLI + id: dora-cache + uses: actions/cache@v4 + with: + path: ~/.local/bin/dora + key: dora-cli-${{ env.DORA_VERSION }}-${{ env.DORA_TARGET }} + - name: Install the dora CLI + if: steps.dora-cache.outputs.cache-hit != 'true' run: | curl -fsSL -o dora-cli.tar.gz \ "https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz" tar xzf dora-cli.tar.gz mkdir -p "$HOME/.local/bin" install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}" + - name: Put the dora CLI on PATH + run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - run: dora --version + # --locked fails if uv.lock is out of date with pyproject.toml, so a + # dependency change that was never locked cannot reach main. + - name: Install + run: uv sync --locked --group dev + + - run: uv run ruff check . + + - run: uv run pytest -q + # strict_types: true in the dataflow turns a type warning into a non-zero # exit, so a mismatched annotation fails here rather than at runtime. + # Both nodes are path nodes with a manifest, so both contracts are + # injected and compared against each other. - name: Validate the dataflow run: dora validate dataflow.yml @@ -55,26 +85,3 @@ jobs: env: SOURCE_FRAMES: 30 run: dora run dataflow.yml --uv - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install the OpenCV shared libraries - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0 - - - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - # --locked fails if uv.lock is out of date with pyproject.toml, so a - # dependency change that was never locked cannot reach main. - - name: Install - run: uv sync --locked --group dev - - - run: uv run ruff check . - - - run: uv run pytest -q