add depth and camera intrinsics, and adopt the typed Arrow image (#4)
Adds a depth panel tiled beside the image, the camera_info input holding the pinhole model, and std/media/v1/Image as the payload for both image and depth. Builds with uv, lints markdown, keeps the workflow where act looks for it, and carries a worked example wiring dora-webcam in.
This commit is contained in:
@@ -69,6 +69,8 @@ jobs:
|
|||||||
|
|
||||||
- run: uv run ruff check .
|
- run: uv run ruff check .
|
||||||
|
|
||||||
|
- run: uv run pymarkdown scan -r --respect-gitignore .
|
||||||
|
|
||||||
- run: uv run pytest -q
|
- run: uv run pytest -q
|
||||||
|
|
||||||
# strict_types: true in the dataflow turns a type warning into a non-zero
|
# strict_types: true in the dataflow turns a type warning into a non-zero
|
||||||
@@ -1,48 +1,28 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to AI coding assistants when working with code in this repository.
|
Rules for AI coding assistants working in this repository.
|
||||||
|
|
||||||
## What this is
|
- **dora >= 1.0.** Verified against `dora-cli 1.0.1`, which CI pins in
|
||||||
|
`.github/workflows/validate.yml`.
|
||||||
A dora-rs Python node that displays a video stream with overlays: it keeps a current frame, bbox set and text string, and every `image` event redraws all three into an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say.
|
- **`uv` for everything:** `uv sync --group dev`, `uv run pytest`,
|
||||||
|
`uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient
|
||||||
Everything the example dataflow needs is in this checkout. Do not reintroduce a dependency on another repository — the example must keep running with no camera, no detector and no network.
|
environment. A node's `build:` is `uv pip install .`, so dataflows must be run
|
||||||
|
with `--uv`.
|
||||||
**It is Python on purpose.** The node was first written in Rust against the `opencv` crate; that crate generates its bindings from the OpenCV headers installed on the build machine, so it needs `libopencv-dev` and `libclang-dev` on every machine and every architecture and does not cross-compile. Every dependency here ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64, so `uv sync` is the whole install. Do not reintroduce a dependency that has to be compiled from source.
|
- **`ruff` is the linter and the formatter.** `uv run ruff check .` must be clean.
|
||||||
|
- **Markdown is linted too.** `uv run pymarkdown scan -r --respect-gitignore .` must be clean. Its line
|
||||||
## Commands
|
length is set in `pyproject.toml`.
|
||||||
|
- **numpy-style docstrings.**
|
||||||
|
- **Dependencies must ship prebuilt wheels for linux-x86_64, linux-aarch64 and
|
||||||
|
macos-arm64.** `uv sync` is the whole install: no system packages, no compiler.
|
||||||
|
- **Run the workflow locally with `act` before asking for a commit:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev # deps plus pytest and ruff
|
act pull_request -P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||||
uv run pytest && uv run ruff check . # no camera/dataflow/display needed
|
|
||||||
dora run dataflow.yml --uv # the example, in a window
|
|
||||||
CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`dora build --uv` / `dora run --uv` rewrites each manifest's `build: pip install .` to `uv pip install .` and gives the node its own managed venv under `.dora/python-envs/<node-id>/`, so what it imports at runtime is what was installed at build time.
|
Workflows live in `.github/workflows/`, not `.gitea/workflows/`, so that `act`
|
||||||
|
finds them with no `-W`. Gitea Actions reads both locations; keep only this one,
|
||||||
|
or a run fires twice.
|
||||||
|
|
||||||
## Architecture
|
- **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.**
|
||||||
- **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node.
|
|
||||||
- **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see.
|
|
||||||
- **`dora_image_view/view.py`** — `View` (the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded, which is what makes the node safe in a pipeline test.
|
|
||||||
- **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is.
|
|
||||||
- **`examples/source.py`** — The synthetic source node: an animated frame, the box around the square in it, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout.
|
|
||||||
- **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either.
|
|
||||||
- **`examples/dora-node.yml`** — Manifest for the source node, and not optional. `dora validate` matches a manifest to a `path:` node by entrypoint; with no manifest beside `examples/source.py`, the root `dora-node.yml` is tried against it, does not match its entrypoint, and warns — which `strict_types: true` turns into a failure. Declaring the source's types here rather than as `output_types:` in `dataflow.yml` is also what makes them *checked*: an inline annotation shadows the manifest, so the manifest would be injected but never compared.
|
|
||||||
- **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. Both nodes are `path:` nodes with a manifest, so both contracts are injected and compared against each other; `bbox` is annotated on neither side, matching the manifests.
|
|
||||||
- **`.gitea/workflows/validate.yml`** — CI: lints, runs the unit tests, validates the dataflow and runs it headless. Needs no camera and no display. One job on purpose — the gates themselves take about a second, and are dwarfed by preparing the runner.
|
|
||||||
|
|
||||||
## Why CI installs libgl1
|
|
||||||
|
|
||||||
`opencv-python` links against libGL at import time, so `import cv2` fails on the bare runner image and CI installs the shared libraries first. At roughly sixteen seconds it is the most expensive step in the job, several times everything else combined.
|
|
||||||
|
|
||||||
`opencv-python-headless` needs none of them, but it also has no `imshow`, which is this node's whole purpose. Removing the step therefore means making the GUI wheel an optional extra — headless by default, `pip install .[gui]` for a real window — and that is a change to what a consumer installs, not just to CI. It has not been done.
|
|
||||||
|
|
||||||
## A caveat on typed optional inputs
|
|
||||||
|
|
||||||
`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- 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,8 +1,8 @@
|
|||||||
# dora-image-view
|
# dora-image-view
|
||||||
|
|
||||||
Dora node that overlays bounding boxes, labels and text on an image input and displays
|
Dora node that displays a colour image and a depth map side by side in one OpenCV
|
||||||
it in an OpenCV window. A display sink for debugging a vision pipeline: it produces no
|
window, with bounding box, text and camera intrinsics overlays. A display sink for debugging a
|
||||||
outputs.
|
vision pipeline: it produces no outputs.
|
||||||
|
|
||||||
Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot)
|
Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot)
|
||||||
from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`.
|
from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`.
|
||||||
@@ -14,12 +14,14 @@ Add to your dataflow:
|
|||||||
```yaml
|
```yaml
|
||||||
nodes:
|
nodes:
|
||||||
- id: view
|
- id: view
|
||||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git
|
git: https://gitea.liria.cl/liria/dora-image-view.git
|
||||||
tag: v0.1.0
|
tag: v0.1.0
|
||||||
build: pip install .
|
build: uv pip install .
|
||||||
path: dora-image-view
|
path: dora-image-view
|
||||||
inputs:
|
inputs:
|
||||||
image: camera/image
|
image: camera/image_bgr
|
||||||
|
depth: camera/depth
|
||||||
|
camera_info: camera/camera_info
|
||||||
bbox: detector/bbox
|
bbox: detector/bbox
|
||||||
text: detector/text
|
text: detector/text
|
||||||
```
|
```
|
||||||
@@ -27,16 +29,20 @@ nodes:
|
|||||||
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
|
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
|
||||||
managed environment.
|
managed environment.
|
||||||
|
|
||||||
Wire `image` from any node publishing one of the encodings below, and `bbox` from any
|
Every input is optional, and each of `image` and `depth` fills one panel of the window,
|
||||||
detector publishing the struct below. Both inputs are optional: an `image` alone is a
|
tiled left to right in that order. Wiring one of them is a plain viewer;
|
||||||
plain viewer.
|
wiring several shows them side by side, scaled to a common height. `bbox`, `text` and `camera_info`
|
||||||
|
are overlays rather than panels — boxes are drawn on the `image` panel, in its own
|
||||||
|
pixels, and the caption on the composite.
|
||||||
|
|
||||||
### Runnable example
|
### End-to-end test
|
||||||
|
|
||||||
[`dataflow.yml`](dataflow.yml) is self-contained. [`examples/source.py`](examples/source.py)
|
[`dataflow.yml`](dataflow.yml) is self-contained. [`tests/source.py`](tests/source.py)
|
||||||
is a synthetic source node that emits an animated frame, the box around the square
|
is a synthetic source node that emits an animated frame, a depth map of the same scene,
|
||||||
moving in it, and a caption — the three inputs this node draws — so the example needs
|
the camera intrinsics, the box around the square moving in the frame, and a caption —
|
||||||
no camera, no detector and no network:
|
every input this node draws — so it needs no camera, no detector and no network. It is
|
||||||
|
a test fixture rather than a worked example; the realistic wiring is the block at the
|
||||||
|
top of this file. Run it to watch the node work:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora run dataflow.yml --uv
|
dora run dataflow.yml --uv
|
||||||
@@ -45,9 +51,12 @@ dora run dataflow.yml --uv
|
|||||||
A window opens with a square bouncing across a gradient, boxed and labelled. Press `q`
|
A window opens with a square bouncing across a gradient, boxed and labelled. Press `q`
|
||||||
to quit.
|
to quit.
|
||||||
|
|
||||||
It builds both nodes from this checkout (`path:` + `build:`) rather than from a tag,
|
It builds the viewer from this checkout (`path:` + `build:`) rather than from a tag,
|
||||||
which is what lets `dora validate` pick up the manifest described below.
|
which is what lets `dora validate` pick up the manifest described below.
|
||||||
|
|
||||||
|
CI runs the same dataflow headless on every push, which is what makes it a gate rather
|
||||||
|
than a demo that rots.
|
||||||
|
|
||||||
Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running
|
Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running
|
||||||
until you quit; that plus `CI=true` is how the same dataflow runs headless as a test:
|
until you quit; that plus `CI=true` is how the same dataflow runs headless as a test:
|
||||||
|
|
||||||
@@ -57,25 +66,55 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv
|
|||||||
|
|
||||||
`SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size.
|
`SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size.
|
||||||
|
|
||||||
|
### With a real camera
|
||||||
|
|
||||||
|
[`dataflow_webcam.yml`](dataflow_webcam.yml) wires
|
||||||
|
[dora-webcam](https://gitea.liria.cl/liria/dora-webcam) into this node, and is the
|
||||||
|
worked example of consuming something that is not a test fixture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dora run dataflow_webcam.yml --uv
|
||||||
|
```
|
||||||
|
|
||||||
|
The camera publishes `std/media/v1/Image` and this node draws it. `depth`,
|
||||||
|
`camera_info`, `text` and `bbox` are simply unwired — every input is optional, so a
|
||||||
|
plain viewer is the result.
|
||||||
|
|
||||||
|
It carries no `strict_types`, deliberately. A camera publishes an image and nothing
|
||||||
|
else, and `dora validate --strict` warns about every *typed* input this node declares
|
||||||
|
that a dataflow leaves unwired. The strictness belongs to `dataflow.yml`, which drives
|
||||||
|
all of them.
|
||||||
|
|
||||||
|
**A `git:` node needs anonymous read access.** dora-cli 1.0.1 fetches through libgit2
|
||||||
|
with no credential callback, so a repository that asks for credentials fails with
|
||||||
|
`remote authentication required but no callback set`, and `ssh://` cannot authenticate
|
||||||
|
at all. Note that marking a repository public is not sufficient on Gitea: a repository
|
||||||
|
inside a **private organization** is treated as internal and still answers anonymous
|
||||||
|
clones with `401`. The organization has to be public too.
|
||||||
|
|
||||||
### Node manifest
|
### Node manifest
|
||||||
|
|
||||||
[`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source
|
[`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source
|
||||||
package is shared between node repositories, so it is the only contract a consumer has,
|
package is shared between node repositories, so it is the only contract a consumer has,
|
||||||
and it is what makes a wiring mistake fail at build time instead of at runtime. The
|
and it is what makes a wiring mistake fail at build time instead of at runtime. The
|
||||||
example source has a manifest of its own, so `dora validate` checks the two against
|
example source is typed inline in the dataflow with `output_types:`, so `dora validate`
|
||||||
each other:
|
checks the two against each other:
|
||||||
|
|
||||||
```
|
```text
|
||||||
$ dora validate dataflow.yml
|
$ dora validate dataflow.yml
|
||||||
node "source": injected 2 contract type(s) from .../examples/dora-node.yml
|
node "view": injected 4 contract type(s) from .../dora-node.yml
|
||||||
node "view": injected 2 contract type(s) from .../dora-node.yml
|
|
||||||
All type annotations OK.
|
All type annotations OK.
|
||||||
```
|
```
|
||||||
|
|
||||||
`image` is `std/core/v1/UInt8` and `text` is `std/core/v1/String`; the payloads are raw
|
There is one manifest because this repository ships one node. The example source is a
|
||||||
buffers described by their metadata, so the URN names the element type rather than a
|
test fixture rather than something anyone installs, so it has none, and runs through
|
||||||
struct. `bbox` is deliberately left untyped — senders disagree on whether its numeric
|
the interpreter to keep `dora validate` from matching the viewer's manifest against it.
|
||||||
fields are 32- or 64-bit, and the node reads either.
|
|
||||||
|
`image` is `std/core/v1/UInt8`, `depth` and `camera_info` are `std/core/v1/Float32` and
|
||||||
|
`text` is `std/core/v1/String`; the payloads are raw buffers described by their
|
||||||
|
metadata, so the URN names the element type rather than a struct. `bbox` is deliberately
|
||||||
|
left untyped — senders disagree on whether its numeric fields are 32- or 64-bit, and the
|
||||||
|
node reads either.
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
@@ -86,8 +125,10 @@ of that state:
|
|||||||
bbox (green rectangle plus `label, conf` text), draws the stored text at the top left,
|
bbox (green rectangle plus `label, conf` text), draws the stored text at the top left,
|
||||||
resizes to `VIEW_WIDTH`x`VIEW_HEIGHT` if both are set, and shows it in a window titled
|
resizes to `VIEW_WIDTH`x`VIEW_HEIGHT` if both are set, and shows it in a window titled
|
||||||
`Dora Node: dora-image-view`. Press `q` in the window to quit.
|
`Dora Node: dora-image-view`. Press `q` in the window to quit.
|
||||||
|
- A **`depth`** event colour-maps the distances onto the depth panel and redraws.
|
||||||
|
- A **`camera_info`** event stores the pinhole model and draws it under the caption.
|
||||||
- A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next
|
- A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next
|
||||||
`image` redraw.
|
redraw.
|
||||||
|
|
||||||
Setting `CI=true` suppresses the display: the node still decodes every input but opens
|
Setting `CI=true` suppresses the display: the node still decodes every input but opens
|
||||||
no window. That is what makes it safe to run in a pipeline test.
|
no window. That is what makes it safe to run in a pipeline test.
|
||||||
@@ -96,16 +137,58 @@ no window. That is what makes it safe to run in a pipeline test.
|
|||||||
|
|
||||||
| Input | Payload | Metadata |
|
| Input | Payload | Metadata |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `image` | Flat `UInt8` array | `width`, `height`, `encoding` |
|
| `image` | `std/media/v1/Image` struct | none — the struct describes itself |
|
||||||
|
| `depth` | `std/media/v1/Image` struct, one channel | `units` (defaults to `mm`) |
|
||||||
|
| `camera_info` | `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` |
|
||||||
| `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` |
|
| `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` |
|
||||||
| `text` | Arrow array of size 1 | — |
|
| `text` | Arrow array of size 1 | — |
|
||||||
|
|
||||||
Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png`
|
`image` and `depth` are both `std/media/v1/Image`: an Arrow struct of `width: UInt32`,
|
||||||
|
`height: UInt32`, `encoding: Utf8` and `data: LargeBinary`. The payload describes
|
||||||
|
itself, so nothing about its shape has to travel as message metadata.
|
||||||
|
|
||||||
|
Depth is an image whose pixels are distances — dora's type registry has no depth type,
|
||||||
|
and this is how ROS spells it too. Its encodings are `32FC1`/`float32` and
|
||||||
|
`16UC1`/`mono16`/`uint16`, one channel per pixel, and `units` metadata still switches
|
||||||
|
millimetres to metres.
|
||||||
|
|
||||||
|
Older payloads are still decoded, so a producer can move at its own pace: a flat `UInt8`
|
||||||
|
buffer with `width`/`height`/`encoding` metadata for `image`, and a flat `Float32`
|
||||||
|
buffer with `shape`/`units` metadata for `depth`.
|
||||||
|
|
||||||
|
**Migrating a producer.** dora's type compatibility is directional — `Image` satisfies a
|
||||||
|
`Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input
|
||||||
|
is a `dora validate` error. A producer still declaring `std/core/v1/UInt8` or
|
||||||
|
`std/core/v1/Float32` therefore has to change its manifest as well as its payload.
|
||||||
|
|
||||||
|
Supported `image` `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png`
|
||||||
(encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw
|
(encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw
|
||||||
encodings — the rest carry their own dimensions. An unsupported encoding raises.
|
encodings — the rest carry their own dimensions. An unsupported encoding raises.
|
||||||
|
|
||||||
Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`).
|
Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`).
|
||||||
|
|
||||||
|
`depth` is in millimetres unless `units` is `m`. In a depth map, `0`
|
||||||
|
and any non-finite value mean missing depth and are drawn black. A payload sent as a
|
||||||
|
raw byte slice — which is what a C++ node does — arrives as `UInt8` despite the
|
||||||
|
`Float32` contract, and is reinterpreted rather than converted.
|
||||||
|
|
||||||
|
### Camera intrinsics
|
||||||
|
|
||||||
|
`camera_info` carries the pinhole model the depth map is interpreted through, either as
|
||||||
|
the four values `[fx, fy, cx, cy]` or as a nine-element row-major `K`, which is how ROS
|
||||||
|
spells the same model in `sensor_msgs/CameraInfo`. Distortion coefficients are read from
|
||||||
|
the `distortion` metadata.
|
||||||
|
|
||||||
|
The node displays the model and holds it. It needs none of it to draw a panel; it is
|
||||||
|
there for what reads it next — turning a depth map into a point cloud needs `fx`, `fy`,
|
||||||
|
`cx` and `cy` for every pixel, and nothing else in a dataflow carries them.
|
||||||
|
|
||||||
|
Intrinsics are also picked up from the metadata of any `image` or `depth` frame that
|
||||||
|
carries `fx`, `fy`, `cx` and `cy`, so **wiring `camera_info` is optional even for a
|
||||||
|
camera that publishes it**. That matters because a camera typically publishes
|
||||||
|
`camera_info` once per session: a viewer started after the camera would otherwise never
|
||||||
|
see it.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
node.send_output("image", pa.array(frame.ravel()),
|
node.send_output("image", pa.array(frame.ravel()),
|
||||||
{"width": 640, "height": 480, "encoding": "bgr8"})
|
{"width": 640, "height": 480, "encoding": "bgr8"})
|
||||||
@@ -127,11 +210,21 @@ None — it is a display sink.
|
|||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VIEW_WIDTH` | image width | Resize the displayed frame to this width, in pixels. |
|
| `VIEW_WIDTH` | composite width | Resize the displayed composite to this width, in pixels. |
|
||||||
| `VIEW_HEIGHT` | image height | Resize the displayed frame to this height, in pixels. |
|
| `VIEW_HEIGHT` | composite height | Resize the displayed composite to this height, in pixels. |
|
||||||
|
| `VIEW_DEPTH_MIN_MM` | auto | Pin the near end of the depth colour range, in millimetres. |
|
||||||
|
| `VIEW_DEPTH_MAX_MM` | auto | Pin the far end of the depth colour range, in millimetres. |
|
||||||
|
|
||||||
Resizing only happens when both are set. Both are also available as `--view-width` and
|
Resizing only happens when both `VIEW_WIDTH` and `VIEW_HEIGHT` are set. Both are also
|
||||||
`--view-height`; the environment variable wins when both are given.
|
available as `--view-width` and `--view-height`; the environment variable wins when both
|
||||||
|
are given.
|
||||||
|
|
||||||
|
Each end of the depth range is auto-ranged over the valid pixels of the current frame
|
||||||
|
unless it is pinned, so pinning one end leaves the other automatic. Auto-ranging makes
|
||||||
|
an unconfigured sensor legible at the cost of colours that shift from frame to frame;
|
||||||
|
pin both ends to compare frames against each other. The range covers the full extent of
|
||||||
|
the valid pixels rather than a percentile of them, so a small near object against a
|
||||||
|
large wall stays visible.
|
||||||
|
|
||||||
`CI=true` suppresses the display window; it is not a configuration knob.
|
`CI=true` suppresses the display window; it is not a configuration knob.
|
||||||
|
|
||||||
|
|||||||
+41
-6
@@ -1,31 +1,66 @@
|
|||||||
# The repository's own example, and its end-to-end test: a synthetic source
|
# The end-to-end test: a synthetic source feeding the viewer. Everything it
|
||||||
# feeding the viewer. Everything it needs is in this checkout -- no camera, no
|
# needs is in this checkout -- no camera, no detector, no network -- which is
|
||||||
# detector, no network -- so it is also what CI runs.
|
# what lets CI run it rather than only validate it.
|
||||||
|
#
|
||||||
|
# It lives at the repository root rather than beside tests/source.py because a
|
||||||
|
# node's working directory is the directory of the dataflow file, and both
|
||||||
|
# nodes build with `uv pip install .`, which needs pyproject.toml in that
|
||||||
|
# directory.
|
||||||
#
|
#
|
||||||
# Both nodes are built from the checkout with `path:` + `build:`, which is what
|
# 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
|
# lets `dora validate` pick up the typed contract in dora-node.yml. A consumer
|
||||||
# wires the viewer from a tag instead -- see README.md.
|
# wires the viewer from a tag instead -- see README.md.
|
||||||
#
|
#
|
||||||
# Run it with `--uv` so every node gets its own managed environment.
|
# Run it with `--uv` so every node gets its own managed environment.
|
||||||
|
# strict_types makes a type warning a non-zero exit. It also means a *typed*
|
||||||
|
# manifest input must be wired below: `dora validate --strict` warns
|
||||||
|
# `input_types key "..." not found in inputs list` for any it leaves unwired, so
|
||||||
|
# a new typed input on the node has to be driven from tests/source.py with a
|
||||||
|
# matching entry in `output_types` here, or left untyped.
|
||||||
strict_types: true
|
strict_types: true
|
||||||
|
|
||||||
nodes:
|
nodes:
|
||||||
- id: source
|
- id: source
|
||||||
path: examples/source.py
|
# Run through the interpreter rather than as `path: tests/source.py`.
|
||||||
|
#
|
||||||
|
# A dora-node.yml describes exactly one node and is what gets published
|
||||||
|
# (`dora hub publish` expects it beside pyproject.toml), and this
|
||||||
|
# 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
|
# The source needs dora-rs, numpy and pyarrow; installing the project gives
|
||||||
# it exactly the versions pinned in uv.lock.
|
# it exactly the versions pinned in uv.lock.
|
||||||
build: pip install .
|
build: uv pip install .
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/33
|
tick: dora/timer/millis/33
|
||||||
outputs:
|
outputs:
|
||||||
- image
|
- image
|
||||||
|
- depth
|
||||||
|
- camera_info
|
||||||
- bbox
|
- bbox
|
||||||
- text
|
- 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/media/v1/Image
|
||||||
|
text: std/core/v1/String
|
||||||
|
depth: std/media/v1/Image
|
||||||
|
camera_info: std/core/v1/Float32
|
||||||
|
|
||||||
- id: view
|
- id: view
|
||||||
path: dora-image-view
|
path: dora-image-view
|
||||||
build: pip install .
|
build: uv pip install .
|
||||||
inputs:
|
inputs:
|
||||||
image: source/image
|
image: source/image
|
||||||
|
depth: source/depth
|
||||||
|
camera_info: source/camera_info
|
||||||
bbox: source/bbox
|
bbox: source/bbox
|
||||||
text: source/text
|
text: source/text
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# A real camera into the viewer, and the worked example of wiring this node to
|
||||||
|
# something that is not a test fixture.
|
||||||
|
#
|
||||||
|
# Not run by CI: it needs a camera and it fetches dora-webcam over the network.
|
||||||
|
# dataflow.yml is the gate, and needs neither.
|
||||||
|
#
|
||||||
|
# This needs anonymous read access to the camera's repository. dora-cli 1.0.1
|
||||||
|
# fetches through libgit2 with no credential callback, so anything that asks for
|
||||||
|
# credentials fails with "remote authentication required but no callback set".
|
||||||
|
# On Gitea, marking the repository public is not enough: a repository inside a
|
||||||
|
# private organization is internal and still answers anonymous clones with 401.
|
||||||
|
#
|
||||||
|
# `strict_types` is deliberately absent. A camera publishes an image and nothing
|
||||||
|
# else, and `dora validate --strict` warns about every *typed* input this node
|
||||||
|
# declares that a dataflow leaves unwired -- depth, camera_info and text here.
|
||||||
|
# The wiring is correct; the strictness is aimed at the self-contained dataflow,
|
||||||
|
# which drives all of them.
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
- id: camera
|
||||||
|
git: https://gitea.liria.cl/liria/dora-webcam.git
|
||||||
|
tag: v0.1.0
|
||||||
|
build: uv pip install .
|
||||||
|
path: dora-webcam
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/33
|
||||||
|
outputs:
|
||||||
|
- image
|
||||||
|
# dora-webcam's own manifest says this, but a `git:` node's manifest is not
|
||||||
|
# read at validate time -- only at build. Repeating it here is what makes
|
||||||
|
# the two contracts checked against each other now rather than at runtime.
|
||||||
|
output_types:
|
||||||
|
image: std/media/v1/Image
|
||||||
|
|
||||||
|
- id: view
|
||||||
|
path: dora-image-view
|
||||||
|
build: uv pip install .
|
||||||
|
inputs:
|
||||||
|
image: camera/image
|
||||||
+48
-14
@@ -7,27 +7,28 @@
|
|||||||
apiVersion: 1
|
apiVersion: 1
|
||||||
name: dora-image-view
|
name: dora-image-view
|
||||||
namespace: liria
|
namespace: liria
|
||||||
description: Overlays bounding boxes, labels, and text on an image input and displays it in an OpenCV window
|
description: Displays an image and a depth map side by side in an OpenCV window, with bounding box, text and camera intrinsics overlays
|
||||||
categories: [visualization, debug]
|
categories: [visualization, debug]
|
||||||
keywords: [opencv, view, bbox, bounding-box, image, visualization, object-detection]
|
keywords: [opencv, view, bbox, bounding-box, image, depth, intrinsics, camera-info, visualization, object-detection]
|
||||||
|
|
||||||
runtime: python
|
runtime: python
|
||||||
entrypoint: dora-image-view
|
entrypoint: dora-image-view
|
||||||
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and
|
# Run the dataflow with --uv, which gives the node its own managed venv under
|
||||||
# gives the node its own managed venv under `.dora/python-envs/`, so the
|
# `.dora/python-envs/`, so what it imports at runtime is what was installed
|
||||||
# dependencies it sees at runtime are the ones installed here.
|
# here. Without --uv this needs a virtualenv to install into.
|
||||||
build: pip install .
|
build: uv pip install .
|
||||||
dora: ">=1.0"
|
dora: ">=1.0"
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
image:
|
image:
|
||||||
type: std/core/v1/UInt8
|
type: std/media/v1/Image
|
||||||
required: false
|
required: false
|
||||||
description: >-
|
description: >-
|
||||||
Base image to draw on: a flat UInt8 buffer described by the metadata
|
Base image to draw on: an Arrow struct {width, height, encoding, data}.
|
||||||
{width, height, encoding}. Supported encodings: bgr8, rgb8,
|
Supported encodings: bgr8, rgb8, jpeg/jpg/jpe/bmp/webp/png (encoded
|
||||||
jpeg/jpg/jpe/bmp/webp/png (encoded bytes), yuv420, avif. Receiving this
|
bytes), yuv420, avif. Receiving this input triggers a redraw and display.
|
||||||
input triggers a redraw and display.
|
A flat UInt8 buffer described by {width, height, encoding} metadata is
|
||||||
|
still decoded, for producers that have not moved to the struct.
|
||||||
bbox:
|
bbox:
|
||||||
required: false
|
required: false
|
||||||
description: >-
|
description: >-
|
||||||
@@ -41,10 +42,41 @@ inputs:
|
|||||||
description: >-
|
description: >-
|
||||||
Text string to overlay at the top-left corner. Stored and drawn on the
|
Text string to overlay at the top-left corner. Stored and drawn on the
|
||||||
next image redraw.
|
next image redraw.
|
||||||
|
camera_info:
|
||||||
|
type: std/core/v1/Float32
|
||||||
|
required: false
|
||||||
|
description: >-
|
||||||
|
The pinhole model the depth map is interpreted through: [fx, fy, cx, cy],
|
||||||
|
or a nine-element row-major K as ROS spells it in sensor_msgs/CameraInfo.
|
||||||
|
Distortion coefficients are read from the `distortion` metadata. Stored
|
||||||
|
and displayed, and held for readers that turn depth into 3D. A sender
|
||||||
|
that also puts fx/fy/cx/cy in each frame's metadata does not need to wire
|
||||||
|
this input -- they are picked up from there, which is what covers a
|
||||||
|
camera that published its intrinsics before this node started.
|
||||||
|
depth:
|
||||||
|
type: std/media/v1/Image
|
||||||
|
required: false
|
||||||
|
description: >-
|
||||||
|
Depth map as an Arrow struct {width, height, encoding, data}, one channel
|
||||||
|
per pixel. Supported encodings: 32FC1/float32 and 16UC1/mono16/uint16 --
|
||||||
|
dora has no depth type, and a depth map is an image whose pixels are
|
||||||
|
distances, which is how ROS spells it too. Millimetres unless the `units`
|
||||||
|
metadata says `m`; 0 and non-finite values are missing depth and draw
|
||||||
|
black. Colour-mapped into its own panel, which receiving this input
|
||||||
|
redraws. A flat Float32 buffer with {shape, units} metadata is still
|
||||||
|
decoded, for producers that have not moved to the struct.
|
||||||
|
|
||||||
outputs: {}
|
outputs: {}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
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:
|
VIEW_WIDTH:
|
||||||
type: int
|
type: int
|
||||||
description: Resize the displayed frame to this width (pixels). Defaults to the image input width.
|
description: Resize the displayed frame to this width (pixels). Defaults to the image input width.
|
||||||
@@ -54,11 +86,13 @@ env:
|
|||||||
|
|
||||||
example: |
|
example: |
|
||||||
- id: view
|
- id: view
|
||||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git
|
git: https://gitea.liria.cl/liria/dora-image-view.git
|
||||||
tag: v0.1.0
|
tag: v0.1.0
|
||||||
build: pip install .
|
build: uv pip install .
|
||||||
path: dora-image-view
|
path: dora-image-view
|
||||||
inputs:
|
inputs:
|
||||||
image: camera/image
|
image: camera/image_bgr
|
||||||
|
depth: camera/depth
|
||||||
|
camera_info: camera/camera_info
|
||||||
bbox: detector/bbox
|
bbox: detector/bbox
|
||||||
text: detector/text
|
text: detector/text
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
"""Decoding of the `image` input into a BGR frame that can be drawn on."""
|
"""Decoding of the `image` input into a BGR frame that can be drawn on.
|
||||||
|
|
||||||
|
Two payload shapes are accepted, because dora has both in the field. The typed
|
||||||
|
one is `std/media/v1/Image`: an Arrow struct carrying width, height, encoding
|
||||||
|
and data together, so the frame is self-describing. The older one is a flat
|
||||||
|
UInt8 buffer whose dimensions and encoding travel beside it in the message
|
||||||
|
metadata, which is what a node declaring `std/core/v1/UInt8` sends -- dora-zed
|
||||||
|
among them. Everything after unpacking is identical.
|
||||||
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow
|
import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow
|
||||||
|
import pyarrow as pa
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
# Encodings that carry their own dimensions, so `width`/`height` metadata is not
|
# Encodings that carry their own dimensions, so `width`/`height` metadata is not
|
||||||
@@ -14,13 +23,25 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"})
|
|||||||
SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"}
|
SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"}
|
||||||
|
|
||||||
|
|
||||||
def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.ndarray:
|
# std/media/v1/Image. A payload carrying these fields describes itself, so its
|
||||||
|
# message metadata is not consulted. Exported as an Arrow type so a producer --
|
||||||
|
# the example source, the tests -- builds it from one definition.
|
||||||
|
IMAGE_TYPE = pa.struct([
|
||||||
|
("width", pa.uint32()),
|
||||||
|
("height", pa.uint32()),
|
||||||
|
("encoding", pa.utf8()),
|
||||||
|
("data", pa.large_binary()),
|
||||||
|
])
|
||||||
|
IMAGE_FIELDS = tuple(field.name for field in IMAGE_TYPE)
|
||||||
|
|
||||||
|
|
||||||
|
def to_bgr(storage, metadata: dict) -> np.ndarray:
|
||||||
"""Decode one `image` payload into a writable BGR array.
|
"""Decode one `image` payload into a writable BGR array.
|
||||||
|
|
||||||
`width` and `height` come from the message metadata and are only required by
|
Accepts either an `std/media/v1/Image` struct or a flat buffer described by
|
||||||
the raw encodings, which carry no dimensions of their own.
|
the message metadata.
|
||||||
"""
|
"""
|
||||||
buffer = storage.to_numpy().astype(np.uint8)
|
buffer, encoding, width, height = unpack(storage, metadata)
|
||||||
|
|
||||||
if encoding == "bgr8":
|
if encoding == "bgr8":
|
||||||
# Copy so the frame can be annotated: to_numpy() may be a view on the
|
# Copy so the frame can be annotated: to_numpy() may be a view on the
|
||||||
@@ -53,6 +74,36 @@ def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.
|
|||||||
raise RuntimeError(f"unsupported image encoding: {encoding}")
|
raise RuntimeError(f"unsupported image encoding: {encoding}")
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | None]:
|
||||||
|
"""Split a payload of either shape into buffer, encoding, width and height."""
|
||||||
|
if is_image_struct(storage):
|
||||||
|
# One image per message: the struct is a single-row array.
|
||||||
|
row = storage[0].as_py()
|
||||||
|
return (
|
||||||
|
np.frombuffer(row["data"], dtype=np.uint8),
|
||||||
|
str(row["encoding"]).lower(),
|
||||||
|
row["width"],
|
||||||
|
row["height"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
storage.to_numpy().astype(np.uint8),
|
||||||
|
str(metadata.get("encoding", "bgr8")).lower(),
|
||||||
|
metadata.get("width"),
|
||||||
|
metadata.get("height"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_struct(storage) -> bool:
|
||||||
|
"""Whether the payload is an Arrow struct with the std/media/v1/Image fields."""
|
||||||
|
fields = getattr(getattr(storage, "type", None), "fields", None)
|
||||||
|
|
||||||
|
if fields is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return all(name in {field.name for field in fields} for name in IMAGE_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str):
|
def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str):
|
||||||
width, height = _dimensions(width, height, encoding)
|
width, height = _dimensions(width, height, encoding)
|
||||||
return _take(buffer, width * height * 3, encoding).reshape(height, width, 3)
|
return _take(buffer, width * height * 3, encoding).reshape(height, width, 3)
|
||||||
|
|||||||
@@ -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", ""))
|
||||||
+34
-12
@@ -1,15 +1,17 @@
|
|||||||
"""A dora display sink: overlays bounding boxes and text on an image input.
|
"""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`
|
`image` and `depth` each fill one panel of the window and redraw. `bbox`,
|
||||||
events only update stored state, which the next `image` event draws.
|
`text` and `camera_info` events only update stored state, which the next redraw
|
||||||
|
draws.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from dora import Node
|
from dora import Node
|
||||||
|
|
||||||
from dora_image_view import bbox, decode
|
from dora_image_view import bbox, decode, depth, intrinsics
|
||||||
from dora_image_view.view import View
|
from dora_image_view.view import View
|
||||||
|
|
||||||
|
|
||||||
@@ -36,15 +38,20 @@ def main() -> None:
|
|||||||
event_id = event["id"]
|
event_id = event["id"]
|
||||||
metadata = event["metadata"]
|
metadata = event["metadata"]
|
||||||
|
|
||||||
if event_id == "image":
|
# A sender that puts intrinsics on every frame keeps them current even
|
||||||
frame = decode.to_bgr(
|
# if the camera_info message itself was published before this node
|
||||||
event["value"],
|
# started, which is when dora-zed sends it.
|
||||||
metadata.get("encoding", "bgr8").lower(),
|
view.intrinsics = intrinsics.from_metadata(metadata) or view.intrinsics
|
||||||
metadata.get("width"),
|
|
||||||
metadata.get("height"),
|
panel = _decode_panel(event_id, event["value"], metadata)
|
||||||
)
|
|
||||||
if view.show(frame):
|
if panel is not None:
|
||||||
|
name, frame = panel
|
||||||
|
view.panels[name] = frame
|
||||||
|
if view.show():
|
||||||
break
|
break
|
||||||
|
elif event_id == "camera_info":
|
||||||
|
view.intrinsics = intrinsics.parse(event["value"], metadata)
|
||||||
elif event_id == "bbox":
|
elif event_id == "bbox":
|
||||||
view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower())
|
view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower())
|
||||||
elif event_id == "text":
|
elif event_id == "text":
|
||||||
@@ -53,9 +60,24 @@ def main() -> None:
|
|||||||
print(f"ignoring unexpected input `{event_id}`")
|
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:
|
def _parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="OpenCV plotter: draws text and bounding boxes on an image.",
|
description="OpenCV viewer: image and depth, with bbox and text overlays.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--name",
|
"--name",
|
||||||
|
|||||||
@@ -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)
|
||||||
+70
-8
@@ -1,16 +1,22 @@
|
|||||||
"""The display state: the frame, the overlays drawn on it, and the window."""
|
"""The display state: the panels, the overlays drawn on them, and the window."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from dora_image_view.intrinsics import Intrinsics
|
||||||
|
|
||||||
WINDOW = "Dora Node: dora-image-view"
|
WINDOW = "Dora Node: dora-image-view"
|
||||||
GREEN = (0, 255, 0)
|
GREEN = (0, 255, 0)
|
||||||
WHITE = (255, 255, 255)
|
WHITE = (255, 255, 255)
|
||||||
FONT = cv2.FONT_HERSHEY_SIMPLEX
|
FONT = cv2.FONT_HERSHEY_SIMPLEX
|
||||||
FONT_SCALE = 0.5
|
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
|
@dataclass
|
||||||
class Bboxes:
|
class Bboxes:
|
||||||
@@ -23,9 +29,11 @@ class Bboxes:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class View:
|
class View:
|
||||||
"""Overlay state, plus the window it is displayed in.
|
"""Panel state, plus the window the panels are displayed in.
|
||||||
|
|
||||||
`headless` suppresses the window; the node still decodes every input.
|
Each panel holds the most recent frame of one stream, as BGR. They are
|
||||||
|
tiled left to right, so wiring only `depth` shows only depth. `headless`
|
||||||
|
suppresses the window; the node still decodes every input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
width: int | None = None
|
width: int | None = None
|
||||||
@@ -33,13 +41,18 @@ class View:
|
|||||||
headless: bool = False
|
headless: bool = False
|
||||||
bboxes: Bboxes = field(default_factory=Bboxes)
|
bboxes: Bboxes = field(default_factory=Bboxes)
|
||||||
text: str = ""
|
text: str = ""
|
||||||
|
panels: dict[str, np.ndarray] = field(default_factory=dict)
|
||||||
|
intrinsics: Intrinsics | None = None
|
||||||
|
|
||||||
def show(self, frame: np.ndarray) -> bool:
|
def show(self) -> bool:
|
||||||
"""Draw the overlays on `frame`, resize it, and display it.
|
"""Draw the overlays, tile the panels, and display them.
|
||||||
|
|
||||||
Returns whether the viewer asked to quit by pressing `q`.
|
Returns whether the viewer asked to quit by pressing `q`.
|
||||||
"""
|
"""
|
||||||
self._draw(frame)
|
frame = self.compose()
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
return False
|
||||||
|
|
||||||
if self.width and self.height:
|
if self.width and self.height:
|
||||||
frame = cv2.resize(frame, (self.width, self.height))
|
frame = cv2.resize(frame, (self.width, self.height))
|
||||||
@@ -50,7 +63,49 @@ class View:
|
|||||||
cv2.imshow(WINDOW, frame)
|
cv2.imshow(WINDOW, frame)
|
||||||
return cv2.waitKey(1) & 0xFF == ord("q")
|
return cv2.waitKey(1) & 0xFF == ord("q")
|
||||||
|
|
||||||
def _draw(self, frame: np.ndarray) -> None:
|
def compose(self) -> np.ndarray | None:
|
||||||
|
"""Tile the populated panels into one frame, tallest panel setting the height."""
|
||||||
|
panels = [(name, self.panels[name]) for name in PANELS if name in self.panels]
|
||||||
|
|
||||||
|
if not panels:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# The boxes are in image pixels, so they are drawn before any scaling.
|
||||||
|
if "image" in self.panels:
|
||||||
|
self._draw_bboxes(self.panels["image"])
|
||||||
|
|
||||||
|
tallest = max(panel.shape[0] for _, panel in panels)
|
||||||
|
scaled = [self._label(_scale(panel, tallest), name) for name, panel in panels]
|
||||||
|
|
||||||
|
frame = scaled[0] if len(scaled) == 1 else cv2.hconcat(scaled)
|
||||||
|
|
||||||
|
# On the composite, not on a panel: the caption describes the frame as a
|
||||||
|
# whole, and a panel it was drawn on might not be the leftmost one.
|
||||||
|
cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1)
|
||||||
|
|
||||||
|
# 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(
|
for corners, conf, label in zip(
|
||||||
self.bboxes.corners,
|
self.bboxes.corners,
|
||||||
self.bboxes.conf,
|
self.bboxes.conf,
|
||||||
@@ -69,4 +124,11 @@ class View:
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1)
|
|
||||||
|
def _scale(panel: np.ndarray, height: int) -> np.ndarray:
|
||||||
|
"""Match a panel to the composite height, keeping its aspect ratio."""
|
||||||
|
if panel.shape[0] == height:
|
||||||
|
return panel
|
||||||
|
|
||||||
|
width = max(round(panel.shape[1] * height / panel.shape[0]), 1)
|
||||||
|
return cv2.resize(panel, (width, height))
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
# Manifest for the example source node.
|
|
||||||
#
|
|
||||||
# Its reason to exist is as much mechanical as documentary: `dora validate`
|
|
||||||
# matches a node manifest to a `path:` node by entrypoint, and without a
|
|
||||||
# manifest here the repository-root dora-node.yml -- whose entrypoint is
|
|
||||||
# `dora-image-view` -- gets tried against `examples/source.py`, does not match,
|
|
||||||
# and warns. Under `strict_types: true` that warning fails the run.
|
|
||||||
#
|
|
||||||
# Declaring the types here rather than as `output_types:` in dataflow.yml is
|
|
||||||
# what makes them checked: an inline annotation shadows the manifest, so the
|
|
||||||
# manifest would be injected but never compared against dora-image-view's.
|
|
||||||
apiVersion: 1
|
|
||||||
name: source
|
|
||||||
namespace: liria
|
|
||||||
description: Synthetic image, bbox and text source for the dora-image-view example
|
|
||||||
categories: [debug]
|
|
||||||
keywords: [example, test, synthetic]
|
|
||||||
|
|
||||||
runtime: python
|
|
||||||
entrypoint: source.py
|
|
||||||
build: pip install .
|
|
||||||
dora: ">=1.0"
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
tick:
|
|
||||||
required: true
|
|
||||||
description: Timer tick; one frame is emitted per tick.
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
image:
|
|
||||||
type: std/core/v1/UInt8
|
|
||||||
description: >-
|
|
||||||
Animated bgr8 frame as a flat UInt8 buffer, with metadata
|
|
||||||
{width, height, encoding}.
|
|
||||||
bbox:
|
|
||||||
description: >-
|
|
||||||
The box around the moving square: an Arrow struct {bbox, conf, labels}
|
|
||||||
with metadata {format: xyxy}. Untyped to match the dora-image-view input.
|
|
||||||
text:
|
|
||||||
type: std/core/v1/String
|
|
||||||
description: A fixed caption.
|
|
||||||
|
|
||||||
env:
|
|
||||||
SOURCE_WIDTH:
|
|
||||||
type: int
|
|
||||||
description: Frame width in pixels. Defaults to 640.
|
|
||||||
SOURCE_HEIGHT:
|
|
||||||
type: int
|
|
||||||
description: Frame height in pixels. Defaults to 480.
|
|
||||||
SOURCE_FRAMES:
|
|
||||||
type: int
|
|
||||||
description: Stop after this many frames. 0, the default, runs until the viewer quits.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Synthetic source node: this repository's stand-in for a camera and a detector.
|
|
||||||
|
|
||||||
Emits an animated frame, the box around the square moving in it, and a caption --
|
|
||||||
the three inputs dora-image-view draws -- so the example dataflow needs no camera, no
|
|
||||||
detector and no network.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pyarrow as pa
|
|
||||||
from dora import Node
|
|
||||||
|
|
||||||
WIDTH = int(os.getenv("SOURCE_WIDTH", "640"))
|
|
||||||
HEIGHT = int(os.getenv("SOURCE_HEIGHT", "480"))
|
|
||||||
# 0 runs until the viewer quits; a positive count is what lets CI run the
|
|
||||||
# dataflow to completion instead of killing it on a timeout.
|
|
||||||
FRAMES = int(os.getenv("SOURCE_FRAMES", "0"))
|
|
||||||
|
|
||||||
BOX = 96
|
|
||||||
SPEED = 6
|
|
||||||
CAPTION = "dora-image-view synthetic source"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Send one image, bbox and text per tick."""
|
|
||||||
node = Node("source")
|
|
||||||
sent = 0
|
|
||||||
|
|
||||||
for event in node:
|
|
||||||
if event["type"] != "INPUT":
|
|
||||||
continue
|
|
||||||
|
|
||||||
frame, corners = render(sent)
|
|
||||||
node.send_output(
|
|
||||||
"image",
|
|
||||||
pa.array(frame.ravel()),
|
|
||||||
{"width": WIDTH, "height": HEIGHT, "encoding": "bgr8"},
|
|
||||||
)
|
|
||||||
node.send_output(
|
|
||||||
"bbox",
|
|
||||||
pa.array([{
|
|
||||||
"bbox": corners,
|
|
||||||
"conf": np.array([0.99]),
|
|
||||||
"labels": np.array(["square"]),
|
|
||||||
}]),
|
|
||||||
{"format": "xyxy"},
|
|
||||||
)
|
|
||||||
node.send_output("text", pa.array([CAPTION]))
|
|
||||||
|
|
||||||
sent += 1
|
|
||||||
if FRAMES and sent >= FRAMES:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def render(index: int) -> tuple[np.ndarray, np.ndarray]:
|
|
||||||
"""Draw frame `index` and return it with the xyxy corners of the square in it."""
|
|
||||||
frame = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
|
|
||||||
frame[:, :, 0] = np.linspace(40, 140, HEIGHT, dtype=np.uint8)[:, None]
|
|
||||||
|
|
||||||
travel = WIDTH - BOX
|
|
||||||
# A triangle wave over `index`: the square starts at the left edge, runs to
|
|
||||||
# the right edge, and comes back.
|
|
||||||
left = travel - abs((index * SPEED) % (2 * travel) - travel)
|
|
||||||
top = (HEIGHT - BOX) // 2
|
|
||||||
frame[top : top + BOX, left : left + BOX] = (60, 200, 255)
|
|
||||||
|
|
||||||
corners = np.array([left, top, left + BOX, top + BOX], dtype=np.float64)
|
|
||||||
return frame, corners
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+11
-2
@@ -24,7 +24,11 @@ dependencies = [
|
|||||||
dora-image-view = "dora_image_view.main:main"
|
dora-image-view = "dora_image_view.main:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest >= 8.1", "ruff >= 0.9"]
|
dev = [
|
||||||
|
"pymarkdownlnt>=0.9.40",
|
||||||
|
"pytest >= 8.1",
|
||||||
|
"ruff >= 0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -37,8 +41,13 @@ packages = ["dora_image_view"]
|
|||||||
extend-select = ["D", "I", "N", "RET", "UP"]
|
extend-select = ["D", "I", "N", "RET", "UP"]
|
||||||
|
|
||||||
[tool.ruff.lint.pydocstyle]
|
[tool.ruff.lint.pydocstyle]
|
||||||
convention = "pep257"
|
convention = "numpy"
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Test names are the documentation here; a docstring would only repeat them.
|
# Test names are the documentation here; a docstring would only repeat them.
|
||||||
"tests/*" = ["D1"]
|
"tests/*" = ["D1"]
|
||||||
|
|
||||||
|
[tool.pymarkdown]
|
||||||
|
# The prose here wraps near ruff's 88; 100 leaves room for a long link or table
|
||||||
|
# row without inviting unwrapped paragraphs.
|
||||||
|
plugins.md013.line_length = 100
|
||||||
|
|||||||
+131
@@ -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()
|
||||||
+252
-13
@@ -4,15 +4,21 @@ import io
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pyarrow as pa
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from dora_image_view import bbox, decode
|
from dora_image_view import bbox, decode, depth, intrinsics
|
||||||
from dora_image_view.view import Bboxes, View
|
from dora_image_view.view import Bboxes, View
|
||||||
|
|
||||||
WIDTH, HEIGHT = 12, 8
|
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:
|
class FakeStorage:
|
||||||
"""Stands in for the Arrow array dora hands to the node."""
|
"""Stands in for the Arrow array dora hands to the node."""
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@ def frame() -> np.ndarray:
|
|||||||
|
|
||||||
|
|
||||||
def test_bgr8_round_trips_unchanged(frame):
|
def test_bgr8_round_trips_unchanged(frame):
|
||||||
decoded = decode.to_bgr(FakeStorage(frame.ravel()), "bgr8", WIDTH, HEIGHT)
|
decoded = decode.to_bgr(FakeStorage(frame.ravel()), _meta("bgr8", WIDTH, HEIGHT))
|
||||||
|
|
||||||
np.testing.assert_array_equal(decoded, frame)
|
np.testing.assert_array_equal(decoded, frame)
|
||||||
|
|
||||||
@@ -38,7 +44,7 @@ def test_bgr8_round_trips_unchanged(frame):
|
|||||||
def test_rgb8_round_trips_unchanged(frame):
|
def test_rgb8_round_trips_unchanged(frame):
|
||||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
decoded = decode.to_bgr(FakeStorage(rgb.ravel()), "rgb8", WIDTH, HEIGHT)
|
decoded = decode.to_bgr(FakeStorage(rgb.ravel()), _meta("rgb8", WIDTH, HEIGHT))
|
||||||
|
|
||||||
np.testing.assert_array_equal(decoded, frame)
|
np.testing.assert_array_equal(decoded, frame)
|
||||||
|
|
||||||
@@ -53,7 +59,7 @@ def test_self_describing_encodings_need_no_dimensions(frame, suffix, encoding):
|
|||||||
ok, buffer = cv2.imencode(suffix, frame)
|
ok, buffer = cv2.imencode(suffix, frame)
|
||||||
assert ok
|
assert ok
|
||||||
|
|
||||||
decoded = decode.to_bgr(FakeStorage(buffer.ravel()), encoding, None, None)
|
decoded = decode.to_bgr(FakeStorage(buffer.ravel()), _meta(encoding))
|
||||||
|
|
||||||
assert decoded.shape == (HEIGHT, WIDTH, 3)
|
assert decoded.shape == (HEIGHT, WIDTH, 3)
|
||||||
|
|
||||||
@@ -61,7 +67,7 @@ def test_self_describing_encodings_need_no_dimensions(frame, suffix, encoding):
|
|||||||
def test_yuv420_is_decoded(frame):
|
def test_yuv420_is_decoded(frame):
|
||||||
planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
|
planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
|
||||||
|
|
||||||
decoded = decode.to_bgr(FakeStorage(planes.ravel()), "yuv420", WIDTH, HEIGHT)
|
decoded = decode.to_bgr(FakeStorage(planes.ravel()), _meta("yuv420", WIDTH, HEIGHT))
|
||||||
|
|
||||||
assert decoded.shape == (HEIGHT, WIDTH, 3)
|
assert decoded.shape == (HEIGHT, WIDTH, 3)
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ def test_avif_is_decoded(frame):
|
|||||||
Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).save(buffer, format="AVIF")
|
Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).save(buffer, format="AVIF")
|
||||||
payload = np.frombuffer(buffer.getvalue(), dtype=np.uint8)
|
payload = np.frombuffer(buffer.getvalue(), dtype=np.uint8)
|
||||||
|
|
||||||
decoded = decode.to_bgr(FakeStorage(payload), "avif", None, None)
|
decoded = decode.to_bgr(FakeStorage(payload), _meta("avif"))
|
||||||
|
|
||||||
assert decoded.shape == (HEIGHT, WIDTH, 3)
|
assert decoded.shape == (HEIGHT, WIDTH, 3)
|
||||||
|
|
||||||
@@ -79,7 +85,7 @@ def test_avif_is_decoded(frame):
|
|||||||
def test_decoded_frame_is_writable():
|
def test_decoded_frame_is_writable():
|
||||||
storage = FakeStorage(np.arange(2 * 3 * 3, dtype=np.uint8))
|
storage = FakeStorage(np.arange(2 * 3 * 3, dtype=np.uint8))
|
||||||
|
|
||||||
decoded = decode.to_bgr(storage, "bgr8", width=3, height=2)
|
decoded = decode.to_bgr(storage, _meta("bgr8", 3, 2))
|
||||||
|
|
||||||
# The frame is annotated in place, so it must not be a read-only view.
|
# The frame is annotated in place, so it must not be a read-only view.
|
||||||
decoded[0, 0] = 0
|
decoded[0, 0] = 0
|
||||||
@@ -88,24 +94,22 @@ def test_decoded_frame_is_writable():
|
|||||||
def test_raw_encodings_need_dimensions():
|
def test_raw_encodings_need_dimensions():
|
||||||
storage = FakeStorage(np.zeros(18, dtype=np.uint8))
|
storage = FakeStorage(np.zeros(18, dtype=np.uint8))
|
||||||
with pytest.raises(RuntimeError, match="width"):
|
with pytest.raises(RuntimeError, match="width"):
|
||||||
decode.to_bgr(storage, "bgr8", width=None, height=None)
|
decode.to_bgr(storage, _meta("bgr8"))
|
||||||
|
|
||||||
|
|
||||||
def test_short_payload_is_rejected():
|
def test_short_payload_is_rejected():
|
||||||
storage = FakeStorage(np.zeros(4, dtype=np.uint8))
|
storage = FakeStorage(np.zeros(4, dtype=np.uint8))
|
||||||
with pytest.raises(RuntimeError, match="expected at least"):
|
with pytest.raises(RuntimeError, match="expected at least"):
|
||||||
decode.to_bgr(storage, "bgr8", width=3, height=2)
|
decode.to_bgr(storage, _meta("bgr8", 3, 2))
|
||||||
|
|
||||||
|
|
||||||
def test_unsupported_encoding_is_rejected():
|
def test_unsupported_encoding_is_rejected():
|
||||||
storage = FakeStorage(np.zeros(4, dtype=np.uint8))
|
storage = FakeStorage(np.zeros(4, dtype=np.uint8))
|
||||||
with pytest.raises(RuntimeError, match="unsupported image encoding"):
|
with pytest.raises(RuntimeError, match="unsupported image encoding"):
|
||||||
decode.to_bgr(storage, "tiff", width=None, height=None)
|
decode.to_bgr(storage, _meta("tiff"))
|
||||||
|
|
||||||
|
|
||||||
def test_xywh_is_converted_to_corners():
|
def test_xywh_is_converted_to_corners():
|
||||||
import pyarrow as pa
|
|
||||||
|
|
||||||
value = pa.array(
|
value = pa.array(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -130,6 +134,241 @@ def test_headless_show_draws_without_a_window():
|
|||||||
labels=np.array(["cat"]),
|
labels=np.array(["cat"]),
|
||||||
)
|
)
|
||||||
frame = np.zeros((32, 32, 3), dtype=np.uint8)
|
frame = np.zeros((32, 32, 3), dtype=np.uint8)
|
||||||
|
view.panels["image"] = frame
|
||||||
|
|
||||||
assert view.show(frame) is False
|
assert view.show() is False
|
||||||
assert frame.any(), "the overlays should have marked the frame"
|
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
|
||||||
|
|||||||
@@ -6,6 +6,34 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.12'",
|
"python_full_version < '3.12'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "application-file-scanner"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "py-walk" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ea/22/e872546d298103527380955f51191ff87cd178e6aac62bb87de73c3e074f/application_file_scanner-0.6.4.tar.gz", hash = "sha256:581c48c5017345747be7f49507da84fec36d1f7b4f67003e9fbaf2f0bc6a3f66", size = 28540, upload-time = "2026-01-19T23:32:15.695Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/83/e9a5a14c1b6c20ad44abcdb7bb2f34d860aa4f4ee64c526247066b6c42fb/application_file_scanner-0.6.4-py3-none-any.whl", hash = "sha256:49c211c60f1932812477facc38701d58037b07b031fdb6a6061fdee3fd53e35f", size = 15445, upload-time = "2026-01-19T23:32:14.4Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "application-properties"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyjson5" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "tomli" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/bb/321da0e373416080801d05b8dd9c0a8da77fe305c697adf8c025470ce170/application_properties-0.9.3.tar.gz", hash = "sha256:7dc7d8f23d11e539427e7b8e3afa70353e159c16f703bad6dd082cc6cdfeeaa8", size = 43304, upload-time = "2026-06-02T03:52:07.6Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/72/3ac1bda73e9c95444b1e13a0de73e847048a776e5ac5b3977c37f04167f9/application_properties-0.9.3-py3-none-any.whl", hash = "sha256:32dbd62b3fb657ec1c7fdc352590463c2ffd14950ffd258a5c091e8798d8aec0", size = 24595, upload-time = "2026-06-02T03:52:06.416Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -15,6 +43,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "dora-image-view"
|
name = "dora-image-view"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -31,6 +72,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pymarkdownlnt" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
@@ -47,6 +89,7 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pymarkdownlnt", specifier = ">=0.9.40" },
|
||||||
{ name = "pytest", specifier = ">=8.1" },
|
{ name = "pytest", specifier = ">=8.1" },
|
||||||
{ name = "ruff", specifier = ">=0.9" },
|
{ name = "ruff", specifier = ">=0.9" },
|
||||||
]
|
]
|
||||||
@@ -427,6 +470,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "py-walk"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "sly" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/b5/e2f3fab1e11d4089b1c3dfd72175fdb2408ff8028e01bdb0d308923609bb/py_walk-0.3.3.tar.gz", hash = "sha256:a1b28d6079f27203fa3098b69a98572675b3ff5bd02286c43e6dacd66615f879", size = 1815727, upload-time = "2024-10-26T14:30:39.421Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/38/56b67abdbf6797475dfe2f62d391b4a6ead851c76acbaf07e118e53651b6/py_walk-0.3.3-py3-none-any.whl", hash = "sha256:238fc018165138021ce0bfd9c351cdc473d3120ccc5534df35611b92608c94d5", size = 14537, upload-time = "2024-10-26T14:30:38.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyarrow"
|
name = "pyarrow"
|
||||||
version = "25.0.1"
|
version = "25.0.1"
|
||||||
@@ -479,6 +534,150 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjson5"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/9a/3db19560e968d6e85b2a4ddf4b949c6ebf9dd1dcfb5a9f37736f8adeb927/pyjson5-2.0.1.tar.gz", hash = "sha256:a5b0e322e847b198a50d8a1ef16d6b2b19129644dc018d76773e81ef1487ca39", size = 352242, upload-time = "2026-05-15T16:12:54.931Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/75/bd6b2297beef9730e7f265679de7105fbc9a49dd85dff28563851b9c679a/pyjson5-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1727cebec0ca73597188057bb826cd96c8503ced9b22a9cc73501f2e68d91ea0", size = 297438, upload-time = "2026-05-15T16:05:33.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/18/663ab5ad9aff987889440a2cb6f0415d334850444aba759211aa3eec10aa/pyjson5-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4833d46e8dc4bcba54db038305c8a7455d05cf8533d52a2297abff634cf69fa3", size = 155300, upload-time = "2026-05-15T16:05:35.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/57/9f68629021052044cf46bb89652a442f14505f3087d786ac4b18268235b9/pyjson5-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d73d09a5cad4e3325bd6737697d649a4594c4fde8df8eb784ed795dea845435f", size = 151680, upload-time = "2026-05-15T16:05:36.26Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/06/21da19b96b3215d266bf4662147d5ef70f65daf9bd4e82305ae61584908a/pyjson5-2.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac66d237c78b9ecda45506225c7eed648d2e6750532e15e1ac2052acf3e2d444", size = 189073, upload-time = "2026-05-15T16:05:37.691Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/5b/48ec81dcee70f8d188ac04499207e3c8a893ec306a1ba4db5c38988f98c1/pyjson5-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1c0fa8af1cac361b2bbb030be942ead42bf872f279bc8e2666a8d5d6e7e7ee", size = 171985, upload-time = "2026-05-15T16:05:39.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/89/d79ac6cd317b3488e38f225c2cd8315fa54145faf939295b853bae70fe3c/pyjson5-2.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2e3ade7f34e1ad2b270fdaa6a11c31c7cb5f07379a422e9a714791d345e4bf", size = 169481, upload-time = "2026-05-15T16:05:41.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/db/e15cc07bf375a59d102536b233171eec621d1b8e9412596c0ef656828baf/pyjson5-2.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:55780f15c6d00a8d7847c7291c1a09492beab61460cf1b4725177b189cfaab65", size = 191706, upload-time = "2026-05-15T16:05:43.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/64/3c1c2b350071ccbfcc3a8e4eae66af960cc3193008cf47846cc1c775aebb/pyjson5-2.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f80ec4e18b2a8d42832f843b48cf06e77240c8e9cb046ea7e10752e8135ce87", size = 195462, upload-time = "2026-05-15T16:05:45.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/87/e0774939571faad2609fce9865ffd7a30b648f828fc2ca5acc364411f0ce/pyjson5-2.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94a645be2b846a6a1155296047176099b6ce987030fb7723208d364cb1b73eed", size = 183993, upload-time = "2026-05-15T16:05:46.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a2/ce59cab1733bed125193765f8d55ff316f151703eeb5c264b63200c4b26a/pyjson5-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c34ddc5a211ecebcfadeb9c698abd047245bac83ab7340811b77fd41aab76e4", size = 182419, upload-time = "2026-05-15T16:05:48.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/8d/cf77e9193cbff64a8488fa005c62544d102fcc7f8d233932b8c026adf00a/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3614a579966cc22bc4e83599a786bd875e2c1de4baf71898763623f670df69a2", size = 1153689, upload-time = "2026-05-15T16:05:50.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/d3/fb75865e713871e0fbb2c6c46a1569365f8778c59601939432dcf540b9d4/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1925fa0b53bf722d6d263e1eeafd6c009dd4dc975bc481e03786497b2c25eb08", size = 1013811, upload-time = "2026-05-15T16:05:52.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/7f/678c7a3ca28439558f42e78560d5e8096918e5fa1995edfc23702cb10a90/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:497b4d61617db4498a20b47827939034d9234c695d44df7dc907ec455068070e", size = 1327361, upload-time = "2026-05-15T16:05:54.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/86/f4b57480f00d546031f6ec6dda17780f996cceb1adf02fed586d2d985aa7/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d8ca0d3e4fc92dd20ae76d067ae692cac35161f83f0712c5b20d8f7ba73730b9", size = 1251975, upload-time = "2026-05-15T16:05:56.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/72/27be9cdb773cdeb334d423d57cc68be30c280df3d3b163c68b7a963d4e50/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6899d9e22660862fb9baf3eb5ac9b2887eb478b18d14f95de94feffed5ce0558", size = 1188190, upload-time = "2026-05-15T16:05:57.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/59/c9672530a00d433e5411440940d2454d39b19fde29c69eab064af136a157/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4671741493245604579cd9a9c044fd12341a0a61cf0fda8fb844812e29a1a488", size = 1367103, upload-time = "2026-05-15T16:05:59.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/14/356b2a67f1d6d062df8345c98b1337556f75c18ca0f9f32921d2a28e1946/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f5ae05506fc86bbe5716ec102cd023d20d2051f43f4eb3a741b557616c3245d", size = 1217145, upload-time = "2026-05-15T16:06:01.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/ca/dbf1f6f20cd54be3588b890648f46c1a0db111c7c988d3325e31c14f3510/pyjson5-2.0.1-cp311-cp311-win32.whl", hash = "sha256:90ef60fe98b6d0eaef3a6cdc81a6b1c6acb135a061bc695c00f3b6fdb86f3f90", size = 114798, upload-time = "2026-05-15T16:06:03.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/87/933712d67031be67cf7c0298d655026b46b30539144f9f12f9084d80b03b/pyjson5-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9f5d62dfe22810a3ad2252c1b0d7f89b0673255263410d999d88bfd6f46b0ca8", size = 134709, upload-time = "2026-05-15T16:06:04.683Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/77/ce42988444b3e567d2b949970f96949e61aadbe535ceacb12afc2570b553/pyjson5-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:25ee8af17c19d0a31adc3672adc9c92429130e171e6fbe374468519892fd218b", size = 117230, upload-time = "2026-05-15T16:06:06.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/41/be622b742fe4749fefa9d8a923b1cf5e51a1e8e4b4930fa8ea8c531b243d/pyjson5-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62394660d54e76ff8a968c8194b252be23d184f05a00238d4def9624454ea6", size = 302636, upload-time = "2026-05-15T16:06:08.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/e2/50378c3bec9164c1bb983ca15ed59f8be498981b790a90d1410a1016ffe2/pyjson5-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:58dfa4cf5c327e14c530b16c23e812c021fe53b19e6f9cdfce257d8c02244895", size = 158588, upload-time = "2026-05-15T16:06:09.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/5de0f82c144b6fc2fa2e3fde5517257733a91d24734b5d2415b7e991e8fd/pyjson5-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07cb90a31f962a6de9e49592a306046ee015da73510c6d215e9481a2a21c9ac2", size = 153741, upload-time = "2026-05-15T16:06:11.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/ac/174ca02e5f3ed8245dc48247e534880bbec1528c0c4eaa768fafa9417dda/pyjson5-2.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ef4026a90a68cd2bcb64d89ca064a1615b60a61c7694714e2b8ef30434f365b5", size = 185450, upload-time = "2026-05-15T16:06:12.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/0d/bfc553176b7e109b72f23697ab1fd1a77d7fd5c6626d6e23936f13d5884e/pyjson5-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04b4929a7adf0e0720da92ac54c92d408e8057158be3ccc34da17c7767a9af78", size = 163331, upload-time = "2026-05-15T16:06:14.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/05/b0e67106f78c8ea5ffcf6ce0b8421f38a04d5f37ea88ac70123199755bcb/pyjson5-2.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e889b039d6bffda9a6b8d822073bbdb4a60166af22f075e7faa0c03c2dfcf17b", size = 166429, upload-time = "2026-05-15T16:06:16.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/3c/2366f8ecf053434470eafe1f120f2e235d13015fc50d7f06e55e39b550ab/pyjson5-2.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a08e99d0a33463728fb016f5eec680504499c4be280dac016de2da70ec55b0db", size = 180499, upload-time = "2026-05-15T16:06:17.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/65/9a70876d4dd09ce17678024266406b94b8f1edf9db6c2e3bf76cfa653a13/pyjson5-2.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:035c094feb6a9d4312183f9f34228d976102eefb971e9e3dd972d8c7900a466a", size = 187469, upload-time = "2026-05-15T16:06:19.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/43/0c0bcbf9a9ef7fdcd49dc0992a3a206244c2c2baa7b409932464cf26f1a5/pyjson5-2.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d178025a5317db44663dc87aab90f34674991b3da40dbedc3c108e7b03b6466", size = 176032, upload-time = "2026-05-15T16:06:21.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/2c/32213674010c44db265d45e977a431cbde971c45342b483f7af337811403/pyjson5-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:381ca5b3172bccf9c29c5823a0173ab9118d579d0f5bb4fca20b803daa6e72d5", size = 171477, upload-time = "2026-05-15T16:06:23.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/49/da5417d592ac75a23d1ca2dfda846391ccf137edd89054b780866c592b65/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e158caf0dd7fbf7afe12f92fcc9cba26bceac4b38615c087fd592e9e3240d9f", size = 1145571, upload-time = "2026-05-15T16:06:24.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/f8/1f0c326b3a3267f28b7b9e2ac07121386ec20ee8f9dd49955af4faa17ba7/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c8010e9a02b6a0719234f8d074639f9051afe579ff0f74d3367af1d8b4c7d839", size = 1010388, upload-time = "2026-05-15T16:06:26.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/c8/76f2739a8715061755f12f4d2795f6c4079aeadd166c56d0992521442d5b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3543e067d1d8ab6cb1cd41add38a42eaf82e9a7e802e8a08f1acda9f22199b00", size = 1323111, upload-time = "2026-05-15T16:06:28.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/7a/683e1586fa076fb344aec23a647d67f427847b6bffb1001d56e86aa8da33/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e496938e7e0defc33bfbd0ff581d4ffdc428c598cc5d679232f34df27f7330a", size = 1240671, upload-time = "2026-05-15T16:06:30.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/8e/6638b0b22344b23cae1271b0211668be54b3f76183bd8d4f89a2edd8e562/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2badbc101908123905a38789cc552b99ab896a7ec0ce4fa6ee2bb7b613c85c1b", size = 1178013, upload-time = "2026-05-15T16:06:32.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/21/d44540d3cdc670e5c6e665471361f557ef8c7d56964cd433524208b3707b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1b0df4898e2e046fa1e9b5df436fe11d9b681b1e5fc9877508d982d2059aaf1", size = 1354907, upload-time = "2026-05-15T16:06:33.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/74/fceedd2709760f5c85e75b508002af9b4c2da8af20a80e62b2d0e2358a15/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fcc96c429888f2293f672e33d01da8a0847cd5116a773c9a1903b8bc2f12bc1", size = 1208847, upload-time = "2026-05-15T16:06:35.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/3a/d2f062b5801b3034ecb16ffb0801d7b4a1d6abae4cd4615482dbf62f1ed8/pyjson5-2.0.1-cp312-cp312-win32.whl", hash = "sha256:48c97e2f7f02171948f8a7ed6c9b2b2faea00653a3348d9e810238a688f07b6c", size = 115562, upload-time = "2026-05-15T16:09:24.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/13/2265cf16720defdc87672b68ff5edf83820bd76e59e32f8384c9cb1ae619/pyjson5-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8dea9976eea0aa7af8b5ad49df3fa831d6bda08ffd7bb11409d6a81961491219", size = 136176, upload-time = "2026-05-15T16:09:25.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/92/a8947e646ce642555ac22b31f44b7168c81b0e364d0f2711d571955229b6/pyjson5-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:264ab65fb7d68a763567c2302151f1f074bf053c8479939aa8e75f5f9518fe31", size = 116752, upload-time = "2026-05-15T16:09:27.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/2c/0619f89a9576f335ba63eb851e73ba507480a8c7f28a2e7de2501ed3d303/pyjson5-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8a661d292801b38434d5288bf7329f9b50b3a693b1d2941c7595175842692eba", size = 301821, upload-time = "2026-05-15T16:09:28.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/31/7824913ec71e7421d6a57bc06228f3e2d946d8e8f738f898572dded0dc57/pyjson5-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cb5c1c066038ce6e1922d9d5be23f5cd14d5b5a3c96e6358aa5d0e379014a93", size = 158201, upload-time = "2026-05-15T16:09:30.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/7d/4ddb249563a838425242342d2cf67976ccc292a831b543a92fa1a8a83b11/pyjson5-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7417eab751817fa5f070e41975d9df4aec3532d21389073318161f63cd96d636", size = 153244, upload-time = "2026-05-15T16:09:32.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/39/7622416ac0570d9ce377447bd5b2ec9383c1282e63cc6d0b65779f1336fa/pyjson5-2.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cc4802093b4ab9039367774988486fd04754cf416c32aebf94a94bafd8b8a478", size = 185568, upload-time = "2026-05-15T16:09:33.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/90/2f317da231477b77481020d79c73bbe10625e4925ceeae77603726ef1763/pyjson5-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc3181274ed19ecb2315cf85a499bf83002554f42c72453666c616059d665a7b", size = 163169, upload-time = "2026-05-15T16:09:34.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/b3/20023c3cfe2f2c2523007d7031b5b7ad7ccd8856d2665547683cebaa6f8e/pyjson5-2.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5398306b8aa253e620af9ed8085767de9cd28d6846da45e535e80fcffa9f33d7", size = 166514, upload-time = "2026-05-15T16:09:36.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/1c/b863e502153477b2845a54b688b72436457aca5107b300fca95e98184551/pyjson5-2.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b97b592287d52a6ec5af84dca94aab02b185f72f8ddb6fc99c58efb8891be5cd", size = 179907, upload-time = "2026-05-15T16:09:38.166Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b2/413fcb76632e5f6fa89a2ec83976755b5bb4696c6d9eca9ff3c70cd717b6/pyjson5-2.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4efbf5e910a3aa6f330a92d1ae940dda5ed662976ed5056526baaf2e1821f95d", size = 187171, upload-time = "2026-05-15T16:09:39.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/11/66151b819407ab589aef36582038257f1ef42dc065e3423b3d8274f4fcd2/pyjson5-2.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e042f9b869e7f21d5f53a2638e5d0cf1daaba2342127c8777c502daf972602e", size = 175564, upload-time = "2026-05-15T16:09:41.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/98/deb70690dab994474ea527cba91f43ae55928bcff498c441e812b60fc1e2/pyjson5-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26074370d7a6dd38b6e8c28a2f10c790fc6dec938ec22ec3de6c93c97d195f10", size = 171530, upload-time = "2026-05-15T16:09:42.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/be/969752a3a052d00698b6e1dd926c627d7a6475a7c8dd390db148363544f8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cb163a495096716a7a01d052b60bda0308ecfaeddcb2d50a247ba9d707e3e40", size = 1145510, upload-time = "2026-05-15T16:09:45.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/be/4c5c92cdda5a911ee4450a74281782335e7ce6715638308322beb96be639/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5fdd66bdddc2d53421ab7427bedcd2ca9b2f1b4b3b464381aa60d305c94be38c", size = 1010114, upload-time = "2026-05-15T16:09:47.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1e/72283bc505d77dcdab7eaeb0020cf0fd79a05d2991e886d1fcfa73e88ae1/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4ad222a3eff1cc93f9b70894c5f3d2ff5e46531e88feaa4818e329d5f3ae4307", size = 1322915, upload-time = "2026-05-15T16:09:48.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b4/94a09e744a6bb6e76108b61a28a7cc5ecab1b8105cacde7548359b3b74a3/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:63f5cf25113dd1bf3bfeda211d99413e58ec6b9036ff34f7b69f85f23695e9c4", size = 1240126, upload-time = "2026-05-15T16:09:50.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/29/4549380cae425ee112f3c154606c35f5211c12ddf415f079c2b23f2d493b/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b0001eecce9080e6c170e978786501e8931e2075a3d15f5260e1bdd6a45e8976", size = 1178022, upload-time = "2026-05-15T16:09:52.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/1a/5a8a869e855645858e45dbd077204cec3a85b2f2e669bc3c8cefe9c4a70e/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40371c73cc83ed81914028786a6cc5950bdf300ae82443df83a5dfa80c582fca", size = 1355161, upload-time = "2026-05-15T16:09:55.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/32/827066cd946447648275a892f494f4572d78e6a79d877fa6b6c14af599d8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9dca542eeb6e8fbbc1fe3373bfd18ae6250f1aedc78f39242ac12c8837091b8", size = 1208864, upload-time = "2026-05-15T16:09:56.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/cc/a9bc12aff47d8bfd3074a21e3fb056ec0028fac28f8ec7e0fe4449d050f5/pyjson5-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d30c8b2c91d530be475a8fab6dde8c3bcd3e89b999c9e0f05aec2bf2c24b0638", size = 115461, upload-time = "2026-05-15T16:10:30.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/c7/fa3fc956fbc3fa6250c8b99ef75a8f52b691780fca1bfb368340283bd898/pyjson5-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:67f8f5b8d3e3b2ca5f618c928729986aa00fb588c476c0d0d3a151633ff41e0d", size = 135836, upload-time = "2026-05-15T16:10:32.062Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/0a/e457b20d1e36a766d3ccd09c418b7ef41acabb679ff2248bb1cd38247ce6/pyjson5-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:bdf30ce5b1242f63254d936b30af75cae237fab0ee71cc2544163c82b6bc9201", size = 116652, upload-time = "2026-05-15T16:10:33.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/4c/6266789e615576b62d2db6de26a3b4b8f4cc7a8ff23fe24e48363bca1682/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:954c14d40022fa40e0d36e0ebd337e9e90fd71145592d25a0e2868344e3daca0", size = 320487, upload-time = "2026-05-15T16:09:58.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/75/c4a563034805e20b274fe4db963e1d480001931d88fe70b7d082033b7dcb/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f57529b98d21e8b76f8bc51af9d3c3057dd04a92287fd21ef7ec55255ca6f5c5", size = 166879, upload-time = "2026-05-15T16:09:59.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/d2/5e6ec3580379794e9a99a402465f74586e93e93814974172202d1254fa89/pyjson5-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:545b655ef0b59f39fc29e2b63b4dadd45e447ad77bad2fdca859ff4db69e21f5", size = 162235, upload-time = "2026-05-15T16:10:01.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/48/b2d0e868ef8375eb0696ddc74f71028fc4fcd26cdddaeb34485214f2dc87/pyjson5-2.0.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b519de49dd4cbf254179749e5c6bfe9bd8fd1f97e7755bff2e17cc6f79f85aa5", size = 182162, upload-time = "2026-05-15T16:10:02.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/18/fed02a3d68c3badb87394e8420b72d7cb165ade208c1e02561637409aa99/pyjson5-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de48fbd0466c114c17f408be7bcbc5b3aedeb29ff5f73187022915e6d5eb7817", size = 169487, upload-time = "2026-05-15T16:10:03.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/90/dc897332dda24e949828616e8e74d7937b587a56789f3aca148fcadddde2/pyjson5-2.0.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fe6516ff9ab94368c18ff8ac0ebdb53fac9e047da093d43e67d5e7cd98cdd0bf", size = 164128, upload-time = "2026-05-15T16:10:05.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/09/90104baeab58adfe63bda804174ef1806b1cabb1692e047fabc2f4aa1799/pyjson5-2.0.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea7aadacac7ac6661117b708175f6beff75e132322c08bbf3794d8ec4ba572b4", size = 184306, upload-time = "2026-05-15T16:10:06.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/73/613efc3cdab5cf9ec399e6c5a0463b672e5f5cbd693a3a4d82cbdda19476/pyjson5-2.0.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5c339bcac113dfc30a1f88f23af273d67bb6169abee1684c0dd27daa71a32f9", size = 192239, upload-time = "2026-05-15T16:10:08.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/2a/17b97e02b37ab9353fc9713e56470be3d2539501ed18d6c676cfbcd0a57c/pyjson5-2.0.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:075de0f1e5e0ffee174a8941b5d9d8af632772a6e5eebe92fb0899db7f6ab7fa", size = 177652, upload-time = "2026-05-15T16:10:10.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/43/a05331ba88fd1aa09ca418548c6b06692e34b2a64b109bb363e01ecafdeb/pyjson5-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c463c9bf508c1d316191898a3c0bfc03dd3de67bdc02f480b0742ac5c859eaeb", size = 174860, upload-time = "2026-05-15T16:10:11.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/00/5d253751f4d27b7e63fdfd765041593fa588395f45b9cea496878d964448/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:54427c8cad0c1a516bad5b4457167cde1e7ef7ee563e28a944f59b3c7413b6a0", size = 1151556, upload-time = "2026-05-15T16:10:13.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/f7/a30eed477295e92dfd59553063cf82bf510a7052efc7720c9c50d0082fdb/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca04839cecd364cb53de7ebcd81fd5915193707d968730ea1c8d4ff5c9c2f1a8", size = 1012330, upload-time = "2026-05-15T16:10:15.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/60/80818fad90a99336f8bfc686d76d0b2d7c8bb51e8de5d031620fa1a2d7a1/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13a2de8b14aeb576b1623c8af419510bf871b8d9cc2308db41a46bcc94d00f2f", size = 1320970, upload-time = "2026-05-15T16:10:16.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/3f/090eb3e0971d067defb58bbfd738f74a09be495c6f4f00d5d76a10755bca/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8a689f5252e9791bfbc8fa916999202556a1e8c7b24dfd72de1ad84c493e10a", size = 1244775, upload-time = "2026-05-15T16:10:19.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/38/e7546ad733affe51a5462bad21bceb4fd659baf24930bd141627ce56279b/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc0c2c80fd9d1e4c26f8b6c91a54d38b48648b93ae3f09355856c75fda3e460", size = 1182149, upload-time = "2026-05-15T16:10:21.696Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/2c/016647580d8a82ee53b4cb9ddb96deb4b157ccea9624bba61de4aa2cd25c/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:13d78b3e2c60b81bd7e6b00e6ab24a664ba1fd64b5d86baa65c4cdfae1fff7ae", size = 1358579, upload-time = "2026-05-15T16:10:23.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/45/8b84ff7a0c4d8c12a76e097c67af62abe986a18d1ab2540764dd6eaa0253/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a7b640dbeabbd7d975170f793f90a7500cc5d510aeae9364a1bc558bcc5336ed", size = 1211015, upload-time = "2026-05-15T16:10:25.4Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/48/d8f34de7a7319f5966bdfd10f133e689ba01f138d2040bf792f1ca8b18e1/pyjson5-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:46e7c5525034fde8abf3aaf100ed7660c1b618ccd3205544ed7c99e4c55cf201", size = 131660, upload-time = "2026-05-15T16:10:26.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/aa/0bd437252134115a846ffc061078c85f8e8c325c86abcaa862c134d3c57e/pyjson5-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:207c00e7f9641e0358bdc848a1c0610f26f2e8bd51d73bd64e5a0ab3b725f1d8", size = 157717, upload-time = "2026-05-15T16:10:28.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/61/7849b04a0dc78f73905189400ac99c45de8eec2ec451ce9c6990fb3588e5/pyjson5-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4917d5b6186bfa48ec4d85326e2a09769a5f7844963307ab2710429e1f743264", size = 126281, upload-time = "2026-05-15T16:10:29.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/41/70aa1cb1fb0a3ac4c9b8cd405c0c85ba935c53fdfae6271b8eae364b92d1/pyjson5-2.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:573fecd7cad4e24d232053f9ebf331c80fe1b0ed6e2064f7614797a7f691e7ca", size = 303706, upload-time = "2026-05-15T16:10:35.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/d9/caf44bf3d33b9502dc4b8ed5d0c7a8af8fbe33001e82414c0407f39f18bd/pyjson5-2.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8ade508a046a5407a322c098e3b7e6033216b158a7746eb8962b7a4cdbf9248", size = 158915, upload-time = "2026-05-15T16:10:36.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/94/f2bab1ce2eac8f77e16dcd0a1ba39de20733a84b9961c3504af0dd68bceb/pyjson5-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4635e349bb1d1f1e854dc790f14be31b6cb198d6453848e8d86818104074f805", size = 154199, upload-time = "2026-05-15T16:10:37.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/c4/d94573ca37486af3d6a72f2582844d7d490d8e55639e0cfba371ce91442f/pyjson5-2.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d274697f81f143abac12ce256a06b61635c30d0f8cc11eefae7182655dac9a5", size = 186060, upload-time = "2026-05-15T16:10:39.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/cc/c42e697def319b286fdcb912939c044cc94bd1cfc7338b6dbb566f817f29/pyjson5-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4e2242e55a2651fd8696cfd67ec8e04f54f4ed6c089cceea2b63763a7516d8", size = 165796, upload-time = "2026-05-15T16:10:40.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/dd/f22a5f0e619ef22b8e32520a91bd92f815d50f9ec2d67cfe5974eab9476c/pyjson5-2.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af855c680feaa39cae4a44914ee2863eeae1549f37ce58069c747b3d4803999a", size = 165262, upload-time = "2026-05-15T16:10:42.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/8b/90e22ecb12d51ccdc68325b6b051002e6103cc4600a335d9ed13828acdd9/pyjson5-2.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb769d90516da904e6cf0ba58f3d5830f4a5804486b5e85c5ca42ba3146d187a", size = 181608, upload-time = "2026-05-15T16:10:43.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/ab/237f9036eed73e08cf8861466d3d1182ef1c88506bd29955740dcadb24ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6484c14e07aa46abeb3cb2ff0204a765260763ad7cf3f87f601f26b20ed607f3", size = 189582, upload-time = "2026-05-15T16:10:45.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/3d/3df8d5f003910a9291e5f04fa178f626e3c8553a5986dc62589ca74195ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc03673adb544324500d79b2acfe2ced038998b2aceb564d335de9cc238cbdb0", size = 176321, upload-time = "2026-05-15T16:10:47.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/d9/a458a54f780bafec1b44ee3c27cb3007a83fb7bd4f1c17ac6bf519fd6151/pyjson5-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d714daf784bec2e14fc06c0c26e4a373a6157eaeddaed2d5a7b7b6ae2aabc33", size = 171956, upload-time = "2026-05-15T16:10:49.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/d8/1010e0147c8862dc0881cf3656364d3d87a5e336d290fe4cb0b92223e14c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cf3ded356730e08b5941a16db6525efc0de26f35468ab09ad573056c4efc6e5", size = 1147679, upload-time = "2026-05-15T16:10:51.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2f/91c8d8cdb4a2e4bbe8f14b9b57d717988e14830f2ed4a4f08d503cb5edde/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cc00d4669fb4170c28b2c9ed3cd1c2af9f21727c55d6866030cc154f31f68747", size = 1008216, upload-time = "2026-05-15T16:10:53.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/d7/25c3f8693660a35be7bb4eb71b0d10d9a6981e986723a4881d607f0c6462/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a77a91c2e019476345c03cde105da44100daaabc3fa56f82f2bd9eb2ebbcb698", size = 1323856, upload-time = "2026-05-15T16:10:55.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/25/82f0f83556ff68ec0ca1129ba9afeb14149cd421dc74823cdf10c209c95c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9ae54e5d717982cb7a082c700ab1f7f965c6aedea6fcb003ec4bdeac4f02bf52", size = 1241860, upload-time = "2026-05-15T16:10:57.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/01/2c4695fc06a0f3f29041a875d3bb24f904c9ae7b3d8dbfa1f8db17ce995c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ce4f5b67b3a6fab623a31d83416b51a5b2c97cb172194f214078484bbb4783a6", size = 1178443, upload-time = "2026-05-15T16:10:59.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/ae/c10f534037b096aca21b90017ac9d41f791572dc3ec31fcd92db8ddcd256/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f8d2e61f7bed0b40cfe5f62375581bf56d7b5b9707c9d74747b5931149d7d376", size = 1357739, upload-time = "2026-05-15T16:11:01.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/f0/655224f78ede087aa6a295c4745423e0104dd31f67928675b1a4cea72a0a/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2930140edd1a64eb42689993c5ae317bca2e6bc785b5dc5053bd5e9d184c98ad", size = 1209542, upload-time = "2026-05-15T16:11:03.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/52/a7b2a26625136fcd0f92e17beaaae51e04923cc2e28502db354d4de061b4/pyjson5-2.0.1-cp314-cp314-win32.whl", hash = "sha256:51733f91dda897239ca10928f363ce6f4b5eadaf797e74477f6c1c3222c185a7", size = 118342, upload-time = "2026-05-15T16:11:40.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/8e/79db426fff3a41610076989d4060cc18a6508ebd3c7877155f6709eea102/pyjson5-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:698c73aacff49ea35bbbf7f700a97785626201ea7aa2e1f1e0a4fe23788c3be2", size = 138408, upload-time = "2026-05-15T16:11:41.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/1b/385da14c05412bca15e86551dde91959686c8bddf7e78722bdc627fa6815/pyjson5-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d447e2e5756f89abfd0ad82d1438075bcbc701c1cc9279b43d24825aaac67356", size = 120905, upload-time = "2026-05-15T16:11:43.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/04/cd69739556d304d3ea48064ea7d47e5ff7321b0032d7ad78864bceaa0cae/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5603be4f0cb9685d7c2cbd408ddec21b33f415252bee00ac7969f20b5789a1d1", size = 321150, upload-time = "2026-05-15T16:11:05.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/d4/6d98268ed07a2cac1e79634fcd3dc280d2503ade5f3c465f7aa095951444/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87267520a256b3f2dba7f2995b745e8b3f2dd72bbf8436010a5191f7809f8ab6", size = 167540, upload-time = "2026-05-15T16:11:06.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/c6/8855a9462bdbc8306cba62793e23cc2952dfcbcbcab8e0413f570c891361/pyjson5-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77e58307b74019d90aff9f2781bcffbbea3315b15fc814f8fa9b8d334b956ff4", size = 162533, upload-time = "2026-05-15T16:11:08.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/a3/1092f68538ee71ed8027393ab5e71aa9c4a114e493e30a55e46e34c2ffb9/pyjson5-2.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd1712038837342dea73ed526883e53c6e85a158242b6f574b9ad9cdd3199c7f", size = 184165, upload-time = "2026-05-15T16:11:09.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/63/bb9f42a4047284ea59064c134c88dfac3b7f5273e40fe548d6a086cea452/pyjson5-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dba36677c77aed5d680827c65478933705ca156a34838f1250ea191ffc27662", size = 170075, upload-time = "2026-05-15T16:11:11.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/2a/0379aa5184e986d0f9af2919712c148d81928ce7d2d7950407e271b1df3b/pyjson5-2.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:04356d0c09e58907f60675d33859f269473479b90add5026231ef2fadba5207e", size = 164100, upload-time = "2026-05-15T16:11:12.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/d5/2087af69695c28c7fe796afc73dec7f0b0b9bc123ec329e45928d58d4705/pyjson5-2.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b331a0fbe2ae26f4ccb88045ebaecc00aeeeab23ff858f9a2223cedd969ec6d6", size = 184674, upload-time = "2026-05-15T16:11:14.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/06/cf1b2744e07f1689cb0ac663e162a060b81cd358474754fe9f3bf02d5398/pyjson5-2.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ca55878d923ba5764254ad1c020c22b33e6161b3555d1f457b8060f2b3c6c17", size = 193047, upload-time = "2026-05-15T16:11:15.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/e6/a6b0deb6a3f393907c6d9115a7ac8e27557109204b56b05d3b57c51fc2ca/pyjson5-2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35e18d11e4c2034b78a664affe18c153a8efbf8872cf6bed7f12dc4fb819d440", size = 178712, upload-time = "2026-05-15T16:11:17.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/48/9e5c16daba56dfdb481baab85d1197e14ae3f403902fc23cdca14d14351b/pyjson5-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b710a7489b6e134890eaa1fdb285e9644a1e730c1cfcf1cd90a87859b87a84a9", size = 175642, upload-time = "2026-05-15T16:11:18.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/32/f07d4dbd8cf733f9e3f3e9c9abedfbc23da1e5cfa7fed46aebae07ca6f25/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:08920c0dd6aba3fb6bc6c849e6f05731c5fb2716cd651de424ff60cdd12eae65", size = 1152219, upload-time = "2026-05-15T16:11:20.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/9d/0ddbb89d381bc27f3740850d5d664c1f6f76620ad879ff5eb2506a4dfd6c/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e166dadb3275025cff5ee8602131372a3ea171c6727b3fc6e44697348e3972c5", size = 1013305, upload-time = "2026-05-15T16:11:22.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/93/0f44886391dd2249ad9cf98c2deb3f26d1a115dd5a215629af7455765968/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea3c2b4e7b8e209e7f59bd6a925d79b69cd0a4ffa12f2df0c6af55514c8b2227", size = 1322840, upload-time = "2026-05-15T16:11:24.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/29/c478dc24dfcfc07e407ff8669df265ecc51fed5e7d47e51a01f17fd1c53f/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b710fc8ce00c984c8865131f05a8536163e1d3caffb9d081647879eca7424670", size = 1245221, upload-time = "2026-05-15T16:11:26.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/f0/5fef8c47c5b7052da79af6425a021040402a9a09b37c5687d91f98696a27/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9784d79275d1b06d95f2920c3f260ce5d3f6671f65a1fae1464f83fbfc834a0", size = 1182991, upload-time = "2026-05-15T16:11:29.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/33/4d3a9d3159cdff1f1886d39f1991e6c8d5927f9b606cfc9743b5bef98c2a/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0ef86c53d0e14991b5b0fe58f225e3d8faec437aa0a70da4762525b6bc2fca10", size = 1359104, upload-time = "2026-05-15T16:11:31.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/04/20eb16c52453aea0af68a697f4058378c9ff871bd2f0bea28eb76ffe12f8/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d9faccf5a9e86f14104eec31c13428ef8baeb867182ce107e0c68fcc5c62477", size = 1212847, upload-time = "2026-05-15T16:11:33.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/a3/69bbe93275eafb7802a5b29230cc5c28f9b924c68d15b3ab2d43cfe19825/pyjson5-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:2df497af7ab03cf82d9278a4707a83d0ae4e6d727f919a883b5ccec3f7d92650", size = 139137, upload-time = "2026-05-15T16:11:35.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/45/b865ca6e0ae6887bc2b6b17cd123470ae43ac0fa04a2362d77d4b9f5bd43/pyjson5-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b31cf4f4a4f01800812865af3b02f6700cefb9377e4d9a9c6b78fdcf41fd973", size = 169412, upload-time = "2026-05-15T16:11:36.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/55/02c734459fef0a955ab3e7e745df415d5f9fc873a4c288765f60f22fbc13/pyjson5-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:15f0d8baea89d35c6c01c60b944c778a79cac10f77f71b727f1b244e2c7a8ccb", size = 129061, upload-time = "2026-05-15T16:11:38.712Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymarkdownlnt"
|
||||||
|
version = "0.9.40"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "application-file-scanner" },
|
||||||
|
{ name = "application-properties" },
|
||||||
|
{ name = "columnar" },
|
||||||
|
{ name = "py-walk" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/60/ea/a784da505745c5eb19b7b6be62a0cee4c25c42ba72e010413a7ebaa40ca7/pymarkdownlnt-0.9.40.tar.gz", hash = "sha256:9e6b1137b9f4b496da10f838bc30f66e4c182a0bf53d93d3afce65a541ded091", size = 467022, upload-time = "2026-09-13T20:29:33.319Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/1e/e2d60317291cedff329cfe08dff7d5a2aefe51ef5f336997e1f2e8a08a94/pymarkdownlnt-0.9.40-py3-none-any.whl", hash = "sha256:de92072911fcb1e5476a7d453078d51457329f5e5cfd78c37d29efc363500262", size = 549389, upload-time = "2026-09-13T20:29:31.653Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.1"
|
version = "9.1.1"
|
||||||
@@ -574,3 +773,93 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sly"
|
||||||
|
version = "0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702, upload-time = "2022-10-25T14:35:30.592Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864, upload-time = "2022-10-25T14:35:28.054Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toolz"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcwidth"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user