davinci-resolve-mcp 2.67.0 → 2.68.0
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.
- package/CHANGELOG.md +117 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/docs/kernels/render-deliver-kernel.md +14 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Numeric image QC for a grade: does the result carry damage the source didn't?
|
|
2
|
+
|
|
3
|
+
This answers a different question from `verify_grade`. That one asks *did Resolve
|
|
4
|
+
apply what I asked* (intended vs applied `.drx` → landed/drifted/missing). This
|
|
5
|
+
asks *is the resulting image any good* — a grade can land perfectly and still be
|
|
6
|
+
flat, milky, crunchy, or banded.
|
|
7
|
+
|
|
8
|
+
The point is to give an agent deterministic grounds to **reject its own grade**
|
|
9
|
+
and iterate, instead of shipping the first look that rendered. Flags carry a
|
|
10
|
+
remedy, not just a label, and `acceptable` is simply "no flags".
|
|
11
|
+
|
|
12
|
+
## Measured on the real frame, never on the transform
|
|
13
|
+
|
|
14
|
+
Every metric is computed from a decoded frame of the *actual* graded result —
|
|
15
|
+
either a rendered file or the source pushed through the LUT by ffmpeg. Applying
|
|
16
|
+
the transform in numpy and measuring that instead would hide exactly the damage
|
|
17
|
+
worth catching: LUT interpolation, encode rounding, and clamping are where
|
|
18
|
+
banding and posterization are actually introduced.
|
|
19
|
+
|
|
20
|
+
## Display-referred only, and it refuses rather than guessing
|
|
21
|
+
|
|
22
|
+
Every metric here is defined on L*/chroma in CIE Lab derived from an sRGB
|
|
23
|
+
transfer function. Log and scene-referred encodings (ACEScct, S-Log3, LogC) run
|
|
24
|
+
through the same arithmetic happily and produce numbers that are meaningless:
|
|
25
|
+
a log frame's "black point" is nowhere near its actual shadow detail.
|
|
26
|
+
|
|
27
|
+
So `working_space` must be declared, non-display-referred values are refused, and
|
|
28
|
+
the file's own `color_transfer` tag is probed to catch a mis-declaration. This
|
|
29
|
+
module will not convert for you — the correct transform depends on the camera
|
|
30
|
+
and the project's colour management, which is not knowable from a frame.
|
|
31
|
+
|
|
32
|
+
## Thresholds are advisory, not standards
|
|
33
|
+
|
|
34
|
+
Unlike loudness there is no published spec for "too much banding". The numbers
|
|
35
|
+
below are chosen so a clean grade raises nothing and obvious damage raises
|
|
36
|
+
something; they are tuning, and the report always includes the raw measurements
|
|
37
|
+
so a human can disagree with the flag rather than only with the verdict.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
from typing import Any, Dict, List, Optional
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger("resolve-mcp.image-qc")
|
|
49
|
+
|
|
50
|
+
try: # numpy is present in most installs but is not a hard requirement.
|
|
51
|
+
import numpy as _np
|
|
52
|
+
except ImportError: # pragma: no cover - exercised on minimal installs
|
|
53
|
+
_np = None
|
|
54
|
+
|
|
55
|
+
from src.utils import colorimetry as _cm
|
|
56
|
+
|
|
57
|
+
#: Every public entry point calls `_require()` before touching numpy, so the
|
|
58
|
+
#: internals below treat `_np` as present. Declared machine-readably rather
|
|
59
|
+
#: than left implied — see tests/test_optional_dependency_guards.py.
|
|
60
|
+
_OPTIONAL_DEPENDENCY_CONTRACT = (
|
|
61
|
+
"numpy: every public entry point calls _require() first; internals assume it is present"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
#: Analysis raster. Large enough that smooth-region and banding statistics are
|
|
65
|
+
#: meaningful, small enough that a frame decodes in well under a second.
|
|
66
|
+
ANALYSIS_WIDTH = 960
|
|
67
|
+
ANALYSIS_HEIGHT = 540
|
|
68
|
+
|
|
69
|
+
#: Encodings these metrics are defined on.
|
|
70
|
+
DISPLAY_REFERRED_SPACES = frozenset({"rec709", "srgb", "bt709", "display_p3", "rec1886"})
|
|
71
|
+
|
|
72
|
+
#: ffprobe `color_transfer` values that mean the frame is NOT display-referred.
|
|
73
|
+
#: Presence of any of these contradicts a display-referred declaration.
|
|
74
|
+
_NON_DISPLAY_TRANSFERS = frozenset({
|
|
75
|
+
"smpte2084", "arib-std-b67", "log100", "log316", "bt1361e", "smpte428",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
#: A region counts as "smooth" when it sits this close to its own local mean.
|
|
79
|
+
#: Skies, walls and gradients — where amplification and banding show first.
|
|
80
|
+
SMOOTH_TOLERANCE_L = 0.8
|
|
81
|
+
#: Below this many smooth pixels the statistic is not worth reporting.
|
|
82
|
+
MIN_SMOOTH_PIXELS = 2000
|
|
83
|
+
#: Shadows, for the shadow-noise ratio.
|
|
84
|
+
SHADOW_L_MAX = 30.0
|
|
85
|
+
#: Highlights, for the posterization measure.
|
|
86
|
+
HIGHLIGHT_L_MIN = 80.0
|
|
87
|
+
#: At/above this L* a pixel counts as clipped.
|
|
88
|
+
CLIPPED_L = 96.5
|
|
89
|
+
|
|
90
|
+
# ── Flag thresholds (advisory; see the module docstring) ─────────────────────
|
|
91
|
+
NOISE_OVERALL_RATIO = 1.5
|
|
92
|
+
NOISE_SHADOW_RATIO = 1.6
|
|
93
|
+
NOISE_SMOOTH_RATIO = 1.6
|
|
94
|
+
BANDING_LOSS = 0.30
|
|
95
|
+
POSTERIZATION_LOSS = 0.25
|
|
96
|
+
CLIP_GROWTH = 0.005
|
|
97
|
+
FLAT_CONTRAST_RATIO = 0.80
|
|
98
|
+
MILKY_BLACK_LIFT_L = 8.0
|
|
99
|
+
#: Chroma collapse. A grade that desaturates this far has thrown colour away —
|
|
100
|
+
#: "clean" is not the same as "grey", and nothing else here would catch it.
|
|
101
|
+
WASHED_OUT_CHROMA_RATIO = 0.60
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: Cost tiers for the vision escalation. `numeric` never spends tokens;
|
|
105
|
+
#: `vision_on_doubt` spends only where the numbers cannot settle the question.
|
|
106
|
+
COST_TIERS = ("numeric", "vision_on_doubt", "vision_always")
|
|
107
|
+
DEFAULT_COST_TIER = "vision_on_doubt"
|
|
108
|
+
|
|
109
|
+
#: A grade that moved this far without raising a flag is worth a second opinion:
|
|
110
|
+
#: the numbers say "undamaged", which is not the same as "good".
|
|
111
|
+
DOUBT_SHIFT_DELTA_E = 6.0
|
|
112
|
+
#: Flags whose correctness depends on what the frame actually shows. A real sky
|
|
113
|
+
#: gradient legitimately has low level density; a face going grey does not.
|
|
114
|
+
_CONTENT_DEPENDENT_FLAGS = frozenset({"banding", "washed_out", "flat"})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ImageQcError(RuntimeError):
|
|
118
|
+
"""A refusal — bad inputs or an encoding these metrics do not apply to."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def capabilities() -> Dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"numpy": _np is not None,
|
|
124
|
+
"ffmpeg": bool(shutil.which("ffmpeg")),
|
|
125
|
+
"ffprobe": bool(shutil.which("ffprobe")),
|
|
126
|
+
"analysis_raster": [ANALYSIS_WIDTH, ANALYSIS_HEIGHT],
|
|
127
|
+
"display_referred_spaces": sorted(DISPLAY_REFERRED_SPACES),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _require() -> None:
|
|
132
|
+
if _np is None:
|
|
133
|
+
raise ImageQcError("numpy is required for image QC")
|
|
134
|
+
if not shutil.which("ffmpeg"):
|
|
135
|
+
raise ImageQcError("ffmpeg not found on PATH")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── decode ───────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _decode_frame(
|
|
142
|
+
path: str,
|
|
143
|
+
time_seconds: float,
|
|
144
|
+
*,
|
|
145
|
+
lut_path: Optional[str] = None,
|
|
146
|
+
width: int = ANALYSIS_WIDTH,
|
|
147
|
+
height: int = ANALYSIS_HEIGHT,
|
|
148
|
+
) -> "Optional[_np.ndarray]":
|
|
149
|
+
"""One frame → (h, w, 3) float array in [0, 1], optionally through a LUT."""
|
|
150
|
+
# Nearest-neighbour, deliberately. Any averaging rescale (bilinear, bicubic,
|
|
151
|
+
# the ffmpeg default) *invents intermediate values* — it manufactures level
|
|
152
|
+
# density on the way in and hides the exact quantisation the banding and
|
|
153
|
+
# posterization metrics exist to find. Upscaling a quantised frame fabricates
|
|
154
|
+
# levels; downscaling averages them away. Neighbour sampling carries real
|
|
155
|
+
# pixel values through unchanged, and preserves noise amplitude too (an
|
|
156
|
+
# averaging downscale attenuates noise by ~sqrt of the ratio, which would
|
|
157
|
+
# quietly deflate the amplification ratios).
|
|
158
|
+
chain = f"scale={width}:{height}:flags=neighbor"
|
|
159
|
+
if lut_path:
|
|
160
|
+
# ffmpeg filter syntax: ':' and '\' are separators inside a filter arg.
|
|
161
|
+
escaped = lut_path.replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
|
|
162
|
+
chain += f",lut3d='{escaped}'"
|
|
163
|
+
chain += ",format=rgb24"
|
|
164
|
+
args = [
|
|
165
|
+
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
166
|
+
"-ss", f"{max(0.0, float(time_seconds)):.3f}",
|
|
167
|
+
"-i", path,
|
|
168
|
+
"-frames:v", "1",
|
|
169
|
+
"-vf", chain,
|
|
170
|
+
"-f", "rawvideo", "-",
|
|
171
|
+
]
|
|
172
|
+
try:
|
|
173
|
+
proc = subprocess.run(args, capture_output=True, timeout=120, check=False)
|
|
174
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
175
|
+
logger.debug("frame decode failed for %s: %s", path, exc)
|
|
176
|
+
return None
|
|
177
|
+
expected = width * height * 3
|
|
178
|
+
if proc.returncode != 0 or len(proc.stdout) < expected:
|
|
179
|
+
return None
|
|
180
|
+
arr = _np.frombuffer(proc.stdout[:expected], dtype=_np.uint8).reshape(height, width, 3)
|
|
181
|
+
return arr.astype(_np.float64) / 255.0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _probe_color_transfer(path: str) -> Optional[str]:
|
|
185
|
+
if not shutil.which("ffprobe"):
|
|
186
|
+
return None
|
|
187
|
+
args = [
|
|
188
|
+
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
189
|
+
"-show_entries", "stream=color_transfer", "-of", "default=nw=1:nk=1", path,
|
|
190
|
+
]
|
|
191
|
+
try:
|
|
192
|
+
proc = subprocess.run(args, capture_output=True, text=True, timeout=30, check=False)
|
|
193
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
194
|
+
return None
|
|
195
|
+
value = (proc.stdout or "").strip().lower()
|
|
196
|
+
return value or None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_working_space(paths: List[str], working_space: str) -> List[str]:
|
|
200
|
+
"""Refuse non-display-referred declarations; warn on a contradicted tag."""
|
|
201
|
+
space = str(working_space or "").strip().lower().replace("-", "").replace(" ", "")
|
|
202
|
+
space = {"bt709": "bt709", "rec709": "rec709", "srgb": "srgb"}.get(space, space)
|
|
203
|
+
if space not in DISPLAY_REFERRED_SPACES:
|
|
204
|
+
raise ImageQcError(
|
|
205
|
+
f"working_space {working_space!r} is not display-referred. These metrics are "
|
|
206
|
+
f"defined on sRGB-encoded L*/chroma; a log or scene-referred frame (ACEScct, "
|
|
207
|
+
f"S-Log3, LogC) produces numbers that look plausible and mean nothing. Convert "
|
|
208
|
+
f"to a display-referred space first, then re-run. Accepted: "
|
|
209
|
+
f"{', '.join(sorted(DISPLAY_REFERRED_SPACES))}."
|
|
210
|
+
)
|
|
211
|
+
warnings: List[str] = []
|
|
212
|
+
for path in paths:
|
|
213
|
+
transfer = _probe_color_transfer(path)
|
|
214
|
+
if transfer and transfer in _NON_DISPLAY_TRANSFERS:
|
|
215
|
+
warnings.append(
|
|
216
|
+
f"{os.path.basename(path)} is tagged color_transfer={transfer}, which "
|
|
217
|
+
f"contradicts working_space={working_space}. The metrics below assume a "
|
|
218
|
+
f"display-referred frame; treat them as unreliable until that is resolved."
|
|
219
|
+
)
|
|
220
|
+
return warnings
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ── primitives ───────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _box_blur(gray: "_np.ndarray", k: int = 5) -> "_np.ndarray":
|
|
227
|
+
"""Mean filter via summed-area table — O(1) per pixel, no scipy."""
|
|
228
|
+
pad = k // 2
|
|
229
|
+
padded = _np.pad(gray, pad, mode="edge")
|
|
230
|
+
csum = _np.cumsum(_np.cumsum(padded, axis=0), axis=1)
|
|
231
|
+
csum = _np.pad(csum, ((1, 0), (1, 0)))
|
|
232
|
+
return (csum[k:, k:] - csum[:-k, k:] - csum[k:, :-k] + csum[:-k, :-k]) / float(k * k)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _smooth_mask(luma: "_np.ndarray", k: int = 7) -> "_np.ndarray":
|
|
236
|
+
"""Pixels sitting close to their local mean — skies, walls, gradients."""
|
|
237
|
+
return _np.abs(luma - _box_blur(luma, k)) < SMOOTH_TOLERANCE_L
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _noise_level(rgb: "_np.ndarray", mask: "Optional[_np.ndarray]" = None) -> float:
|
|
241
|
+
"""High-frequency residual std — a grain / compression-noise estimate."""
|
|
242
|
+
gray = rgb.mean(axis=-1)
|
|
243
|
+
residual = gray - _box_blur(gray, 5)
|
|
244
|
+
if mask is not None:
|
|
245
|
+
if not mask.any():
|
|
246
|
+
return 0.0
|
|
247
|
+
residual = residual[mask]
|
|
248
|
+
return float(residual.std())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _level_density_loss(src_l: "_np.ndarray", out_l: "_np.ndarray", mask: "_np.ndarray") -> Optional[float]:
|
|
252
|
+
"""Fraction of distinct L* levels the grade collapsed inside `mask`.
|
|
253
|
+
|
|
254
|
+
Counting distinct quantised levels is what separates banding from a merely
|
|
255
|
+
darker picture: a healthy grade moves levels, a damaging one merges them.
|
|
256
|
+
"""
|
|
257
|
+
if mask.sum() < MIN_SMOOTH_PIXELS:
|
|
258
|
+
return None
|
|
259
|
+
levels_src = len(_np.unique(_np.round(src_l[mask], 1))) or 1
|
|
260
|
+
levels_out = len(_np.unique(_np.round(out_l[mask], 1)))
|
|
261
|
+
return round(max(0.0, 1.0 - levels_out / levels_src), 4)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _tonal_stats(lab: "_np.ndarray") -> Dict[str, float]:
|
|
265
|
+
flat = lab.reshape(-1, 3)
|
|
266
|
+
luma = flat[:, 0]
|
|
267
|
+
return {
|
|
268
|
+
"black_point_L": round(float(_np.percentile(luma, 0.5)), 2),
|
|
269
|
+
"white_point_L": round(float(_np.percentile(luma, 99.5)), 2),
|
|
270
|
+
"contrast_L_std": round(float(luma.std()), 2),
|
|
271
|
+
"mean_chroma": round(float(_np.hypot(flat[:, 1], flat[:, 2]).mean()), 2),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _noise_amplification(src_rgb: "_np.ndarray", out_rgb: "_np.ndarray", src_l: "_np.ndarray") -> Dict[str, float]:
|
|
276
|
+
shadows = src_l < SHADOW_L_MAX
|
|
277
|
+
smooth = _smooth_mask(src_l)
|
|
278
|
+
|
|
279
|
+
def ratio(mask: "Optional[_np.ndarray]") -> float:
|
|
280
|
+
base = _noise_level(src_rgb, mask)
|
|
281
|
+
if base < 1e-6:
|
|
282
|
+
return 1.0
|
|
283
|
+
return round(_noise_level(out_rgb, mask) / base, 3)
|
|
284
|
+
|
|
285
|
+
out = {"overall_ratio": ratio(None), "shadow_ratio": ratio(shadows)}
|
|
286
|
+
out["smooth_region_ratio"] = ratio(smooth) if smooth.sum() >= MIN_SMOOTH_PIXELS else 1.0
|
|
287
|
+
out["smooth_region_pixels"] = int(smooth.sum())
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _damage(src_lab: "_np.ndarray", out_lab: "_np.ndarray") -> Dict[str, Any]:
|
|
292
|
+
src_l, out_l = src_lab[..., 0], out_lab[..., 0]
|
|
293
|
+
smooth = _smooth_mask(src_l)
|
|
294
|
+
highlights = out_l > HIGHLIGHT_L_MIN
|
|
295
|
+
return {
|
|
296
|
+
"clipped_fraction_source": round(float((src_l > CLIPPED_L).mean()), 5),
|
|
297
|
+
"clipped_fraction_result": round(float((out_l > CLIPPED_L).mean()), 5),
|
|
298
|
+
"highlight_posterization": _level_density_loss(src_l, out_l, highlights),
|
|
299
|
+
"smooth_region_banding": _level_density_loss(src_l, out_l, smooth),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ── verdict ──────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _flags(source: Dict[str, float], result: Dict[str, float], noise: Dict[str, float], damage: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
307
|
+
"""Every flag carries a remedy — a label alone does not tell an agent what to change."""
|
|
308
|
+
flags: List[Dict[str, str]] = []
|
|
309
|
+
|
|
310
|
+
def flag(flag_id: str, detail: str, remedy: str) -> None:
|
|
311
|
+
flags.append({"id": flag_id, "detail": detail, "remedy": remedy})
|
|
312
|
+
|
|
313
|
+
if result["contrast_L_std"] < FLAT_CONTRAST_RATIO * source["contrast_L_std"]:
|
|
314
|
+
flag(
|
|
315
|
+
"flat",
|
|
316
|
+
f"contrast fell from {source['contrast_L_std']} to {result['contrast_L_std']} (L* std)",
|
|
317
|
+
"raise contrast or lower the lift; a match on mean colour can still read as flat",
|
|
318
|
+
)
|
|
319
|
+
if result["mean_chroma"] < WASHED_OUT_CHROMA_RATIO * source["mean_chroma"]:
|
|
320
|
+
flag(
|
|
321
|
+
"washed_out",
|
|
322
|
+
f"mean chroma fell from {source['mean_chroma']} to {result['mean_chroma']}",
|
|
323
|
+
"restore saturation; a desaturated image is not the same as a clean one",
|
|
324
|
+
)
|
|
325
|
+
if result["black_point_L"] > source["black_point_L"] + MILKY_BLACK_LIFT_L:
|
|
326
|
+
flag(
|
|
327
|
+
"milky",
|
|
328
|
+
f"black point lifted from {source['black_point_L']} to {result['black_point_L']} L*",
|
|
329
|
+
"bring the black point back down, or reduce the shadow lift",
|
|
330
|
+
)
|
|
331
|
+
if noise["shadow_ratio"] > NOISE_SHADOW_RATIO or noise["overall_ratio"] > NOISE_OVERALL_RATIO or noise["smooth_region_ratio"] > NOISE_SMOOTH_RATIO:
|
|
332
|
+
flag(
|
|
333
|
+
"noisy",
|
|
334
|
+
f"noise ratios overall {noise['overall_ratio']}, shadow {noise['shadow_ratio']}, "
|
|
335
|
+
f"smooth-region {noise['smooth_region_ratio']}",
|
|
336
|
+
"reduce the shadow lift, temper the strength, or denoise before the grade node",
|
|
337
|
+
)
|
|
338
|
+
grew = damage["clipped_fraction_result"] - damage["clipped_fraction_source"]
|
|
339
|
+
if grew > CLIP_GROWTH:
|
|
340
|
+
flag(
|
|
341
|
+
"clipped",
|
|
342
|
+
f"clipped area grew by {round(grew * 100, 2)}% of frame",
|
|
343
|
+
"soften the white point or add a highlight knee",
|
|
344
|
+
)
|
|
345
|
+
if (damage["highlight_posterization"] or 0.0) > POSTERIZATION_LOSS:
|
|
346
|
+
flag(
|
|
347
|
+
"posterized",
|
|
348
|
+
f"{round((damage['highlight_posterization'] or 0) * 100)}% of highlight levels collapsed",
|
|
349
|
+
"reduce the stretch, or grade in higher precision before baking",
|
|
350
|
+
)
|
|
351
|
+
if (damage["smooth_region_banding"] or 0.0) > BANDING_LOSS:
|
|
352
|
+
flag(
|
|
353
|
+
"banding",
|
|
354
|
+
f"{round((damage['smooth_region_banding'] or 0) * 100)}% of smooth-region levels collapsed",
|
|
355
|
+
"soften the curve, reduce strength, or dither/denoise the source",
|
|
356
|
+
)
|
|
357
|
+
return flags
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def vision_escalation(report: Dict[str, Any], *, cost_tier: str = DEFAULT_COST_TIER) -> Dict[str, Any]:
|
|
361
|
+
"""Decide whether this report warrants spending a vision call.
|
|
362
|
+
|
|
363
|
+
The numeric pass is free and deterministic; vision is neither. This is the
|
|
364
|
+
gate that keeps a clean grade from costing anything, and it states its
|
|
365
|
+
reasoning so the spend is never mysterious.
|
|
366
|
+
|
|
367
|
+
Escalates on two situations the numbers genuinely cannot settle:
|
|
368
|
+
|
|
369
|
+
- **No flags but a large move.** "Undamaged" is not "good". A grade that
|
|
370
|
+
shifted the image a long way while tripping no metric is exactly where a
|
|
371
|
+
look can be wrong in ways no threshold models.
|
|
372
|
+
- **A content-dependent flag.** `banding` on a real sky gradient is correct
|
|
373
|
+
behaviour, not damage; `washed_out` on a deliberately desaturated look is
|
|
374
|
+
the intent. Only the picture can say which.
|
|
375
|
+
|
|
376
|
+
Clipping, posterization and noise are *not* content-dependent — they are
|
|
377
|
+
damage wherever they appear, so they never buy a vision call on their own.
|
|
378
|
+
"""
|
|
379
|
+
if cost_tier not in COST_TIERS:
|
|
380
|
+
raise ImageQcError(f"unknown cost_tier {cost_tier!r}; valid: {', '.join(COST_TIERS)}")
|
|
381
|
+
|
|
382
|
+
flag_ids = [f["id"] for f in report.get("flags", [])]
|
|
383
|
+
shift = float(report.get("grade_shift_delta_e2000") or 0.0)
|
|
384
|
+
|
|
385
|
+
if cost_tier == "numeric":
|
|
386
|
+
return {"escalate": False, "cost_tier": cost_tier, "reason": "numeric tier never calls vision"}
|
|
387
|
+
if cost_tier == "vision_always":
|
|
388
|
+
return {"escalate": True, "cost_tier": cost_tier, "reason": "vision_always tier"}
|
|
389
|
+
|
|
390
|
+
content_dependent = sorted(set(flag_ids) & _CONTENT_DEPENDENT_FLAGS)
|
|
391
|
+
if content_dependent:
|
|
392
|
+
return {
|
|
393
|
+
"escalate": True,
|
|
394
|
+
"cost_tier": cost_tier,
|
|
395
|
+
"reason": (
|
|
396
|
+
f"flags {content_dependent} depend on what the frame shows — a real gradient "
|
|
397
|
+
"bands legitimately and a deliberate desaturation is not damage"
|
|
398
|
+
),
|
|
399
|
+
"triggering_flags": content_dependent,
|
|
400
|
+
}
|
|
401
|
+
if not flag_ids and shift >= DOUBT_SHIFT_DELTA_E:
|
|
402
|
+
return {
|
|
403
|
+
"escalate": True,
|
|
404
|
+
"cost_tier": cost_tier,
|
|
405
|
+
"reason": (
|
|
406
|
+
f"no flags, but the grade moved {round(shift, 2)} ΔE2000 "
|
|
407
|
+
f"(>= {DOUBT_SHIFT_DELTA_E}) — undamaged is not the same as good"
|
|
408
|
+
),
|
|
409
|
+
"grade_shift_delta_e2000": round(shift, 3),
|
|
410
|
+
}
|
|
411
|
+
if flag_ids:
|
|
412
|
+
return {
|
|
413
|
+
"escalate": False,
|
|
414
|
+
"cost_tier": cost_tier,
|
|
415
|
+
"reason": f"flags {sorted(flag_ids)} are damage wherever they appear; no judgement needed",
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
"escalate": False,
|
|
419
|
+
"cost_tier": cost_tier,
|
|
420
|
+
"reason": f"clean grade, {round(shift, 2)} ΔE2000 shift — nothing for vision to settle",
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def assess_grade(
|
|
425
|
+
source_path: str,
|
|
426
|
+
*,
|
|
427
|
+
time_seconds: float,
|
|
428
|
+
graded_path: Optional[str] = None,
|
|
429
|
+
lut_path: Optional[str] = None,
|
|
430
|
+
working_space: str = "rec709",
|
|
431
|
+
cost_tier: str = DEFAULT_COST_TIER,
|
|
432
|
+
) -> Dict[str, Any]:
|
|
433
|
+
"""Compare a graded frame against its source and report measurable damage.
|
|
434
|
+
|
|
435
|
+
Supply exactly one of `graded_path` (a rendered result) or `lut_path` (the
|
|
436
|
+
source pushed through a 3D LUT by ffmpeg). Both routes decode a real frame.
|
|
437
|
+
|
|
438
|
+
When comparing against a rendered file, the two are sampled at the same
|
|
439
|
+
timestamp — that holds for a render of the same cut and does not hold for a
|
|
440
|
+
re-timed or re-conformed one, in which case the comparison is meaningless.
|
|
441
|
+
"""
|
|
442
|
+
_require()
|
|
443
|
+
if bool(graded_path) == bool(lut_path):
|
|
444
|
+
raise ImageQcError("supply exactly one of graded_path or lut_path")
|
|
445
|
+
for path in (source_path, graded_path, lut_path):
|
|
446
|
+
if path and not os.path.isfile(path):
|
|
447
|
+
raise ImageQcError(f"file not found: {path}")
|
|
448
|
+
|
|
449
|
+
warnings = _check_working_space([p for p in (source_path, graded_path) if p], working_space)
|
|
450
|
+
|
|
451
|
+
src_rgb = _decode_frame(source_path, time_seconds)
|
|
452
|
+
if src_rgb is None:
|
|
453
|
+
raise ImageQcError(f"could not decode a frame from {source_path} at {time_seconds}s")
|
|
454
|
+
if graded_path:
|
|
455
|
+
out_rgb = _decode_frame(graded_path, time_seconds)
|
|
456
|
+
basis = "rendered_file"
|
|
457
|
+
else:
|
|
458
|
+
out_rgb = _decode_frame(source_path, time_seconds, lut_path=lut_path)
|
|
459
|
+
basis = "lut_applied"
|
|
460
|
+
if out_rgb is None:
|
|
461
|
+
raise ImageQcError("could not decode the graded frame")
|
|
462
|
+
|
|
463
|
+
src_lab = _cm.srgb_to_lab(src_rgb)
|
|
464
|
+
out_lab = _cm.srgb_to_lab(out_rgb)
|
|
465
|
+
source_stats = _tonal_stats(src_lab)
|
|
466
|
+
result_stats = _tonal_stats(out_lab)
|
|
467
|
+
noise = _noise_amplification(src_rgb, out_rgb, src_lab[..., 0])
|
|
468
|
+
damage = _damage(src_lab, out_lab)
|
|
469
|
+
flags = _flags(source_stats, result_stats, noise, damage)
|
|
470
|
+
|
|
471
|
+
# How far the grade moved the image, in a perceptually sane metric. This is
|
|
472
|
+
# a magnitude, not a verdict — a big move is not automatically wrong.
|
|
473
|
+
shift = float(
|
|
474
|
+
_cm.delta_e2000(src_lab.reshape(-1, 3).mean(axis=0), out_lab.reshape(-1, 3).mean(axis=0))
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
report: Dict[str, Any] = {
|
|
478
|
+
"acceptable": not flags,
|
|
479
|
+
"flags": flags,
|
|
480
|
+
"basis": basis,
|
|
481
|
+
"working_space": working_space,
|
|
482
|
+
"time_seconds": round(float(time_seconds), 3),
|
|
483
|
+
"grade_shift_delta_e2000": round(shift, 3),
|
|
484
|
+
"source": source_stats,
|
|
485
|
+
"result": result_stats,
|
|
486
|
+
"noise_amplification": noise,
|
|
487
|
+
"damage": damage,
|
|
488
|
+
"warnings": warnings,
|
|
489
|
+
"measurement": {
|
|
490
|
+
"raster": [ANALYSIS_WIDTH, ANALYSIS_HEIGHT],
|
|
491
|
+
"note": (
|
|
492
|
+
"Measured on a decoded frame of the real graded result, not on a simulated "
|
|
493
|
+
"transform — LUT interpolation and encode rounding are where banding is "
|
|
494
|
+
"actually introduced."
|
|
495
|
+
),
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
report["vision"] = vision_escalation(report, cost_tier=cost_tier)
|
|
499
|
+
return report
|
package/src/utils/page_lock.py
CHANGED
|
@@ -59,7 +59,11 @@ def page_lock():
|
|
|
59
59
|
yield
|
|
60
60
|
finally:
|
|
61
61
|
_depth -= 1
|
|
62
|
-
|
|
62
|
+
# `_fh` is only ever set inside the `_HAS_FCNTL` branch above, so this
|
|
63
|
+
# is transitively safe — but state the dependency rather than relying on
|
|
64
|
+
# a reader tracing it, since a future assignment elsewhere would make
|
|
65
|
+
# this an AttributeError on a platform without fcntl.
|
|
66
|
+
if _depth == 0 and _fh is not None and _HAS_FCNTL:
|
|
63
67
|
try:
|
|
64
68
|
fcntl.flock(_fh, fcntl.LOCK_UN)
|
|
65
69
|
except OSError:
|