Repository navigation
Expand file tree
/
Copy pathhdr_color.py
More file actions
174 lines (133 loc) · 5.94 KB
/
Copy pathhdr_color.py
File metadata and controls
174 lines (133 loc) · 5.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""ACEScct working-space + primaries math for HDR Comfy paths.
Kept in lockstep with ``ltx_core.hdr`` / ``ltx_core.color.primaries`` on
ltx-2-internal: ACEScct curve constants match ``ltx_core.hdr``, and primaries
matrices / EXR chromaticities come from ``colour-science`` with Bradford CAT
(same source OpenColorIO-Config-ACES uses for its utility CLFs).
``colour-science`` is required (same as ``ltx_core.color.primaries``).
Used by HDR Comfy nodes in ``hdr_nodes`` (EXR ↔ VAE ACEScct). SDR→ACEScct ingest
uses Comfy Convert Image Color Space. File I/O helpers live in ``hdr_io``.
"""
from __future__ import annotations
import enum
import logging
import colour
import torch
from torch import Tensor
logger = logging.getLogger("LTXVideo.hdr_color")
# ACEScct (AMPAS S-2016-001) — same constants as ``ltx_core.hdr``
_ACESCCT_A_LIN = 10.5402377416545
_ACESCCT_B_LIN = 0.0729055341958355
_ACESCCT_X_BRK = 0.0078125
_ACESCCT_Y_BRK = 0.155251141552511
_ACESCCT_LOG_M = 17.52
_ACESCCT_LOG_B = 9.72
_CAT = "Bradford"
_CS_ACESCG = colour.RGB_COLOURSPACES["ACEScg"]
_CS_REC709 = colour.RGB_COLOURSPACES["ITU-R BT.709"]
def _rgb_to_rgb(src: colour.RGB_Colourspace, dst: colour.RGB_Colourspace) -> Tensor:
"""Linear RGB→RGB matrix from colour-science, as float32."""
return torch.as_tensor(
colour.matrix_RGB_to_RGB(src, dst, chromatic_adaptation_transform=_CAT),
dtype=torch.float32,
)
def _exr_chroma(cs: colour.RGB_Colourspace) -> tuple[float, ...]:
"""EXR ``chromaticities`` order: Rxy, Gxy, Bxy, Wxy."""
r, g, b = cs.primaries
w = cs.whitepoint
return (
float(r[0]),
float(r[1]),
float(g[0]),
float(g[1]),
float(b[0]),
float(b[1]),
float(w[0]),
float(w[1]),
)
_ACESCG_TO_SRGB = _rgb_to_rgb(_CS_ACESCG, _CS_REC709)
_SRGB_TO_ACESCG = torch.linalg.inv(_ACESCG_TO_SRGB.double()).to(torch.float32)
_CHROMA_REC709 = _exr_chroma(_CS_REC709)
_CHROMA_ACESCG = _exr_chroma(_CS_ACESCG)
_MATRICES_SOURCE = "colour-science"
# Public aliases (parity with ``ltx_core.color.primaries`` exports).
ACESCG_TO_SRGB = _ACESCG_TO_SRGB
SRGB_TO_ACESCG = _SRGB_TO_ACESCG
_CHROMATICITIES = {
"rec709": _CHROMA_REC709,
"acescg": _CHROMA_ACESCG,
}
class Primaries(enum.Enum):
"""Authoring / working-space colour primaries (linear light)."""
REC709 = "rec709"
ACESCG = "acescg"
@property
def exr_chromaticities(self) -> tuple[float, ...]:
"""EXR ``chromaticities`` header (R/G/B/W x,y)."""
return _CHROMATICITIES[self.value]
def to(self, target: Primaries, video: Tensor) -> Tensor:
"""Convert linear light from this basis into ``target``. Identity when same."""
if self is target:
return video
if (self, target) == (Primaries.REC709, Primaries.ACESCG):
return _apply_matrix(video, _SRGB_TO_ACESCG)
if (self, target) == (Primaries.ACESCG, Primaries.REC709):
return _apply_matrix(video, _ACESCG_TO_SRGB)
raise ValueError(f"No primaries conversion from {self!r} to {target!r}.")
class HDRColorSpace(enum.Enum):
"""Source / EXR authoring colour space (matches pipelines ``--hdr``)."""
SRGB_LINEAR = "srgb_linear"
ACESCG = "acescg"
ACESCCT = "acescct"
@property
def is_log_working(self) -> bool:
return self is HDRColorSpace.ACESCCT
@property
def source_primaries(self) -> Primaries:
if self in (HDRColorSpace.ACESCG, HDRColorSpace.ACESCCT):
return Primaries.ACESCG
return Primaries.REC709
def exr_output_tags(self) -> tuple[Primaries, str]:
"""``(primaries, colorSpace tag)`` for EXR headers."""
if self is HDRColorSpace.ACESCCT:
return Primaries.ACESCG, "ACEScct"
if self is HDRColorSpace.ACESCG:
return Primaries.ACESCG, "ACEScg"
return Primaries.REC709, "sRGB"
def _apply_matrix(video: Tensor, mat: Tensor) -> Tensor:
"""Apply 3x3 on channel-last ``[..., H, W, 3]`` or channel-first ``[..., 3, H, W]``.
Comfy IMAGE tensors are channel-last; ``ltx_core`` often uses channel-first.
"""
mat = mat.to(device=video.device, dtype=video.dtype)
if video.shape[-1] == 3:
return video @ mat.T
if video.shape[-3] == 3:
return torch.einsum("...chw,dc->...dhw", video, mat)
raise ValueError(f"Expected RGB in last or -3 dim; got shape {tuple(video.shape)}")
def compress_acescct(hdr: Tensor) -> Tensor:
"""Linear HDR [0, ∞) → ACEScct [0, 1]."""
x = torch.clamp(hdr, min=0.0)
log_part = (torch.log2(torch.clamp(x, min=1e-12)) + _ACESCCT_LOG_B) / _ACESCCT_LOG_M
lin_part = _ACESCCT_A_LIN * x + _ACESCCT_B_LIN
return torch.clamp(torch.where(x > _ACESCCT_X_BRK, log_part, lin_part), 0.0, 1.0)
def decompress_acescct(ct: Tensor) -> Tensor:
"""ACEScct [0, 1] → linear ACEScg [0, ∞)."""
ct = torch.clamp(ct, 0.0, 1.0)
lin_from_log = torch.pow(2.0, ct * _ACESCCT_LOG_M - _ACESCCT_LOG_B)
lin_from_lin = (ct - _ACESCCT_B_LIN) / _ACESCCT_A_LIN
return torch.where(ct > _ACESCCT_Y_BRK, lin_from_log, lin_from_lin)
def to_acescct_working_space(linear_rgb: Tensor, source_primaries: Primaries) -> Tensor:
"""Scene-linear RGB → ACEScct [0, 1] (channel-last or channel-first)."""
native = source_primaries.to(Primaries.ACESCG, linear_rgb.float())
return compress_acescct(native.clamp(min=0.0))
def acescct_to_linear(
working: Tensor, out_primaries: Primaries = Primaries.REC709
) -> Tensor:
"""ACEScct [0, 1] → scene-linear in ``out_primaries``."""
linear_acescg = decompress_acescct(working.float())
return Primaries.ACESCG.to(out_primaries, linear_acescg).clamp(min=0.0)
def images_to_acescct(images: Tensor, color_space: HDRColorSpace) -> Tensor:
"""Convert loaded EXR frames ``[F,H,W,3]`` into ACEScct ``[0,1]`` for the VAE."""
images = images.float()
if color_space.is_log_working:
return images.clamp(0.0, 1.0)
return to_acescct_working_space(images, color_space.source_primaries)