Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
/cuvis/cuvis_il.py
/cuvis/_cuvis_pyil.pyd
/venv
/__pycache__
/cuvis/__pycache__
/cuvis/git-hash.txt

/tests/__pycache__
/examples/__pycache__
/.claude

36 changes: 31 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,59 @@ Pre-releases (`b*`, `rc*`) are not listed.
- `CI` - `.github/workflows/release.yml` is driven by `v*.*.*.*` tags: it validates the tag against `pyproject.toml` and against this file, builds, publishes to TestPyPI, and publishes to PyPI plus a GitHub Release after manual approval.
- `CI` - `scripts/check_changelog.py` validates this file's structure (header format, allowed section names, descending versions) and the tag/version/changelog agreement at release time.
- `CONTRIBUTING.md` - documents the branch model, the version scheme, the changelog conventions and the release checklist.
- `cuvis.BindingInfo` - new frozen dataclass with the fields `built_against: str`, `library_version: str`, `library_path: str` and `missing_symbols: Tuple[str, ...]`, the read-only property `is_complete: bool`, and a `__str__` rendering a report fit for a bug report.
- `cuvis.BindingInfo` - new frozen dataclass with the fields `built_against: str`, `library_version: str`, `library_path: str` and `missing_symbols: tuple[str, ...]`, the read-only property `is_complete: bool`, and a `__str__` rendering a report fit for a bug report.
- `cuvis.CudaImageData` - new class, device-resident image data backed by a shareable CUDA buffer, exposed zero-copy through DLPack or `__cuda_array_interface__` with no host copy.
- `cuvis.CudaImageData.export_payload` - new method, returns `bytes`.
- `cuvis.CudaImageData.make_ipc` - new method, returns `bytes`.
- `cuvis.CudaImageData.to_torch` - new method, returns `torch.Tensor`.
- `cuvis.Measurement.get_cube` - new method, returns `ImageData | CudaImageData` depending on whether `cuvis.cuda.enable` was called.
- `cuvis.Measurement.get_cube_cuda` - new method, returns `CudaImageData`.
- `cuvis.Measurement.get_cube_cuda_ipc` - new method, returns `CudaImageData`.
- `cuvis.SdkSettings` - new class, a `MutableMapping` of setting id to value that writes the SDK's `cuvis.settings` file, so the SDK configuration can be built in Python instead of maintained by hand.
Values are stored as strings: `bool` becomes `true`/`false`, an `Enum` becomes its value, anything else goes through `str()`, and `None` drops the entry.
- `cuvis.SdkSettings.__enter__`, `cuvis.SdkSettings.__exit__` - new methods; entering the context serializes the settings into a temporary directory and returns its path as `str`, leaving the context removes the directory.
- `cuvis.SdkSettings.save` - new method, writes the settings to a file, or into a directory as `cuvis.settings`.
- `cuvis.SdkSettings.xml_str` - new read-only property, returns the serialized settings document as `str`.
- `cuvis.UnavailableSDKFunction` - new exception deriving from both `cuvis.cuvis_aux.SDKException` and `RuntimeError`, with the field `names: Tuple[str, ...]`.
- `cuvis.UnavailableSDKFunction` - new exception deriving from both `cuvis.cuvis_aux.SDKException` and `RuntimeError`, with the field `names: tuple[str, ...]`.
- `cuvis.binding` - new module reporting the compiled binding, the cuvis library loaded beside it, and the functions that library does not provide.
Nothing in it needs the SDK to be initialised, so it can be called before `cuvis.init`.
- `cuvis.binding.available` - new function, returns `bool`.
- `cuvis.binding.info` - new function, returns `BindingInfo`.
- `cuvis.binding.missing_symbols` - new function, returns `FrozenSet[str]`.
- `cuvis.binding.missing_symbols` - new function, returns `frozenset[str]`.
- `cuvis.binding.require` - new function, raises `UnavailableSDKFunction` naming whichever of the given functions the installed cuvis library does not provide.
- `cuvis.binding.unavailable` - new function, returns `tuple[str, ...]`.
A function the binding never exposed is unusable as well, so availability cannot be answered from the reported-missing list alone.
- `cuvis.cuda` - new module gating the optional CUDA feature surface; CUDA stays off until `cuvis.cuda.enable` is called.
- `cuvis.cuda.BACKEND_NONE`, `cuvis.cuda.BACKEND_POOL`, `cuvis.cuda.BACKEND_LEGACY`, `cuvis.cuda.BACKEND_VMM` - new constants, the IPC backend codes from `cuvis.h`.
- `cuvis.cuda.CudaCapabilities` - new `NamedTuple` with the `bool` fields `same_process`, `ipc_pool`, `ipc_legacy`, `ipc_vmm`, `torch` and `cuda_python`, and the read-only property `any_ipc: bool`.
- `cuvis.cuda.capabilities` - new function, returns `CudaCapabilities`.
- `cuvis.cuda.disable` - new function.
- `cuvis.cuda.enable` - new function.
- `cuvis.cuda.is_enabled` - new function, returns `bool`.
- `cuvis.cuda.require_device` - new function, raises `UnavailableSDKFunction` unless the installed library provides the same-process device path.
- `cuvis.cuda.require_ipc` - new function, raises `UnavailableSDKFunction` unless the installed library provides the cross-process export path.
- `cuvis_ipc` - new top-level module, the consumer side of cross-process CUDA IPC.
It sits outside the `cuvis` package because importing `cuvis` requires the SDK and a consumer process does not have one; its only import is `struct`.
- `cuvis_ipc.ImportedCube` - new class, a mapped IPC buffer in the consumer process, usable as a context manager.
- `cuvis_ipc.open` - new function, returns `ImportedCube`.
- `cuvis_ipc.open_descriptor` - new function, returns `ImportedCube`.
- `cuvis_ipc.pack_payload` - new function, returns `bytes`.
- `pyproject.toml` - `py-modules` declaring the top-level `cuvis_ipc`.
- `tests/` - `test_binding.py`, `test_cuda.py` and `test_cuvis_ipc.py`.

### Changed

- Whole tree reformatted with `ruff format`; no behaviour change.
- `README.md` - documents the version scheme, and lists Python 3.14 among the supported interpreters as `pyproject.toml` already did.
- `prebuild.py` - writes `cuvis/git-hash.txt` instead of `git-hash.txt` at the repository root, so the file lands inside the package that declares it as package data.
- `cuvis.General.init` - parameter `settings_path` type changed from `str` to `Union[str, Path, SdkSettings]`.
- `cuvis.General.init` - parameter `settings_path` type changed from `str` to `str | Path | SdkSettings`.
An `SdkSettings` is written to a temporary directory that exists only for the duration of the call, since the SDK reads the settings once during initialisation.
- `cuvis.FileWriteSettings.GeneralExportSettings.__repr__`, `cuvis.FileWriteSettings.ViewerSettings.__repr__` - the docstring that sat below the nested helper, where it was a dead expression rather than a docstring, moved to the top of the method.
- `cuvis.Measurement.capture_time`, `cuvis.Measurement.factory_calibration`, `cuvis.GPSData.time`, `cuvis.SensorInfo.readout_time` - type changed from a naive `datetime.datetime` to one carrying `tzinfo=datetime.timezone.utc`.
The instant is unchanged, only the `+00:00` label is added; comparing or subtracting against a naive `datetime` now raises `TypeError`, so use `datetime.datetime.now(datetime.timezone.utc)` or `.astimezone()` for local time.
- `cuvis.CalibrationInfo.calibration_date` - type changed from `int` to a `datetime.datetime` carrying `tzinfo=datetime.timezone.utc`; the field was annotated as a `datetime` but returned the raw epoch milliseconds unconverted.
The SDK derives this value as midnight on the calibration day in the host's standard local time, so unlike the other timestamps the instant it denotes shifts with the reading machine; treat it as a day, not as an exact moment.
- `cuvis.Measurement.factory_calibration` - type changed from `datetime.datetime` to `Optional[datetime.datetime]`, matching the existing fallback to `None` for SDK values the `datetime` range cannot represent.
- `cuvis.Measurement.factory_calibration` - type changed from `datetime.datetime` to `datetime.datetime | None`, matching the existing fallback to `None` for SDK values the `datetime` range cannot represent.
Only the day carries meaning: the SDK stores it as midnight in the local time of the machine that wrote the file, so the time component is an artifact of that machine and the day can be off by one when the file is read in another timezone.
- `pyproject.toml` - `requires-python` raised from `>=3.9` to `>=3.10`, Python 3.9 having reached end of life in October 2025.
- Annotations throughout `cuvis` restated in the forms Python 3.10 provides: `Union[A, B]` and `Optional[A]` became `A | B` and `A | None`, and `Tuple`, `FrozenSet`, `Sequence`, `Callable` and `Awaitable` now come from `builtins` and `collections.abc` rather than `typing`.
Expand Down
152 changes: 152 additions & 0 deletions ImageData-api-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# ImageData: API notes and proposed additions

Written 2026-08-19, against `hotfix/fix_broken_qmini_imagedata_wrapping` at version 3.5.3.2.

This is a design note, not a plan.
It records what the class looks like after the QMini hotfix, which additions are worth making next, and which tempting ones should be refused.
Nothing here is scheduled.

The reason to write it down now is that 3.5.3.2 introduces the whole numeric surface of `ImageData` at once.
Every member it ships becomes permanent.
The additions below are the ones that fit that surface without contradicting it, and a few of them are cheaper to do before the release than after.

## What shipped in 3.5.3.2

Attributes: `array`, `width`, `height`, `channels`, `wavelength`.
Properties: `shape`, `dtype`, `is_spectrum`, `spectrum`.
Protocols: `__getitem__`, `__array__`, `__array_ufunc__`, `__repr__`, the seven arithmetic operators with their reflected forms, `__neg__`, `__abs__`.
Constructors: `__init__` from a `cuvis_imbuffer_t`, `from_array` from a NumPy array.

Two invariants hold the design together.
`array` is always three dimensional, `(height, width, channels)`, so a point spectrometer arrives as `(1, 1, channels)` rather than as a bare vector.
`wavelength` is either `None` or exactly `channels` long, and is never guessed: a slice whose effect on the band axis cannot be determined drops the wavelengths rather than inventing them.

## Proposed additions

### 1. Band lookup by wavelength

The gap a user notices first.
Hyperspectral work is expressed in nanometres, but every accessor on the class takes band indices, so callers hand-compute indices from the `wavelength` list before they can slice.

The minimal addition is one function that converts, leaving the existing slicing to do the rest:

```python
def band_at(self, nm: int) -> int:
"""The index of the band whose centre is closest to `nm`."""
```

which composes with what already exists:

```python
red = cube[:, :, cube.band_at(650)]
window = cube[:, :, cube.band_at(600) : cube.band_at(700) + 1]
```

Nearest match is the only honest semantic.
The SDK reports wavelengths as `uint32_t` nanometres (`cuvis.h:939`), the grid is whatever the camera's calibration produced, and an exact-match lookup would fail for most inputs a user types.
`band_at` should raise when `wavelength is None`, because there is no defensible answer for a preview or an info layer.

A richer alternative is an indexer object, `cube.nm[600:700]`, so that nanometres read like slicing.
It is more pleasant at the call site and considerably more machinery: a second indexing protocol to document, test and keep in step with `__getitem__`.
Not worth it for what is fundamentally a coordinate conversion.
`band_at` first; revisit only if call sites turn out to be dominated by ranges.

### 2. Mean spectrum over a region

After slicing, the most common hyperspectral operation is averaging a spatial region into one spectrum.
Today that loses the wavelengths, because the shape changes and the ufunc machinery correctly declines to carry metadata across a reduction:

```python
np.mean(cube, axis=(0, 1)) # plain ndarray, shape (channels,), wavelengths gone
```

so the caller reassembles by hand.
A method that returns a single pixel `ImageData` closes the loop and keeps the band axis labelled:

```python
def mean_spectrum(self) -> "ImageData":
"""The spatial mean, as a (1, 1, channels) ImageData carrying this instance's wavelengths."""
```

Then `cube[100:200, 50:150].mean_spectrum()` is the whole region-of-interest workflow, and the result is `is_spectrum` and plots exactly like a QMini reading.
This is the one addition that turns the existing pieces into a workflow rather than adding another spelling of something already possible.

### 3. Comparison operators

`np.asarray(cube) > 500` works and is documented.
`cube > 500` raises `TypeError`, and `np.greater(cube, 500)` returns a plain boolean array since the guard added in 3.5.3.2.
The asymmetry is the kind that costs a user ten minutes.

Adding `__lt__`, `__le__`, `__gt__`, `__ge__` through the existing `_binary_op` factory, returning the plain array rather than rewrapping, removes it for about four lines.

`__eq__` and `__ne__` must stay out.
Defining them would make `img == img` elementwise, which silently breaks every truth test on the result, and it would take `__hash__` with it unless explicitly restored.
`ImageData` is hashable today and identity comparison is the useful default for a handle-like object.
Asymmetric operator sets are unusual enough to deserve a comment at the definition site saying why.

### 4. `spectrum` as a method taking pixel coordinates

Discussed during the hotfix and deliberately left out of it.

`spectrum` is currently a property restricted to single pixel images.
The general operation is "the band vector at a pixel", which `__getitem__` already performs, except that it returns `(values, wavelengths)` rather than a bare array.
So there are two ways to reach a band vector with two different return types, and the property covers only the `0, 0` case:

```python
point.spectrum # ndarray
cube[10, 10] # (ndarray, wavelengths)
```

A method subsumes both with one return type and no arbitrary restriction:

```python
def spectrum(self, y: int = 0, x: int = 0) -> np.ndarray:
```

`point.spectrum()` keeps reading well, `cube.spectrum(10, 10)` gains what the property could not express, and `is_spectrum` reverts to what it should have been all along: an informational shape check, not the precondition of another member.

The catch is timing.
Turning a property into a method is a breaking change, so this is free before 3.5.3.2 ships and a deprecation cycle afterwards.
It is listed here rather than applied because it widens a hotfix, but it is the item on this list whose cost grows the fastest.

### 5. Store the buffer format, or stop requiring it

Not an addition so much as a wart to resolve, recorded here because it touches the constructor signature.

`__init__` accepts `dformat`, raises `TypeError` when it is missing, and never reads it.
The format is taken from `img_buf.format` directly, two lines further down.
The three call sites do not even agree on the type they pass: `Measurement.py:108` passes a `DataFormat` enum member, `SessionFile.py:60` and `Viewer.py:46` pass the raw integer.

Either the value is worth keeping, in which case store it as a public `format` and use it instead of re-reading the buffer, or it is not, in which case drop the parameter.
The current state is the worst of the three: a required argument, inconsistently supplied, with no effect.
Dropping it is technically a signature change, but `ImageData(img_buf, dformat)` is not something callers outside the wrapper construct.

## Considered and refused

**`wavelength` as an ndarray.**
It is a list of Python ints today, so callers wrap it in `np.asarray` when they want arithmetic.
Changing the type would break `wavelength == [450, 458]` comparisons, including several in `tests/test_cube_utils.py`, and turn every truth test on the result into an ambiguity error.
Adding a second `wavelength_nm` property alongside it trades that break for a permanent duplicate.
The `np.asarray` at the call site is the smaller cost.

**Iteration and `__len__`.**
There is no defensible answer to what iterating an image yields.
Rows, pixels and bands are all plausible, and a wrong guess is worse than a `TypeError`.

**`is_cube`, `is_image`, or other siblings of `is_spectrum`.**
`not img.is_spectrum` already says it.
`is_spectrum` earns its place because the alternative forces callers to know the `(1, 1, channels)` convention; a negation does not clear that bar.

**More conversion spellings.**
`array`, `to_numpy()` and `np.asarray(img)` are already three ways to reach the same buffer.
The direction of travel should be fewer, not more: `to_numpy()` is the redundant one, and if anything happens here it should be a deprecation.

## Ordering

If these are picked up, the order that yields the most per change:

1. `spectrum(y, x)`, if and only if it happens before 3.5.3.2 ships. Afterwards it drops to last, behind a deprecation cycle.
2. `band_at`. Largest gap, smallest implementation, no interaction with anything else.
3. `mean_spectrum`. Depends on nothing, completes the region-of-interest workflow.
4. Comparison operators. Small and self-contained.
5. The `dformat` cleanup. Internal, do it alongside whichever of the above touches the constructor.
Loading
Loading