From 66c52c777d7aa476e82ce78e01f69e2ea139e419 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Wed, 30 Sep 2026 11:22:35 -0400 Subject: [PATCH 1/2] fix(cube): reject non-finite domain and table values --- CHANGELOG.md | 8 ++++ README.md | 4 +- pycube_lut/cube.py | 44 +++++++++++++++++++--- tests/test_cube.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5faba89..b6579c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- `load_cube()` and `Cube` now reject NaN, infinite and float32-overflowing values in the + domain and table, and an inverted domain on a directly constructed `Cube`, with a + `CubeError` instead of letting them reach the image cast as undefined values. + ## 0.1.0 First release. diff --git a/README.md b/README.md index 5d1d55d..c70429d 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ Cube specification requires, and it is the single most common way to get a LUT w ### Errors Every failure raises `CubeError` (a `ValueError` subclass) and always names the path. -Failure is closed — a bad file never yields a silently truncated table. +Failure is closed — a bad file never yields a silently truncated table, and a NaN, +infinite or float32-overflowing domain or table value is rejected at load time rather +than surfacing later as garbage pixels. ```python from pycube_lut import CubeError, load_cube diff --git a/pycube_lut/cube.py b/pycube_lut/cube.py index d2fcb57..88ee874 100644 --- a/pycube_lut/cube.py +++ b/pycube_lut/cube.py @@ -49,6 +49,36 @@ class Cube: domain_min: np.ndarray # (3,) float32 domain_max: np.ndarray # (3,) float32 + def __post_init__(self) -> None: + """Fail closed on a cube the applier could only turn into garbage. + + A NaN/infinite table or domain entry (or a float32 overflow) would + otherwise reach the final ``astype`` cast as an undefined-behaviour value. + """ + _require_finite(self.table, "table") + _require_finite(self.domain_min, "DOMAIN_MIN") + _require_finite(self.domain_max, "DOMAIN_MAX") + if np.any(self.domain_max <= self.domain_min): + raise CubeError("DOMAIN_MAX must exceed DOMAIN_MIN on every channel") + + +def _require_finite(values: np.ndarray, what: str, path: Path | None = None) -> None: + """Raise :class:`CubeError` unless every entry of ``values`` is finite. + + ``values`` is already float32, so a literal like ``1e39`` -- finite as text, + infinite once stored -- is caught here too. + """ + if not np.all(np.isfinite(values)): + where = f"{path}: " if path is not None else "" + raise CubeError(f"{where}{what} must be finite (no NaN or infinity, within float32 range)") + + +def _to_float32(values) -> np.ndarray: + """``float32`` copy of ``values``; an out-of-range entry becomes ``inf`` silently, + for :func:`_require_finite` to report with the path attached.""" + with np.errstate(over="ignore", invalid="ignore"): + return np.asarray(values, dtype=np.float32) + def _table_from_rows(rows, size: int) -> np.ndarray: """Fold ``size**3`` R-fastest samples into a table indexed ``[r, g, b]``. @@ -57,7 +87,7 @@ def _table_from_rows(rows, size: int) -> np.ndarray: order a HALD CLUT's pixels run in), so reshaping to ``(b, g, r, 3)`` and transposing gives the ``[r, g, b]`` table :func:`apply_cube` indexes. """ - table = np.asarray(rows, dtype=np.float32).reshape(size, size, size, 3) + table = _to_float32(rows).reshape(size, size, size, 3) return np.ascontiguousarray(table.transpose(2, 1, 0, 3)) @@ -132,8 +162,9 @@ def load_cube(path: str | Path) -> Cube: separable 3D cube at :data:`LUT_1D_TO_3D_SIZE`. Raises :class:`CubeError` -- always naming the path -- on a missing/unreadable - file, a bad size directive, bad HALD geometry, or a data-row count that does - not match the declared size. Failure is always closed, never a silently + file, a bad size directive, bad HALD geometry, a data-row count that does + not match the declared size, or a NaN/infinite (or float32-overflowing) + domain or table value. Failure is always closed, never a silently truncated table. """ path = Path(path) @@ -175,7 +206,8 @@ def load_cube(path: str | Path) -> Cube: if len(vals) != 3: raise CubeError(f"{path}: {key} needs 3 values, got {line!r}") target = domain_min if key == "DOMAIN_MIN" else domain_max - target[:] = vals + target[:] = _to_float32(vals) + _require_finite(target, key, path) continue if key in ("TITLE",): continue @@ -195,7 +227,8 @@ def load_cube(path: str | Path) -> Cube: if size is None: # a 1D tone-curve file: one row per entry, three curves wide if len(data) != size_1d: raise CubeError(f"{path}: expected {size_1d} data rows for a 1D LUT, found {len(data)}") - curves = np.asarray(data, dtype=np.float32) + curves = _to_float32(data) + _require_finite(curves, "1D table", path) return _separable_cube_from_curves(curves, (domain_min, domain_max)) if len(data) != size**3: @@ -203,6 +236,7 @@ def load_cube(path: str | Path) -> Cube: f"{path}: expected {size**3} data rows for a {size}^3 LUT, found {len(data)}" ) table = _table_from_rows(data, size) + _require_finite(table, "table", path) return Cube(size=size, table=table, domain_min=domain_min, domain_max=domain_max) diff --git a/tests/test_cube.py b/tests/test_cube.py index ae469f4..9e53acf 100644 --- a/tests/test_cube.py +++ b/tests/test_cube.py @@ -1,5 +1,7 @@ """Reader + trilinear applier: parsing, indexing order, domain, formats, strength.""" +import warnings + import numpy as np import pytest @@ -314,3 +316,95 @@ def test_out_of_range_strength_is_clamped(tmp_path): img = np.full((4, 4, 3), 200, dtype=np.uint8) assert np.array_equal(apply_cube(img, cube, 5.0), apply_cube(img, cube, 1.0)) assert np.array_equal(apply_cube(img, cube, -2.0), apply_cube(img, cube, 0.0)) + + +# --- LUT-1: non-finite / unrepresentable values are rejected at the boundary ---------- + + +def _cube_text(header: str, rows: list[str]) -> str: + return header + "\n" + "\n".join(rows) + "\n" + + +@pytest.mark.parametrize("bad", ["nan", "NaN", "inf", "-inf", "infinity", "1e999", "1e39"]) +def test_non_finite_table_entry_raises_before_apply(tmp_path, bad): + # 1e39 is finite as a Python float but overflows the float32 table to inf. + p = tmp_path / "badrow.cube" + rows = ["0 0 0"] * 7 + [f"{bad} 0.5 0.5"] + p.write_text(_cube_text("LUT_3D_SIZE 2", rows)) + with pytest.raises(CubeError, match="finite") as exc: + load_cube(p) + assert str(p) in str(exc.value) + + +@pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "1e39"]) +@pytest.mark.parametrize("key", ["DOMAIN_MIN", "DOMAIN_MAX"]) +def test_non_finite_domain_raises(tmp_path, key, bad): + p = tmp_path / "baddom.cube" + vals = f"{bad} 0 0" if key == "DOMAIN_MIN" else f"1 {bad} 1" + p.write_text(_cube_text(f"LUT_3D_SIZE 2\n{key} {vals}", ["0 0 0"] * 8)) + with pytest.raises(CubeError, match="finite") as exc: + load_cube(p) + assert str(p) in str(exc.value) + + +@pytest.mark.parametrize("bad", ["nan", "inf", "1e39"]) +def test_non_finite_1d_entry_raises(tmp_path, bad): + p = tmp_path / "bad1d.cube" + p.write_text(_cube_text("LUT_1D_SIZE 4", ["0 0 0", f"0.3 {bad} 0.3", "0.6 0.6 0.6", "1 1 1"])) + with pytest.raises(CubeError, match="finite") as exc: + load_cube(p) + assert str(p) in str(exc.value) + + +def test_directly_constructed_cube_rejects_non_finite_values(): + table = np.zeros((2, 2, 2, 3), np.float32) + table[1, 1, 1, 2] = np.nan + with pytest.raises(CubeError, match="finite"): + Cube(2, table, np.zeros(3, np.float32), np.ones(3, np.float32)) + with pytest.raises(CubeError, match="finite"): + Cube( + 2, + np.zeros((2, 2, 2, 3), np.float32), + np.array([0, np.nan, 0], np.float32), + np.ones(3, np.float32), + ) + + +def test_directly_constructed_cube_rejects_inverted_domain(): + with pytest.raises(CubeError, match="DOMAIN_MAX must exceed DOMAIN_MIN"): + Cube( + 2, + np.zeros((2, 2, 2, 3), np.float32), + np.ones(3, np.float32), + np.zeros(3, np.float32), + ) + + +def test_finite_out_of_unit_range_table_values_are_kept(tmp_path): + # Legitimate LUTs overshoot [0, 1]; only non-finite values are rejected, and the + # applier clamps on output without a cast warning. + p = tmp_path / "overshoot.cube" + rows = ["-0.25 1.5 0.5"] * 8 + p.write_text(_cube_text("LUT_3D_SIZE 2", rows)) + cube = load_cube(p) + assert cube.table.min() == np.float32(-0.25) and cube.table.max() == np.float32(1.5) + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = apply_cube(np.full((2, 2, 3), 100, np.uint8), cube) + assert out.tolist() == [[[0, 255, 128]] * 2] * 2 + + +def test_valid_fixtures_apply_without_runtime_warnings(tmp_path): + p3 = tmp_path / "swap.cube" + _write_cube(p3, 9, lambda r, g, b: (b, r, g)) + p1 = tmp_path / "curve.cube" + p1.write_text( + _cube_text("LUT_1D_SIZE 4", [f"{v:.6f} {v:.6f} {1 - v:.6f}" for v in np.linspace(0, 1, 4)]) + ) + img = np.full((3, 3, 3), 64, np.uint8) + with warnings.catch_warnings(): + warnings.simplefilter("error") + out3 = apply_cube(img, load_cube(p3)) + out1 = apply_cube(img, load_cube(p1)) + assert out3[0, 0].tolist() == [64, 64, 64] + assert abs(int(out1[0, 0, 2]) - 191) <= 2 From 27af4049c69fd5879719e7c8abe7cea38918951e Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Wed, 30 Sep 2026 11:23:13 -0400 Subject: [PATCH 2/2] fix(cube): accept only uint8 and uint16 pixels in apply_cube --- CHANGELOG.md | 3 +++ README.md | 6 ++++-- pycube_lut/cube.py | 19 +++++++++++++---- tests/test_cube.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6579c5..e60b47c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ All notable changes to this project are documented here. The format follows - `load_cube()` and `Cube` now reject NaN, infinite and float32-overflowing values in the domain and table, and an inverted domain on a directly constructed `Cube`, with a `CubeError` instead of letting them reach the image cast as undefined values. +- `apply_cube()` now raises `CubeError` for any dtype other than uint8 or uint16 (float32 + `[0, 1]`, signed and wider integers, bool). Previously these were silently scaled as + 8-bit, so a float image came back saturated or garbled. ## 0.1.0 diff --git a/README.md b/README.md index c70429d..12d6901 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ byte-identical to the full-strength LUT. **uint16 in, uint16 out.** A look laid over a 16-bit master must not be the step that throws the master's headroom away, so the output dtype always matches the input. +Any other dtype (float32 in `[0, 1]`, signed integers, `uint32`, bool) raises +`CubeError` instead of being silently scaled as if it were 8-bit. ### Runnable end-to-end example @@ -148,8 +150,8 @@ image-analysis pass already produces. - **Trilinear interpolation only.** Resolve, Photoshop and ffmpeg's `lut3d` default to *tetrahedral*, which differs slightly along the cube's diagonals. Expect small deviations from those tools on steep LUTs; use a denser LUT if it matters. -- **8-bit and 16-bit integer images only.** Float images are not handled; normalise to - uint16 first. +- **8-bit and 16-bit integer images only.** `apply_cube` raises `CubeError` for any + other dtype, float included; convert to uint8 or uint16 first. - **Read-only.** There is no LUT *writer*, no `.cube` generator, and no LUT inversion. - **1D tone curves are resampled to 33 points per axis.** The equivalent 3D table costs the cube of the 1D length, so a 1024-entry curve would mean ~10⁹ entries. Tone curves diff --git a/pycube_lut/cube.py b/pycube_lut/cube.py index 88ee874..a104650 100644 --- a/pycube_lut/cube.py +++ b/pycube_lut/cube.py @@ -243,9 +243,12 @@ def load_cube(path: str | Path) -> Cube: def apply_cube(rgb: np.ndarray, cube: Cube, strength: float = 1.0) -> np.ndarray: """Trilinearly interpolate an ``HxWx3`` image through ``cube``. - Accepts uint8 or uint16 input and returns the SAME dtype: a film emulation - laid over a 16-bit archival master must not be the step that throws the - master's headroom away. Pure NumPy, no ffmpeg. Deterministic. + Accepts uint8 or uint16 input and returns the SAME dtype; any other dtype + (float, signed or wider integer, bool) raises :class:`CubeError` rather than + being guessed at, because the normalisation peak (255 or 65535) is defined only + by those two. A film emulation laid over a 16-bit archival master must not be + the step that throws the master's headroom away. Pure NumPy, no ffmpeg. + Deterministic. ``strength`` is the LUT opacity in ``[0, 1]`` (clamped): the full-strength interpolated output is blended toward the ORIGINAL input in normalised @@ -255,8 +258,16 @@ def apply_cube(rgb: np.ndarray, cube: Cube, strength: float = 1.0) -> np.ndarray """ if rgb.ndim != 3 or rgb.shape[2] != 3: raise CubeError(f"expected an HxWx3 image, got shape {rgb.shape}") + if rgb.dtype == np.uint8: + peak = 255.0 + elif rgb.dtype == np.uint16: + peak = 65535.0 + else: + raise CubeError( + f"expected a uint8 or uint16 image, got dtype {rgb.dtype.name}; " + "convert to one of those first (float and signed images are not supported)" + ) strength = min(1.0, max(0.0, float(strength))) - peak = 65535.0 if rgb.dtype == np.uint16 else 255.0 norm = rgb.astype(np.float32) / peak span = cube.domain_max - cube.domain_min diff --git a/tests/test_cube.py b/tests/test_cube.py index 9e53acf..5613ef5 100644 --- a/tests/test_cube.py +++ b/tests/test_cube.py @@ -408,3 +408,54 @@ def test_valid_fixtures_apply_without_runtime_warnings(tmp_path): out1 = apply_cube(img, load_cube(p1)) assert out3[0, 0].tolist() == [64, 64, 64] assert abs(int(out1[0, 0, 2]) - 191) <= 2 + + +# --- LUT-2: only uint8 / uint16 pixels are accepted; nothing is silently 255-scaled --- + + +def _identity_cube(tmp_path): + p = tmp_path / "id.cube" + _write_cube(p, 5, lambda r, g, b: (r, g, b)) + return load_cube(p) + + +@pytest.mark.parametrize( + "dtype", + [np.float32, np.float64, np.int8, np.int16, np.int32, np.int64, np.uint32, np.uint64, np.bool_], +) +def test_unsupported_pixel_dtype_is_rejected_not_scaled(tmp_path, dtype): + # A float32 [0, 1] image used to be divided by 255 and come back near-black (or, + # cast back to float, as 0..255 values); signed ints wrapped or clipped. Refuse. + cube = _identity_cube(tmp_path) + img = np.full((2, 2, 3), 1, dtype=dtype) + with pytest.raises(CubeError, match="uint8 or uint16") as exc: + apply_cube(img, cube) + assert np.dtype(dtype).name in str(exc.value) + + +def test_float_unit_range_image_cannot_silently_use_255_scaling(tmp_path): + cube = _identity_cube(tmp_path) + img = np.linspace(0.0, 1.0, 2 * 2 * 3, dtype=np.float32).reshape(2, 2, 3) + with pytest.raises(CubeError, match="uint8 or uint16"): + apply_cube(img, cube) + + +def test_uint16_precision_is_kept_beyond_8_bits(tmp_path): + # A LUT that halves every channel: 40000 -> 20000. Quantizing through 8 bits + # first would land on a multiple of ~128, so this pins real 16-bit precision. + p = tmp_path / "half.cube" + _write_cube(p, 2, lambda r, g, b: (r / 2, g / 2, b / 2)) + cube = load_cube(p) + img = np.full((2, 2, 3), 40001, dtype=np.uint16) + out = apply_cube(img, cube) + assert out.dtype == np.uint16 + assert np.abs(out.astype(int) - 20000).max() <= 1 + + +def test_uint8_known_output_is_unchanged(tmp_path): + p = tmp_path / "half.cube" + _write_cube(p, 2, lambda r, g, b: (r / 2, g / 2, b / 2)) + cube = load_cube(p) + out = apply_cube(np.full((2, 2, 3), 200, dtype=np.uint8), cube) + assert out.dtype == np.uint8 + assert np.abs(out.astype(int) - 100).max() <= 1