Skip to content

Commit 57f0bad

Browse files
[LTX-2.5] Add ACEScct colour transforms and HLG/EXR export for SDR-To-HDR
Port the colour pipeline of the LTX-2.5 SDR-To-HDR IC-LoRA from the Lightricks LTX-2 reference (ltx_core.hdr, ltx_core.color, ltx_pipelines media_io): - image_processor: sRGB EOTF, Bradford Rec.709/AP1/Rec.2020 matrices, ACEScct encode/decode, the srgb_gamma/srgb/acescg/acescct input transforms and the ACEScct -> ACEScg/Rec.709 linear output transform, as pure torch functions. LTX2VideoHDRProcessor gains hdr_transform="acescct" with input_colorspace / output_colorspace options; the LogC3 default is unchanged. - export_utils: encode_hdr_tensor_to_hlg_mp4 (BT.2020 HLG 10-bit HEVC via PyAV/libx265) and save_exr_frame / export_to_exr_sequence (half-float ZIP EXR with chromaticities and colorSpace), behind a new optional is_openexr_available() guard. - tests: exact values computed with colour-science, round trips, HLG stream properties and EXR read-back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 36438e2 commit 57f0bad

5 files changed

Lines changed: 1114 additions & 15 deletions

File tree

‎src/diffusers/pipelines/ltx2/export_utils.py‎

Lines changed: 337 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import math
17+
import os
1618
from fractions import Fraction
1719
from pathlib import Path
1820
from typing import Callable
1921

2022
import numpy as np
2123
import torch
24+
import torch.nn.functional as F
2225

23-
from ...utils import is_av_available
26+
from ...utils import is_av_available, is_openexr_available
27+
from .image_processor import ACESCG_TO_REC2020, REC709_TO_REC2020
2428

2529

2630
_CAN_USE_AV = is_av_available()
@@ -108,3 +112,335 @@ def simple_tone_map(x: np.ndarray) -> np.ndarray:
108112
container.mux(packet)
109113
finally:
110114
container.close()
115+
116+
117+
# ARIB STD-B67 HLG OETF constants, as used by the LTX-2 reference (`colour` `CONSTANTS_ARIBSTDB67`).
118+
_HLG_A = 0.17883277
119+
_HLG_B = 0.28466892
120+
_HLG_C = 0.55991073
121+
122+
# FFmpeg colour tags (`AVCOL_PRI_BT2020`, `AVCOL_TRC_ARIB_STD_B67`, `AVCOL_SPC_BT2020_NCL`, `AVCOL_RANGE_MPEG`).
123+
_AV_COLOR_PRIMARIES_BT2020 = 9
124+
_AV_COLOR_TRC_ARIB_STD_B67 = 18
125+
_AV_COLORSPACE_BT2020_NCL = 9
126+
_AV_COLOR_RANGE_MPEG = 1
127+
128+
# Full-range RGB -> Y'CbCr matrix with the BT.2020 non-constant-luminance weights (Kr = 0.2627, Kb = 0.0593). The
129+
# reference computes it as the float64 inverse of `colour.matrix_YCbCr(WEIGHTS_YCBCR["ITU-R BT.2020"])` stored as
130+
# float32; these are those float32 values.
131+
_RGB_TO_YCBCR_BT2020 = (
132+
(0.26269999146461487, 0.6779999732971191, 0.059300001710653305),
133+
(-0.13963006436824799, -0.3603699505329132, 0.5),
134+
(0.5, -0.45978569984436035, -0.04021429643034935),
135+
)
136+
137+
_HLG_PRIMARIES_TO_REC2020 = {"rec709": REC709_TO_REC2020, "acescg": ACESCG_TO_REC2020}
138+
139+
140+
def _hlg_inverse_oetf(signal: float) -> float:
141+
r"""Inverse HLG OETF (ITU-R BT.2100 reference constants): HLG signal `[0, 1]` -> scene-linear `[0, 1]`."""
142+
a = _HLG_A
143+
b = 1.0 - 4.0 * a
144+
c = 0.5 - a * math.log(4.0 * a)
145+
linear = (signal / 0.5) ** 2 if signal <= 0.5 else math.exp((signal - c) / a) + b
146+
return linear / 12.0
147+
148+
149+
def _hlg_oetf(x: torch.Tensor) -> torch.Tensor:
150+
r"""HLG OETF (ARIB STD-B67): scene-linear `[0, 1]` -> HLG signal, clamped to `[0, 1]`."""
151+
return torch.where(
152+
x <= 1.0 / 12.0,
153+
torch.sqrt((3.0 * x).clamp(min=0.0)),
154+
_HLG_A * torch.log((12.0 * x - _HLG_B).clamp(min=1e-12)) + _HLG_C,
155+
).clamp(0.0, 1.0)
156+
157+
158+
def _linear_to_hlg_signal(
159+
rgb_linear: torch.Tensor, primaries_matrix: torch.Tensor, white_x: float, roll_k: float
160+
) -> torch.Tensor:
161+
r"""Scene-linear `(..., 3, H, W)` RGB -> Rec.2020 HLG signal `[0, 1]`, with diffuse white mapped to `white_x`."""
162+
lin = torch.nan_to_num(
163+
torch.einsum("...chw,dc->...dhw", rgb_linear, primaries_matrix).clamp(min=0.0),
164+
nan=0.0,
165+
neginf=0.0,
166+
)
167+
# Diffuse white (linear 1.0) maps to `white_x`; highlights roll off exponentially toward 1.0.
168+
x = torch.where(
169+
lin <= 1.0,
170+
lin * white_x,
171+
1.0 - (1.0 - white_x) * torch.exp(-roll_k * (lin - 1.0)),
172+
)
173+
return _hlg_oetf(x)
174+
175+
176+
def _rgb_to_yuv420p10_bt2020_limited(rgb: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
177+
r"""
178+
Float RGB `[0, 1]` `(F, 3, H, W)` -> planar 10-bit limited-range BT.2020 NCL Y, U, V code values (4:2:0).
179+
180+
Returns `int32` tensors with values in `[0, 1023]`.
181+
"""
182+
_, _, height, width = rgb.shape
183+
if height % 2 != 0 or width % 2 != 0:
184+
raise ValueError(f"HLG export requires an even frame height and width, got {height}x{width}.")
185+
matrix = torch.as_tensor(_RGB_TO_YCBCR_BT2020, dtype=torch.float32).to(device=rgb.device, dtype=rgb.dtype)
186+
yuv = (rgb.movedim(-3, -1).flatten(-3, -2) @ matrix.T).unflatten(-2, (height, width)).movedim(-1, -3)
187+
y = yuv[:, :1]
188+
uv = F.avg_pool2d(yuv[:, 1:3].contiguous(), kernel_size=2, stride=2)
189+
# Limited ("MPEG") range at 10 bits: Y = (219 * E' + 16) * 4, Cb/Cr = (224 * E' + 128) * 4.
190+
y = y * (219 * 4) + 16 * 4
191+
uv = uv * (224 * 4) + 128 * 4
192+
y = y[:, 0].round().clamp(0, 1023).to(torch.int32)
193+
u = uv[:, 0].round().clamp(0, 1023).to(torch.int32)
194+
v = uv[:, 1].round().clamp(0, 1023).to(torch.int32)
195+
return y, u, v
196+
197+
198+
def _x265_params(threads: int, width: int, height: int) -> str:
199+
r"""libx265 `x265-params` for a BT.2020 HLG `hvc1` MP4, as in the reference implementation."""
200+
params = (
201+
"colorprim=bt2020:transfer=arib-std-b67:colormatrix=bt2020nc:range=limited:"
202+
f"repeat-headers=1:info=0:pools={threads}"
203+
)
204+
if width <= 32 and height <= 32:
205+
# With a single CTU per axis, frame-threading and B-frames can flush packets that the MP4 muxer rejects on
206+
# very short clips.
207+
return f"{params}:frame-threads=1:bframes=0:lookahead=0"
208+
return f"{params}:frame-threads=4"
209+
210+
211+
def encode_hdr_tensor_to_hlg_mp4(
212+
frames: torch.Tensor | np.ndarray,
213+
output_mp4: str | Path,
214+
frame_rate: float,
215+
primaries: str = "rec709",
216+
white_signal: float = 0.75,
217+
rolloff_k: float | None = None,
218+
crf: int = 12,
219+
preset: str = "ultrafast",
220+
thread_count: int = 0,
221+
device: str | torch.device | None = None,
222+
) -> None:
223+
r"""
224+
Encodes a scene-linear HDR tensor to a BT.2020 / HLG / 10-bit HEVC `.mp4` file, following the LTX-2 reference HDR
225+
export.
226+
227+
Each frame is converted to Rec.2020 primaries; diffuse white (linear `1.0`) is mapped to the HLG signal
228+
`white_signal` (`0.75` is the ITU-R BT.2408 HDR reference white) and brighter values roll off exponentially toward
229+
the HLG peak, with a slope that is continuous at diffuse white. The ARIB STD-B67 OETF is then applied, followed by
230+
a conversion to 10-bit limited-range BT.2020 non-constant-luminance Y'CbCr 4:2:0. The result is encoded with
231+
`libx265` (`yuv420p10le`, `hvc1` tag) and tagged as BT.2020 primaries, ARIB STD-B67 transfer, BT.2020 NCL matrix
232+
and limited range. No mastering-display or content-light-level metadata is written.
233+
234+
Requires a PyAV build whose FFmpeg includes `libx265`.
235+
236+
Args:
237+
frames (`torch.Tensor` or `np.ndarray`):
238+
Scene-linear HDR frames of shape `(F, H, W, 3)` with values in `[0, ∞)`, for example the output of
239+
[`LTX2VideoHDRProcessor.postprocess_hdr_video`] for a single video. `H` and `W` must be even.
240+
output_mp4 (`str` or `pathlib.Path`):
241+
Output MP4 path.
242+
frame_rate (`float`):
243+
Frame rate for the output video.
244+
primaries (`str`, *optional*, defaults to `"rec709"`):
245+
Primaries of `frames`: `"rec709"` or `"acescg"`.
246+
white_signal (`float`, *optional*, defaults to `0.75`):
247+
HLG signal level that diffuse white (linear `1.0`) is mapped to.
248+
rolloff_k (`float`, *optional*):
249+
Exponential highlight roll-off rate. Defaults to `white_x / (1 - white_x)`, where `white_x` is the
250+
scene-linear value of `white_signal`, which makes the mapping C1-continuous at linear `1.0`.
251+
crf (`int`, *optional*, defaults to `12`):
252+
libx265 CRF quality factor. Lower values produce higher quality.
253+
preset (`str`, *optional*, defaults to `"ultrafast"`):
254+
libx265 preset.
255+
thread_count (`int`, *optional*, defaults to `0`):
256+
libx265 thread pool size. `0` uses the number of CPUs, capped at 16.
257+
device (`str` or `torch.device`, *optional*):
258+
Device for the colour conversion. Defaults to the device of `frames` (CPU for NumPy input).
259+
"""
260+
if "libx265" not in av.codecs_available:
261+
raise RuntimeError(
262+
"HLG export requires the `libx265` encoder, but the FFmpeg build used by PyAV does not include it."
263+
)
264+
if primaries not in _HLG_PRIMARIES_TO_REC2020:
265+
raise ValueError(f"Unsupported primaries {primaries!r}. Expected 'rec709' or 'acescg'.")
266+
if not 0.0 < white_signal < 1.0:
267+
raise ValueError(f"`white_signal` must be in (0, 1), got {white_signal}.")
268+
269+
frames = torch.as_tensor(frames) if isinstance(frames, np.ndarray) else frames.detach()
270+
if frames.ndim != 4 or frames.shape[-1] != 3:
271+
raise ValueError(f"Expected `frames` of shape (F, H, W, 3), got {tuple(frames.shape)}.")
272+
num_frames, height, width, _ = frames.shape
273+
if num_frames == 0:
274+
raise ValueError("No HDR frames to encode.")
275+
if height % 2 != 0 or width % 2 != 0:
276+
raise ValueError(f"HLG export requires an even frame height and width, got {height}x{width}.")
277+
278+
device = frames.device if device is None else torch.device(device)
279+
primaries_matrix = torch.as_tensor(_HLG_PRIMARIES_TO_REC2020[primaries], dtype=torch.float32).to(device)
280+
white_x = _hlg_inverse_oetf(white_signal)
281+
roll_k = rolloff_k if rolloff_k is not None else white_x / (1.0 - white_x)
282+
threads = thread_count if thread_count > 0 else max(1, min(os.cpu_count() or 8, 16))
283+
284+
output_mp4 = Path(output_mp4)
285+
container = av.open(str(output_mp4), mode="w", options={"movflags": "+faststart"})
286+
try:
287+
stream = container.add_stream("libx265", rate=Fraction(frame_rate).limit_denominator(1000))
288+
stream.width = width
289+
stream.height = height
290+
stream.pix_fmt = "yuv420p10le"
291+
stream.codec_tag = "hvc1"
292+
stream.options = {"crf": str(crf), "preset": preset, "x265-params": _x265_params(threads, width, height)}
293+
codec_context = stream.codec_context
294+
codec_context.thread_count = threads
295+
codec_context.thread_type = "FRAME"
296+
codec_context.color_primaries = _AV_COLOR_PRIMARIES_BT2020
297+
codec_context.color_trc = _AV_COLOR_TRC_ARIB_STD_B67
298+
codec_context.colorspace = _AV_COLORSPACE_BT2020_NCL
299+
codec_context.color_range = _AV_COLOR_RANGE_MPEG
300+
301+
for index in range(num_frames):
302+
rgb = frames[index : index + 1].to(device=device, dtype=torch.float32).movedim(-1, -3)
303+
hlg = _linear_to_hlg_signal(rgb, primaries_matrix, white_x, roll_k)
304+
planes_yuv = [plane[0].cpu().numpy().astype(np.uint16) for plane in _rgb_to_yuv420p10_bt2020_limited(hlg)]
305+
306+
frame = av.VideoFrame(width, height, "yuv420p10le")
307+
for plane, src in zip(frame.planes, planes_yuv):
308+
dest = np.frombuffer(plane, dtype=np.uint16).reshape(plane.height, plane.line_size // 2)
309+
dest[:, : src.shape[1]] = src
310+
frame.colorspace = _AV_COLORSPACE_BT2020_NCL
311+
frame.color_range = _AV_COLOR_RANGE_MPEG
312+
for packet in stream.encode(frame):
313+
container.mux(packet)
314+
315+
for packet in stream.encode():
316+
container.mux(packet)
317+
except BaseException:
318+
container.close()
319+
output_mp4.unlink(missing_ok=True)
320+
raise
321+
container.close()
322+
323+
324+
# OpenEXR `chromaticities` (R, G, B and white point xy) and `colorSpace` tags of the reference EXR writer, per EXR
325+
# colour space (`ltx_pipelines` `EXRColorSpace`). ACEScct frames are tagged with AP1 chromaticities.
326+
_EXR_CHROMATICITIES = {
327+
"rec709": (0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290),
328+
"acescg": (0.713, 0.293, 0.165, 0.830, 0.128, 0.044, 0.32168, 0.33767),
329+
}
330+
_EXR_COLORSPACES = {
331+
"srgb_linear": ("rec709", "sRGB"),
332+
"acescg": ("acescg", "ACEScg"),
333+
"acescct": ("acescg", "ACEScct"),
334+
}
335+
336+
337+
def _import_openexr():
338+
if not is_openexr_available():
339+
raise ImportError("OpenEXR is required to write EXR frames. You can install it with `pip install OpenEXR`.")
340+
import OpenEXR
341+
342+
# `OpenEXR.File` was added in OpenEXR 3.3; older bindings only expose the legacy `OutputFile` API.
343+
if not hasattr(OpenEXR, "File"):
344+
raise ImportError(
345+
"OpenEXR>=3.3 is required to write EXR frames. You can upgrade it with `pip install -U OpenEXR`."
346+
)
347+
return OpenEXR
348+
349+
350+
def save_exr_frame(
351+
frame: torch.Tensor | np.ndarray,
352+
output_exr: str | Path,
353+
primaries: str = "rec709",
354+
color_space: str = "sRGB",
355+
half: bool = True,
356+
) -> None:
357+
r"""
358+
Saves a single RGB frame as an OpenEXR file tagged with its colour space, following the LTX-2 reference EXR writer:
359+
a scanline image with `R`, `G` and `B` channels, ZIP compression, and `chromaticities` and `colorSpace` header
360+
attributes.
361+
362+
Requires the `OpenEXR` package (`pip install OpenEXR`, version 3.3 or later).
363+
364+
Args:
365+
frame (`torch.Tensor` or `np.ndarray`):
366+
Float frame of shape `(H, W, 3)` or `(3, H, W)`.
367+
output_exr (`str` or `pathlib.Path`):
368+
Output EXR path.
369+
primaries (`str`, *optional*, defaults to `"rec709"`):
370+
Colour primaries written to the `chromaticities` attribute: `"rec709"` or `"acescg"` (AP1).
371+
color_space (`str`, *optional*, defaults to `"sRGB"`):
372+
Value of the `colorSpace` string attribute, which describes the encoding (e.g. `"sRGB"` or `"ACEScg"` for
373+
scene-linear values, `"ACEScct"` for log codes).
374+
half (`bool`, *optional*, defaults to `True`):
375+
Write 16-bit half floats. When `False`, 32-bit floats are written, unless `frame` is already float16.
376+
"""
377+
OpenEXR = _import_openexr()
378+
if primaries not in _EXR_CHROMATICITIES:
379+
raise ValueError(f"Unsupported primaries {primaries!r}. Expected 'rec709' or 'acescg'.")
380+
381+
if isinstance(frame, torch.Tensor):
382+
use_half = half or frame.dtype == torch.float16
383+
frame = frame.detach().cpu().float().numpy()
384+
else:
385+
use_half = half or frame.dtype == np.float16
386+
frame = np.asarray(frame, dtype=np.float32)
387+
if frame.ndim == 3 and frame.shape[0] == 3:
388+
frame = frame.transpose(1, 2, 0)
389+
if frame.ndim != 3 or frame.shape[-1] != 3:
390+
raise ValueError(f"Expected `frame` of shape (H, W, 3) or (3, H, W), got {frame.shape}.")
391+
frame = frame.astype(np.float16 if use_half else np.float32)
392+
393+
header = {
394+
"type": OpenEXR.scanlineimage,
395+
"compression": OpenEXR.ZIP_COMPRESSION,
396+
"chromaticities": _EXR_CHROMATICITIES[primaries],
397+
"colorSpace": color_space,
398+
}
399+
channels = {name: np.ascontiguousarray(frame[..., index]) for index, name in enumerate("RGB")}
400+
with OpenEXR.File(header, channels) as exr_file:
401+
exr_file.write(str(output_exr))
402+
403+
404+
def export_to_exr_sequence(
405+
frames: torch.Tensor | np.ndarray,
406+
output_dir: str | Path,
407+
exr_colorspace: str = "acescg",
408+
half: bool = True,
409+
) -> list[str]:
410+
r"""
411+
Saves HDR frames as a directory of OpenEXR files named `frame_00000.exr`, `frame_00001.exr`, ..., following the
412+
LTX-2 reference HDR export. Each file is written with [`~pipelines.ltx2.export_utils.save_exr_frame`].
413+
414+
Requires the `OpenEXR` package (`pip install OpenEXR`, version 3.3 or later).
415+
416+
Args:
417+
frames (`torch.Tensor` or `np.ndarray`):
418+
Frames of shape `(F, H, W, 3)` in the colour space given by `exr_colorspace`, for example the output of
419+
[`LTX2VideoHDRProcessor.postprocess_hdr_video`] for a single video with the matching `output_colorspace`.
420+
output_dir (`str` or `pathlib.Path`):
421+
Output directory. It is created if it does not exist.
422+
exr_colorspace (`str`, *optional*, defaults to `"acescg"`):
423+
Colour space of `frames`, which sets the EXR tags: `"acescg"` (scene-linear ACEScg, AP1 chromaticities,
424+
`colorSpace="ACEScg"`), `"srgb_linear"` (scene-linear Rec.709, Rec.709 chromaticities, `colorSpace="sRGB"`)
425+
or `"acescct"` (ACEScct codes, AP1 chromaticities, `colorSpace="ACEScct"`).
426+
half (`bool`, *optional*, defaults to `True`):
427+
Write 16-bit half floats.
428+
429+
Returns:
430+
`list[str]`: Paths of the written EXR files.
431+
"""
432+
_import_openexr()
433+
if exr_colorspace not in _EXR_COLORSPACES:
434+
raise ValueError(f"Unsupported EXR colorspace {exr_colorspace!r}. Expected one of {tuple(_EXR_COLORSPACES)}.")
435+
if frames.ndim != 4 or frames.shape[-1] != 3:
436+
raise ValueError(f"Expected `frames` of shape (F, H, W, 3), got {tuple(frames.shape)}.")
437+
primaries, color_space = _EXR_COLORSPACES[exr_colorspace]
438+
439+
output_dir = Path(output_dir)
440+
output_dir.mkdir(parents=True, exist_ok=True)
441+
paths = []
442+
for index, frame in enumerate(frames):
443+
path = output_dir / f"frame_{index:05d}.exr"
444+
save_exr_frame(frame, path, primaries=primaries, color_space=color_space, half=half)
445+
paths.append(str(path))
446+
return paths

0 commit comments

Comments
 (0)