Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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.
- `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

First release.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -98,7 +100,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
Expand Down Expand Up @@ -146,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
Expand Down
63 changes: 54 additions & 9 deletions pycube_lut/cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]``.
Expand All @@ -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))


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -195,23 +227,28 @@ 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:
raise CubeError(
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)


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
Expand All @@ -221,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
Expand Down
145 changes: 145 additions & 0 deletions tests/test_cube.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Reader + trilinear applier: parsing, indexing order, domain, formats, strength."""

import warnings

import numpy as np
import pytest

Expand Down Expand Up @@ -314,3 +316,146 @@ 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


# --- 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
Loading