chromainvert 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chromainvert/__init__.py +3 -0
- chromainvert/__main__.py +4 -0
- chromainvert/benchmark.py +206 -0
- chromainvert/cli.py +256 -0
- chromainvert/color.py +259 -0
- chromainvert/cube.py +109 -0
- chromainvert/losses.py +96 -0
- chromainvert/lut_module.py +30 -0
- chromainvert/lut_predictor.py +57 -0
- chromainvert/masks.py +36 -0
- chromainvert/optimize.py +254 -0
- chromainvert/spline.py +45 -0
- chromainvert-0.1.0.dist-info/METADATA +83 -0
- chromainvert-0.1.0.dist-info/RECORD +18 -0
- chromainvert-0.1.0.dist-info/WHEEL +5 -0
- chromainvert-0.1.0.dist-info/entry_points.txt +2 -0
- chromainvert-0.1.0.dist-info/licenses/LICENSE +201 -0
- chromainvert-0.1.0.dist-info/top_level.txt +1 -0
chromainvert/__init__.py
ADDED
chromainvert/__main__.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import torch
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
|
|
7
|
+
from chromainvert.color import delta_e_2000, linear_srgb_to_lab
|
|
8
|
+
from chromainvert.cube import CubeLut
|
|
9
|
+
from chromainvert.lut_module import DifferentiableLUT3D
|
|
10
|
+
from chromainvert.optimize import OptimizeConfig, optimize_lut
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hsv_to_rgb(hsv: NDArray[np.floating]) -> NDArray[np.float64]:
|
|
14
|
+
hsv = np.asarray(hsv, dtype=np.float64)
|
|
15
|
+
h = (hsv[..., 0] * 6.0) % 6.0
|
|
16
|
+
s = hsv[..., 1]
|
|
17
|
+
v = hsv[..., 2]
|
|
18
|
+
i = np.floor(h).astype(np.int64)
|
|
19
|
+
f = h - np.floor(h)
|
|
20
|
+
p = v * (1.0 - s)
|
|
21
|
+
q = v * (1.0 - f * s)
|
|
22
|
+
t = v * (1.0 - (1.0 - f) * s)
|
|
23
|
+
out = np.empty(hsv.shape)
|
|
24
|
+
table = [
|
|
25
|
+
(v, t, p),
|
|
26
|
+
(q, v, p),
|
|
27
|
+
(p, v, t),
|
|
28
|
+
(p, q, v),
|
|
29
|
+
(t, p, v),
|
|
30
|
+
(v, p, q),
|
|
31
|
+
]
|
|
32
|
+
for k, (r, g, b) in enumerate(table):
|
|
33
|
+
m = i == k
|
|
34
|
+
out[m, 0], out[m, 1], out[m, 2] = r[m], g[m], b[m]
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_chart(width: int = 1536, height: int = 512, seed: int = 7) -> NDArray[np.float64]:
|
|
39
|
+
rng = np.random.default_rng(seed)
|
|
40
|
+
yy, xx = np.mgrid[0:height, 0:width].astype(np.float64)
|
|
41
|
+
chart = np.zeros((height, width, 3))
|
|
42
|
+
chart[..., 0] = 0.05 + 0.9 * xx / width
|
|
43
|
+
chart[..., 1] = 0.05 + 0.9 * yy / height
|
|
44
|
+
chart[..., 2] = 0.05 + 0.9 * ((xx + yy) / (width + height))
|
|
45
|
+
rows, cols = 4, 8
|
|
46
|
+
ph, pw = height * 3 // 4 // rows, width // cols
|
|
47
|
+
for r in range(rows):
|
|
48
|
+
for c in range(cols):
|
|
49
|
+
hue = (r * cols + c) / (rows * cols)
|
|
50
|
+
patch = hsv_to_rgb(np.array([[[hue, 0.85, 0.45 + 0.5 * ((r + c) % 2)]]]))
|
|
51
|
+
chart[r * ph : (r + 1) * ph, c * pw : (c + 1) * pw] = patch
|
|
52
|
+
cy, cx, ry, rx = height // 3, width // 6, height // 8, width // 20
|
|
53
|
+
ellipse = ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 <= 1.0
|
|
54
|
+
chart[ellipse] = (0.78, 0.57, 0.47)
|
|
55
|
+
ramp = np.linspace(0.0, 1.0, width).reshape(1, width, 1)
|
|
56
|
+
chart[height * 7 // 8 :, :, :] = ramp
|
|
57
|
+
chart = chart + rng.normal(0.0, 0.005, chart.shape)
|
|
58
|
+
return np.clip(chart, 0.0, 1.0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def commercial_style_lattice(size: int = 33) -> NDArray[np.float64]:
|
|
62
|
+
coords = np.linspace(0.0, 1.0, size)
|
|
63
|
+
grid = np.stack(np.meshgrid(coords, coords, coords, indexing="ij"), axis=-1)[..., ::-1]
|
|
64
|
+
rgb = grid * np.array([1.08, 1.0, 0.92]) + np.array([0.02, 0.01, 0.03])
|
|
65
|
+
luma = rgb @ np.array([0.2126, 0.7152, 0.0722])
|
|
66
|
+
scale = (luma + 0.5 * luma * (1.0 - luma) * (luma - 0.5)) / np.clip(luma, 1e-6, None)
|
|
67
|
+
rgb = rgb * scale[..., None]
|
|
68
|
+
luma = np.clip(rgb @ np.array([0.2126, 0.7152, 0.0722]), 0.0, 1.0)
|
|
69
|
+
rgb = luma[..., None] + 1.25 * (rgb - luma[..., None])
|
|
70
|
+
shadow_w = (1.0 - luma) ** 2
|
|
71
|
+
highlight_w = luma**2
|
|
72
|
+
rgb = rgb + shadow_w[..., None] * np.array([-0.03, 0.01, 0.04])
|
|
73
|
+
rgb = rgb + highlight_w[..., None] * np.array([0.04, 0.01, -0.03])
|
|
74
|
+
return np.ascontiguousarray(np.clip(rgb, 0.0, 1.0), dtype=np.float64)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def apply_lut(image: NDArray[np.floating], lattice: NDArray[np.floating]) -> NDArray[np.float32]:
|
|
78
|
+
module = DifferentiableLUT3D(int(lattice.shape[0]), lattice=np.asarray(lattice, dtype=np.float32))
|
|
79
|
+
with torch.no_grad():
|
|
80
|
+
return module(torch.from_numpy(np.asarray(image, dtype=np.float32))).numpy()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mean_de00(a: NDArray[np.floating], b: NDArray[np.floating], stride: int = 16) -> float:
|
|
84
|
+
a = np.asarray(a).reshape(-1, 3)[::stride]
|
|
85
|
+
b = np.asarray(b).reshape(-1, 3)[::stride]
|
|
86
|
+
return float(delta_e_2000(linear_srgb_to_lab(a), linear_srgb_to_lab(b)).mean())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mean_de00_full(a: NDArray[np.floating], b: NDArray[np.floating]) -> float:
|
|
90
|
+
return mean_de00(a, b, stride=1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def random_rgb_probe(n: int = 10000, seed: int = 99) -> NDArray[np.float32]:
|
|
94
|
+
if n <= 0:
|
|
95
|
+
raise ValueError("probe_count must be positive")
|
|
96
|
+
rng = np.random.default_rng(seed)
|
|
97
|
+
return np.ascontiguousarray(rng.uniform(0.0, 1.0, size=(n, 3)), dtype=np.float32)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def style_probe_metrics(
|
|
101
|
+
probe: NDArray[np.floating],
|
|
102
|
+
truth_lattice: NDArray[np.floating],
|
|
103
|
+
recovered_lattice: NDArray[np.floating],
|
|
104
|
+
) -> dict[str, float]:
|
|
105
|
+
probe_metrics = heldout_probe_metrics(probe, truth_lattice, recovered_lattice)
|
|
106
|
+
return {
|
|
107
|
+
"style_probe_de00": probe_metrics["recovered_probe_de00"],
|
|
108
|
+
"style_baseline_de00": probe_metrics["baseline_probe_de00"],
|
|
109
|
+
"style_improvement": probe_metrics["improvement_probe"],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_probe_gate_pass(baseline_probe: float, recovered_probe: float, improvement_probe: float, threshold: float = 0.4) -> bool:
|
|
114
|
+
# Absolute fallback requires strict improvement: recovered must beat baseline and sit below 3.0.
|
|
115
|
+
return bool(improvement_probe >= threshold or (recovered_probe < 3.0 and recovered_probe < baseline_probe))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def heldout_probe_metrics(
|
|
119
|
+
probe: NDArray[np.floating],
|
|
120
|
+
truth_lattice: NDArray[np.floating],
|
|
121
|
+
recovered_lattice: NDArray[np.floating],
|
|
122
|
+
) -> dict[str, float]:
|
|
123
|
+
probe_arr = np.asarray(probe, dtype=np.float32).reshape(-1, 3)
|
|
124
|
+
truth_probe = apply_lut(probe_arr[:, None, :], truth_lattice).reshape(-1, 3)
|
|
125
|
+
recovered_probe = apply_lut(probe_arr[:, None, :], recovered_lattice).reshape(-1, 3)
|
|
126
|
+
baseline = mean_de00_full(truth_probe, probe_arr)
|
|
127
|
+
recovered_de = mean_de00_full(truth_probe, recovered_probe)
|
|
128
|
+
improvement = (baseline - recovered_de) / baseline if baseline > 0 else 0.0
|
|
129
|
+
return {
|
|
130
|
+
"baseline_probe_de00": baseline,
|
|
131
|
+
"recovered_probe_de00": recovered_de,
|
|
132
|
+
"improvement_probe": improvement,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_benchmark(
|
|
137
|
+
coarse_size: int = 17,
|
|
138
|
+
fine_size: int = 33,
|
|
139
|
+
iterations: int = 400,
|
|
140
|
+
fine_iterations: int = 30,
|
|
141
|
+
seed: int = 123,
|
|
142
|
+
device: str = "auto",
|
|
143
|
+
pixel_budget: int = 65536,
|
|
144
|
+
deterministic: bool = False,
|
|
145
|
+
probe_count: int = 10000,
|
|
146
|
+
probe_seed: int | None = None,
|
|
147
|
+
) -> dict:
|
|
148
|
+
if probe_count <= 0:
|
|
149
|
+
raise ValueError("probe_count must be positive")
|
|
150
|
+
started = time.monotonic()
|
|
151
|
+
chart = make_chart(seed=seed)
|
|
152
|
+
other = make_chart(seed=seed + 1)
|
|
153
|
+
heldout = make_chart(seed=seed + 2)
|
|
154
|
+
source = chart
|
|
155
|
+
truth = CubeLut(size=33, lattice=commercial_style_lattice(33).astype(np.float32))
|
|
156
|
+
reference = apply_lut(other, truth.lattice)
|
|
157
|
+
if probe_seed is None:
|
|
158
|
+
probe_seed = seed + 100
|
|
159
|
+
config = OptimizeConfig(
|
|
160
|
+
coarse_size=coarse_size,
|
|
161
|
+
fine_size=fine_size,
|
|
162
|
+
iterations=iterations,
|
|
163
|
+
fine_iterations=fine_iterations,
|
|
164
|
+
seed=seed,
|
|
165
|
+
device=device,
|
|
166
|
+
pixel_budget=pixel_budget,
|
|
167
|
+
deterministic=deterministic,
|
|
168
|
+
)
|
|
169
|
+
lut, report = optimize_lut([source], [reference], config)
|
|
170
|
+
graded_heldout = apply_lut(heldout, lut.lattice)
|
|
171
|
+
truth_heldout = apply_lut(heldout, truth.lattice)
|
|
172
|
+
baseline = mean_de00(truth_heldout, heldout)
|
|
173
|
+
recovered = mean_de00(truth_heldout, graded_heldout)
|
|
174
|
+
rmse = float(np.sqrt(np.mean((truth_heldout - graded_heldout) ** 2)))
|
|
175
|
+
probe = random_rgb_probe(probe_count, probe_seed)
|
|
176
|
+
probe_metrics = heldout_probe_metrics(probe, truth.lattice, lut.lattice)
|
|
177
|
+
baseline_probe = probe_metrics["baseline_probe_de00"]
|
|
178
|
+
recovered_probe = probe_metrics["recovered_probe_de00"]
|
|
179
|
+
improvement_probe = probe_metrics["improvement_probe"]
|
|
180
|
+
improvement_heldout = (baseline - recovered) / baseline if baseline > 0 else 0.0
|
|
181
|
+
chart_gate_pass = bool(improvement_heldout >= 0.4)
|
|
182
|
+
probe_gate_pass = is_probe_gate_pass(baseline_probe, recovered_probe, improvement_probe)
|
|
183
|
+
return {
|
|
184
|
+
"seed": seed,
|
|
185
|
+
"coarse_size": coarse_size,
|
|
186
|
+
"fine_size": fine_size,
|
|
187
|
+
"iterations": iterations,
|
|
188
|
+
"fine_iterations": fine_iterations,
|
|
189
|
+
"baseline_heldout_de00": baseline,
|
|
190
|
+
"recovered_heldout_de00": recovered,
|
|
191
|
+
"improvement_heldout": improvement_heldout,
|
|
192
|
+
"heldout_psnr": 20.0 * np.log10(1.0 / rmse) if rmse > 0 else float("inf"),
|
|
193
|
+
"seconds": time.monotonic() - started,
|
|
194
|
+
"optimizer_report": report,
|
|
195
|
+
"baseline_probe_de00": baseline_probe,
|
|
196
|
+
"recovered_probe_de00": recovered_probe,
|
|
197
|
+
"improvement_probe": improvement_probe,
|
|
198
|
+
"probe_count": probe_count,
|
|
199
|
+
"probe_seed": probe_seed,
|
|
200
|
+
"heldout_gate_pass": probe_gate_pass,
|
|
201
|
+
"probe_gate_pass": probe_gate_pass,
|
|
202
|
+
"chart_gate_pass": chart_gate_pass,
|
|
203
|
+
"benchmark_gate_pass": bool(chart_gate_pass and probe_gate_pass),
|
|
204
|
+
"heldout_gate_threshold": 0.4,
|
|
205
|
+
"heldout_gate_absolute": 3.0,
|
|
206
|
+
}
|
chromainvert/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import imageio.v2 as imageio
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
from chromainvert.color import decode_input, encode_input, premiere_apple_log_to_working
|
|
12
|
+
from chromainvert.cube import CubeLut, identity_lattice, write_cube
|
|
13
|
+
from chromainvert.lut_module import DifferentiableLUT3D
|
|
14
|
+
from chromainvert.lut_predictor import LutPredictor, grade_features, lattice_to_cube
|
|
15
|
+
from chromainvert.optimize import OptimizeConfig, optimize_lut
|
|
16
|
+
|
|
17
|
+
_ALLOWED_PROFILES = {"rec709", "rec2020", "apple_log", "slog3", "slog3_sgamut3cine", "slog3_sgamut3.cine", "slog3_sgamut3_cine"}
|
|
18
|
+
_LOG_PROFILES = {"apple_log", "slog3", "slog3_sgamut3cine", "slog3_sgamut3.cine", "slog3_sgamut3_cine"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_reference_profile(profile: str) -> str:
|
|
22
|
+
return "rec709" if profile in _LOG_PROFILES else profile
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _canonical_profile(profile: str) -> str:
|
|
26
|
+
return "slog3" if profile in _LOG_PROFILES and profile != "apple_log" else profile
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bake_export_lut(lut: CubeLut, input_profile: str, reference_profile: str, export_target: str = "log") -> CubeLut:
|
|
30
|
+
if input_profile in _LOG_PROFILES and _canonical_profile(reference_profile) == _canonical_profile(input_profile):
|
|
31
|
+
return lut
|
|
32
|
+
codes = identity_lattice(lut.size).astype(np.float64)
|
|
33
|
+
working = premiere_apple_log_to_working(codes) if export_target == "premiere" else decode_input(codes, input_profile)
|
|
34
|
+
module = DifferentiableLUT3D(lut.size, lattice=lut.lattice)
|
|
35
|
+
with torch.no_grad():
|
|
36
|
+
graded = module(torch.from_numpy(np.ascontiguousarray(working, dtype=np.float32))).numpy()
|
|
37
|
+
display = encode_input(graded.astype(np.float64), reference_profile)
|
|
38
|
+
lattice = np.ascontiguousarray(np.clip(display, 0.0, 1.0), dtype=np.float32)
|
|
39
|
+
return CubeLut(size=lut.size, lattice=lattice, title=lut.title)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve(base: Path, p: str) -> Path:
|
|
43
|
+
path = Path(p)
|
|
44
|
+
return path if path.is_absolute() else base / path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_frames(paths: list[str], profile: str, workdir: Path) -> list[np.ndarray]:
|
|
48
|
+
frames: list[np.ndarray] = []
|
|
49
|
+
for p in paths:
|
|
50
|
+
raw = imageio.imread(resolve(workdir, p))
|
|
51
|
+
if raw.ndim == 2:
|
|
52
|
+
raw = np.stack((raw, raw, raw), axis=-1)
|
|
53
|
+
if raw.ndim != 3 or raw.shape[-1] not in (3, 4):
|
|
54
|
+
raise ValueError(f"expected an RGB or RGBA image, got shape {raw.shape}")
|
|
55
|
+
if np.issubdtype(raw.dtype, np.integer):
|
|
56
|
+
scale = float(np.iinfo(raw.dtype).max)
|
|
57
|
+
elif np.issubdtype(raw.dtype, np.floating):
|
|
58
|
+
scale = 1.0
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"unsupported image dtype: {raw.dtype}")
|
|
61
|
+
image = raw[..., :3].astype(np.float64) / scale
|
|
62
|
+
frames.append(decode_input(image, profile))
|
|
63
|
+
return frames
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def blend_toward_identity(lut: CubeLut, strength: float) -> CubeLut:
|
|
67
|
+
if not 0.0 <= strength <= 1.0:
|
|
68
|
+
raise ValueError("strength must be in [0, 1]")
|
|
69
|
+
lattice = (strength * lut.lattice + (1.0 - strength) * identity_lattice(lut.size)).astype(np.float32)
|
|
70
|
+
return CubeLut(size=lut.size, lattice=lattice, title=lut.title)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
74
|
+
job_path = Path(args.job)
|
|
75
|
+
workdir = job_path.parent
|
|
76
|
+
job = json.loads(job_path.read_text(encoding="utf-8"))
|
|
77
|
+
contract_version = job.get("contract_version")
|
|
78
|
+
if type(contract_version) is not int or contract_version not in (1, 2):
|
|
79
|
+
raise ValueError("unsupported contract_version, expected 1 or 2")
|
|
80
|
+
for key in ("reference_frames", "output_cube", "report_json"):
|
|
81
|
+
if key not in job:
|
|
82
|
+
raise ValueError(f"job is missing required key: {key}")
|
|
83
|
+
reference_only = bool(job.get("reference_only", False))
|
|
84
|
+
sources = job.get("source_frames", [])
|
|
85
|
+
if reference_only:
|
|
86
|
+
if sources:
|
|
87
|
+
raise ValueError("source_frames must be omitted when reference_only is true")
|
|
88
|
+
if "predict_weights" not in job:
|
|
89
|
+
raise ValueError("job is missing required key: predict_weights")
|
|
90
|
+
elif "source_frames" not in job:
|
|
91
|
+
raise ValueError("job is missing required key: source_frames")
|
|
92
|
+
config = OptimizeConfig(
|
|
93
|
+
coarse_size=int(job.get("coarse_size", 17)),
|
|
94
|
+
fine_size=int(job.get("lut_size", 33)),
|
|
95
|
+
iterations=int(job.get("iterations", 400)),
|
|
96
|
+
fine_iterations=int(job.get("fine_iterations", 30)),
|
|
97
|
+
seed=int(job.get("seed", 42)),
|
|
98
|
+
device=str(job.get("device", "auto")),
|
|
99
|
+
deterministic=bool(job.get("deterministic", False)),
|
|
100
|
+
weight_tv=float(job.get("weight_tv", 0.1)),
|
|
101
|
+
weight_shadow=float(job.get("weight_shadow", 0.25)),
|
|
102
|
+
weight_paired=float(job.get("weight_paired", 0.0)),
|
|
103
|
+
)
|
|
104
|
+
profile = str(job.get("input_profile", "rec709"))
|
|
105
|
+
if profile not in _ALLOWED_PROFILES:
|
|
106
|
+
raise ValueError(f"unsupported input_profile {profile!r}, expected one of {sorted(_ALLOWED_PROFILES)}")
|
|
107
|
+
if contract_version == 1 and profile not in ("rec709", "rec2020", "apple_log"):
|
|
108
|
+
raise ValueError(f"input_profile {profile!r} requires contract_version 2")
|
|
109
|
+
reference_profile = str(job.get("reference_profile", default_reference_profile(profile)))
|
|
110
|
+
if reference_profile not in _ALLOWED_PROFILES:
|
|
111
|
+
raise ValueError(f"unsupported reference_profile {reference_profile!r}, expected one of {sorted(_ALLOWED_PROFILES)}")
|
|
112
|
+
if reference_profile in _LOG_PROFILES and _canonical_profile(reference_profile) != _canonical_profile(profile):
|
|
113
|
+
raise ValueError(f"reference_profile {reference_profile!r} is a log profile; graded references must use a display profile such as 'rec709'")
|
|
114
|
+
export_target = str(job.get("export_target", "log"))
|
|
115
|
+
if export_target not in ("log", "premiere"):
|
|
116
|
+
raise ValueError(f"unsupported export_target {export_target!r}, expected 'log' or 'premiere'")
|
|
117
|
+
if export_target == "premiere" and (profile != "apple_log" or reference_profile in _LOG_PROFILES):
|
|
118
|
+
raise ValueError("export_target 'premiere' requires input_profile 'apple_log' and a display reference_profile")
|
|
119
|
+
strength = float(job.get("strength", 1.0))
|
|
120
|
+
if not 0.0 <= strength <= 1.0:
|
|
121
|
+
raise ValueError("strength must be in [0, 1]")
|
|
122
|
+
source = load_frames(job["source_frames"], profile, workdir) if not reference_only else []
|
|
123
|
+
reference = load_frames(job["reference_frames"], reference_profile, workdir)
|
|
124
|
+
started = time.monotonic()
|
|
125
|
+
|
|
126
|
+
def progress(i: int, total: int, loss: float, elapsed: float) -> None:
|
|
127
|
+
print(json.dumps({"event": "progress", "iter": i, "iters": total, "loss": loss, "elapsed_s": elapsed}), flush=True)
|
|
128
|
+
|
|
129
|
+
if reference_only:
|
|
130
|
+
lut = predict_for_frames(reference, str(job["predict_weights"]), profile)
|
|
131
|
+
report = {"mode": "single_image", "predict_weights": str(job["predict_weights"])}
|
|
132
|
+
else:
|
|
133
|
+
lut, report = optimize_lut(source, reference, config, progress=progress)
|
|
134
|
+
report["mode"] = "paired"
|
|
135
|
+
lut = blend_toward_identity(lut, strength)
|
|
136
|
+
lut = bake_export_lut(lut, profile, reference_profile, export_target)
|
|
137
|
+
lut.title = "ChromaInvert"
|
|
138
|
+
cube_path = resolve(workdir, job["output_cube"])
|
|
139
|
+
write_cube(cube_path, lut)
|
|
140
|
+
report["contract_version"] = contract_version
|
|
141
|
+
report["input_profile"] = profile
|
|
142
|
+
report["reference_profile"] = reference_profile
|
|
143
|
+
report["weight_tv"] = config.weight_tv
|
|
144
|
+
report["weight_shadow"] = config.weight_shadow
|
|
145
|
+
report["weight_paired"] = config.weight_paired
|
|
146
|
+
report["export_domain"] = "working" if profile in _LOG_PROFILES and _canonical_profile(reference_profile) == _canonical_profile(profile) else "host"
|
|
147
|
+
report["export_target"] = export_target
|
|
148
|
+
report["strength"] = strength
|
|
149
|
+
report["versions"] = {"python": sys.version.split()[0], "numpy": np.__version__, "torch": torch.__version__}
|
|
150
|
+
resolve(workdir, job["report_json"]).write_text(json.dumps(report, indent=2))
|
|
151
|
+
print(json.dumps({"event": "done", "cube": str(cube_path), "seconds": time.monotonic() - started}), flush=True)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def predict_for_frames(reference_frames: list[np.ndarray], weights: str, profile: str) -> CubeLut:
|
|
156
|
+
if not reference_frames:
|
|
157
|
+
raise ValueError("predict needs at least one reference frame")
|
|
158
|
+
stacked = np.concatenate([np.asarray(f, dtype=np.float32).reshape(-1, 3) for f in reference_frames], axis=0)
|
|
159
|
+
features = torch.from_numpy(grade_features(stacked)).unsqueeze(0)
|
|
160
|
+
checkpoint = torch.load(weights, map_location="cpu")
|
|
161
|
+
if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
|
|
162
|
+
model = LutPredictor(hidden=tuple(checkpoint.get("hidden", (128, 256))))
|
|
163
|
+
model.load_state_dict(checkpoint["state_dict"])
|
|
164
|
+
else:
|
|
165
|
+
model = LutPredictor()
|
|
166
|
+
model.load_state_dict(checkpoint)
|
|
167
|
+
model.eval()
|
|
168
|
+
with torch.no_grad():
|
|
169
|
+
lattice = model(features).squeeze(0).numpy()
|
|
170
|
+
return lattice_to_cube(np.ascontiguousarray(lattice, dtype=np.float32))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_predict(args: argparse.Namespace) -> int:
|
|
174
|
+
raw = imageio.imread(args.image)
|
|
175
|
+
if raw.ndim == 2:
|
|
176
|
+
raw = np.stack((raw, raw, raw), axis=-1)
|
|
177
|
+
if raw.ndim != 3 or raw.shape[-1] not in (3, 4):
|
|
178
|
+
raise ValueError(f"expected an RGB or RGBA image, got shape {raw.shape}")
|
|
179
|
+
if np.issubdtype(raw.dtype, np.integer):
|
|
180
|
+
pixels = raw[..., :3].astype(np.float64) / float(np.iinfo(raw.dtype).max)
|
|
181
|
+
else:
|
|
182
|
+
pixels = raw[..., :3].astype(np.float64)
|
|
183
|
+
profile = str(args.profile)
|
|
184
|
+
if profile not in _ALLOWED_PROFILES:
|
|
185
|
+
raise ValueError(f"unsupported input_profile {profile!r}")
|
|
186
|
+
reference_profile = default_reference_profile(profile)
|
|
187
|
+
working = np.asarray(decode_input(pixels, reference_profile), dtype=np.float32)
|
|
188
|
+
started = time.monotonic()
|
|
189
|
+
cube = predict_for_frames([working], str(args.weights), profile)
|
|
190
|
+
cube = bake_export_lut(cube, profile, reference_profile)
|
|
191
|
+
cube_path = Path(args.out)
|
|
192
|
+
write_cube(cube_path, cube)
|
|
193
|
+
print(json.dumps({"event": "done", "cube": str(cube_path), "seconds": time.monotonic() - started}), flush=True)
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_benchmark(args: argparse.Namespace) -> int:
|
|
198
|
+
from chromainvert.benchmark import run_benchmark
|
|
199
|
+
|
|
200
|
+
metrics = run_benchmark(
|
|
201
|
+
coarse_size=args.coarse,
|
|
202
|
+
fine_size=args.fine,
|
|
203
|
+
iterations=args.iterations,
|
|
204
|
+
fine_iterations=args.fine_iterations,
|
|
205
|
+
seed=args.seed,
|
|
206
|
+
device=args.device,
|
|
207
|
+
pixel_budget=args.pixels,
|
|
208
|
+
deterministic=args.deterministic,
|
|
209
|
+
probe_count=args.probe_count,
|
|
210
|
+
probe_seed=args.probe_seed,
|
|
211
|
+
)
|
|
212
|
+
out = Path(args.out)
|
|
213
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
out.write_text(json.dumps(metrics, indent=2))
|
|
215
|
+
print(f"baseline held-out mean dE00: {metrics['baseline_heldout_de00']:.3f}")
|
|
216
|
+
print(f"recovered held-out mean dE00: {metrics['recovered_heldout_de00']:.3f}")
|
|
217
|
+
print(f"improvement: {100.0 * metrics['improvement_heldout']:.1f}%")
|
|
218
|
+
print(f"baseline probe dE00: {metrics['baseline_probe_de00']:.3f}")
|
|
219
|
+
print(f"recovered probe dE00: {metrics['recovered_probe_de00']:.3f}")
|
|
220
|
+
print(f"probe improvement: {100.0 * metrics['improvement_probe']:.1f}%")
|
|
221
|
+
print(f"probe gate: {'PASS' if metrics['heldout_gate_pass'] else 'FAIL'}")
|
|
222
|
+
print(f"seconds: {metrics['seconds']:.1f}")
|
|
223
|
+
return 0 if bool(metrics.get("benchmark_gate_pass", False)) else 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
parser = argparse.ArgumentParser(prog="chromainvert")
|
|
228
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
229
|
+
p_run = sub.add_parser("run", help="optimize a LUT from a job file")
|
|
230
|
+
p_run.add_argument("job")
|
|
231
|
+
p_run.set_defaults(func=cmd_run)
|
|
232
|
+
p_pred = sub.add_parser("predict", help="predict a LUT from a single graded image")
|
|
233
|
+
p_pred.add_argument("image")
|
|
234
|
+
p_pred.add_argument("--profile", default="rec709")
|
|
235
|
+
p_pred.add_argument("--weights", required=True)
|
|
236
|
+
p_pred.add_argument("--out", required=True)
|
|
237
|
+
p_pred.set_defaults(func=cmd_predict)
|
|
238
|
+
p_bench = sub.add_parser("benchmark", help="synthetic ground-truth recovery check")
|
|
239
|
+
p_bench.add_argument("--out", default="work/benchmark.json")
|
|
240
|
+
p_bench.add_argument("--coarse", type=int, default=17)
|
|
241
|
+
p_bench.add_argument("--fine", type=int, default=33)
|
|
242
|
+
p_bench.add_argument("--iterations", type=int, default=400)
|
|
243
|
+
p_bench.add_argument("--fine-iterations", type=int, default=30)
|
|
244
|
+
p_bench.add_argument("--seed", type=int, default=123)
|
|
245
|
+
p_bench.add_argument("--device", default="auto")
|
|
246
|
+
p_bench.add_argument("--pixels", type=int, default=65536)
|
|
247
|
+
p_bench.add_argument("--deterministic", action="store_true")
|
|
248
|
+
p_bench.add_argument("--probe-count", type=int, default=10000, dest="probe_count")
|
|
249
|
+
p_bench.add_argument("--probe-seed", type=int, default=None, dest="probe_seed")
|
|
250
|
+
p_bench.set_defaults(func=cmd_benchmark)
|
|
251
|
+
parsed = parser.parse_args(argv)
|
|
252
|
+
return parsed.func(parsed)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
sys.exit(main())
|