Compare commits
26
Commits
d5d3026b92
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de1f4b7b2 | ||
|
|
8c3b3aac92 | ||
|
|
a371bb07ba | ||
|
|
7eeb83bf6d | ||
|
|
344a527097 | ||
|
|
c178afd58e | ||
|
|
7eb00000ea | ||
|
|
f7171de2af | ||
|
|
15e0b9155a | ||
|
|
363bf006be | ||
|
|
c9715a34d5 | ||
|
|
c1dba8998d | ||
|
|
093715e78f | ||
|
|
7d9ebe8b38 | ||
|
|
2b70d07868 | ||
|
|
8a7bda5867 | ||
|
|
a6fa3ce32b | ||
|
|
92abe8a446 | ||
|
|
1c7a9c816d | ||
|
|
5a0f13d4db | ||
|
|
0efeacd538 | ||
|
|
407395ab88 | ||
|
|
b9d9843fa1 | ||
|
|
00dc4c7b71 | ||
|
|
22bd1e9e89 | ||
|
|
e2debe0581 |
@@ -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
|
||||
@@ -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
|
||||
@@ -1,53 +1,28 @@
|
||||
# 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`.
|
||||
- **`uv` for everything:** `uv sync --group dev`, `uv run pytest`,
|
||||
`uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient
|
||||
environment. A node's `build:` is `uv pip install .`, so dataflows must be run
|
||||
with `--uv`.
|
||||
- **`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
|
||||
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:**
|
||||
|
||||
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.
|
||||
```bash
|
||||
act pull_request -P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
```
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
**It is Python on purpose.** The node was first written in Rust against the `opencv` crate; that crate generates its bindings from the OpenCV headers installed on the build machine, so it needs `libopencv-dev` and `libclang-dev` on every machine and every architecture and does not cross-compile. Every dependency here ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64, so `uv sync` is the whole install. Do not reintroduce a dependency that has to be compiled from source.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
uv sync # runtime deps
|
||||
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.
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ (`dora-rs` 1.0.1 requires it), `uv`, and a display — or `CI=true`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # 14 tests, no camera/dataflow/display needed
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
## 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.
|
||||
- **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.**
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# GEMINI.md
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for repository guidance.
|
||||
@@ -1,11 +1,12 @@
|
||||
# dora-plot
|
||||
# dora-image-view
|
||||
|
||||
Dora node that overlays bounding boxes, labels and text on an image input and displays
|
||||
it in an OpenCV window. A display sink for debugging a vision pipeline: it produces no
|
||||
outputs.
|
||||
Dora node that displays a colour image and a depth map side by side in one OpenCV
|
||||
window, with bounding box, text and camera intrinsics overlays. A display sink for
|
||||
debugging a vision pipeline: it consumes frames and produces no outputs.
|
||||
|
||||
Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot)
|
||||
from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`.
|
||||
from dora-hub, brought up to the dora 1.0 node contract, renamed, and changed to take
|
||||
the typed Arrow image struct rather than a flat buffer plus metadata.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -13,108 +14,89 @@ Add to your dataflow:
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
- id: plot
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git
|
||||
tag: v0.1.0
|
||||
build: pip install .
|
||||
path: opencv-plot
|
||||
- id: view
|
||||
git: https://gitea.liria.cl/liria/dora-image-view.git
|
||||
tag: v0.1.1
|
||||
build: uv pip install .
|
||||
path: dora-image-view
|
||||
inputs:
|
||||
image: camera/image
|
||||
depth: camera/depth
|
||||
camera_info: camera/camera_info
|
||||
bbox: detector/bbox
|
||||
text: detector/text
|
||||
```
|
||||
|
||||
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
|
||||
managed environment.
|
||||
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`): the build command is
|
||||
`uv pip install .`, so the node gets its own managed environment and never installs into
|
||||
an ambient one.
|
||||
|
||||
### Runnable example
|
||||
Every input is optional. `image` and `depth` each fill a panel, tiled left to right and
|
||||
scaled to a common height, so wiring one is a plain viewer and wiring both shows them
|
||||
side by side. `bbox`, `text` and `camera_info` are overlays rather than panels: boxes on
|
||||
the image panel in its own pixels, caption and camera model on the composite.
|
||||
|
||||
[`dataflow.yml`](dataflow.yml) wires a webcam and a fixed caption from dora-hub into
|
||||
the viewer:
|
||||
### Runnable dataflows
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
Run either with `dora run <file> --uv`:
|
||||
|
||||
It builds the node from this checkout (`path:` + `build:`) rather than from a tag,
|
||||
which is what lets `dora validate` pick up the manifest described below.
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| [`dataflow.yml`](dataflow.yml) | A synthetic source driving every input. What CI runs. |
|
||||
| [`dataflow_webcam.yml`](dataflow_webcam.yml) | A real camera, via [dora-webcam][webcam] |
|
||||
|
||||
`bbox` is left unwired there, because every bbox source is a detector and pulling one
|
||||
in would put a model download in front of a webcam preview. To see boxes, add
|
||||
[`dora-yolo`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/dora-yolo) between
|
||||
the camera and the viewer:
|
||||
[webcam]: https://gitea.liria.cl/liria/dora-webcam
|
||||
|
||||
```yaml
|
||||
- id: detector
|
||||
git: https://github.com/dora-rs/dora-hub.git
|
||||
build: pip install ./node-hub/dora-yolo
|
||||
path: dora-yolo
|
||||
inputs:
|
||||
image: camera/image
|
||||
outputs:
|
||||
- bbox
|
||||
`dataflow.yml` needs no camera, no detector and no network: a window opens with a square
|
||||
bouncing across a gradient, boxed and labelled, beside its depth map. Press `q` to quit.
|
||||
CI runs it headless on every push, which is what keeps it from rotting.
|
||||
|
||||
- id: plot
|
||||
path: opencv-plot
|
||||
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
|
||||
`image: zed-camera/image_bgr` — that node publishes `bgr8` with the `width`, `height`
|
||||
and `encoding` metadata this one expects.
|
||||
|
||||
### Node manifest
|
||||
|
||||
[`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source
|
||||
package is shared between node repositories, so it is the only contract a consumer has,
|
||||
and it is what makes a wiring mistake fail at build time instead of at runtime:
|
||||
|
||||
```
|
||||
$ dora validate dataflow.yml
|
||||
node "plot": injected 2 contract type(s) from .../dora-node.yml
|
||||
All type annotations OK.
|
||||
```
|
||||
|
||||
`image` is `std/core/v1/UInt8` and `text` is `std/core/v1/String`; the payloads are raw
|
||||
buffers described by their metadata, so the URN names the element type rather than a
|
||||
struct. `bbox` is deliberately left untyped — senders disagree on whether its numeric
|
||||
fields are 32- or 64-bit, and the node reads either.
|
||||
|
||||
## Behavior
|
||||
|
||||
The node keeps a current frame, bbox set and text string. Each input updates its slice
|
||||
of that state:
|
||||
|
||||
- An **`image`** event decodes the frame and immediately redraws: it draws every stored
|
||||
bbox (green rectangle plus `label, conf` text), draws the stored text at the top left,
|
||||
resizes to `PLOT_WIDTH`x`PLOT_HEIGHT` if both are set, and shows it in a window titled
|
||||
`Dora Node: opencv-plot`. Press `q` in the window to quit.
|
||||
- A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next
|
||||
`image` redraw.
|
||||
|
||||
Setting `CI=true` suppresses the display: the node still decodes every input but opens
|
||||
no window. That is what makes it safe to run in a pipeline test.
|
||||
`dataflow_webcam.yml` is the worked example of consuming a real producer. A `git:` node
|
||||
needs **anonymous** read access to fetch — on Gitea that means a public organization, not
|
||||
just a public repository.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Payload | Metadata |
|
||||
|---|---|---|
|
||||
| `image` | Flat `UInt8` array | `width`, `height`, `encoding` |
|
||||
| `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` |
|
||||
| `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 row-major `K` | `distortion` |
|
||||
| `bbox` | Arrow struct `{bbox, conf, labels}` | `format`: `xyxy` or `xywh` |
|
||||
| `text` | Arrow array of size 1 | — |
|
||||
|
||||
Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png`
|
||||
(encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw
|
||||
encodings — the rest carry their own dimensions. An unsupported encoding raises.
|
||||
`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 travels as message metadata.
|
||||
|
||||
Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`).
|
||||
Image encodings: `bgr8`, `rgb8`, `yuv420`, `avif`, and `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/
|
||||
`png` as an encoded byte stream. Depth encodings: `32FC1`/`float32` and
|
||||
`16UC1`/`mono16`/`uint16`, one channel per pixel — dora's registry has no depth type, and
|
||||
a depth map is an image whose pixels are distances, as ROS also treats it. `0` and
|
||||
non-finite values mean missing depth and draw black.
|
||||
|
||||
`camera_info` is also read from the metadata of any frame carrying `fx`, `fy`, `cx` and
|
||||
`cy`, so **wiring it is optional even for a camera that publishes it** — which covers a
|
||||
camera that publishes it once per session and a viewer started afterwards. The node needs
|
||||
none of it to draw; it is held for whatever turns depth into 3D.
|
||||
|
||||
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` for `depth`. Note that dora's type compatibility is directional, so
|
||||
a producer still *declaring* `std/core/v1/UInt8` fails `dora validate` against an
|
||||
`std/media/v1/Image` input whatever it sends — its manifest has to move too.
|
||||
|
||||
Publishing to it:
|
||||
|
||||
```python
|
||||
node.send_output("image", pa.array(frame.ravel()),
|
||||
{"width": 640, "height": 480, "encoding": "bgr8"})
|
||||
image = pa.array(
|
||||
[{"width": 640, "height": 480, "encoding": "bgr8", "data": frame.tobytes()}],
|
||||
type=pa.struct([
|
||||
("width", pa.uint32()), ("height", pa.uint32()),
|
||||
("encoding", pa.utf8()), ("data", pa.large_binary()),
|
||||
]),
|
||||
)
|
||||
node.send_output("image", image, {})
|
||||
|
||||
node.send_output("bbox", pa.array([{
|
||||
"bbox": np.array([...]), # flattened, four values per box
|
||||
@@ -129,17 +111,37 @@ node.send_output("text", pa.array(["hello"]))
|
||||
|
||||
None — it is a display sink.
|
||||
|
||||
## Environment variables
|
||||
## Configuration
|
||||
|
||||
Settings are environment variables, which a dataflow can carry per node, keeping them
|
||||
with the wiring rather than in whatever shell starts it:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
VIEW_DEPTH_MIN_MM: 200
|
||||
VIEW_DEPTH_MAX_MM: 600
|
||||
```
|
||||
|
||||
A node's `env:` wins over the environment the dataflow was started in; variables it does
|
||||
not set still pass through.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `PLOT_WIDTH` | image width | Resize the displayed frame to this width, in pixels. |
|
||||
| `PLOT_HEIGHT` | image height | Resize the displayed frame to this height, in pixels. |
|
||||
| `VIEW_WIDTH` | composite width | Resize the displayed composite to this width, in pixels. |
|
||||
| `VIEW_HEIGHT` | composite height | Resize the displayed composite to this height, in pixels. |
|
||||
| `VIEW_DEPTH_MIN_MM` | auto | Pin the near end of the depth colour range, in millimetres. |
|
||||
| `VIEW_DEPTH_MAX_MM` | auto | Pin the far end of the depth colour range, in millimetres. |
|
||||
|
||||
Resizing only happens when both are set. Both are also available as `--plot-width` and
|
||||
`--plot-height`; the environment variable wins when both are given.
|
||||
Resizing only happens when both `VIEW_WIDTH` and `VIEW_HEIGHT` are set; both are also
|
||||
available as `--view-width` and `--view-height`, and the environment variable wins.
|
||||
|
||||
`CI=true` suppresses the display window; it is not a configuration knob.
|
||||
Each end of the depth range is auto-ranged over the current frame's valid pixels unless
|
||||
pinned, so pinning one end leaves the other automatic. Auto-ranging makes an
|
||||
unconfigured sensor legible at the cost of colours that shift between frames; pin both
|
||||
ends to compare frames against each other.
|
||||
|
||||
`CI=true` suppresses the window while still decoding every input, which is what makes the
|
||||
node safe inside a pipeline test. It is not a configuration knob.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -148,11 +150,12 @@ uv sync
|
||||
```
|
||||
|
||||
Every dependency ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64,
|
||||
so the node installs with no system packages and no compiler on any of them — including
|
||||
AVIF support, which comes from `pillow-avif-plugin` rather than OpenCV.
|
||||
so the node installs with no system packages and no compiler — including AVIF support,
|
||||
which comes from `pillow-avif-plugin` rather than OpenCV.
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
|
||||
display to show the window on (or `CI=true` to run without one).
|
||||
display (or `CI=true` to run without one). On a bare Linux image `opencv-python` needs
|
||||
the libGL shared libraries (`libgl1`, `libglib2.0-0`), which it links at import time.
|
||||
|
||||
## Test
|
||||
|
||||
@@ -160,5 +163,6 @@ display to show the window on (or `CI=true` to run without one).
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
The tests cover decoding, the bbox format conversion and the headless draw path, so they
|
||||
need neither a camera, a dataflow nor a display.
|
||||
Unit tests over decoding, depth, intrinsics, the bbox conversion and the headless draw
|
||||
path; they need neither a camera, a dataflow nor a display. The headless `dataflow.yml`
|
||||
run covers the rest — the manifest, the wiring and the event loop.
|
||||
|
||||
+55
-30
@@ -1,41 +1,66 @@
|
||||
# The repository's own example: a webcam and a fixed caption feeding the viewer.
|
||||
# The plot node is built from this checkout with `path:` + `build:`, so
|
||||
# `dora validate` picks up the typed contract in dora-node.yml next to it.
|
||||
# The end-to-end test: a synthetic source feeding the viewer. Everything it
|
||||
# needs is in this checkout -- no camera, no detector, no network -- which is
|
||||
# what lets CI run it rather than only validate it.
|
||||
#
|
||||
# Run it with `--uv` so every Python node gets its own managed environment.
|
||||
# A consumer wires the node from a tag instead — see README.md.
|
||||
# It lives at the repository root rather than beside tests/source.py because a
|
||||
# node's working directory is the directory of the dataflow file, and both
|
||||
# nodes build with `uv pip install .`, which needs pyproject.toml in that
|
||||
# directory.
|
||||
#
|
||||
# `bbox` is left unwired: every bbox source is a detector, and pulling one in
|
||||
# would put a model download in the way of a webcam preview. README.md shows
|
||||
# the wiring.
|
||||
# Both nodes are built from the checkout with `path:` + `build:`, which is what
|
||||
# lets `dora validate` pick up the typed contract in dora-node.yml. A consumer
|
||||
# wires the viewer from a tag instead -- see README.md.
|
||||
#
|
||||
# Run it with `--uv` so every node gets its own managed environment.
|
||||
# strict_types makes a type warning a non-zero exit. It also means a *typed*
|
||||
# manifest input must be wired below: `dora validate --strict` warns
|
||||
# `input_types key "..." not found in inputs list` for any it leaves unwired, so
|
||||
# a new typed input on the node has to be driven from tests/source.py with a
|
||||
# matching entry in `output_types` here, or left untyped.
|
||||
strict_types: true
|
||||
|
||||
nodes:
|
||||
- id: camera
|
||||
git: https://github.com/dora-rs/dora-hub.git
|
||||
build: pip install ./node-hub/opencv-video-capture
|
||||
path: opencv-video-capture
|
||||
- id: source
|
||||
# Run through the interpreter rather than as `path: tests/source.py`.
|
||||
#
|
||||
# A dora-node.yml describes exactly one node and is what gets published
|
||||
# (`dora hub publish` expects it beside pyproject.toml), and this
|
||||
# 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:
|
||||
tick: dora/timer/millis/100
|
||||
tick: dora/timer/millis/33
|
||||
outputs:
|
||||
- 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:
|
||||
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
|
||||
git: https://github.com/dora-rs/dora-hub.git
|
||||
build: pip install ./node-hub/pyarrow-sender
|
||||
path: pyarrow-sender
|
||||
env:
|
||||
DATA: "'dora-plot'"
|
||||
outputs:
|
||||
- data
|
||||
output_types:
|
||||
data: std/core/v1/String
|
||||
|
||||
- id: plot
|
||||
path: opencv-plot
|
||||
build: pip install .
|
||||
- id: view
|
||||
path: dora-image-view
|
||||
build: uv pip install .
|
||||
inputs:
|
||||
image: camera/image
|
||||
text: caption/data
|
||||
image: source/image
|
||||
depth: source/depth
|
||||
camera_info: source/camera_info
|
||||
bbox: source/bbox
|
||||
text: source/text
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# A real camera into the viewer, and the worked example of wiring this node to
|
||||
# something that is not a test fixture.
|
||||
#
|
||||
# Not run by CI: it needs a camera and it fetches dora-webcam over the network.
|
||||
# dataflow.yml is the gate, and needs neither.
|
||||
#
|
||||
# This needs anonymous read access to the camera's repository. dora-cli 1.0.1
|
||||
# fetches through libgit2 with no credential callback, so ssh:// cannot
|
||||
# authenticate at all and anything asking 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 answers an anonymous clone with 401, so the organization must be public.
|
||||
#
|
||||
# `strict_types` is deliberately absent. A camera publishes an image and nothing
|
||||
# else, and `dora validate --strict` warns about every *typed* input this node
|
||||
# declares that a dataflow leaves unwired -- depth, camera_info and text here.
|
||||
# The wiring is correct; the strictness is aimed at the self-contained dataflow,
|
||||
# which drives all of them.
|
||||
|
||||
nodes:
|
||||
- id: camera
|
||||
git: https://gitea.liria.cl/liria/dora-webcam.git
|
||||
tag: v0.1.1
|
||||
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
@@ -5,29 +5,30 @@
|
||||
# dataflow against it without running the node, so CI needs neither OpenCV nor
|
||||
# a display.
|
||||
apiVersion: 1
|
||||
name: opencv-plot
|
||||
name: dora-image-view
|
||||
namespace: liria
|
||||
description: Overlays bounding boxes, labels, and text on an image input and displays it in an OpenCV window
|
||||
description: Displays an image and a depth map side by side in an OpenCV window, with bounding box, text and camera intrinsics overlays
|
||||
categories: [visualization, debug]
|
||||
keywords: [opencv, plot, bbox, bounding-box, image, visualization, object-detection]
|
||||
keywords: [opencv, view, bbox, bounding-box, image, depth, intrinsics, camera-info, visualization, object-detection]
|
||||
|
||||
runtime: python
|
||||
entrypoint: opencv-plot
|
||||
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and
|
||||
# gives the node its own managed venv under `.dora/python-envs/`, so the
|
||||
# dependencies it sees at runtime are the ones installed here.
|
||||
build: pip install .
|
||||
entrypoint: dora-image-view
|
||||
# Run the dataflow with --uv, which gives the node its own managed venv under
|
||||
# `.dora/python-envs/`, so what it imports at runtime is what was installed
|
||||
# here. Without --uv this needs a virtualenv to install into.
|
||||
build: uv pip install .
|
||||
dora: ">=1.0"
|
||||
|
||||
inputs:
|
||||
image:
|
||||
type: std/core/v1/UInt8
|
||||
type: std/media/v1/Image
|
||||
required: false
|
||||
description: >-
|
||||
Base image to draw on: a flat UInt8 buffer described by the metadata
|
||||
{width, height, encoding}. Supported encodings: bgr8, rgb8,
|
||||
jpeg/jpg/jpe/bmp/webp/png (encoded bytes), yuv420, avif. Receiving this
|
||||
input triggers a redraw and display.
|
||||
Base image to draw on: an Arrow struct {width, height, encoding, data}.
|
||||
Supported encodings: bgr8, rgb8, jpeg/jpg/jpe/bmp/webp/png (encoded
|
||||
bytes), yuv420, avif. Receiving this input triggers a redraw and display.
|
||||
A flat UInt8 buffer described by {width, height, encoding} metadata is
|
||||
still decoded, for producers that have not moved to the struct.
|
||||
bbox:
|
||||
required: false
|
||||
description: >-
|
||||
@@ -41,24 +42,57 @@ inputs:
|
||||
description: >-
|
||||
Text string to overlay at the top-left corner. Stored and drawn on the
|
||||
next image redraw.
|
||||
camera_info:
|
||||
type: std/core/v1/Float32
|
||||
required: false
|
||||
description: >-
|
||||
The pinhole model the depth map is interpreted through: [fx, fy, cx, cy],
|
||||
or a nine-element row-major K as ROS spells it in sensor_msgs/CameraInfo.
|
||||
Distortion coefficients are read from the `distortion` metadata. Stored
|
||||
and displayed, and held for readers that turn depth into 3D. A sender
|
||||
that also puts fx/fy/cx/cy in each frame's metadata does not need to wire
|
||||
this input -- they are picked up from there, which is what covers a
|
||||
camera that published its intrinsics before this node started.
|
||||
depth:
|
||||
type: std/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: {}
|
||||
|
||||
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
|
||||
description: Resize the displayed frame to this width (pixels). Defaults to the image input width.
|
||||
PLOT_HEIGHT:
|
||||
VIEW_HEIGHT:
|
||||
type: int
|
||||
description: Resize the displayed frame to this height (pixels). Defaults to the image input height.
|
||||
|
||||
example: |
|
||||
- id: plot
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-plot.git
|
||||
- id: view
|
||||
git: https://gitea.liria.cl/liria/dora-image-view.git
|
||||
tag: v0.1.0
|
||||
build: pip install .
|
||||
path: opencv-plot
|
||||
build: uv pip install .
|
||||
path: dora-image-view
|
||||
inputs:
|
||||
image: camera/image
|
||||
image: camera/image_bgr
|
||||
depth: camera/depth
|
||||
camera_info: camera/camera_info
|
||||
bbox: detector/bbox
|
||||
text: detector/text
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import numpy as np
|
||||
|
||||
from opencv_plot.plot import Bboxes
|
||||
from dora_image_view.view import 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 cv2
|
||||
import numpy as np
|
||||
import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow
|
||||
import pyarrow as pa
|
||||
from PIL import Image
|
||||
|
||||
# Encodings that carry their own dimensions, so `width`/`height` metadata is not
|
||||
@@ -14,13 +23,25 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"})
|
||||
SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"}
|
||||
|
||||
|
||||
def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.ndarray:
|
||||
# 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.
|
||||
|
||||
`width` and `height` come from the message metadata and are only required by
|
||||
the raw encodings, which carry no dimensions of their own.
|
||||
Accepts either an `std/media/v1/Image` struct or a flat buffer described by
|
||||
the message metadata.
|
||||
"""
|
||||
buffer = storage.to_numpy().astype(np.uint8)
|
||||
buffer, encoding, width, height = unpack(storage, metadata)
|
||||
|
||||
if encoding == "bgr8":
|
||||
# Copy so the frame can be annotated: to_numpy() may be a view on the
|
||||
@@ -53,6 +74,36 @@ def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.
|
||||
raise RuntimeError(f"unsupported image encoding: {encoding}")
|
||||
|
||||
|
||||
def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | None]:
|
||||
"""Split a payload of either shape into buffer, encoding, width and height."""
|
||||
if is_image_struct(storage):
|
||||
# One image per message: the struct is a single-row array.
|
||||
row = storage[0].as_py()
|
||||
return (
|
||||
np.frombuffer(row["data"], dtype=np.uint8),
|
||||
str(row["encoding"]).lower(),
|
||||
row["width"],
|
||||
row["height"],
|
||||
)
|
||||
|
||||
return (
|
||||
storage.to_numpy().astype(np.uint8),
|
||||
str(metadata.get("encoding", "bgr8")).lower(),
|
||||
metadata.get("width"),
|
||||
metadata.get("height"),
|
||||
)
|
||||
|
||||
|
||||
def is_image_struct(storage) -> bool:
|
||||
"""Whether the payload is an Arrow struct with the std/media/v1/Image fields."""
|
||||
fields = getattr(getattr(storage, "type", None), "fields", None)
|
||||
|
||||
if fields is None:
|
||||
return False
|
||||
|
||||
return all(name in {field.name for field in fields} for name in IMAGE_FIELDS)
|
||||
|
||||
|
||||
def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str):
|
||||
width, height = _dimensions(width, height, encoding)
|
||||
return _take(buffer, width * height * 3, encoding).reshape(height, width, 3)
|
||||
@@ -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
|
||||
@@ -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", ""))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
+21
-5
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "opencv-plot"
|
||||
name = "dora-image-view"
|
||||
version = "0.1.0"
|
||||
description = "Dora node that overlays bounding boxes and text on an image and displays it in an OpenCV window"
|
||||
readme = "README.md"
|
||||
@@ -21,24 +21,40 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
opencv-plot = "opencv_plot.main:main"
|
||||
dora-image-view = "dora_image_view.main:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest >= 8.1", "ruff >= 0.9"]
|
||||
dev = [
|
||||
"pymarkdownlnt>=0.9.40",
|
||||
"pytest >= 8.1",
|
||||
"ruff >= 0.9",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["opencv_plot"]
|
||||
packages = ["dora_image_view"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# Only this repository's tests. `dora run` vendors a `git:` node's source into
|
||||
# git/ inside the checkout, and pytest would otherwise collect that node's tests
|
||||
# too -- reporting another project's results as this one's, and going red when
|
||||
# they fail.
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["D", "I", "N", "RET", "UP"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "pep257"
|
||||
convention = "numpy"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Test names are the documentation here; a docstring would only repeat them.
|
||||
"tests/*" = ["D1"]
|
||||
|
||||
[tool.pymarkdown]
|
||||
# The prose here wraps near ruff's 88; 100 leaves room for a long link or table
|
||||
# row without inviting unwrapped paragraphs.
|
||||
plugins.md013.line_length = 100
|
||||
|
||||
+131
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -6,6 +6,34 @@ resolution-markers = [
|
||||
"python_full_version < '3.12'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "application-file-scanner"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "py-walk" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/22/e872546d298103527380955f51191ff87cd178e6aac62bb87de73c3e074f/application_file_scanner-0.6.4.tar.gz", hash = "sha256:581c48c5017345747be7f49507da84fec36d1f7b4f67003e9fbaf2f0bc6a3f66", size = 28540, upload-time = "2026-01-19T23:32:15.695Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/83/e9a5a14c1b6c20ad44abcdb7bb2f34d860aa4f4ee64c526247066b6c42fb/application_file_scanner-0.6.4-py3-none-any.whl", hash = "sha256:49c211c60f1932812477facc38701d58037b07b031fdb6a6061fdee3fd53e35f", size = 15445, upload-time = "2026-01-19T23:32:14.4Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "application-properties"
|
||||
version = "0.9.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyjson5" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/bb/321da0e373416080801d05b8dd9c0a8da77fe305c697adf8c025470ce170/application_properties-0.9.3.tar.gz", hash = "sha256:7dc7d8f23d11e539427e7b8e3afa70353e159c16f703bad6dd082cc6cdfeeaa8", size = 43304, upload-time = "2026-06-02T03:52:07.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/72/3ac1bda73e9c95444b1e13a0de73e847048a776e5ac5b3977c37f04167f9/application_properties-0.9.3-py3-none-any.whl", hash = "sha256:32dbd62b3fb657ec1c7fdc352590463c2ffd14950ffd258a5c091e8798d8aec0", size = 24595, upload-time = "2026-06-02T03:52:06.416Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -15,6 +43,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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "dora-rs"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-plot"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "dora-rs" },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "opencv-python" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pillow-avif-plugin" },
|
||||
{ name = "pyarrow" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||
{ name = "numpy", specifier = ">=2.0" },
|
||||
{ name = "opencv-python", specifier = ">=4.9" },
|
||||
{ name = "pillow", specifier = ">=10.4" },
|
||||
{ name = "pillow-avif-plugin", specifier = ">=1.4" },
|
||||
{ name = "pyarrow", specifier = ">=17.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.1" },
|
||||
{ name = "ruff", specifier = ">=0.9" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python"
|
||||
version = "5.0.0.93"
|
||||
@@ -427,6 +470,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-walk"
|
||||
version = "0.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sly" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/b5/e2f3fab1e11d4089b1c3dfd72175fdb2408ff8028e01bdb0d308923609bb/py_walk-0.3.3.tar.gz", hash = "sha256:a1b28d6079f27203fa3098b69a98572675b3ff5bd02286c43e6dacd66615f879", size = 1815727, upload-time = "2024-10-26T14:30:39.421Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/38/56b67abdbf6797475dfe2f62d391b4a6ead851c76acbaf07e118e53651b6/py_walk-0.3.3-py3-none-any.whl", hash = "sha256:238fc018165138021ce0bfd9c351cdc473d3120ccc5534df35611b92608c94d5", size = 14537, upload-time = "2024-10-26T14:30:38.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "25.0.1"
|
||||
@@ -479,6 +534,150 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjson5"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/9a/3db19560e968d6e85b2a4ddf4b949c6ebf9dd1dcfb5a9f37736f8adeb927/pyjson5-2.0.1.tar.gz", hash = "sha256:a5b0e322e847b198a50d8a1ef16d6b2b19129644dc018d76773e81ef1487ca39", size = 352242, upload-time = "2026-05-15T16:12:54.931Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/75/bd6b2297beef9730e7f265679de7105fbc9a49dd85dff28563851b9c679a/pyjson5-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1727cebec0ca73597188057bb826cd96c8503ced9b22a9cc73501f2e68d91ea0", size = 297438, upload-time = "2026-05-15T16:05:33.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/18/663ab5ad9aff987889440a2cb6f0415d334850444aba759211aa3eec10aa/pyjson5-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4833d46e8dc4bcba54db038305c8a7455d05cf8533d52a2297abff634cf69fa3", size = 155300, upload-time = "2026-05-15T16:05:35.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/57/9f68629021052044cf46bb89652a442f14505f3087d786ac4b18268235b9/pyjson5-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d73d09a5cad4e3325bd6737697d649a4594c4fde8df8eb784ed795dea845435f", size = 151680, upload-time = "2026-05-15T16:05:36.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/06/21da19b96b3215d266bf4662147d5ef70f65daf9bd4e82305ae61584908a/pyjson5-2.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac66d237c78b9ecda45506225c7eed648d2e6750532e15e1ac2052acf3e2d444", size = 189073, upload-time = "2026-05-15T16:05:37.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5b/48ec81dcee70f8d188ac04499207e3c8a893ec306a1ba4db5c38988f98c1/pyjson5-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1c0fa8af1cac361b2bbb030be942ead42bf872f279bc8e2666a8d5d6e7e7ee", size = 171985, upload-time = "2026-05-15T16:05:39.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/89/d79ac6cd317b3488e38f225c2cd8315fa54145faf939295b853bae70fe3c/pyjson5-2.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2e3ade7f34e1ad2b270fdaa6a11c31c7cb5f07379a422e9a714791d345e4bf", size = 169481, upload-time = "2026-05-15T16:05:41.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/db/e15cc07bf375a59d102536b233171eec621d1b8e9412596c0ef656828baf/pyjson5-2.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:55780f15c6d00a8d7847c7291c1a09492beab61460cf1b4725177b189cfaab65", size = 191706, upload-time = "2026-05-15T16:05:43.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/64/3c1c2b350071ccbfcc3a8e4eae66af960cc3193008cf47846cc1c775aebb/pyjson5-2.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f80ec4e18b2a8d42832f843b48cf06e77240c8e9cb046ea7e10752e8135ce87", size = 195462, upload-time = "2026-05-15T16:05:45.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/87/e0774939571faad2609fce9865ffd7a30b648f828fc2ca5acc364411f0ce/pyjson5-2.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94a645be2b846a6a1155296047176099b6ce987030fb7723208d364cb1b73eed", size = 183993, upload-time = "2026-05-15T16:05:46.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a2/ce59cab1733bed125193765f8d55ff316f151703eeb5c264b63200c4b26a/pyjson5-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c34ddc5a211ecebcfadeb9c698abd047245bac83ab7340811b77fd41aab76e4", size = 182419, upload-time = "2026-05-15T16:05:48.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8d/cf77e9193cbff64a8488fa005c62544d102fcc7f8d233932b8c026adf00a/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3614a579966cc22bc4e83599a786bd875e2c1de4baf71898763623f670df69a2", size = 1153689, upload-time = "2026-05-15T16:05:50.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d3/fb75865e713871e0fbb2c6c46a1569365f8778c59601939432dcf540b9d4/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1925fa0b53bf722d6d263e1eeafd6c009dd4dc975bc481e03786497b2c25eb08", size = 1013811, upload-time = "2026-05-15T16:05:52.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/7f/678c7a3ca28439558f42e78560d5e8096918e5fa1995edfc23702cb10a90/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:497b4d61617db4498a20b47827939034d9234c695d44df7dc907ec455068070e", size = 1327361, upload-time = "2026-05-15T16:05:54.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/86/f4b57480f00d546031f6ec6dda17780f996cceb1adf02fed586d2d985aa7/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d8ca0d3e4fc92dd20ae76d067ae692cac35161f83f0712c5b20d8f7ba73730b9", size = 1251975, upload-time = "2026-05-15T16:05:56.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/72/27be9cdb773cdeb334d423d57cc68be30c280df3d3b163c68b7a963d4e50/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6899d9e22660862fb9baf3eb5ac9b2887eb478b18d14f95de94feffed5ce0558", size = 1188190, upload-time = "2026-05-15T16:05:57.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/59/c9672530a00d433e5411440940d2454d39b19fde29c69eab064af136a157/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4671741493245604579cd9a9c044fd12341a0a61cf0fda8fb844812e29a1a488", size = 1367103, upload-time = "2026-05-15T16:05:59.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/14/356b2a67f1d6d062df8345c98b1337556f75c18ca0f9f32921d2a28e1946/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f5ae05506fc86bbe5716ec102cd023d20d2051f43f4eb3a741b557616c3245d", size = 1217145, upload-time = "2026-05-15T16:06:01.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/ca/dbf1f6f20cd54be3588b890648f46c1a0db111c7c988d3325e31c14f3510/pyjson5-2.0.1-cp311-cp311-win32.whl", hash = "sha256:90ef60fe98b6d0eaef3a6cdc81a6b1c6acb135a061bc695c00f3b6fdb86f3f90", size = 114798, upload-time = "2026-05-15T16:06:03.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/87/933712d67031be67cf7c0298d655026b46b30539144f9f12f9084d80b03b/pyjson5-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9f5d62dfe22810a3ad2252c1b0d7f89b0673255263410d999d88bfd6f46b0ca8", size = 134709, upload-time = "2026-05-15T16:06:04.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/77/ce42988444b3e567d2b949970f96949e61aadbe535ceacb12afc2570b553/pyjson5-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:25ee8af17c19d0a31adc3672adc9c92429130e171e6fbe374468519892fd218b", size = 117230, upload-time = "2026-05-15T16:06:06.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/41/be622b742fe4749fefa9d8a923b1cf5e51a1e8e4b4930fa8ea8c531b243d/pyjson5-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62394660d54e76ff8a968c8194b252be23d184f05a00238d4def9624454ea6", size = 302636, upload-time = "2026-05-15T16:06:08.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e2/50378c3bec9164c1bb983ca15ed59f8be498981b790a90d1410a1016ffe2/pyjson5-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:58dfa4cf5c327e14c530b16c23e812c021fe53b19e6f9cdfce257d8c02244895", size = 158588, upload-time = "2026-05-15T16:06:09.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7d/5de0f82c144b6fc2fa2e3fde5517257733a91d24734b5d2415b7e991e8fd/pyjson5-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07cb90a31f962a6de9e49592a306046ee015da73510c6d215e9481a2a21c9ac2", size = 153741, upload-time = "2026-05-15T16:06:11.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ac/174ca02e5f3ed8245dc48247e534880bbec1528c0c4eaa768fafa9417dda/pyjson5-2.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ef4026a90a68cd2bcb64d89ca064a1615b60a61c7694714e2b8ef30434f365b5", size = 185450, upload-time = "2026-05-15T16:06:12.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/0d/bfc553176b7e109b72f23697ab1fd1a77d7fd5c6626d6e23936f13d5884e/pyjson5-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04b4929a7adf0e0720da92ac54c92d408e8057158be3ccc34da17c7767a9af78", size = 163331, upload-time = "2026-05-15T16:06:14.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/05/b0e67106f78c8ea5ffcf6ce0b8421f38a04d5f37ea88ac70123199755bcb/pyjson5-2.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e889b039d6bffda9a6b8d822073bbdb4a60166af22f075e7faa0c03c2dfcf17b", size = 166429, upload-time = "2026-05-15T16:06:16.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/3c/2366f8ecf053434470eafe1f120f2e235d13015fc50d7f06e55e39b550ab/pyjson5-2.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a08e99d0a33463728fb016f5eec680504499c4be280dac016de2da70ec55b0db", size = 180499, upload-time = "2026-05-15T16:06:17.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/65/9a70876d4dd09ce17678024266406b94b8f1edf9db6c2e3bf76cfa653a13/pyjson5-2.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:035c094feb6a9d4312183f9f34228d976102eefb971e9e3dd972d8c7900a466a", size = 187469, upload-time = "2026-05-15T16:06:19.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/43/0c0bcbf9a9ef7fdcd49dc0992a3a206244c2c2baa7b409932464cf26f1a5/pyjson5-2.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d178025a5317db44663dc87aab90f34674991b3da40dbedc3c108e7b03b6466", size = 176032, upload-time = "2026-05-15T16:06:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2c/32213674010c44db265d45e977a431cbde971c45342b483f7af337811403/pyjson5-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:381ca5b3172bccf9c29c5823a0173ab9118d579d0f5bb4fca20b803daa6e72d5", size = 171477, upload-time = "2026-05-15T16:06:23.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/49/da5417d592ac75a23d1ca2dfda846391ccf137edd89054b780866c592b65/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e158caf0dd7fbf7afe12f92fcc9cba26bceac4b38615c087fd592e9e3240d9f", size = 1145571, upload-time = "2026-05-15T16:06:24.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f8/1f0c326b3a3267f28b7b9e2ac07121386ec20ee8f9dd49955af4faa17ba7/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c8010e9a02b6a0719234f8d074639f9051afe579ff0f74d3367af1d8b4c7d839", size = 1010388, upload-time = "2026-05-15T16:06:26.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c8/76f2739a8715061755f12f4d2795f6c4079aeadd166c56d0992521442d5b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3543e067d1d8ab6cb1cd41add38a42eaf82e9a7e802e8a08f1acda9f22199b00", size = 1323111, upload-time = "2026-05-15T16:06:28.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/7a/683e1586fa076fb344aec23a647d67f427847b6bffb1001d56e86aa8da33/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e496938e7e0defc33bfbd0ff581d4ffdc428c598cc5d679232f34df27f7330a", size = 1240671, upload-time = "2026-05-15T16:06:30.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/8e/6638b0b22344b23cae1271b0211668be54b3f76183bd8d4f89a2edd8e562/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2badbc101908123905a38789cc552b99ab896a7ec0ce4fa6ee2bb7b613c85c1b", size = 1178013, upload-time = "2026-05-15T16:06:32.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/21/d44540d3cdc670e5c6e665471361f557ef8c7d56964cd433524208b3707b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1b0df4898e2e046fa1e9b5df436fe11d9b681b1e5fc9877508d982d2059aaf1", size = 1354907, upload-time = "2026-05-15T16:06:33.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/74/fceedd2709760f5c85e75b508002af9b4c2da8af20a80e62b2d0e2358a15/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fcc96c429888f2293f672e33d01da8a0847cd5116a773c9a1903b8bc2f12bc1", size = 1208847, upload-time = "2026-05-15T16:06:35.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/3a/d2f062b5801b3034ecb16ffb0801d7b4a1d6abae4cd4615482dbf62f1ed8/pyjson5-2.0.1-cp312-cp312-win32.whl", hash = "sha256:48c97e2f7f02171948f8a7ed6c9b2b2faea00653a3348d9e810238a688f07b6c", size = 115562, upload-time = "2026-05-15T16:09:24.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/13/2265cf16720defdc87672b68ff5edf83820bd76e59e32f8384c9cb1ae619/pyjson5-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8dea9976eea0aa7af8b5ad49df3fa831d6bda08ffd7bb11409d6a81961491219", size = 136176, upload-time = "2026-05-15T16:09:25.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/92/a8947e646ce642555ac22b31f44b7168c81b0e364d0f2711d571955229b6/pyjson5-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:264ab65fb7d68a763567c2302151f1f074bf053c8479939aa8e75f5f9518fe31", size = 116752, upload-time = "2026-05-15T16:09:27.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/2c/0619f89a9576f335ba63eb851e73ba507480a8c7f28a2e7de2501ed3d303/pyjson5-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8a661d292801b38434d5288bf7329f9b50b3a693b1d2941c7595175842692eba", size = 301821, upload-time = "2026-05-15T16:09:28.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/31/7824913ec71e7421d6a57bc06228f3e2d946d8e8f738f898572dded0dc57/pyjson5-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cb5c1c066038ce6e1922d9d5be23f5cd14d5b5a3c96e6358aa5d0e379014a93", size = 158201, upload-time = "2026-05-15T16:09:30.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/7d/4ddb249563a838425242342d2cf67976ccc292a831b543a92fa1a8a83b11/pyjson5-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7417eab751817fa5f070e41975d9df4aec3532d21389073318161f63cd96d636", size = 153244, upload-time = "2026-05-15T16:09:32.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/39/7622416ac0570d9ce377447bd5b2ec9383c1282e63cc6d0b65779f1336fa/pyjson5-2.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cc4802093b4ab9039367774988486fd04754cf416c32aebf94a94bafd8b8a478", size = 185568, upload-time = "2026-05-15T16:09:33.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/90/2f317da231477b77481020d79c73bbe10625e4925ceeae77603726ef1763/pyjson5-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc3181274ed19ecb2315cf85a499bf83002554f42c72453666c616059d665a7b", size = 163169, upload-time = "2026-05-15T16:09:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/b3/20023c3cfe2f2c2523007d7031b5b7ad7ccd8856d2665547683cebaa6f8e/pyjson5-2.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5398306b8aa253e620af9ed8085767de9cd28d6846da45e535e80fcffa9f33d7", size = 166514, upload-time = "2026-05-15T16:09:36.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/1c/b863e502153477b2845a54b688b72436457aca5107b300fca95e98184551/pyjson5-2.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b97b592287d52a6ec5af84dca94aab02b185f72f8ddb6fc99c58efb8891be5cd", size = 179907, upload-time = "2026-05-15T16:09:38.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b2/413fcb76632e5f6fa89a2ec83976755b5bb4696c6d9eca9ff3c70cd717b6/pyjson5-2.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4efbf5e910a3aa6f330a92d1ae940dda5ed662976ed5056526baaf2e1821f95d", size = 187171, upload-time = "2026-05-15T16:09:39.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/11/66151b819407ab589aef36582038257f1ef42dc065e3423b3d8274f4fcd2/pyjson5-2.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e042f9b869e7f21d5f53a2638e5d0cf1daaba2342127c8777c502daf972602e", size = 175564, upload-time = "2026-05-15T16:09:41.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/98/deb70690dab994474ea527cba91f43ae55928bcff498c441e812b60fc1e2/pyjson5-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26074370d7a6dd38b6e8c28a2f10c790fc6dec938ec22ec3de6c93c97d195f10", size = 171530, upload-time = "2026-05-15T16:09:42.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/be/969752a3a052d00698b6e1dd926c627d7a6475a7c8dd390db148363544f8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cb163a495096716a7a01d052b60bda0308ecfaeddcb2d50a247ba9d707e3e40", size = 1145510, upload-time = "2026-05-15T16:09:45.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/be/4c5c92cdda5a911ee4450a74281782335e7ce6715638308322beb96be639/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5fdd66bdddc2d53421ab7427bedcd2ca9b2f1b4b3b464381aa60d305c94be38c", size = 1010114, upload-time = "2026-05-15T16:09:47.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1e/72283bc505d77dcdab7eaeb0020cf0fd79a05d2991e886d1fcfa73e88ae1/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4ad222a3eff1cc93f9b70894c5f3d2ff5e46531e88feaa4818e329d5f3ae4307", size = 1322915, upload-time = "2026-05-15T16:09:48.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b4/94a09e744a6bb6e76108b61a28a7cc5ecab1b8105cacde7548359b3b74a3/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:63f5cf25113dd1bf3bfeda211d99413e58ec6b9036ff34f7b69f85f23695e9c4", size = 1240126, upload-time = "2026-05-15T16:09:50.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/29/4549380cae425ee112f3c154606c35f5211c12ddf415f079c2b23f2d493b/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b0001eecce9080e6c170e978786501e8931e2075a3d15f5260e1bdd6a45e8976", size = 1178022, upload-time = "2026-05-15T16:09:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/1a/5a8a869e855645858e45dbd077204cec3a85b2f2e669bc3c8cefe9c4a70e/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40371c73cc83ed81914028786a6cc5950bdf300ae82443df83a5dfa80c582fca", size = 1355161, upload-time = "2026-05-15T16:09:55.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/32/827066cd946447648275a892f494f4572d78e6a79d877fa6b6c14af599d8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9dca542eeb6e8fbbc1fe3373bfd18ae6250f1aedc78f39242ac12c8837091b8", size = 1208864, upload-time = "2026-05-15T16:09:56.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cc/a9bc12aff47d8bfd3074a21e3fb056ec0028fac28f8ec7e0fe4449d050f5/pyjson5-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d30c8b2c91d530be475a8fab6dde8c3bcd3e89b999c9e0f05aec2bf2c24b0638", size = 115461, upload-time = "2026-05-15T16:10:30.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/c7/fa3fc956fbc3fa6250c8b99ef75a8f52b691780fca1bfb368340283bd898/pyjson5-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:67f8f5b8d3e3b2ca5f618c928729986aa00fb588c476c0d0d3a151633ff41e0d", size = 135836, upload-time = "2026-05-15T16:10:32.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/e457b20d1e36a766d3ccd09c418b7ef41acabb679ff2248bb1cd38247ce6/pyjson5-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:bdf30ce5b1242f63254d936b30af75cae237fab0ee71cc2544163c82b6bc9201", size = 116652, upload-time = "2026-05-15T16:10:33.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/4c/6266789e615576b62d2db6de26a3b4b8f4cc7a8ff23fe24e48363bca1682/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:954c14d40022fa40e0d36e0ebd337e9e90fd71145592d25a0e2868344e3daca0", size = 320487, upload-time = "2026-05-15T16:09:58.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/75/c4a563034805e20b274fe4db963e1d480001931d88fe70b7d082033b7dcb/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f57529b98d21e8b76f8bc51af9d3c3057dd04a92287fd21ef7ec55255ca6f5c5", size = 166879, upload-time = "2026-05-15T16:09:59.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/5e6ec3580379794e9a99a402465f74586e93e93814974172202d1254fa89/pyjson5-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:545b655ef0b59f39fc29e2b63b4dadd45e447ad77bad2fdca859ff4db69e21f5", size = 162235, upload-time = "2026-05-15T16:10:01.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/48/b2d0e868ef8375eb0696ddc74f71028fc4fcd26cdddaeb34485214f2dc87/pyjson5-2.0.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b519de49dd4cbf254179749e5c6bfe9bd8fd1f97e7755bff2e17cc6f79f85aa5", size = 182162, upload-time = "2026-05-15T16:10:02.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/18/fed02a3d68c3badb87394e8420b72d7cb165ade208c1e02561637409aa99/pyjson5-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de48fbd0466c114c17f408be7bcbc5b3aedeb29ff5f73187022915e6d5eb7817", size = 169487, upload-time = "2026-05-15T16:10:03.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/90/dc897332dda24e949828616e8e74d7937b587a56789f3aca148fcadddde2/pyjson5-2.0.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fe6516ff9ab94368c18ff8ac0ebdb53fac9e047da093d43e67d5e7cd98cdd0bf", size = 164128, upload-time = "2026-05-15T16:10:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/09/90104baeab58adfe63bda804174ef1806b1cabb1692e047fabc2f4aa1799/pyjson5-2.0.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea7aadacac7ac6661117b708175f6beff75e132322c08bbf3794d8ec4ba572b4", size = 184306, upload-time = "2026-05-15T16:10:06.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/73/613efc3cdab5cf9ec399e6c5a0463b672e5f5cbd693a3a4d82cbdda19476/pyjson5-2.0.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5c339bcac113dfc30a1f88f23af273d67bb6169abee1684c0dd27daa71a32f9", size = 192239, upload-time = "2026-05-15T16:10:08.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/2a/17b97e02b37ab9353fc9713e56470be3d2539501ed18d6c676cfbcd0a57c/pyjson5-2.0.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:075de0f1e5e0ffee174a8941b5d9d8af632772a6e5eebe92fb0899db7f6ab7fa", size = 177652, upload-time = "2026-05-15T16:10:10.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/43/a05331ba88fd1aa09ca418548c6b06692e34b2a64b109bb363e01ecafdeb/pyjson5-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c463c9bf508c1d316191898a3c0bfc03dd3de67bdc02f480b0742ac5c859eaeb", size = 174860, upload-time = "2026-05-15T16:10:11.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/00/5d253751f4d27b7e63fdfd765041593fa588395f45b9cea496878d964448/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:54427c8cad0c1a516bad5b4457167cde1e7ef7ee563e28a944f59b3c7413b6a0", size = 1151556, upload-time = "2026-05-15T16:10:13.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f7/a30eed477295e92dfd59553063cf82bf510a7052efc7720c9c50d0082fdb/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca04839cecd364cb53de7ebcd81fd5915193707d968730ea1c8d4ff5c9c2f1a8", size = 1012330, upload-time = "2026-05-15T16:10:15.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/60/80818fad90a99336f8bfc686d76d0b2d7c8bb51e8de5d031620fa1a2d7a1/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13a2de8b14aeb576b1623c8af419510bf871b8d9cc2308db41a46bcc94d00f2f", size = 1320970, upload-time = "2026-05-15T16:10:16.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/3f/090eb3e0971d067defb58bbfd738f74a09be495c6f4f00d5d76a10755bca/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8a689f5252e9791bfbc8fa916999202556a1e8c7b24dfd72de1ad84c493e10a", size = 1244775, upload-time = "2026-05-15T16:10:19.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/38/e7546ad733affe51a5462bad21bceb4fd659baf24930bd141627ce56279b/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc0c2c80fd9d1e4c26f8b6c91a54d38b48648b93ae3f09355856c75fda3e460", size = 1182149, upload-time = "2026-05-15T16:10:21.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/2c/016647580d8a82ee53b4cb9ddb96deb4b157ccea9624bba61de4aa2cd25c/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:13d78b3e2c60b81bd7e6b00e6ab24a664ba1fd64b5d86baa65c4cdfae1fff7ae", size = 1358579, upload-time = "2026-05-15T16:10:23.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/45/8b84ff7a0c4d8c12a76e097c67af62abe986a18d1ab2540764dd6eaa0253/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a7b640dbeabbd7d975170f793f90a7500cc5d510aeae9364a1bc558bcc5336ed", size = 1211015, upload-time = "2026-05-15T16:10:25.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/48/d8f34de7a7319f5966bdfd10f133e689ba01f138d2040bf792f1ca8b18e1/pyjson5-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:46e7c5525034fde8abf3aaf100ed7660c1b618ccd3205544ed7c99e4c55cf201", size = 131660, upload-time = "2026-05-15T16:10:26.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/aa/0bd437252134115a846ffc061078c85f8e8c325c86abcaa862c134d3c57e/pyjson5-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:207c00e7f9641e0358bdc848a1c0610f26f2e8bd51d73bd64e5a0ab3b725f1d8", size = 157717, upload-time = "2026-05-15T16:10:28.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/61/7849b04a0dc78f73905189400ac99c45de8eec2ec451ce9c6990fb3588e5/pyjson5-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4917d5b6186bfa48ec4d85326e2a09769a5f7844963307ab2710429e1f743264", size = 126281, upload-time = "2026-05-15T16:10:29.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/41/70aa1cb1fb0a3ac4c9b8cd405c0c85ba935c53fdfae6271b8eae364b92d1/pyjson5-2.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:573fecd7cad4e24d232053f9ebf331c80fe1b0ed6e2064f7614797a7f691e7ca", size = 303706, upload-time = "2026-05-15T16:10:35.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d9/caf44bf3d33b9502dc4b8ed5d0c7a8af8fbe33001e82414c0407f39f18bd/pyjson5-2.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8ade508a046a5407a322c098e3b7e6033216b158a7746eb8962b7a4cdbf9248", size = 158915, upload-time = "2026-05-15T16:10:36.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/94/f2bab1ce2eac8f77e16dcd0a1ba39de20733a84b9961c3504af0dd68bceb/pyjson5-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4635e349bb1d1f1e854dc790f14be31b6cb198d6453848e8d86818104074f805", size = 154199, upload-time = "2026-05-15T16:10:37.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c4/d94573ca37486af3d6a72f2582844d7d490d8e55639e0cfba371ce91442f/pyjson5-2.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d274697f81f143abac12ce256a06b61635c30d0f8cc11eefae7182655dac9a5", size = 186060, upload-time = "2026-05-15T16:10:39.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/cc/c42e697def319b286fdcb912939c044cc94bd1cfc7338b6dbb566f817f29/pyjson5-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4e2242e55a2651fd8696cfd67ec8e04f54f4ed6c089cceea2b63763a7516d8", size = 165796, upload-time = "2026-05-15T16:10:40.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/dd/f22a5f0e619ef22b8e32520a91bd92f815d50f9ec2d67cfe5974eab9476c/pyjson5-2.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af855c680feaa39cae4a44914ee2863eeae1549f37ce58069c747b3d4803999a", size = 165262, upload-time = "2026-05-15T16:10:42.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/8b/90e22ecb12d51ccdc68325b6b051002e6103cc4600a335d9ed13828acdd9/pyjson5-2.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb769d90516da904e6cf0ba58f3d5830f4a5804486b5e85c5ca42ba3146d187a", size = 181608, upload-time = "2026-05-15T16:10:43.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ab/237f9036eed73e08cf8861466d3d1182ef1c88506bd29955740dcadb24ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6484c14e07aa46abeb3cb2ff0204a765260763ad7cf3f87f601f26b20ed607f3", size = 189582, upload-time = "2026-05-15T16:10:45.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3d/3df8d5f003910a9291e5f04fa178f626e3c8553a5986dc62589ca74195ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc03673adb544324500d79b2acfe2ced038998b2aceb564d335de9cc238cbdb0", size = 176321, upload-time = "2026-05-15T16:10:47.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d9/a458a54f780bafec1b44ee3c27cb3007a83fb7bd4f1c17ac6bf519fd6151/pyjson5-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d714daf784bec2e14fc06c0c26e4a373a6157eaeddaed2d5a7b7b6ae2aabc33", size = 171956, upload-time = "2026-05-15T16:10:49.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d8/1010e0147c8862dc0881cf3656364d3d87a5e336d290fe4cb0b92223e14c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cf3ded356730e08b5941a16db6525efc0de26f35468ab09ad573056c4efc6e5", size = 1147679, upload-time = "2026-05-15T16:10:51.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2f/91c8d8cdb4a2e4bbe8f14b9b57d717988e14830f2ed4a4f08d503cb5edde/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cc00d4669fb4170c28b2c9ed3cd1c2af9f21727c55d6866030cc154f31f68747", size = 1008216, upload-time = "2026-05-15T16:10:53.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d7/25c3f8693660a35be7bb4eb71b0d10d9a6981e986723a4881d607f0c6462/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a77a91c2e019476345c03cde105da44100daaabc3fa56f82f2bd9eb2ebbcb698", size = 1323856, upload-time = "2026-05-15T16:10:55.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/25/82f0f83556ff68ec0ca1129ba9afeb14149cd421dc74823cdf10c209c95c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9ae54e5d717982cb7a082c700ab1f7f965c6aedea6fcb003ec4bdeac4f02bf52", size = 1241860, upload-time = "2026-05-15T16:10:57.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/01/2c4695fc06a0f3f29041a875d3bb24f904c9ae7b3d8dbfa1f8db17ce995c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ce4f5b67b3a6fab623a31d83416b51a5b2c97cb172194f214078484bbb4783a6", size = 1178443, upload-time = "2026-05-15T16:10:59.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ae/c10f534037b096aca21b90017ac9d41f791572dc3ec31fcd92db8ddcd256/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f8d2e61f7bed0b40cfe5f62375581bf56d7b5b9707c9d74747b5931149d7d376", size = 1357739, upload-time = "2026-05-15T16:11:01.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f0/655224f78ede087aa6a295c4745423e0104dd31f67928675b1a4cea72a0a/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2930140edd1a64eb42689993c5ae317bca2e6bc785b5dc5053bd5e9d184c98ad", size = 1209542, upload-time = "2026-05-15T16:11:03.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/52/a7b2a26625136fcd0f92e17beaaae51e04923cc2e28502db354d4de061b4/pyjson5-2.0.1-cp314-cp314-win32.whl", hash = "sha256:51733f91dda897239ca10928f363ce6f4b5eadaf797e74477f6c1c3222c185a7", size = 118342, upload-time = "2026-05-15T16:11:40.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8e/79db426fff3a41610076989d4060cc18a6508ebd3c7877155f6709eea102/pyjson5-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:698c73aacff49ea35bbbf7f700a97785626201ea7aa2e1f1e0a4fe23788c3be2", size = 138408, upload-time = "2026-05-15T16:11:41.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/1b/385da14c05412bca15e86551dde91959686c8bddf7e78722bdc627fa6815/pyjson5-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d447e2e5756f89abfd0ad82d1438075bcbc701c1cc9279b43d24825aaac67356", size = 120905, upload-time = "2026-05-15T16:11:43.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/cd69739556d304d3ea48064ea7d47e5ff7321b0032d7ad78864bceaa0cae/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5603be4f0cb9685d7c2cbd408ddec21b33f415252bee00ac7969f20b5789a1d1", size = 321150, upload-time = "2026-05-15T16:11:05.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d4/6d98268ed07a2cac1e79634fcd3dc280d2503ade5f3c465f7aa095951444/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87267520a256b3f2dba7f2995b745e8b3f2dd72bbf8436010a5191f7809f8ab6", size = 167540, upload-time = "2026-05-15T16:11:06.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c6/8855a9462bdbc8306cba62793e23cc2952dfcbcbcab8e0413f570c891361/pyjson5-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77e58307b74019d90aff9f2781bcffbbea3315b15fc814f8fa9b8d334b956ff4", size = 162533, upload-time = "2026-05-15T16:11:08.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a3/1092f68538ee71ed8027393ab5e71aa9c4a114e493e30a55e46e34c2ffb9/pyjson5-2.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd1712038837342dea73ed526883e53c6e85a158242b6f574b9ad9cdd3199c7f", size = 184165, upload-time = "2026-05-15T16:11:09.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/63/bb9f42a4047284ea59064c134c88dfac3b7f5273e40fe548d6a086cea452/pyjson5-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dba36677c77aed5d680827c65478933705ca156a34838f1250ea191ffc27662", size = 170075, upload-time = "2026-05-15T16:11:11.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/2a/0379aa5184e986d0f9af2919712c148d81928ce7d2d7950407e271b1df3b/pyjson5-2.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:04356d0c09e58907f60675d33859f269473479b90add5026231ef2fadba5207e", size = 164100, upload-time = "2026-05-15T16:11:12.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d5/2087af69695c28c7fe796afc73dec7f0b0b9bc123ec329e45928d58d4705/pyjson5-2.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b331a0fbe2ae26f4ccb88045ebaecc00aeeeab23ff858f9a2223cedd969ec6d6", size = 184674, upload-time = "2026-05-15T16:11:14.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/06/cf1b2744e07f1689cb0ac663e162a060b81cd358474754fe9f3bf02d5398/pyjson5-2.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ca55878d923ba5764254ad1c020c22b33e6161b3555d1f457b8060f2b3c6c17", size = 193047, upload-time = "2026-05-15T16:11:15.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e6/a6b0deb6a3f393907c6d9115a7ac8e27557109204b56b05d3b57c51fc2ca/pyjson5-2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35e18d11e4c2034b78a664affe18c153a8efbf8872cf6bed7f12dc4fb819d440", size = 178712, upload-time = "2026-05-15T16:11:17.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/48/9e5c16daba56dfdb481baab85d1197e14ae3f403902fc23cdca14d14351b/pyjson5-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b710a7489b6e134890eaa1fdb285e9644a1e730c1cfcf1cd90a87859b87a84a9", size = 175642, upload-time = "2026-05-15T16:11:18.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/32/f07d4dbd8cf733f9e3f3e9c9abedfbc23da1e5cfa7fed46aebae07ca6f25/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:08920c0dd6aba3fb6bc6c849e6f05731c5fb2716cd651de424ff60cdd12eae65", size = 1152219, upload-time = "2026-05-15T16:11:20.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9d/0ddbb89d381bc27f3740850d5d664c1f6f76620ad879ff5eb2506a4dfd6c/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e166dadb3275025cff5ee8602131372a3ea171c6727b3fc6e44697348e3972c5", size = 1013305, upload-time = "2026-05-15T16:11:22.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/93/0f44886391dd2249ad9cf98c2deb3f26d1a115dd5a215629af7455765968/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea3c2b4e7b8e209e7f59bd6a925d79b69cd0a4ffa12f2df0c6af55514c8b2227", size = 1322840, upload-time = "2026-05-15T16:11:24.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/29/c478dc24dfcfc07e407ff8669df265ecc51fed5e7d47e51a01f17fd1c53f/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b710fc8ce00c984c8865131f05a8536163e1d3caffb9d081647879eca7424670", size = 1245221, upload-time = "2026-05-15T16:11:26.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f0/5fef8c47c5b7052da79af6425a021040402a9a09b37c5687d91f98696a27/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9784d79275d1b06d95f2920c3f260ce5d3f6671f65a1fae1464f83fbfc834a0", size = 1182991, upload-time = "2026-05-15T16:11:29.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/33/4d3a9d3159cdff1f1886d39f1991e6c8d5927f9b606cfc9743b5bef98c2a/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0ef86c53d0e14991b5b0fe58f225e3d8faec437aa0a70da4762525b6bc2fca10", size = 1359104, upload-time = "2026-05-15T16:11:31.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/20eb16c52453aea0af68a697f4058378c9ff871bd2f0bea28eb76ffe12f8/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d9faccf5a9e86f14104eec31c13428ef8baeb867182ce107e0c68fcc5c62477", size = 1212847, upload-time = "2026-05-15T16:11:33.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/a3/69bbe93275eafb7802a5b29230cc5c28f9b924c68d15b3ab2d43cfe19825/pyjson5-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:2df497af7ab03cf82d9278a4707a83d0ae4e6d727f919a883b5ccec3f7d92650", size = 139137, upload-time = "2026-05-15T16:11:35.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/b865ca6e0ae6887bc2b6b17cd123470ae43ac0fa04a2362d77d4b9f5bd43/pyjson5-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b31cf4f4a4f01800812865af3b02f6700cefb9377e4d9a9c6b78fdcf41fd973", size = 169412, upload-time = "2026-05-15T16:11:36.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/55/02c734459fef0a955ab3e7e745df415d5f9fc873a4c288765f60f22fbc13/pyjson5-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:15f0d8baea89d35c6c01c60b944c778a79cac10f77f71b727f1b244e2c7a8ccb", size = 129061, upload-time = "2026-05-15T16:11:38.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymarkdownlnt"
|
||||
version = "0.9.40"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "application-file-scanner" },
|
||||
{ name = "application-properties" },
|
||||
{ name = "columnar" },
|
||||
{ name = "py-walk" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/ea/a784da505745c5eb19b7b6be62a0cee4c25c42ba72e010413a7ebaa40ca7/pymarkdownlnt-0.9.40.tar.gz", hash = "sha256:9e6b1137b9f4b496da10f838bc30f66e4c182a0bf53d93d3afce65a541ded091", size = 467022, upload-time = "2026-09-13T20:29:33.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/1e/e2d60317291cedff329cfe08dff7d5a2aefe51ef5f336997e1f2e8a08a94/pymarkdownlnt-0.9.40-py3-none-any.whl", hash = "sha256:de92072911fcb1e5476a7d453078d51457329f5e5cfd78c37d29efc363500262", size = 549389, upload-time = "2026-09-13T20:29:31.653Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
@@ -574,3 +773,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sly"
|
||||
version = "0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702, upload-time = "2022-10-25T14:35:30.592Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864, upload-time = "2022-10-25T14:35:28.054Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toolz"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user