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.
@@ -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
@@ -59,7 +59,11 @@ def page_lock():
59
59
  yield
60
60
  finally:
61
61
  _depth -= 1
62
- if _depth == 0 and _fh is not None:
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: