use std/media/v1/Image for image and depth

Both inputs now declare dora's Arrow struct {width, height, encoding, data}
rather than a flat buffer plus loose metadata. The payload describes itself, so
nothing about its shape has to be agreed out of band, and the frame travels as
one Arrow value instead of a buffer whose meaning lives in a sidecar dict.

Depth uses the same type. dora's registry has no depth type, and a depth map is
an image whose pixels are distances -- the reading ROS takes as well -- so it
arrives with a single-channel encoding: 32FC1/float32 or 16UC1/mono16/uint16.
`units` stays in message metadata because the struct has no field for it.

decode.IMAGE_TYPE is the single definition of the struct; the example source
and the tests build payloads from it rather than respelling the schema.

The older shapes are still decoded -- a flat UInt8 buffer with
width/height/encoding for image, a flat Float32 buffer with shape/units for
depth -- so a producer's payload can move before its manifest does. That is
only half a migration path: type compatibility is directional, so a producer
still declaring std/core/v1/UInt8 fails validate against an
std/media/v1/Image input regardless of what it sends, and has to update its
manifest too.

Verified: 41 tests, validate clean with 4 contracts injected, the dataflow runs
to a clean exit, and a composite rendered through the struct path is identical
to the one rendered before the migration.
This commit is contained in:
2026-09-15 05:27:40 -03:00
parent a6fa3ce32b
commit 8a7bda5867
8 changed files with 153 additions and 53 deletions
+4 -2
View File
@@ -26,9 +26,11 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless
- **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node. - **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node.
- **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `unpack()` accepts two payload shapes — the `std/media/v1/Image` struct (`width`, `height`, `encoding`, `data`), and the flat `UInt8` buffer described by message metadata that a `std/core/v1/UInt8` producer sends. A struct describes itself, so its metadata is ignored; everything after unpacking is common. - **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. `unpack()` accepts two payload shapes — the `std/media/v1/Image` struct (`width`, `height`, `encoding`, `data`), and the flat `UInt8` buffer described by message metadata that a `std/core/v1/UInt8` producer sends. A struct describes itself, so its metadata is ignored; everything after unpacking is common.
**The declared type is still `std/core/v1/UInt8` and the reason is interop.** Type compatibility is directional: `Image` satisfies a `Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input is a validate error. dora-zed declares `image_bgr` as `std/core/v1/UInt8`, so flipping this manifest to `Image` breaks the camera until dora-zed is migrated. Accepting both shapes first is what makes that migration orderable: producers move, then the contract. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see. `IMAGE_TYPE` is the one definition of that struct; the example source and the tests build payloads from it rather than respelling the schema.
**Interop cost of the contract being `Image`.** Type compatibility is directional: `Image` satisfies a `Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input is a validate error. A producer still declaring `std/core/v1/UInt8` — dora-zed's `image_bgr` at time of writing — has to change its manifest, not just its payload, before it can feed this node. The decoder accepting the flat form does not rescue it: the failure is at validate, before anything runs. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see.
- **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one. - **`dora_image_view/view.py`** — `View` (the panels, the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded and composed, which is what makes the node safe in a pipeline test. Panels are tiled in the fixed order in `PANELS`, not in arrival order, so a panel does not jump when a stream drops a frame. Boxes are drawn on the `image` panel before scaling, because they are in image pixels; the caption is drawn on the composite, because the panel it would otherwise land on may not be the leftmost one.
- **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging. - **`dora_image_view/depth.py`** — `to_millimetres()` and `colorize()`. Decoding normalises to float32 millimetres with 0 for missing, so the colouring only ever sees one representation. Depth arrives as `std/media/v1/Image` with a single-channel encoding (`DEPTH_DTYPES`) because dora's registry has no depth type and a depth map *is* an image of distances — the same reading ROS takes. `units` stays in message metadata: the struct has no field for it. One thing is deliberate: the auto range is the valid pixels' full min/max, **not** a percentile. Trimming the tails looks safer and breaks the exact scene this node exists for — a small object in front of a large wall is a few percent of the pixels, so both tails land on the wall and the object clips away. A rendered check caught that; do not reintroduce percentile ranging.
- **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it. - **`dora_image_view/payload.py`** — `as_float32()`: reinterprets a `uint8` payload rather than converting it, because a C++ node sending a raw byte slice produces a `UInt8` arrow array despite declaring `Float32`. Shared by `depth` and `intrinsics`, which both meet it.
- **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible. - **`dora_image_view/intrinsics.py`** — `Intrinsics`, `parse()` for the `camera_info` payload and `from_metadata()` for the copy senders attach to each frame. Both exist because dora-zed publishes the same numbers twice, and the per-frame copy is the one that survives a viewer starting after the camera did — `camera_info` goes out **once per session**. A nine-element row-major K is accepted alongside the compact four values, since that is how ROS spells the same model. The node needs none of this to draw; it is held for whatever turns depth into 3D, and drawn so a wrong camera model is visible rather than merely plausible.
- **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is. - **`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.
+19 -13
View File
@@ -106,25 +106,31 @@ no window. That is what makes it safe to run in a pipeline test.
| Input | Payload | Metadata | | Input | Payload | Metadata |
|---|---|---| |---|---|---|
| `image` | `std/media/v1/Image` struct, **or** a flat `UInt8` array | none for the struct; `width`, `height`, `encoding` for the flat form | | `image` | `std/media/v1/Image` struct | none — the struct describes itself |
| `depth` | Flat `Float32` array, `[H, W]` | `shape`, `units` | | `depth` | `std/media/v1/Image` struct, one channel | `units` (defaults to `mm`) |
| `camera_info` | Flat `Float32` array, `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` | | `camera_info` | Flat `Float32` array, `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` |
| `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` |
| `text` | Arrow array of size 1 | — | | `text` | Arrow array of size 1 | — |
Two image payload shapes are accepted. The typed one is `std/media/v1/Image`, an Arrow `image` and `depth` are both `std/media/v1/Image`: an Arrow struct of `width: UInt32`,
struct of `width: UInt32`, `height: UInt32`, `encoding: Utf8` and `data: LargeBinary`, `height: UInt32`, `encoding: Utf8` and `data: LargeBinary`. The payload describes
which carries inside the payload what the flat form carries beside it; when it is itself, so nothing about its shape has to travel as message metadata.
received the message metadata is not consulted at all. The flat form is a raw `UInt8`
buffer described by `width`, `height` and `encoding` metadata, which is what a node
declaring `std/core/v1/UInt8` sends.
The declared contract is still `std/core/v1/UInt8`, so a producer sending the struct Depth is an image whose pixels are distances — dora's type registry has no depth type,
should keep declaring that until every producer in the dataflow has moved: dora's type and this is how ROS spells it too. Its encodings are `32FC1`/`float32` and
compatibility is directional, and a `std/core/v1/UInt8` output wired into an `16UC1`/`mono16`/`uint16`, one channel per pixel, and `units` metadata still switches
`std/media/v1/Image` input fails `dora validate`. millimetres to metres.
Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png` Older payloads are still decoded, so a producer can move at its own pace: a flat `UInt8`
buffer with `width`/`height`/`encoding` metadata for `image`, and a flat `Float32`
buffer with `shape`/`units` metadata for `depth`.
**Migrating a producer.** dora's type compatibility is directional — `Image` satisfies a
`Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input
is a `dora validate` error. A producer still declaring `std/core/v1/UInt8` or
`std/core/v1/Float32` therefore has to change its manifest as well as its payload.
Supported `image` `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png`
(encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw (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.
+2 -2
View File
@@ -40,9 +40,9 @@ nodes:
# bbox is deliberately absent: it is untyped on the viewer's side too, # bbox is deliberately absent: it is untyped on the viewer's side too,
# because senders disagree on 32- vs 64-bit numeric fields. # because senders disagree on 32- vs 64-bit numeric fields.
output_types: output_types:
image: std/core/v1/UInt8 image: std/media/v1/Image
text: std/core/v1/String text: std/core/v1/String
depth: std/core/v1/Float32 depth: std/media/v1/Image
camera_info: std/core/v1/Float32 camera_info: std/core/v1/Float32
- id: view - id: view
+15 -10
View File
@@ -21,13 +21,14 @@ 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: >-
@@ -53,13 +54,17 @@ inputs:
this input -- they are picked up from there, which is what covers a this input -- they are picked up from there, which is what covers a
camera that published its intrinsics before this node started. camera that published its intrinsics before this node started.
depth: depth:
type: std/core/v1/Float32 type: std/media/v1/Image
required: false required: false
description: >- description: >-
Depth map as a flat Float32 buffer, described by the metadata Depth map as an Arrow struct {width, height, encoding, data}, one channel
{shape: [height, width], units}. Millimetres unless `units` says `m`; per pixel. Supported encodings: 32FC1/float32 and 16UC1/mono16/uint16 --
0 and non-finite values are missing depth and draw black. Colour-mapped dora has no depth type, and a depth map is an image whose pixels are
into its own panel, which receiving this input redraws. 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: {}
+13 -5
View File
@@ -13,6 +13,7 @@ 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
@@ -22,9 +23,16 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"})
SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"} SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"}
# The fields of std/media/v1/Image. A payload carrying them describes itself and # std/media/v1/Image. A payload carrying these fields describes itself, so its
# its metadata is not consulted. # message metadata is not consulted. Exported as an Arrow type so a producer --
IMAGE_FIELDS = ("width", "height", "encoding", "data") # 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: def to_bgr(storage, metadata: dict) -> np.ndarray:
@@ -68,7 +76,7 @@ def to_bgr(storage, metadata: dict) -> np.ndarray:
def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | None]: 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.""" """Split a payload of either shape into buffer, encoding, width and height."""
if _is_image_struct(storage): if is_image_struct(storage):
# One image per message: the struct is a single-row array. # One image per message: the struct is a single-row array.
row = storage[0].as_py() row = storage[0].as_py()
return ( return (
@@ -86,7 +94,7 @@ def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int |
) )
def _is_image_struct(storage) -> bool: def is_image_struct(storage) -> bool:
"""Whether the payload is an Arrow struct with the std/media/v1/Image fields.""" """Whether the payload is an Arrow struct with the std/media/v1/Image fields."""
fields = getattr(getattr(storage, "type", None), "fields", None) fields = getattr(getattr(storage, "type", None), "fields", None)
+35 -2
View File
@@ -11,6 +11,7 @@ import os
import cv2 import cv2
import numpy as np import numpy as np
from dora_image_view.decode import is_image_struct
from dora_image_view.payload import as_float32 from dora_image_view.payload import as_float32
# Perceptually uniform and readable in both directions, unlike JET. # Perceptually uniform and readable in both directions, unlike JET.
@@ -18,6 +19,18 @@ COLORMAP = cv2.COLORMAP_TURBO
MISSING = 0.0 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: def to_millimetres(storage, metadata: dict) -> np.ndarray:
"""Decode one `depth` payload into a float32 array of millimetres. """Decode one `depth` payload into a float32 array of millimetres.
@@ -25,8 +38,7 @@ def to_millimetres(storage, metadata: dict) -> np.ndarray:
Missing depth is 0. Non-finite values are normalised to 0 as well, so Missing depth is 0. Non-finite values are normalised to 0 as well, so
everything downstream only has to test for it. everything downstream only has to test for it.
""" """
depth = as_float32(storage) depth, height, width = _values(storage, metadata)
height, width = _hw(metadata, depth.size)
if depth.size < height * width: if depth.size < height * width:
raise RuntimeError( raise RuntimeError(
@@ -87,6 +99,27 @@ def resolve_range(depth_mm: np.ndarray) -> tuple[float, float]:
return float(low), float(high) 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]: def _hw(metadata: dict, size: int) -> tuple[int, int]:
shape = metadata.get("shape") shape = metadata.get("shape")
+23 -13
View File
@@ -16,6 +16,8 @@ import numpy as np
import pyarrow as pa import pyarrow as pa
from dora import Node from dora import Node
from dora_image_view.decode import IMAGE_TYPE
WIDTH = int(os.getenv("SOURCE_WIDTH", "640")) WIDTH = int(os.getenv("SOURCE_WIDTH", "640"))
HEIGHT = int(os.getenv("SOURCE_HEIGHT", "480")) HEIGHT = int(os.getenv("SOURCE_HEIGHT", "480"))
# 0 runs until the viewer quits; a positive count is what lets CI run the # 0 runs until the viewer quits; a positive count is what lets CI run the
@@ -55,11 +57,7 @@ def main() -> None:
) )
frame, corners = render(sent) frame, corners = render(sent)
node.send_output( node.send_output("image", image_struct(frame, "bgr8"), INTRINSICS_METADATA)
"image",
pa.array(frame.ravel()),
{"width": WIDTH, "height": HEIGHT, "encoding": "bgr8", **INTRINSICS_METADATA},
)
node.send_output( node.send_output(
"bbox", "bbox",
pa.array([{ pa.array([{
@@ -72,14 +70,8 @@ def main() -> None:
depth = render_depth(corners) depth = render_depth(corners)
node.send_output( node.send_output(
"depth", "depth",
pa.array(depth.ravel()), image_struct(depth, "32FC1"),
{ {"units": "mm", **INTRINSICS_METADATA},
"shape": [HEIGHT, WIDTH],
"dtype": "float32",
"layout": "HW",
"units": "mm",
**INTRINSICS_METADATA,
},
) )
node.send_output("text", pa.array([CAPTION])) node.send_output("text", pa.array([CAPTION]))
@@ -88,6 +80,24 @@ def main() -> None:
break 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]: def render(index: int) -> tuple[np.ndarray, np.ndarray]:
"""Draw frame `index` and return it with the xyxy corners of the square in it.""" """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 = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
+42 -6
View File
@@ -298,12 +298,7 @@ def _image_struct(frame: np.ndarray, encoding: str = "bgr8"):
"encoding": encoding, "encoding": encoding,
"data": frame.tobytes(), "data": frame.tobytes(),
}], }],
type=pa.struct([ type=decode.IMAGE_TYPE,
("width", pa.uint32()),
("height", pa.uint32()),
("encoding", pa.utf8()),
("data", pa.large_binary()),
]),
) )
@@ -336,3 +331,44 @@ def test_a_decoded_image_struct_frame_is_writable(frame):
decoded[0, 0] = (1, 2, 3) decoded[0, 0] = (1, 2, 3)
assert tuple(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