davinci-resolve-mcp 2.67.1 → 2.68.1
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 +147 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- 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 +378 -41
- 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,323 @@
|
|
|
1
|
+
"""Caption generation from word timings: SRT / WebVTT under broadcast rules.
|
|
2
|
+
|
|
3
|
+
Turning a transcript into captions is not line-wrapping. A caption that is
|
|
4
|
+
technically synchronised but breaks mid-clause, orphans a word, or sits on
|
|
5
|
+
screen for nine seconds fails QC and is unreadable, so the rules a human
|
|
6
|
+
captioner follows are encoded here rather than left to the caller:
|
|
7
|
+
|
|
8
|
+
- a character cap per line, and a cap on lines per block;
|
|
9
|
+
- a maximum and a **minimum** block duration — a 4-frame flash is unreadable
|
|
10
|
+
even though it is perfectly in sync;
|
|
11
|
+
- breaks preferred at sentence ends, then at clause boundaries, then at long
|
|
12
|
+
pauses, and only then on length alone;
|
|
13
|
+
- timings snapped to the words actually spoken, never interpolated;
|
|
14
|
+
- a minimum gap between blocks so consecutive captions do not visually merge;
|
|
15
|
+
- no single-word orphan line.
|
|
16
|
+
|
|
17
|
+
## Reading rate is a cap, not a target
|
|
18
|
+
|
|
19
|
+
Blocks are extended toward `min_block_seconds` when the words are quick, but
|
|
20
|
+
never past the next block's start. A caption that overruns its successor is
|
|
21
|
+
worse than a short one.
|
|
22
|
+
|
|
23
|
+
## Frame rates
|
|
24
|
+
|
|
25
|
+
SRT and WebVTT both carry wall-clock timestamps, so nothing here needs a frame
|
|
26
|
+
rate. Retiming across rates is a separate concern and deliberately not folded
|
|
27
|
+
in — a caption file that has been silently conformed is very hard to debug.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
#: Broadcast-conventional defaults. Every one is overridable per call.
|
|
36
|
+
DEFAULT_MAX_CHARS_PER_LINE = 42
|
|
37
|
+
DEFAULT_MAX_LINES = 2
|
|
38
|
+
DEFAULT_MAX_BLOCK_SECONDS = 7.0
|
|
39
|
+
DEFAULT_MIN_BLOCK_SECONDS = 0.833 # ~20 frames at 24fps; below this is a flash
|
|
40
|
+
DEFAULT_MIN_GAP_SECONDS = 0.084 # ~2 frames, so blocks do not visually merge
|
|
41
|
+
#: A silence at least this long is a natural caption break.
|
|
42
|
+
DEFAULT_PAUSE_BREAK_SECONDS = 0.6
|
|
43
|
+
|
|
44
|
+
_SENTENCE_END_RE = re.compile(r"[.!?]['\"”’)]*$")
|
|
45
|
+
_CLAUSE_END_RE = re.compile(r"[,;:—-]['\"”’)]*$")
|
|
46
|
+
|
|
47
|
+
FORMATS = ("srt", "vtt")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CaptionError(ValueError):
|
|
51
|
+
"""Invalid caption parameters — refused rather than silently clamped."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _word_time(word: Dict[str, Any], key: str, fallback: str) -> Optional[float]:
|
|
55
|
+
value = word.get(key, word.get(fallback))
|
|
56
|
+
return float(value) if isinstance(value, (int, float)) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean(word: Dict[str, Any]) -> str:
|
|
60
|
+
return str(word.get("word") or "").strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wrap(text: str, max_chars: int, max_lines: int) -> Optional[List[str]]:
|
|
64
|
+
"""Greedy wrap; None when it will not fit in `max_lines`.
|
|
65
|
+
|
|
66
|
+
Returning None rather than truncating is deliberate — silently dropping
|
|
67
|
+
words from a caption is the worst possible failure for accessibility.
|
|
68
|
+
"""
|
|
69
|
+
lines: List[str] = []
|
|
70
|
+
current = ""
|
|
71
|
+
for token in text.split():
|
|
72
|
+
candidate = f"{current} {token}".strip()
|
|
73
|
+
if len(candidate) <= max_chars:
|
|
74
|
+
current = candidate
|
|
75
|
+
continue
|
|
76
|
+
if current:
|
|
77
|
+
lines.append(current)
|
|
78
|
+
current = token
|
|
79
|
+
if len(current) > max_chars:
|
|
80
|
+
return None # a single token longer than the line cap
|
|
81
|
+
if current:
|
|
82
|
+
lines.append(current)
|
|
83
|
+
return lines if len(lines) <= max_lines else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _no_orphan(lines: List[str]) -> List[str]:
|
|
87
|
+
"""Rebalance so no line is a lone word when a neighbour could give one up."""
|
|
88
|
+
if len(lines) < 2:
|
|
89
|
+
return lines
|
|
90
|
+
if len(lines[-1].split()) == 1 and len(lines[-2].split()) > 2:
|
|
91
|
+
head = lines[-2].split()
|
|
92
|
+
lines = lines[:-2] + [" ".join(head[:-1]), f"{head[-1]} {lines[-1]}"]
|
|
93
|
+
return lines
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _break_score(word: Dict[str, Any], gap_after: float, pause_break: float) -> int:
|
|
97
|
+
"""Higher is a better place to end a caption block."""
|
|
98
|
+
text = _clean(word)
|
|
99
|
+
if _SENTENCE_END_RE.search(text):
|
|
100
|
+
return 3
|
|
101
|
+
if gap_after >= pause_break:
|
|
102
|
+
return 2
|
|
103
|
+
if _CLAUSE_END_RE.search(text):
|
|
104
|
+
return 1
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_blocks(
|
|
109
|
+
words: Sequence[Dict[str, Any]],
|
|
110
|
+
*,
|
|
111
|
+
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE,
|
|
112
|
+
max_lines: int = DEFAULT_MAX_LINES,
|
|
113
|
+
max_block_seconds: float = DEFAULT_MAX_BLOCK_SECONDS,
|
|
114
|
+
min_block_seconds: float = DEFAULT_MIN_BLOCK_SECONDS,
|
|
115
|
+
min_gap_seconds: float = DEFAULT_MIN_GAP_SECONDS,
|
|
116
|
+
pause_break_seconds: float = DEFAULT_PAUSE_BREAK_SECONDS,
|
|
117
|
+
) -> List[Dict[str, Any]]:
|
|
118
|
+
"""Group words into caption blocks obeying every rule above."""
|
|
119
|
+
if max_chars_per_line < 8:
|
|
120
|
+
raise CaptionError("max_chars_per_line below 8 cannot hold readable text")
|
|
121
|
+
if max_lines < 1:
|
|
122
|
+
raise CaptionError("max_lines must be at least 1")
|
|
123
|
+
if min_block_seconds > max_block_seconds:
|
|
124
|
+
raise CaptionError("min_block_seconds cannot exceed max_block_seconds")
|
|
125
|
+
|
|
126
|
+
timed = [
|
|
127
|
+
w for w in words
|
|
128
|
+
if _clean(w) and _word_time(w, "start_seconds", "start") is not None
|
|
129
|
+
]
|
|
130
|
+
if not timed:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
capacity = max_chars_per_line * max_lines
|
|
134
|
+
blocks: List[Dict[str, Any]] = []
|
|
135
|
+
current: List[Dict[str, Any]] = []
|
|
136
|
+
|
|
137
|
+
def flush() -> None:
|
|
138
|
+
if not current:
|
|
139
|
+
return
|
|
140
|
+
text = " ".join(_clean(w) for w in current)
|
|
141
|
+
lines = _wrap(text, max_chars_per_line, max_lines)
|
|
142
|
+
if lines is None:
|
|
143
|
+
lines = [text[:max_chars_per_line]]
|
|
144
|
+
blocks.append({
|
|
145
|
+
"start_seconds": _word_time(current[0], "start_seconds", "start"),
|
|
146
|
+
"end_seconds": (
|
|
147
|
+
_word_time(current[-1], "end_seconds", "end")
|
|
148
|
+
or _word_time(current[-1], "start_seconds", "start")
|
|
149
|
+
),
|
|
150
|
+
"lines": _no_orphan(lines),
|
|
151
|
+
"word_count": len(current),
|
|
152
|
+
})
|
|
153
|
+
current.clear()
|
|
154
|
+
|
|
155
|
+
for index, word in enumerate(timed):
|
|
156
|
+
current.append(word)
|
|
157
|
+
following = timed[index + 1] if index + 1 < len(timed) else None
|
|
158
|
+
if following is None:
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
this_end = _word_time(word, "end_seconds", "end") or _word_time(word, "start_seconds", "start")
|
|
162
|
+
next_start = _word_time(following, "start_seconds", "start")
|
|
163
|
+
gap = max(0.0, (next_start or 0.0) - (this_end or 0.0))
|
|
164
|
+
|
|
165
|
+
text_now = " ".join(_clean(w) for w in current)
|
|
166
|
+
text_next = f"{text_now} {_clean(following)}"
|
|
167
|
+
block_start = _word_time(current[0], "start_seconds", "start") or 0.0
|
|
168
|
+
would_be_long = ((next_start or 0.0) - block_start) > max_block_seconds
|
|
169
|
+
would_not_fit = len(text_next) > capacity or _wrap(text_next, max_chars_per_line, max_lines) is None
|
|
170
|
+
|
|
171
|
+
score = _break_score(word, gap, pause_break_seconds)
|
|
172
|
+
# Sentence ends and real pauses always break. A block that spans a
|
|
173
|
+
# silence leaves text on screen through it, which is the thing captioners
|
|
174
|
+
# break on — and short blocks are already made readable by the
|
|
175
|
+
# min_block_seconds extension below, so no length guard is needed here.
|
|
176
|
+
# Clause breaks (score 1) are only a preference and never force a flush.
|
|
177
|
+
if would_not_fit or would_be_long or score >= 2:
|
|
178
|
+
flush()
|
|
179
|
+
flush()
|
|
180
|
+
|
|
181
|
+
# Extend short blocks toward the readable minimum, never into the next one.
|
|
182
|
+
for index, block in enumerate(blocks):
|
|
183
|
+
limit = (
|
|
184
|
+
blocks[index + 1]["start_seconds"] - min_gap_seconds
|
|
185
|
+
if index + 1 < len(blocks)
|
|
186
|
+
else block["end_seconds"] + min_block_seconds
|
|
187
|
+
)
|
|
188
|
+
if block["end_seconds"] - block["start_seconds"] < min_block_seconds:
|
|
189
|
+
block["end_seconds"] = max(block["end_seconds"], min(block["start_seconds"] + min_block_seconds, limit))
|
|
190
|
+
# Enforce the inter-block gap even when no extension happened.
|
|
191
|
+
if index + 1 < len(blocks):
|
|
192
|
+
block["end_seconds"] = min(block["end_seconds"], blocks[index + 1]["start_seconds"] - min_gap_seconds)
|
|
193
|
+
block["end_seconds"] = max(block["end_seconds"], block["start_seconds"] + 1e-3)
|
|
194
|
+
block["start_seconds"] = round(block["start_seconds"], 3)
|
|
195
|
+
block["end_seconds"] = round(block["end_seconds"], 3)
|
|
196
|
+
block["duration_seconds"] = round(block["end_seconds"] - block["start_seconds"], 3)
|
|
197
|
+
return blocks
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── serialisation ────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _timestamp(seconds: float, *, comma: bool) -> str:
|
|
204
|
+
seconds = max(0.0, float(seconds))
|
|
205
|
+
hours, rest = divmod(seconds, 3600.0)
|
|
206
|
+
minutes, secs = divmod(rest, 60.0)
|
|
207
|
+
millis = int(round((secs - int(secs)) * 1000))
|
|
208
|
+
if millis == 1000: # rounding carried into the next second
|
|
209
|
+
secs, millis = int(secs) + 1, 0
|
|
210
|
+
sep = "," if comma else "."
|
|
211
|
+
return f"{int(hours):02d}:{int(minutes):02d}:{int(secs):02d}{sep}{millis:03d}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def to_srt(blocks: Sequence[Dict[str, Any]]) -> str:
|
|
215
|
+
out: List[str] = []
|
|
216
|
+
for index, block in enumerate(blocks, start=1):
|
|
217
|
+
out.append(str(index))
|
|
218
|
+
out.append(
|
|
219
|
+
f"{_timestamp(block['start_seconds'], comma=True)} --> "
|
|
220
|
+
f"{_timestamp(block['end_seconds'], comma=True)}"
|
|
221
|
+
)
|
|
222
|
+
out.extend(block["lines"])
|
|
223
|
+
out.append("")
|
|
224
|
+
return "\n".join(out)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def to_vtt(blocks: Sequence[Dict[str, Any]]) -> str:
|
|
228
|
+
out: List[str] = ["WEBVTT", ""]
|
|
229
|
+
for block in blocks:
|
|
230
|
+
out.append(
|
|
231
|
+
f"{_timestamp(block['start_seconds'], comma=False)} --> "
|
|
232
|
+
f"{_timestamp(block['end_seconds'], comma=False)}"
|
|
233
|
+
)
|
|
234
|
+
out.extend(block["lines"])
|
|
235
|
+
out.append("")
|
|
236
|
+
return "\n".join(out)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def render(blocks: Sequence[Dict[str, Any]], fmt: str) -> str:
|
|
240
|
+
if fmt not in FORMATS:
|
|
241
|
+
raise CaptionError(f"unknown caption format {fmt!r}; valid: {', '.join(FORMATS)}")
|
|
242
|
+
return to_srt(blocks) if fmt == "srt" else to_vtt(blocks)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── chapters ─────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def chapters_from_blocks(
|
|
249
|
+
blocks: Sequence[Dict[str, Any]],
|
|
250
|
+
*,
|
|
251
|
+
min_spacing_seconds: float = 30.0,
|
|
252
|
+
max_title_chars: int = 60,
|
|
253
|
+
) -> List[Dict[str, Any]]:
|
|
254
|
+
"""Derive chapters from caption blocks by topic-shift heuristic.
|
|
255
|
+
|
|
256
|
+
A long pause landing on a sentence start is the cheapest usable signal for
|
|
257
|
+
"new topic". This is explicitly a heuristic — it produces a starting point
|
|
258
|
+
an editor renames, not a finished chapter list.
|
|
259
|
+
|
|
260
|
+
The first chapter is always forced to 00:00 because that is a hard
|
|
261
|
+
requirement of the YouTube description format, not a stylistic choice.
|
|
262
|
+
"""
|
|
263
|
+
if not blocks:
|
|
264
|
+
return []
|
|
265
|
+
chapters: List[Dict[str, Any]] = [{
|
|
266
|
+
"start_seconds": 0.0,
|
|
267
|
+
"title": " ".join(blocks[0]["lines"])[:max_title_chars].strip(),
|
|
268
|
+
}]
|
|
269
|
+
for previous, block in zip(blocks, blocks[1:]):
|
|
270
|
+
gap = block["start_seconds"] - previous["end_seconds"]
|
|
271
|
+
if gap < DEFAULT_PAUSE_BREAK_SECONDS:
|
|
272
|
+
continue
|
|
273
|
+
if block["start_seconds"] - chapters[-1]["start_seconds"] < min_spacing_seconds:
|
|
274
|
+
continue
|
|
275
|
+
chapters.append({
|
|
276
|
+
"start_seconds": round(block["start_seconds"], 3),
|
|
277
|
+
"title": " ".join(block["lines"])[:max_title_chars].strip(),
|
|
278
|
+
})
|
|
279
|
+
return chapters
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def chapters_to_youtube(chapters: Sequence[Dict[str, Any]]) -> str:
|
|
283
|
+
"""YouTube description text. Always starts at 00:00 or YouTube ignores it."""
|
|
284
|
+
lines = []
|
|
285
|
+
for chapter in chapters:
|
|
286
|
+
total = int(chapter["start_seconds"])
|
|
287
|
+
hours, rest = divmod(total, 3600)
|
|
288
|
+
minutes, secs = divmod(rest, 60)
|
|
289
|
+
stamp = f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
|
|
290
|
+
lines.append(f"{stamp} {chapter['title']}".rstrip())
|
|
291
|
+
return "\n".join(lines)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def generate(
|
|
295
|
+
words: Sequence[Dict[str, Any]],
|
|
296
|
+
*,
|
|
297
|
+
fmt: str = "srt",
|
|
298
|
+
with_chapters: bool = False,
|
|
299
|
+
**options: Any,
|
|
300
|
+
) -> Dict[str, Any]:
|
|
301
|
+
"""Word timings → caption text plus the block list and optional chapters."""
|
|
302
|
+
if fmt not in FORMATS:
|
|
303
|
+
raise CaptionError(f"unknown caption format {fmt!r}; valid: {', '.join(FORMATS)}")
|
|
304
|
+
blocks = build_blocks(words, **options)
|
|
305
|
+
if not blocks:
|
|
306
|
+
return {
|
|
307
|
+
"success": False,
|
|
308
|
+
"error": "No timed words to caption.",
|
|
309
|
+
"remediation": "Run transcription for this clip first, then retry.",
|
|
310
|
+
}
|
|
311
|
+
result: Dict[str, Any] = {
|
|
312
|
+
"success": True,
|
|
313
|
+
"format": fmt,
|
|
314
|
+
"text": render(blocks, fmt),
|
|
315
|
+
"blocks": blocks,
|
|
316
|
+
"block_count": len(blocks),
|
|
317
|
+
"duration_seconds": round(blocks[-1]["end_seconds"], 3),
|
|
318
|
+
}
|
|
319
|
+
if with_chapters:
|
|
320
|
+
chapters = chapters_from_blocks(blocks)
|
|
321
|
+
result["chapters"] = chapters
|
|
322
|
+
result["chapters_youtube"] = chapters_to_youtube(chapters)
|
|
323
|
+
return result
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Display-referred colour science: sRGB ↔ linear ↔ XYZ ↔ CIE Lab (D65), ΔE2000.
|
|
2
|
+
|
|
3
|
+
Pure numpy, no third-party colour library, so the conversions can be unit-tested
|
|
4
|
+
against published reference values rather than trusted.
|
|
5
|
+
|
|
6
|
+
Everything here is **display-referred**. RGB is float in [0, 1] with an sRGB
|
|
7
|
+
transfer function; Lab is L* in [0, 100] with a/b roughly [-128, 128]. Feeding
|
|
8
|
+
log or scene-referred RGB (ACEScct, S-Log3, LogC) through these functions
|
|
9
|
+
produces numbers that look plausible and mean nothing — `image_qc` is the layer
|
|
10
|
+
that refuses that case, and it refuses rather than converting silently because
|
|
11
|
+
the correct conversion depends on a transform this module cannot know.
|
|
12
|
+
|
|
13
|
+
ΔE2000 is implemented rather than ΔE76 because ΔE76 badly under-weights hue
|
|
14
|
+
error in the blues and over-weights chroma error in saturated colours, and any
|
|
15
|
+
number a human reads as "how close is this" should not carry that distortion.
|
|
16
|
+
The implementation is validated against the Sharma/Wu/Dalal (2005) test set,
|
|
17
|
+
which is specifically constructed to exercise the hue-rotation and chroma
|
|
18
|
+
discontinuities where naive implementations disagree.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
# sRGB (IEC 61966-2-1) companding thresholds.
|
|
26
|
+
_SRGB_THRESH = 0.04045
|
|
27
|
+
_SRGB_LIN_THRESH = 0.0031308
|
|
28
|
+
|
|
29
|
+
# sRGB primaries → XYZ (D65).
|
|
30
|
+
_RGB2XYZ = np.array(
|
|
31
|
+
[
|
|
32
|
+
[0.4124564, 0.3575761, 0.1804375],
|
|
33
|
+
[0.2126729, 0.7151522, 0.0721750],
|
|
34
|
+
[0.0193339, 0.1191920, 0.9503041],
|
|
35
|
+
],
|
|
36
|
+
dtype=np.float64,
|
|
37
|
+
)
|
|
38
|
+
_XYZ2RGB = np.linalg.inv(_RGB2XYZ)
|
|
39
|
+
|
|
40
|
+
#: D65 reference white, derived from the matrix rather than quoted.
|
|
41
|
+
#:
|
|
42
|
+
#: The usual rounded literal [0.95047, 1.0, 1.08883] is *not* exactly the white
|
|
43
|
+
#: this matrix produces from RGB 1,1,1 — they disagree in the 6th decimal, which
|
|
44
|
+
#: leaves sRGB white landing at L* = 100.0000039 with a non-zero a*/b*. Deriving
|
|
45
|
+
#: it keeps the two constants consistent by construction, so white is exactly
|
|
46
|
+
#: neutral and the Lab round-trip closes.
|
|
47
|
+
_D65 = _RGB2XYZ @ np.ones(3, dtype=np.float64)
|
|
48
|
+
|
|
49
|
+
# CIE Lab constants, in the exact-rational form (CIE 15:2004) rather than the
|
|
50
|
+
# rounded 0.008856 / 903.3 that older references use — the rounded pair puts a
|
|
51
|
+
# small discontinuity at the linear/cube-root join.
|
|
52
|
+
_LAB_EPS = 216.0 / 24389.0 # (6/29)^3
|
|
53
|
+
_LAB_KAPPA = 24389.0 / 27.0 # (29/3)^3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
|
|
57
|
+
rgb = np.asarray(rgb, dtype=np.float64)
|
|
58
|
+
return np.where(rgb <= _SRGB_THRESH, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def linear_to_srgb(rgb: np.ndarray) -> np.ndarray:
|
|
62
|
+
rgb = np.asarray(rgb, dtype=np.float64)
|
|
63
|
+
return np.where(
|
|
64
|
+
rgb <= _SRGB_LIN_THRESH,
|
|
65
|
+
rgb * 12.92,
|
|
66
|
+
1.055 * np.power(np.clip(rgb, 0.0, None), 1.0 / 2.4) - 0.055,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def linear_to_xyz(rgb_lin: np.ndarray) -> np.ndarray:
|
|
71
|
+
return np.asarray(rgb_lin, dtype=np.float64) @ _RGB2XYZ.T
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def xyz_to_linear(xyz: np.ndarray) -> np.ndarray:
|
|
75
|
+
return np.asarray(xyz, dtype=np.float64) @ _XYZ2RGB.T
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _f_forward(t: np.ndarray) -> np.ndarray:
|
|
79
|
+
return np.where(t > _LAB_EPS, np.cbrt(t), (_LAB_KAPPA * t + 16.0) / 116.0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _f_inverse(f: np.ndarray) -> np.ndarray:
|
|
83
|
+
f3 = f**3
|
|
84
|
+
return np.where(f3 > _LAB_EPS, f3, (116.0 * f - 16.0) / _LAB_KAPPA)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def xyz_to_lab(xyz: np.ndarray) -> np.ndarray:
|
|
88
|
+
scaled = np.asarray(xyz, dtype=np.float64) / _D65
|
|
89
|
+
fx, fy, fz = (_f_forward(scaled[..., i]) for i in range(3))
|
|
90
|
+
return np.stack([116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)], axis=-1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def lab_to_xyz(lab: np.ndarray) -> np.ndarray:
|
|
94
|
+
lab = np.asarray(lab, dtype=np.float64)
|
|
95
|
+
fy = (lab[..., 0] + 16.0) / 116.0
|
|
96
|
+
fx = fy + lab[..., 1] / 500.0
|
|
97
|
+
fz = fy - lab[..., 2] / 200.0
|
|
98
|
+
return np.stack([_f_inverse(fx) * _D65[0], _f_inverse(fy) * _D65[1], _f_inverse(fz) * _D65[2]], axis=-1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
|
|
102
|
+
return xyz_to_lab(linear_to_xyz(srgb_to_linear(rgb)))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def lab_to_srgb(lab: np.ndarray, *, clip: bool = True) -> np.ndarray:
|
|
106
|
+
rgb = linear_to_srgb(xyz_to_linear(lab_to_xyz(lab)))
|
|
107
|
+
return np.clip(rgb, 0.0, 1.0) if clip else rgb
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def delta_e76(lab_a: np.ndarray, lab_b: np.ndarray) -> np.ndarray:
|
|
111
|
+
"""Plain Euclidean distance in Lab. Kept for cheap internal comparisons only.
|
|
112
|
+
|
|
113
|
+
Not for anything a human reads as a quality figure — use `delta_e2000`.
|
|
114
|
+
"""
|
|
115
|
+
a = np.asarray(lab_a, dtype=np.float64)
|
|
116
|
+
b = np.asarray(lab_b, dtype=np.float64)
|
|
117
|
+
return np.sqrt(np.sum((a - b) ** 2, axis=-1))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def delta_e2000(
|
|
121
|
+
lab_a: np.ndarray, lab_b: np.ndarray, *, k_l: float = 1.0, k_c: float = 1.0, k_h: float = 1.0
|
|
122
|
+
) -> np.ndarray:
|
|
123
|
+
"""CIEDE2000 colour difference (CIE 142:2001).
|
|
124
|
+
|
|
125
|
+
Validated against the Sharma/Wu/Dalal (2005) test set — see
|
|
126
|
+
`tests/test_colorimetry.py`. The awkward parts are the two hue-difference
|
|
127
|
+
branches, which must use the 180°/360° wrap conventions exactly as specified;
|
|
128
|
+
getting them subtly wrong still passes most casual test pairs.
|
|
129
|
+
"""
|
|
130
|
+
a = np.asarray(lab_a, dtype=np.float64)
|
|
131
|
+
b = np.asarray(lab_b, dtype=np.float64)
|
|
132
|
+
l1, a1, b1 = a[..., 0], a[..., 1], a[..., 2]
|
|
133
|
+
l2, a2, b2 = b[..., 0], b[..., 1], b[..., 2]
|
|
134
|
+
|
|
135
|
+
c1 = np.hypot(a1, b1)
|
|
136
|
+
c2 = np.hypot(a2, b2)
|
|
137
|
+
c_bar = (c1 + c2) / 2.0
|
|
138
|
+
g = 0.5 * (1.0 - np.sqrt(c_bar**7 / (c_bar**7 + 25.0**7)))
|
|
139
|
+
|
|
140
|
+
a1p, a2p = (1.0 + g) * a1, (1.0 + g) * a2
|
|
141
|
+
c1p, c2p = np.hypot(a1p, b1), np.hypot(a2p, b2)
|
|
142
|
+
|
|
143
|
+
# Hue angles in degrees; undefined (0) when chroma is zero, per the standard.
|
|
144
|
+
h1p = np.degrees(np.arctan2(b1, a1p)) % 360.0
|
|
145
|
+
h2p = np.degrees(np.arctan2(b2, a2p)) % 360.0
|
|
146
|
+
h1p = np.where(c1p == 0, 0.0, h1p)
|
|
147
|
+
h2p = np.where(c2p == 0, 0.0, h2p)
|
|
148
|
+
|
|
149
|
+
dlp = l2 - l1
|
|
150
|
+
dcp = c2p - c1p
|
|
151
|
+
|
|
152
|
+
# Δh′: zero when either chroma is zero, else wrapped to (-180, 180].
|
|
153
|
+
dhp = h2p - h1p
|
|
154
|
+
dhp = np.where(dhp > 180.0, dhp - 360.0, dhp)
|
|
155
|
+
dhp = np.where(dhp < -180.0, dhp + 360.0, dhp)
|
|
156
|
+
dhp = np.where(c1p * c2p == 0, 0.0, dhp)
|
|
157
|
+
dhp_term = 2.0 * np.sqrt(c1p * c2p) * np.sin(np.radians(dhp) / 2.0)
|
|
158
|
+
|
|
159
|
+
lp_bar = (l1 + l2) / 2.0
|
|
160
|
+
cp_bar = (c1p + c2p) / 2.0
|
|
161
|
+
|
|
162
|
+
# H̄′: the mean hue, which needs the wrap handled separately from Δh′ — the
|
|
163
|
+
# sum/2 convention differs from the difference convention above.
|
|
164
|
+
h_sum = h1p + h2p
|
|
165
|
+
h_abs = np.abs(h1p - h2p)
|
|
166
|
+
hp_bar = np.where(
|
|
167
|
+
c1p * c2p == 0,
|
|
168
|
+
h_sum,
|
|
169
|
+
np.where(
|
|
170
|
+
h_abs <= 180.0,
|
|
171
|
+
h_sum / 2.0,
|
|
172
|
+
np.where(h_sum < 360.0, (h_sum + 360.0) / 2.0, (h_sum - 360.0) / 2.0),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
t = (
|
|
177
|
+
1.0
|
|
178
|
+
- 0.17 * np.cos(np.radians(hp_bar - 30.0))
|
|
179
|
+
+ 0.24 * np.cos(np.radians(2.0 * hp_bar))
|
|
180
|
+
+ 0.32 * np.cos(np.radians(3.0 * hp_bar + 6.0))
|
|
181
|
+
- 0.20 * np.cos(np.radians(4.0 * hp_bar - 63.0))
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
d_theta = 30.0 * np.exp(-(((hp_bar - 275.0) / 25.0) ** 2))
|
|
185
|
+
r_c = 2.0 * np.sqrt(cp_bar**7 / (cp_bar**7 + 25.0**7))
|
|
186
|
+
s_l = 1.0 + (0.015 * (lp_bar - 50.0) ** 2) / np.sqrt(20.0 + (lp_bar - 50.0) ** 2)
|
|
187
|
+
s_c = 1.0 + 0.045 * cp_bar
|
|
188
|
+
s_h = 1.0 + 0.015 * cp_bar * t
|
|
189
|
+
r_t = -np.sin(np.radians(2.0 * d_theta)) * r_c
|
|
190
|
+
|
|
191
|
+
return np.sqrt(
|
|
192
|
+
(dlp / (k_l * s_l)) ** 2
|
|
193
|
+
+ (dcp / (k_c * s_c)) ** 2
|
|
194
|
+
+ (dhp_term / (k_h * s_h)) ** 2
|
|
195
|
+
+ r_t * (dcp / (k_c * s_c)) * (dhp_term / (k_h * s_h))
|
|
196
|
+
)
|