Author SHA1 Message Date
cristhian c178afd58e add depth and camera intrinsics, and adopt the typed Arrow image (#4)
validate / validate (push) Successful in 59m16s
Adds a depth panel tiled beside the image, the camera_info input holding the
pinhole model, and std/media/v1/Image as the payload for both image and depth.
Builds with uv, lints markdown, keeps the workflow where act looks for it, and
carries a worked example wiring dora-webcam in.
2026-09-15 09:36:16 +00:00
cristhian 7eb00000ea make the node standalone and rename it to dora-image-view (#3)
validate / validate (push) Successful in 59m14s
The node was named opencv-plot everywhere, and the example dataflow could not
run without a webcam and two nodes fetched over the network.

Renames the node to dora-image-view, replaces the external example with a
synthetic source in this checkout, types that source inline rather than
shipping a second manifest, and merges the two CI jobs into one.
2026-09-15 09:33:11 +00:00
cristhian f7171de2af fetch git nodes over https
validate / validate (pull_request) Successful in 59m10s
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.
2026-09-15 06:29:37 -03:00
cristhian 15e0b9155a 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.
2026-09-15 06:26:10 -03:00
cristhian 363bf006be 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.
2026-09-15 06:20:14 -03:00
cristhian c9715a34d5 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.
2026-09-15 06:14:00 -03:00
cristhian c1dba8998d 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.
2026-09-15 06:09:33 -03:00
cristhian 093715e78f 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.
2026-09-15 06:03:40 -03:00
cristhian 7d9ebe8b38 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.
2026-09-15 05:31:47 -03:00
cristhian 2b70d07868 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.
2026-09-15 05:30:03 -03:00
cristhian 8a7bda5867 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.
2026-09-15 05:27:40 -03:00
cristhian a6fa3ce32b 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.
2026-09-15 05:25:18 -03:00
cristhian 92abe8a446 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.
2026-09-15 05:21:36 -03:00
cristhian 1c7a9c816d 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.
2026-09-15 05:17:33 -03:00
cristhian 5a0f13d4db 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.
2026-09-15 05:09:07 -03:00
cristhian 0efeacd538 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.
2026-09-15 05:06:50 -03:00
cristhian 407395ab88 refactor CI into a single job and cache the dora CLI
validate / validate (pull_request) Successful in 59m51s
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.
2026-09-15 03:22:27 -03:00
cristhian b9d9843fa1 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.
2026-09-15 03:22:27 -03:00
cristhian 00dc4c7b71 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.
2026-09-15 03:22:27 -03:00
cristhianandClaude Opus 5 22bd1e9e89 add an in-repo synthetic source so the example needs no network
validate / dataflow (pull_request) Failing after 59m9s
validate / test (pull_request) Successful in 59m8s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WLVZCFFcCH1EpTXGfS9aD
2026-09-14 16:09:01 -05:00
cristhianandClaude Opus 5 e2debe0581 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WLVZCFFcCH1EpTXGfS9aD
2026-09-14 16:08:48 -05:00
cristhian d5d3026b92 Merge pull request 'add opencv-plot node for dora dataflows' (#2) from add-opencv-plot-node into main
validate / validate (push) Successful in 58m50s
validate / test (push) Successful in 1h0m5s
Reviewed-on: liria/dora-plot#2
2026-09-11 10:34:34 +00:00
23 changed files with 1797 additions and 556 deletions
-64
View File
@@ -1,64 +0,0 @@
# 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.
name: validate
# A pull request would otherwise run this twice on the same commit: once for
# the branch push and once for the PR.
on:
push:
branches: [main]
pull_request:
env:
DORA_VERSION: v1.0.1
DORA_TARGET: x86_64-unknown-linux-gnu
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the dora CLI
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}"
- run: dora --version
# 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.
- name: Validate the dataflow
run: dora validate dataflow.yml
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
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 .
# 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
+89
View File
@@ -0,0 +1,89 @@
# 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
# the branch push and once for the PR.
on:
push:
branches: [main]
pull_request:
env:
DORA_VERSION: v1.0.1
DORA_TARGET: x86_64-unknown-linux-gnu
jobs:
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. 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
sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
- uses: astral-sh/setup-uv@v5
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"
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 pymarkdown scan -r --respect-gitignore .
- 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
# 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
+20 -45
View File
@@ -1,53 +1,28 @@
# AGENTS.md # 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
`.github/workflows/validate.yml`.
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. - **`uv` for everything:** `uv sync --group dev`, `uv run pytest`,
`uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient
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`. environment. A node's `build:` is `uv pip install .`, so dataflows must be run
with `--uv`.
**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. - **`ruff` is the linter and the formatter.** `uv run ruff check .` must be clean.
- **Markdown is linted too.** `uv run pymarkdown scan -r --respect-gitignore .` must be clean. Its line
## Build 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:**
```bash ```bash
uv sync # runtime deps act pull_request -P ubuntu-latest=catthehacker/ubuntu:act-latest
uv sync --group dev # plus pytest and ruff
``` ```
`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/<node-id>/`, so what it imports at runtime is what was installed at build time. 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.
**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`. - **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.**
## Run
```bash
dora run dataflow.yml --uv
```
## Test
```bash
uv run pytest # 14 tests, no camera/dataflow/display needed
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-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.
## 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.
## Rules
- Do not mention AI tools in commit messages or documentation.
-3
View File
@@ -1,3 +0,0 @@
# GEMINI.md
See [AGENTS.md](AGENTS.md) for repository guidance.
+144 -54
View File
@@ -1,8 +1,8 @@
# dora-plot # dora-image-view
Dora node that overlays bounding boxes, labels and text on an image input and displays Dora node that displays a colour image and a depth map side by side in one OpenCV
it in an OpenCV window. A display sink for debugging a vision pipeline: it produces no window, with bounding box, text and camera intrinsics overlays. A display sink for debugging a
outputs. vision pipeline: it produces no outputs.
Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot) 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`. from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`.
@@ -13,13 +13,15 @@ Add to your dataflow:
```yaml ```yaml
nodes: nodes:
- id: plot - id: view
git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git git: https://gitea.liria.cl/liria/dora-image-view.git
tag: v0.1.0 tag: v0.1.0
build: pip install . build: uv pip install .
path: opencv-plot path: dora-image-view
inputs: inputs:
image: camera/image image: camera/image_bgr
depth: camera/depth
camera_info: camera/camera_info
bbox: detector/bbox bbox: detector/bbox
text: detector/text text: detector/text
``` ```
@@ -27,61 +29,92 @@ nodes:
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
managed environment. managed environment.
### Runnable example 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`, `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.
[`dataflow.yml`](dataflow.yml) wires a webcam and a fixed caption from dora-hub into ### End-to-end test
the viewer:
[`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 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 ```bash
dora run dataflow.yml --uv 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 the viewer from this checkout (`path:` + `build:`) rather than from a tag,
which is what lets `dora validate` pick up the manifest described below. 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 CI runs the same dataflow headless on every push, which is what makes it a gate rather
in would put a model download in front of a webcam preview. To see boxes, add than a demo that rots.
[`dora-yolo`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/dora-yolo) between
the camera and the viewer:
```yaml Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running
- id: detector until you quit; that plus `CI=true` is how the same dataflow runs headless as a test:
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 ```bash
path: opencv-plot CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv
build: pip install .
inputs:
image: camera/image
bbox: detector/bbox
``` ```
To display a [dora-zed](https://gitea.liria.cl/liria/dora-zed) camera instead, wire `SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size.
`image: zed-camera/image_bgr` — that node publishes `bgr8` with the `width`, `height`
and `encoding` metadata this one expects. ### 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.
**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 ### Node manifest
[`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source [`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, 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 is typed inline in the dataflow with `output_types:`, so `dora validate`
checks the two against each other:
``` ```text
$ dora validate dataflow.yml $ dora validate dataflow.yml
node "plot": injected 2 contract type(s) from .../dora-node.yml node "view": injected 4 contract type(s) from .../dora-node.yml
All type annotations OK. All type annotations OK.
``` ```
`image` is `std/core/v1/UInt8` and `text` is `std/core/v1/String`; the payloads are raw There is one manifest because this repository ships one node. The example source is a
buffers described by their metadata, so the URN names the element type rather than a test fixture rather than something anyone installs, so it has none, and runs through
struct. `bbox` is deliberately left untyped — senders disagree on whether its numeric the interpreter to keep `dora validate` from matching the viewer's manifest against it.
fields are 32- or 64-bit, and the node reads either.
`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.
## Behavior ## Behavior
@@ -90,10 +123,12 @@ of that state:
- An **`image`** event decodes the frame and immediately redraws: it draws every stored - 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, 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 resizes to `VIEW_WIDTH`x`VIEW_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-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 - 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 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. no window. That is what makes it safe to run in a pipeline test.
@@ -102,16 +137,58 @@ no window. That is what makes it safe to run in a pipeline test.
| Input | Payload | Metadata | | Input | Payload | Metadata |
|---|---|---| |---|---|---|
| `image` | Flat `UInt8` array | `width`, `height`, `encoding` | | `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` | `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` |
| `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` |
| `text` | Arrow array of size 1 | — | | `text` | Arrow array of size 1 | — |
Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png` `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.
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.
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 (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. encodings — the rest carry their own dimensions. An unsupported encoding raises.
Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`). 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.
### 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 ```python
node.send_output("image", pa.array(frame.ravel()), node.send_output("image", pa.array(frame.ravel()),
{"width": 640, "height": 480, "encoding": "bgr8"}) {"width": 640, "height": 480, "encoding": "bgr8"})
@@ -133,11 +210,21 @@ None — it is a display sink.
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `PLOT_WIDTH` | image width | Resize the displayed frame to this width, in pixels. | | `VIEW_WIDTH` | composite width | Resize the displayed composite to this width, in pixels. |
| `PLOT_HEIGHT` | image height | Resize the displayed frame to this height, 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 `--plot-width` and Resizing only happens when both `VIEW_WIDTH` and `VIEW_HEIGHT` are set. Both are also
`--plot-height`; the environment variable wins when both are given. 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. `CI=true` suppresses the display window; it is not a configuration knob.
@@ -152,7 +239,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. 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 **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 ## Test
@@ -160,5 +249,6 @@ display to show the window on (or `CI=true` to run without one).
uv run pytest uv run pytest
``` ```
The tests cover decoding, the bbox format conversion and the headless draw path, so they Unit tests over decoding, the bbox format conversion and the headless draw path; they
need neither a camera, a dataflow nor a display. 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.
+55 -30
View File
@@ -1,41 +1,66 @@
# The repository's own example: a webcam and a fixed caption feeding the viewer. # The end-to-end test: a synthetic source feeding the viewer. Everything it
# The plot node is built from this checkout with `path:` + `build:`, so # needs is in this checkout -- no camera, no detector, no network -- which is
# `dora validate` picks up the typed contract in dora-node.yml next to it. # what lets CI run it rather than only validate it.
# #
# Run it with `--uv` so every Python node gets its own managed environment. # It lives at the repository root rather than beside tests/source.py because a
# A consumer wires the node from a tag instead — see README.md. # node's working directory is the directory of the dataflow file, and both
# nodes build with `uv pip install .`, which needs pyproject.toml in that
# directory.
# #
# `bbox` is left unwired: every bbox source is a detector, and pulling one in # Both nodes are built from the checkout with `path:` + `build:`, which is what
# would put a model download in the way of a webcam preview. README.md shows # lets `dora validate` pick up the typed contract in dora-node.yml. A consumer
# the wiring. # 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 strict_types: true
nodes: nodes:
- id: camera - id: source
git: https://github.com/dora-rs/dora-hub.git # Run through the interpreter rather than as `path: tests/source.py`.
build: pip install ./node-hub/opencv-video-capture #
path: opencv-video-capture # 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: tests/source.py
# The source needs dora-rs, numpy and pyarrow; installing the project gives
# it exactly the versions pinned in uv.lock.
build: uv pip install .
inputs: inputs:
tick: dora/timer/millis/100 tick: dora/timer/millis/33
outputs: outputs:
- image - image
- depth
- 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: output_types:
image: std/core/v1/UInt8 image: std/media/v1/Image
text: std/core/v1/String
depth: std/media/v1/Image
camera_info: std/core/v1/Float32
- id: caption - id: view
git: https://github.com/dora-rs/dora-hub.git path: dora-image-view
build: pip install ./node-hub/pyarrow-sender build: uv pip install .
path: pyarrow-sender
env:
DATA: "'dora-plot'"
outputs:
- data
output_types:
data: std/core/v1/String
- id: plot
path: opencv-plot
build: pip install .
inputs: inputs:
image: camera/image image: source/image
text: caption/data depth: source/depth
camera_info: source/camera_info
bbox: source/bbox
text: source/text
+39
View File
@@ -0,0 +1,39 @@
# 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.
#
# 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
# 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: https://gitea.liria.cl/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
+54 -20
View File
@@ -5,29 +5,30 @@
# dataflow against it without running the node, so CI needs neither OpenCV nor # dataflow against it without running the node, so CI needs neither OpenCV nor
# a display. # a display.
apiVersion: 1 apiVersion: 1
name: opencv-plot name: dora-image-view
namespace: liria 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, text and camera intrinsics overlays
categories: [visualization, debug] categories: [visualization, debug]
keywords: [opencv, plot, bbox, bounding-box, image, visualization, object-detection] keywords: [opencv, view, bbox, bounding-box, image, depth, intrinsics, camera-info, visualization, object-detection]
runtime: python runtime: python
entrypoint: opencv-plot entrypoint: dora-image-view
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and # Run the dataflow with --uv, which gives the node its own managed venv under
# gives the node its own managed venv under `.dora/python-envs/`, so the # `.dora/python-envs/`, so what it imports at runtime is what was installed
# dependencies it sees at runtime are the ones installed here. # here. Without --uv this needs a virtualenv to install into.
build: pip install . build: uv pip install .
dora: ">=1.0" dora: ">=1.0"
inputs: inputs:
image: image:
type: std/core/v1/UInt8 type: std/media/v1/Image
required: false required: false
description: >- description: >-
Base image to draw on: a flat UInt8 buffer described by the metadata Base image to draw on: an Arrow struct {width, height, encoding, data}.
{width, height, encoding}. Supported encodings: bgr8, rgb8, Supported encodings: bgr8, rgb8, jpeg/jpg/jpe/bmp/webp/png (encoded
jpeg/jpg/jpe/bmp/webp/png (encoded bytes), yuv420, avif. Receiving this bytes), yuv420, avif. Receiving this input triggers a redraw and display.
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: bbox:
required: false required: false
description: >- description: >-
@@ -41,24 +42,57 @@ inputs:
description: >- description: >-
Text string to overlay at the top-left corner. Stored and drawn on the Text string to overlay at the top-left corner. Stored and drawn on the
next image redraw. 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/media/v1/Image
required: false
description: >-
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: {} outputs: {}
env: env:
PLOT_WIDTH: 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 type: int
description: Resize the displayed frame to this width (pixels). Defaults to the image input width. description: Resize the displayed frame to this width (pixels). Defaults to the image input width.
PLOT_HEIGHT: VIEW_HEIGHT:
type: int type: int
description: Resize the displayed frame to this height (pixels). Defaults to the image input height. description: Resize the displayed frame to this height (pixels). Defaults to the image input height.
example: | example: |
- id: plot - id: view
git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git git: https://gitea.liria.cl/liria/dora-image-view.git
tag: v0.1.0 tag: v0.1.0
build: pip install . build: uv pip install .
path: opencv-plot path: dora-image-view
inputs: inputs:
image: camera/image image: camera/image_bgr
depth: camera/depth
camera_info: camera/camera_info
bbox: detector/bbox bbox: detector/bbox
text: detector/text text: detector/text
@@ -2,7 +2,7 @@
import numpy as np import numpy as np
from opencv_plot.plot import Bboxes from dora_image_view.view import Bboxes
def parse(value, bbox_format: str) -> Bboxes: def parse(value, bbox_format: str) -> Bboxes:
@@ -1,10 +1,19 @@
"""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 import io
import cv2 import cv2
import numpy as np import numpy as np
import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow
import pyarrow as pa
from PIL import Image from PIL import Image
# Encodings that carry their own dimensions, so `width`/`height` metadata is not # Encodings that carry their own dimensions, so `width`/`height` metadata is not
@@ -14,13 +23,25 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"})
SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"} SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"}
def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.ndarray: # 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:
"""Decode one `image` payload into a writable BGR array. """Decode one `image` payload into a writable BGR array.
`width` and `height` come from the message metadata and are only required by Accepts either an `std/media/v1/Image` struct or a flat buffer described by
the raw encodings, which carry no dimensions of their own. the message metadata.
""" """
buffer = storage.to_numpy().astype(np.uint8) buffer, encoding, width, height = unpack(storage, metadata)
if encoding == "bgr8": if encoding == "bgr8":
# Copy so the frame can be annotated: to_numpy() may be a view on the # Copy so the frame can be annotated: to_numpy() may be a view on the
@@ -53,6 +74,36 @@ def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.
raise RuntimeError(f"unsupported image encoding: {encoding}") 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): def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str):
width, height = _dimensions(width, height, encoding) width, height = _dimensions(width, height, encoding)
return _take(buffer, width * height * 3, encoding).reshape(height, width, 3) return _take(buffer, width * height * 3, encoding).reshape(height, width, 3)
+146
View File
@@ -0,0 +1,146 @@
"""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
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.
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.
Missing depth is 0. Non-finite values are normalised to 0 as well, so
everything downstream only has to test for it.
"""
depth, height, width = _values(storage, metadata)
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 _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")
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
+89
View File
@@ -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", ""))
+106
View File
@@ -0,0 +1,106 @@
"""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`,
`text` and `camera_info` 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, depth, intrinsics
from dora_image_view.view import View
def main() -> None:
"""Connect to the dataflow and display every frame that arrives."""
args = _parse_args()
view = View(
width=_size("VIEW_WIDTH", args.view_width),
height=_size("VIEW_HEIGHT", args.view_height),
headless=os.getenv("CI") == "true",
)
# Passing the name lets the node also run standalone as a dynamic node; when
# dora spawns it, the id from the dataflow wins.
node = Node(args.name)
for event in node:
if event["type"] == "ERROR":
raise RuntimeError(event["error"])
if event["type"] != "INPUT":
continue
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:
name, frame = panel
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":
view.text = event["value"][0].as_py()
else:
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)
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 viewer: image and depth, with bbox and text overlays.",
)
parser.add_argument(
"--name",
default="dora-image-view",
help="The name of the node in the dataflow, used when running as a dynamic node.",
)
parser.add_argument(
"--view-width",
type=int,
help="Resize the displayed frame to this width. Overridden by VIEW_WIDTH.",
)
parser.add_argument(
"--view-height",
type=int,
help="Resize the displayed frame to this height. Overridden by VIEW_HEIGHT.",
)
return parser.parse_args()
def _size(name: str, fallback: int | None) -> int | None:
value = os.getenv(name)
return int(value) if value and value.isnumeric() else fallback
if __name__ == "__main__":
main()
+18
View File
@@ -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)
+134
View File
@@ -0,0 +1,134 @@
"""The display state: the panels, the overlays drawn on them, and the window."""
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)
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:
"""One overlay's worth of boxes, always stored as xyxy corners."""
corners: np.ndarray = field(default_factory=lambda: np.empty((0, 4)))
conf: np.ndarray = field(default_factory=lambda: np.empty(0))
labels: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=object))
@dataclass
class View:
"""Panel state, plus the window the panels are displayed in.
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
height: int | None = None
headless: bool = False
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.
Returns whether the viewer asked to quit by pressing `q`.
"""
frame = self.compose()
if frame is None:
return False
if self.width and self.height:
frame = cv2.resize(frame, (self.width, self.height))
if self.headless:
return False
cv2.imshow(WINDOW, frame)
return cv2.waitKey(1) & 0xFF == ord("q")
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)
# 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:
"""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,
self.bboxes.labels,
strict=False,
):
min_x, min_y, max_x, max_y = (int(value) for value in corners)
cv2.rectangle(frame, (min_x, min_y), (max_x, max_y), GREEN, 2)
cv2.putText(
frame,
f"{label}, {conf:0.2f}",
(max_x - 120, max_y - 10),
FONT,
FONT_SCALE,
GREEN,
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))
-84
View File
@@ -1,84 +0,0 @@
"""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.
"""
import argparse
import os
from dora import Node
from opencv_plot import bbox, decode
from opencv_plot.plot import Plot
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),
headless=os.getenv("CI") == "true",
)
# Passing the name lets the node also run standalone as a dynamic node; when
# dora spawns it, the id from the dataflow wins.
node = Node(args.name)
for event in node:
if event["type"] == "ERROR":
raise RuntimeError(event["error"])
if event["type"] != "INPUT":
continue
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 plot.show(frame):
break
elif event_id == "bbox":
plot.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower())
elif event_id == "text":
plot.text = event["value"][0].as_py()
else:
print(f"ignoring unexpected input `{event_id}`")
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="OpenCV plotter: draws text and bounding boxes on an image.",
)
parser.add_argument(
"--name",
default="opencv-plot",
help="The name of the node in the dataflow, used when running as a dynamic node.",
)
parser.add_argument(
"--plot-width",
type=int,
help="Resize the displayed frame to this width. Overridden by PLOT_WIDTH.",
)
parser.add_argument(
"--plot-height",
type=int,
help="Resize the displayed frame to this height. Overridden by PLOT_HEIGHT.",
)
return parser.parse_args()
def _size(name: str, fallback: int | None) -> int | None:
value = os.getenv(name)
return int(value) if value and value.isnumeric() else fallback
if __name__ == "__main__":
main()
-72
View File
@@ -1,72 +0,0 @@
"""The display state: the frame, the overlays drawn on it, and the window."""
from dataclasses import dataclass, field
import cv2
import numpy as np
WINDOW = "Dora Node: opencv-plot"
GREEN = (0, 255, 0)
WHITE = (255, 255, 255)
FONT = cv2.FONT_HERSHEY_SIMPLEX
FONT_SCALE = 0.5
@dataclass
class Bboxes:
"""One overlay's worth of boxes, always stored as xyxy corners."""
corners: np.ndarray = field(default_factory=lambda: np.empty((0, 4)))
conf: np.ndarray = field(default_factory=lambda: np.empty(0))
labels: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=object))
@dataclass
class Plot:
"""Overlay state, plus the window it is displayed in.
`headless` suppresses the window; the node still decodes every input.
"""
width: int | None = None
height: int | None = None
headless: bool = False
bboxes: Bboxes = field(default_factory=Bboxes)
text: str = ""
def show(self, frame: np.ndarray) -> bool:
"""Draw the overlays on `frame`, resize it, and display it.
Returns whether the viewer asked to quit by pressing `q`.
"""
self._draw(frame)
if self.width and self.height:
frame = cv2.resize(frame, (self.width, self.height))
if self.headless:
return False
cv2.imshow(WINDOW, frame)
return cv2.waitKey(1) & 0xFF == ord("q")
def _draw(self, frame: np.ndarray) -> None:
for corners, conf, label in zip(
self.bboxes.corners,
self.bboxes.conf,
self.bboxes.labels,
strict=False,
):
min_x, min_y, max_x, max_y = (int(value) for value in corners)
cv2.rectangle(frame, (min_x, min_y), (max_x, max_y), GREEN, 2)
cv2.putText(
frame,
f"{label}, {conf:0.2f}",
(max_x - 120, max_y - 10),
FONT,
FONT_SCALE,
GREEN,
1,
)
cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1)
+14 -5
View File
@@ -1,5 +1,5 @@
[project] [project]
name = "opencv-plot" name = "dora-image-view"
version = "0.1.0" version = "0.1.0"
description = "Dora node that overlays bounding boxes and text on an image and displays it in an OpenCV window" description = "Dora node that overlays bounding boxes and text on an image and displays it in an OpenCV window"
readme = "README.md" readme = "README.md"
@@ -21,24 +21,33 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
opencv-plot = "opencv_plot.main:main" dora-image-view = "dora_image_view.main:main"
[dependency-groups] [dependency-groups]
dev = ["pytest >= 8.1", "ruff >= 0.9"] dev = [
"pymarkdownlnt>=0.9.40",
"pytest >= 8.1",
"ruff >= 0.9",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["opencv_plot"] packages = ["dora_image_view"]
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["D", "I", "N", "RET", "UP"] extend-select = ["D", "I", "N", "RET", "UP"]
[tool.ruff.lint.pydocstyle] [tool.ruff.lint.pydocstyle]
convention = "pep257" convention = "numpy"
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# Test names are the documentation here; a docstring would only repeat them. # Test names are the documentation here; a docstring would only repeat them.
"tests/*" = ["D1"] "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
+131
View File
@@ -0,0 +1,131 @@
"""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 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
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
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
# dataflow to completion instead of killing it on a timeout.
FRAMES = int(os.getenv("SOURCE_FRAMES", "0"))
BOX = 96
SPEED = 6
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, and camera_info once."""
node = Node("source")
sent = 0
for event in node:
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", image_struct(frame, "bgr8"), INTRINSICS_METADATA)
node.send_output(
"bbox",
pa.array([{
"bbox": corners,
"conf": np.array([0.99]),
"labels": np.array(["square"]),
}]),
{"format": "xyxy"},
)
depth = render_depth(corners)
node.send_output(
"depth",
image_struct(depth, "32FC1"),
{"units": "mm", **INTRINSICS_METADATA},
)
node.send_output("text", pa.array([CAPTION]))
sent += 1
if FRAMES and sent >= FRAMES:
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)
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
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()
+374
View File
@@ -0,0 +1,374 @@
"""Tests that need neither a dataflow nor a display."""
import io
import cv2
import numpy as np
import pyarrow as pa
import pytest
from PIL import Image
from dora_image_view import bbox, decode, depth, intrinsics
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."""
def __init__(self, data: np.ndarray) -> None:
self._data = data
def to_numpy(self) -> np.ndarray:
return self._data
@pytest.fixture
def frame() -> np.ndarray:
rng = np.random.default_rng(seed=0)
return rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8)
def test_bgr8_round_trips_unchanged(frame):
decoded = decode.to_bgr(FakeStorage(frame.ravel()), _meta("bgr8", WIDTH, HEIGHT))
np.testing.assert_array_equal(decoded, frame)
def test_rgb8_round_trips_unchanged(frame):
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
decoded = decode.to_bgr(FakeStorage(rgb.ravel()), _meta("rgb8", WIDTH, HEIGHT))
np.testing.assert_array_equal(decoded, frame)
@pytest.mark.parametrize(("suffix", "encoding"), [
(".png", "png"),
(".jpg", "jpeg"),
(".bmp", "bmp"),
(".webp", "webp"),
])
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()), _meta(encoding))
assert decoded.shape == (HEIGHT, WIDTH, 3)
def test_yuv420_is_decoded(frame):
planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
decoded = decode.to_bgr(FakeStorage(planes.ravel()), _meta("yuv420", WIDTH, HEIGHT))
assert decoded.shape == (HEIGHT, WIDTH, 3)
def test_avif_is_decoded(frame):
buffer = io.BytesIO()
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), _meta("avif"))
assert decoded.shape == (HEIGHT, WIDTH, 3)
def test_decoded_frame_is_writable():
storage = FakeStorage(np.arange(2 * 3 * 3, dtype=np.uint8))
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
def test_raw_encodings_need_dimensions():
storage = FakeStorage(np.zeros(18, dtype=np.uint8))
with pytest.raises(RuntimeError, match="width"):
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, _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, _meta("tiff"))
def test_xywh_is_converted_to_corners():
value = pa.array(
[
{
"bbox": np.array([10.0, 20.0, 4.0, 6.0]),
"conf": np.array([0.5]),
"labels": np.array(["cat"]),
},
],
)
boxes = bbox.parse(value, "xywh")
np.testing.assert_allclose(boxes.corners, [[8.0, 17.0, 12.0, 23.0]])
assert list(boxes.labels) == ["cat"]
def test_headless_show_draws_without_a_window():
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)
view.panels["image"] = frame
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
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"
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=decode.IMAGE_TYPE,
)
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)
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
-135
View File
@@ -1,135 +0,0 @@
"""Tests that need neither a dataflow nor a display."""
import io
import cv2
import numpy as np
import pytest
from PIL import Image
from opencv_plot import bbox, decode
from opencv_plot.plot import Bboxes, Plot
WIDTH, HEIGHT = 12, 8
class FakeStorage:
"""Stands in for the Arrow array dora hands to the node."""
def __init__(self, data: np.ndarray) -> None:
self._data = data
def to_numpy(self) -> np.ndarray:
return self._data
@pytest.fixture
def frame() -> np.ndarray:
rng = np.random.default_rng(seed=0)
return rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8)
def test_bgr8_round_trips_unchanged(frame):
decoded = decode.to_bgr(FakeStorage(frame.ravel()), "bgr8", WIDTH, HEIGHT)
np.testing.assert_array_equal(decoded, 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)
np.testing.assert_array_equal(decoded, frame)
@pytest.mark.parametrize(("suffix", "encoding"), [
(".png", "png"),
(".jpg", "jpeg"),
(".bmp", "bmp"),
(".webp", "webp"),
])
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)
assert decoded.shape == (HEIGHT, WIDTH, 3)
def test_yuv420_is_decoded(frame):
planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
decoded = decode.to_bgr(FakeStorage(planes.ravel()), "yuv420", WIDTH, HEIGHT)
assert decoded.shape == (HEIGHT, WIDTH, 3)
def test_avif_is_decoded(frame):
buffer = io.BytesIO()
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)
assert decoded.shape == (HEIGHT, WIDTH, 3)
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)
# The frame is annotated in place, so it must not be a read-only view.
decoded[0, 0] = 0
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)
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)
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)
def test_xywh_is_converted_to_corners():
import pyarrow as pa
value = pa.array(
[
{
"bbox": np.array([10.0, 20.0, 4.0, 6.0]),
"conf": np.array([0.5]),
"labels": np.array(["cat"]),
},
],
)
boxes = bbox.parse(value, "xywh")
np.testing.assert_allclose(boxes.corners, [[8.0, 17.0, 12.0, 23.0]])
assert list(boxes.labels) == ["cat"]
def test_headless_show_draws_without_a_window():
plot = Plot(headless=True, text="hello")
plot.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 frame.any(), "the overlays should have marked the frame"
Generated
+325 -36
View File
@@ -6,6 +6,34 @@ resolution-markers = [
"python_full_version < '3.12'", "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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -15,6 +43,57 @@ 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" }, { 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"
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 = "pymarkdownlnt" },
{ 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 = "pymarkdownlnt", specifier = ">=0.9.40" },
{ name = "pytest", specifier = ">=8.1" },
{ name = "ruff", specifier = ">=0.9" },
]
[[package]] [[package]]
name = "dora-rs" name = "dora-rs"
version = "1.0.1" version = "1.0.1"
@@ -206,42 +285,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" }, { 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]] [[package]]
name = "opencv-python" name = "opencv-python"
version = "5.0.0.93" version = "5.0.0.93"
@@ -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" }, { 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]] [[package]]
name = "pyarrow" name = "pyarrow"
version = "25.0.1" 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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" 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/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" }, { 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" },
]