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.
@@ -0,0 +1,3 @@
1
+ from chromainvert.cube import CubeLut, identity_lattice, read_cube, write_cube
2
+
3
+ __all__ = ["CubeLut", "identity_lattice", "read_cube", "write_cube"]
@@ -0,0 +1,4 @@
1
+ from chromainvert.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())