Guest User

Untitled

a guest
Mar 24th, 2026
176
0
Never
1
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 31.64 KB | None | 0 0
  1. """
  2. subtitle_mask.py
  3. ===================
  4. VapourSynth subtitle mask creation, based on InpaintDelogo.avsi (v3.7) by VoodooFX.
  5. Original: https://github.com/Purfview/InpaintDelogo
  6.  
  7. Implements the dynamic mask generation paths used for subtitle detection:
  8. - DynMask=2 : Combo mask — DynTune (luma binarise) + optional DynColor + DynTEdge
  9. - DynMask=4 : Subtitle-with-halo mask — DynTune + DynMask4H hysteresis
  10. - DynMask=6 : Luma-only binarise mask (DynTune)
  11.  
  12. The returned clip is a single-plane (GRAY8/GRAY16) binary mask in the same
  13. spatial region as the source (full frame or a cropped 'loc' area).
  14.  
  15. Required plugins
  16. ----------------
  17. - vapoursynth-descale / resize (built-in resizers)
  18. - vs-placebo or similar for colour-key masking [DynColor path, optional]
  19. - akarin / std.Expr (built-in Expr) – akarin is preferred if available
  20. - rgvs (RemoveGrain) [KillNoise paths]
  21. - tedgemask [DynTEdge path, optional]
  22. """
  23.  
  24. from __future__ import annotations
  25. import vapoursynth as vs
  26. from vapoursynth import core
  27. from typing import Union, Optional
  28.  
  29. # ---------------------------------------------------------------------------
  30. # Helpers
  31. # ---------------------------------------------------------------------------
  32.  
  33. def _to_gray(clip: vs.VideoNode) -> vs.VideoNode:
  34. """
  35. Extract luma as GRAY8, preserving the original range.
  36.  
  37. We do NOT force range expansion here — luma values stay as-is so that
  38. thresholds in _binarise/_dyntune_mask mean the same thing as what you
  39. see in a frame inspector (e.g. 16-235 for limited, 0-255 for full).
  40. """
  41. if clip.format.color_family == vs.GRAY:
  42. if clip.format.id != vs.GRAY8:
  43. clip = core.resize.Bilinear(clip, format=vs.GRAY8)
  44. return clip
  45. # ShufflePlanes pulls the Y plane without any range/matrix conversion,
  46. # so the luma values are exactly what they are in the source.
  47. return core.std.ShufflePlanes(clip, planes=0, colorfamily=vs.GRAY)
  48.  
  49.  
  50. def _binarise(clip: vs.VideoNode, threshold: int) -> vs.VideoNode:
  51. """Full-range GRAY8 binarise: pixels >= threshold → 255, else 0."""
  52. assert clip.format.id == vs.GRAY8
  53. return core.std.Binarize(clip, threshold=threshold)
  54.  
  55.  
  56. def _inflate(clip: vs.VideoNode, radius: int = 1) -> vs.VideoNode:
  57. """Morphological dilation (maximum filter) by *radius* iterations."""
  58. for _ in range(radius):
  59. clip = core.std.Maximum(clip)
  60. return clip
  61.  
  62.  
  63. def _inpand(clip: vs.VideoNode, radius: int = 1) -> vs.VideoNode:
  64. """Morphological erosion (minimum filter) by *radius* iterations."""
  65. for _ in range(radius):
  66. clip = core.std.Minimum(clip)
  67. return clip
  68.  
  69.  
  70. # ---------------------------------------------------------------------------
  71. # Plugin capability detection — resolved once at import time
  72. # ---------------------------------------------------------------------------
  73.  
  74. def _has_plugin(namespace: str, func: str) -> bool:
  75. return hasattr(core, namespace) and hasattr(getattr(core, namespace), func)
  76.  
  77. _USE_ZSMOOTH = _has_plugin("zsmooth", "RemoveGrain")
  78. _USE_MISC_HYS = _has_plugin("misc", "Hysteresis") # vs-miscfilters-obsolete
  79. _USE_HYS_HYS = _has_plugin("hysteresis", "Hysteresis") # sgt0/vapoursynth-hysteresis
  80. _USE_AKARIN_EXPR = _has_plugin("akarin", "Expr")
  81.  
  82. print(f"[subtitle_mask] hysteresis.Hysteresis={_USE_HYS_HYS} "
  83. f"misc.Hysteresis={_USE_MISC_HYS} "
  84. f"zsmooth.RemoveGrain={_USE_ZSMOOTH} "
  85. f"akarin.Expr={_USE_AKARIN_EXPR}")
  86.  
  87. # ---------------------------------------------------------------------------
  88. # Expression wrapper – uses akarin.Expr if available, otherwise std.Expr
  89. # ---------------------------------------------------------------------------
  90.  
  91. def _expr(clips, expr):
  92. """
  93. Wrapper for core.akarin.Expr (preferred) or core.std.Expr.
  94.  
  95. Automatically converts a single clip into a list for consistency.
  96. """
  97. if not isinstance(clips, list):
  98. clips = [clips]
  99. if _USE_AKARIN_EXPR:
  100. return core.akarin.Expr(clips, expr)
  101. else:
  102. return core.std.Expr(clips, expr)
  103.  
  104.  
  105. def _remove_grain(clip: vs.VideoNode, mode: int = 2) -> vs.VideoNode:
  106. """
  107. RemoveGrain mode wrapper.
  108.  
  109. Prefers zsmooth.RemoveGrain when available — it is a modern,
  110. maintained reimplementation with identical mode semantics and is
  111. generally faster. Falls back to rgvs.RemoveGrain otherwise.
  112.  
  113. https://github.com/adworacz/zsmooth
  114. """
  115. if _USE_ZSMOOTH:
  116. return core.zsmooth.RemoveGrain(clip, mode=mode)
  117. return core.rgvs.RemoveGrain(clip, mode=mode)
  118.  
  119.  
  120. def _hysteresis(clip_weak: vs.VideoNode,
  121. clip_strong: vs.VideoNode) -> vs.VideoNode:
  122. """
  123. Hysteresis: grow clip_strong into clip_weak.
  124.  
  125. Both plugins share the same argument order:
  126. clipa = strong seed (the smaller, reliable mask)
  127. clipb = weak candidate (the larger region to grow into)
  128.  
  129. Priority: hysteresis.Hysteresis (sgt0) → misc.Hysteresis → AND fallback.
  130. The AND fallback is a last resort — it only keeps pixels where both
  131. masks directly overlap, with no flood-fill, so mode 4 will be poor.
  132. """
  133. if _USE_HYS_HYS:
  134. return core.hysteresis.Hysteresis(clip_strong, clip_weak)
  135. if _USE_MISC_HYS:
  136. return core.misc.Hysteresis(clip_strong, clip_weak)
  137. # Fallback: simple bitwise AND (no flood-fill, much less accurate)
  138. return _expr([clip_weak, clip_strong], "x y and")
  139.  
  140.  
  141. # ---------------------------------------------------------------------------
  142. # Diagnostics
  143. # ---------------------------------------------------------------------------
  144.  
  145. def debug_levels(clip: vs.VideoNode,
  146. loc: Optional[tuple[int, int, int, int]] = None,
  147. frame: int = 0) -> dict:
  148. """
  149. Print the min/max/average luma in the subtitle region for a given frame.
  150. Use this to find the right dyn_tune and dynmask4h thresholds.
  151.  
  152. Example
  153. -------
  154. import subtitle_mask
  155. info = subtitle_mask.debug_levels(src, loc=(100,1380,60,440), frame=500)
  156. # Then set dyn_tune just below the max, dynmask4h just above the min.
  157. """
  158. if loc is not None:
  159. left, top, right, bottom = loc
  160. clip = core.std.Crop(clip, left=left, top=top,
  161. right=right, bottom=bottom)
  162.  
  163. gray = core.std.ShufflePlanes(clip, planes=0, colorfamily=vs.GRAY) \
  164. if clip.format.color_family != vs.GRAY else clip
  165.  
  166. f = gray.get_frame(frame)
  167. import array
  168. # Read raw plane data - memoryview is the clean VS way
  169. plane = f[0]
  170. data = array.array('B', bytes(plane))
  171.  
  172. mn = min(data)
  173. mx = max(data)
  174. avg = sum(data) / len(data)
  175.  
  176. # Bucket histogram: 16 buckets of 16 values each (0-15, 16-31, ... 240-255)
  177. buckets = [0] * 16
  178. for v in data:
  179. buckets[v >> 4] += 1
  180. total = len(data)
  181.  
  182. print(f"[debug_levels] frame={frame} min={mn} max={mx} avg={avg:.1f} total_px={total}")
  183. print(f" Luma histogram (each bar = % of pixels in that 16-value bucket):")
  184. for i, count in enumerate(buckets):
  185. lo = i * 16
  186. hi = lo + 15
  187. pct = 100 * count / total
  188. bar = "█" * int(pct / 2)
  189. print(f" {lo:3d}-{hi:3d} {bar:25s} {pct:5.1f}% ({count} px)")
  190.  
  191. # Find the top-2 peaks to guess text and outline luma ranges
  192. sorted_b = sorted(enumerate(buckets), key=lambda x: -x[1])
  193. peak1_idx = sorted_b[0][0]
  194. peak2_idx = sorted_b[1][0]
  195. peaks = sorted([peak1_idx, peak2_idx])
  196. text_lo = peaks[1] * 16 # higher-luma peak = text
  197. text_hi = text_lo + 15
  198. dark_lo = peaks[0] * 16 # lower-luma peak = outline/background
  199. dark_hi = dark_lo + 15
  200.  
  201. print(f" Two dominant luma bands: {dark_lo}-{dark_hi} and {text_lo}-{text_hi}")
  202. print(f" → Suggested dyn_tune (text lower bound): {text_lo}")
  203. print(f" → Suggested dynmask4h (outline upper bound): {dark_hi}")
  204. print(f" Note: for coloured subs (yellow etc.) luma is lower than white.")
  205. print(f" Use dyn_color for reliable colour-based detection.")
  206.  
  207. result = {"min": mn, "max": mx, "avg": round(avg, 1), "frame": frame,
  208. "histogram": buckets,
  209. "suggested_dyn_tune": text_lo,
  210. "suggested_dynmask4h": dark_hi}
  211. return result
  212.  
  213.  
  214. def debug_color(clip: vs.VideoNode,
  215. loc: Optional[tuple[int, int, int, int]] = None,
  216. frame: int = 0,
  217. dyn_tune: int = 140) -> None:
  218. """
  219. Sample the average RGB colour of pixels above dyn_tune threshold.
  220. Use this to find the dyn_color hex value for coloured subtitles.
  221.  
  222. Example
  223. -------
  224. subtitle_mask.debug_color(src, loc=(100,1380,60,440), frame=150, dyn_tune=140)
  225. # Output: Avg colour of bright pixels: R=255 G=255 B=0 → dyn_color=0xFFFF00
  226. """
  227. if loc is not None:
  228. left, top, right, bottom = loc
  229. clip = core.std.Crop(clip, left=left, top=top,
  230. right=right, bottom=bottom)
  231.  
  232. # Convert to RGB24 for easy R/G/B sampling
  233. rgb = core.resize.Bilinear(clip, format=vs.RGB24, matrix_in_s="709")
  234. gray = core.std.ShufflePlanes(clip, planes=0, colorfamily=vs.GRAY) if clip.format.color_family != vs.GRAY else clip
  235.  
  236. import array
  237. fg = gray.get_frame(frame)
  238. fr = rgb.get_frame(frame)
  239. y_data = array.array('B', bytes(fg[0]))
  240. # VS RGB24 plane order: 0=G, 1=B, 2=R
  241. g_data = array.array('B', bytes(fr[0]))
  242. b_data = array.array('B', bytes(fr[1]))
  243. r_data = array.array('B', bytes(fr[2]))
  244.  
  245. # Sample only pixels above the luma threshold (likely text pixels)
  246. r_vals, g_vals, b_vals = [], [], []
  247. for i, y in enumerate(y_data):
  248. if y >= dyn_tune:
  249. r_vals.append(r_data[i])
  250. g_vals.append(g_data[i])
  251. b_vals.append(b_data[i])
  252.  
  253. if not r_vals:
  254. print(f"[debug_color] No pixels above dyn_tune={dyn_tune}. Try lowering it.")
  255. return
  256.  
  257. avg_r = int(sum(r_vals) / len(r_vals))
  258. avg_g = int(sum(g_vals) / len(g_vals))
  259. avg_b = int(sum(b_vals) / len(b_vals))
  260. hex_color = (avg_r << 16) | (avg_g << 8) | avg_b
  261. print(f"[debug_color] frame={frame} sampled {len(r_vals)} pixels above Y>={dyn_tune}")
  262. print(f" Avg colour: R={avg_r} G={avg_g} B={avg_b} → dyn_color=0x{hex_color:06X}")
  263. print(f" Use: subtitle_mask(clip, dyn_mask_mode=2, dyn_color=0x{hex_color:06X}, "
  264. f"dyn_color_tol=20, dyn_tune={dyn_tune})")
  265.  
  266.  
  267. def debug_mask_stages(clip: vs.VideoNode,
  268. loc: Optional[tuple[int, int, int, int]] = None,
  269. dyn_tune: int = 200,
  270. dynmask4h: int = 60,
  271. frame: int = 0) -> None:
  272. """
  273. Print pixel statistics for each mask stage to diagnose an all-black result.
  274.  
  275. Example
  276. -------
  277. subtitle_mask.debug_mask_stages(src, loc=(100,1380,60,440),
  278. dyn_tune=200, dynmask4h=60, frame=500)
  279. """
  280. if loc is not None:
  281. left, top, right, bottom = loc
  282. clip = core.std.Crop(clip, left=left, top=top,
  283. right=right, bottom=bottom)
  284.  
  285. gray = core.std.ShufflePlanes(clip, planes=0, colorfamily=vs.GRAY) if clip.format.color_family != vs.GRAY else clip
  286.  
  287. def _stats(name: str, node: vs.VideoNode) -> None:
  288. import array
  289. f = node.get_frame(frame)
  290. data = array.array('B', bytes(f[0]))
  291. white = sum(1 for v in data if v > 0)
  292. total = len(data)
  293. print(f" {name:30s} min={min(data):3d} max={max(data):3d}"
  294. f" white_px={white}/{total} ({100*white//total}%)")
  295.  
  296. print(f"\n[debug_mask_stages] frame={frame} "
  297. f"clip={clip.width}x{clip.height} "
  298. f"format={clip.format.name}")
  299. print(f" hysteresis backend: "
  300. f"{'hysteresis' if _USE_HYS_HYS else 'misc' if _USE_MISC_HYS else 'AND-fallback (inaccurate — install hysteresis or misc plugin!)'}")
  301. _stats("luma (raw Y plane)", gray)
  302. _stats(f"dyn_tune>={dyn_tune}", core.std.Binarize(gray, threshold=dyn_tune))
  303. _stats(f"dynmask4h<={dynmask4h}", _expr(gray, f"x {dynmask4h} <= 255 0 ?"))
  304.  
  305. text_mask = core.std.Binarize(gray, threshold=dyn_tune)
  306. halo_mask = _expr(gray, f"x {dynmask4h} <= 255 0 ?")
  307. _stats("halo_mask (dark pixels)", halo_mask)
  308.  
  309. # Find minimum inflate needed for text seed to touch the dark outline
  310. print(" Inflate sweep (text seed vs halo overlap):")
  311. for inf in range(0, 6):
  312. seed = text_mask
  313. for _ in range(inf):
  314. seed = core.std.Maximum(seed)
  315. overlap = _expr([seed, halo_mask], "x y and")
  316. import array as _arr
  317. f2 = overlap.get_frame(frame)
  318. d2 = _arr.array('B', bytes(f2[0]))
  319. white = sum(1 for v in d2 if v > 0)
  320. print(f" inflate={inf} overlap white_px={white}"
  321. + (" ← first hit" if inf > 0 and white > 0 else "")
  322. + (" ← use this or higher" if white > 0 else " (no overlap yet)"))
  323. if white > 0 and inf >= 2:
  324. break
  325. print(" → If all inflates show 0 overlap, dyn_tune is too high or dynmask4h too low.")
  326. print(" Try lowering dyn_tune by 20 or raising dynmask4h by 20 and re-run.")
  327.  
  328.  
  329. # ---------------------------------------------------------------------------
  330. # KillNoise — mirrors InpaintDelogo's KillNoise parameter
  331. # ---------------------------------------------------------------------------
  332.  
  333. def _kill_noise(clip: vs.VideoNode, mode: int) -> vs.VideoNode:
  334. """
  335. Reduce small mask artifacts.
  336.  
  337. mode 0 : disabled
  338. mode 1 : single Undot (RemoveGrain mode 2)
  339. mode 2 : two-pass Undot
  340. mode 3 : Hysteresis cleanup (inpand → hysteresis)
  341. mode 4 : Undot + Hysteresis
  342. mode 5 : two-pass Undot + Hysteresis
  343.  
  344. Negative modes pre-apply expand→inpand to reduce sponginess.
  345. """
  346. if mode == 0:
  347. return clip
  348.  
  349. if mode < 0:
  350. # Pre-sponge reduction
  351. clip = _inflate(clip)
  352. clip = _inpand(clip)
  353. mode = -mode
  354.  
  355. if mode in (1, 4, 5):
  356. clip = _remove_grain(clip, mode=2)
  357. if mode in (2, 5):
  358. clip = _remove_grain(clip, mode=2)
  359. if mode in (3, 4, 5):
  360. eroded = _inpand(clip)
  361. clip = _hysteresis(clip, eroded)
  362.  
  363. return clip
  364.  
  365.  
  366. # ---------------------------------------------------------------------------
  367. # KillBlobs — mirrors InpaintDelogo's KillBlobs parameter
  368. # ---------------------------------------------------------------------------
  369.  
  370. def _kill_blobs(clip: vs.VideoNode,
  371. strength: int,
  372. pre_blobs: int = 0) -> vs.VideoNode:
  373. """
  374. Remove blobs thicker than logo/subtitle text.
  375.  
  376. strength > 0 : inpand→expand method (safer, leaves blob edges)
  377. strength < 0 : inpand→hysteresis method (removes blobs fully)
  378. pre_blobs : pre-sponginess reduction (±1 = 'both' mode, ±2 = 'square')
  379.  
  380. Mirrors AviSynth's positive/negative KillBlobs logic.
  381. """
  382. if strength == 0:
  383. return clip
  384.  
  385. n = abs(strength)
  386.  
  387. # Pre-blob sponginess reduction
  388. if pre_blobs != 0:
  389. if pre_blobs > 0:
  390. expanded = _inflate(clip)
  391. clip = _kill_blobs_inner(clip, n, strength > 0)
  392. clip = _inpand(expanded) # not quite right but close
  393. else:
  394. expanded = _inflate(clip)
  395. eroded = _inpand(expanded)
  396. clip = eroded
  397. clip = _kill_blobs_inner(clip, n, strength > 0)
  398. return clip
  399.  
  400. return _kill_blobs_inner(clip, n, strength > 0)
  401.  
  402.  
  403. def _kill_blobs_inner(clip: vs.VideoNode,
  404. n: int,
  405. positive: bool) -> vs.VideoNode:
  406. eroded = clip
  407. for _ in range(n):
  408. eroded = _inpand(eroded)
  409.  
  410. if positive:
  411. result = _inflate(eroded)
  412. # keep only pixels present in the shrunken+expanded version
  413. result = _expr([clip, result], "x y min")
  414. else:
  415. result = _hysteresis(clip, eroded)
  416.  
  417. return result
  418.  
  419.  
  420. # ---------------------------------------------------------------------------
  421. # DynTune — luma binarise mask (DynMask=6, and as part of 2/4)
  422. # ---------------------------------------------------------------------------
  423.  
  424. def _dyntune_mask(clip: vs.VideoNode,
  425. dyn_tune: Union[int, str] = 200,
  426. dyn_tune_inf: int = 0) -> vs.VideoNode:
  427. """
  428. Binarise the luma plane at *dyn_tune* threshold.
  429.  
  430. dyn_tune can be:
  431. int : simple threshold — pixels >= dyn_tune → 255
  432. str : range string like "180 - 200" → keep only pixels in [low, high]
  433.  
  434. dyn_tune_inf : inflate the resulting mask by this many pixels
  435. (-1 means inpand by 1).
  436. """
  437. gray = _to_gray(clip)
  438.  
  439. if isinstance(dyn_tune, str):
  440. # Range mask: "low - high"
  441. parts = [p.strip() for p in dyn_tune.split("-")]
  442. lo, hi = int(parts[0]), int(parts[1])
  443. mask = _expr(gray, f"x {lo} >= x {hi} <= and 255 0 ?")
  444. else:
  445. mask = _binarise(gray, int(dyn_tune))
  446.  
  447. if dyn_tune_inf > 0:
  448. mask = _inflate(mask, dyn_tune_inf)
  449. elif dyn_tune_inf < 0:
  450. mask = _inpand(mask, abs(dyn_tune_inf))
  451.  
  452. return mask
  453.  
  454.  
  455. # ---------------------------------------------------------------------------
  456. # DynColor — colour-key mask (optional, needs vs-placebo or similar)
  457. # ---------------------------------------------------------------------------
  458.  
  459. def _dyncolor_mask(clip: vs.VideoNode,
  460. color_rgb: int,
  461. tolerance: int = 10,
  462. inflate: int = 1) -> vs.VideoNode:
  463. """
  464. Create a mask based on a single target colour.
  465.  
  466. color_rgb : RGB hex value, e.g. 0xFFFFFF for white.
  467. tolerance : colour tolerance (1-140).
  468. inflate : inflate result by this many pixels.
  469.  
  470. Implementation uses std.Expr on an RGB24 intermediate.
  471. For multiple colours use the string form (not yet implemented here).
  472. """
  473. r = (color_rgb >> 16) & 0xFF
  474. g = (color_rgb >> 8) & 0xFF
  475. b = (color_rgb ) & 0xFF
  476.  
  477. rgb = core.resize.Bilinear(clip, format=vs.RGB24,
  478. matrix_in_s="709",
  479. range_in_s="limited")
  480. # Each plane in RGB24: plane 0=G, 1=B, 2=R (GBR packing in VS)
  481. # Compute per-channel distance and threshold
  482. tol = tolerance
  483. mask = _expr(
  484. core.std.SplitPlanes(rgb),
  485. expr=[f"x {g} - abs {tol} <= y {b} - abs {tol} <= z {r} - abs {tol} <= and and 255 0 ?"]
  486. )
  487. mask = core.std.ShufflePlanes(mask, 0, vs.GRAY)
  488. mask = core.resize.Bilinear(mask, format=vs.GRAY8)
  489.  
  490. if inflate > 0:
  491. mask = _inflate(mask, inflate)
  492.  
  493. return mask
  494.  
  495.  
  496. # ---------------------------------------------------------------------------
  497. # DynTEdge — gradient-magnitude edge mask (optional, needs tedgemask)
  498. # ---------------------------------------------------------------------------
  499.  
  500. def _dyntedge_mask(clip: vs.VideoNode,
  501. threshold_y: int = 20,
  502. inflate: int = 2) -> vs.VideoNode:
  503. """
  504. Gradient-magnitude dynamic mask. Requires the tedgemask plugin.
  505.  
  506. threshold_y : magnitude threshold (2-100).
  507. inflate : inflate result by this many pixels.
  508. """
  509. gray = _to_gray(clip)
  510. try:
  511. edge = core.tedgemask.TEdgeMask(gray, threshold=threshold_y, planes=[0])
  512. except AttributeError:
  513. raise RuntimeError(
  514. "DynTEdge path requires the tedgemask plugin. "
  515. "Install it or set dyn_tedge=False."
  516. )
  517. edge = _to_gray(edge)
  518. edge = _binarise(edge, 1) # anything non-zero → white
  519.  
  520. if inflate > 0:
  521. edge = _inflate(edge, inflate)
  522.  
  523. return edge
  524.  
  525.  
  526. # ---------------------------------------------------------------------------
  527. # DynMask=4 halo mask (DynMask4H)
  528. # ---------------------------------------------------------------------------
  529.  
  530. def _dynmask4h_mask(clip: vs.VideoNode,
  531. dynmask4h: int = 60) -> vs.VideoNode:
  532. """
  533. Mask for dark subtitle outlines/halos.
  534.  
  535. The halo is darker than the background, so we detect it by inverting:
  536. pixels BELOW dynmask4h threshold (i.e. dark pixels) become white in
  537. the mask. This is then merged with the bright text core via hysteresis
  538. so only dark pixels adjacent to bright text survive.
  539. """
  540. gray = _to_gray(clip)
  541. # Invert: dark pixels (outline) → white, bright pixels → black
  542. return _expr(gray, f"x {dynmask4h} <= 255 0 ?")
  543.  
  544.  
  545. # ---------------------------------------------------------------------------
  546. # Temporal difference mask
  547. # ---------------------------------------------------------------------------
  548.  
  549. def _temporal_diff_mask(clip: vs.VideoNode,
  550. threshold: int = 10,
  551. inflate: int = 1) -> vs.VideoNode:
  552. """
  553. Detect pixels that changed between adjacent frames.
  554.  
  555. Subtitles appear and disappear; static background produces near-zero
  556. diff. ANDing this with a luma mask eliminates background bleed almost
  557. entirely.
  558.  
  559. threshold : minimum luma change to count as a subtitle pixel (1-255).
  560. Lower = more sensitive but catches more noise.
  561. Start around 10-20 for typical broadcast subs.
  562. inflate : grow the diff mask slightly to cover full glyph bodies,
  563. since only the edges of appearing text change between frames.
  564. """
  565. gray = _to_gray(clip)
  566.  
  567. # Build a 1-frame-shifted version by duplicating the first frame at the
  568. # start (so frame N is compared with frame N-1, no frame is dropped).
  569. prev = core.std.DuplicateFrames(gray, frames=[0])
  570. prev = core.std.Trim(prev, first=0, last=gray.num_frames - 1)
  571.  
  572. diff = _expr([gray, prev], "x y - abs")
  573. mask = core.std.Binarize(diff, threshold=threshold)
  574.  
  575. if inflate > 0:
  576. mask = _inflate(mask, inflate)
  577.  
  578. return mask
  579.  
  580.  
  581. # ---------------------------------------------------------------------------
  582. # Main public function
  583. # ---------------------------------------------------------------------------
  584.  
  585. def subtitle_mask(
  586. clip: vs.VideoNode,
  587. # Crop / location: pixels to remove from each side (left, top, right, bottom)
  588. loc: Optional[tuple[int, int, int, int]] = None,
  589.  
  590. # Which mask method to use
  591. dyn_mask_mode: int = 2, # 2=combo, 4=halo subs, 6=luma only
  592.  
  593. # DynTune — luma binarise
  594. dyn_tune: Union[int, str] = 200,
  595. dyn_tune_inf: int = 0,
  596.  
  597. # DynColor — colour key (DynMask modes 2, 5)
  598. dyn_color: Optional[int] = None, # RGB hex; None = disabled
  599. dyn_color_tol: int = 10,
  600. dyn_color_inf: int = 1,
  601.  
  602. # DynTEdge — gradient magnitude (DynMask modes 2, 7)
  603. dyn_tedge: bool = False,
  604. dyn_tedge_th_y: int = 20,
  605. dyn_tedge_inf: int = 2,
  606.  
  607. # DynMask=4 halo threshold
  608. dynmask4h: int = 60,
  609.  
  610. # Temporal difference AND mask — eliminates static background
  611. dyn_diff: bool = False, # AND result with a temporal diff mask
  612. dyn_diff_threshold: int = 10, # minimum luma change to count as subtitle
  613. dyn_diff_inflate: int = 1, # inflate diff mask to cover glyph bodies
  614.  
  615. # Global inflate after combining
  616. dyn_inflate: int = 0,
  617.  
  618. # Artifact removal
  619. kill_noise: int = 0,
  620. kill_blobs: int = 0,
  621. pre_blobs: int = 0,
  622. ) -> vs.VideoNode:
  623. """
  624. Generate a binary subtitle mask clip.
  625.  
  626. Parameters
  627. ----------
  628. clip : vs.VideoNode
  629. Input video. Any format — internally converted to GRAY8 as needed.
  630. loc : (left, top, right, bottom) or None
  631. Pixels to remove from each side — same as VapourSynth std.Crop.
  632. e.g. loc=(100, 1380, 60, 500) removes 100px from left, 1380 from
  633. top, 60 from right, 500 from bottom. None = full frame.
  634. dyn_mask_mode : int
  635. 2 — Combo: DynTune AND optionally DynColor AND/or DynTEdge
  636. 4 — Subtitle-with-halo: DynTune merged with DynMask4H via hysteresis
  637. 6 — Luma binarise only (DynTune)
  638. dyn_tune : int or str
  639. Binarise threshold (1–254). String form "lo - hi" for a luma range.
  640. dyn_tune_inf : int
  641. Inflate (+) or inpand (–1) the DynTune mask. Range: -1 to 4.
  642. dyn_color : int or None
  643. RGB hex colour for colour-key masking. None disables this path.
  644. When set, this mask is logically ANDed with the DynTune mask.
  645. dyn_color_tol : int
  646. Colour tolerance (1–140).
  647. dyn_color_inf : int
  648. Inflate the DynColor mask (0–4).
  649. dyn_tedge : bool
  650. Enable gradient-magnitude edge mask (requires tedgemask plugin).
  651. dyn_tedge_th_y : int
  652. Edge magnitude threshold (2–100).
  653. dyn_tedge_inf : int
  654. Inflate the edge mask (0–8).
  655. dynmask4h : int
  656. Halo threshold for DynMask=4 (1–200).
  657. dyn_diff : bool
  658. AND the mask with a temporal difference mask. Pixels that do not
  659. change between frames (static background) are removed. This is
  660. the most effective way to eliminate background bleed.
  661. Note: causes the first frame to always be empty (no previous frame).
  662. dyn_diff_threshold : int
  663. Minimum per-pixel luma change to be considered a subtitle pixel (1–255).
  664. Start around 10–20; lower catches more but also more noise.
  665. dyn_diff_inflate : int
  666. Inflate the diff mask to cover full glyph bodies (only edges change).
  667.  
  668. dyn_inflate : int
  669. Final inflate of the combined mask (0–10).
  670. Note: forced to 0 when dyn_mask_mode is 5, 6, or 7 (matches AVS behaviour).
  671. kill_noise : int
  672. Small-artifact removal. 0 = off, 1–5 = increasingly aggressive.
  673. Negative values pre-apply expand→inpand. See module docstring.
  674. kill_blobs : int
  675. Blob removal strength. 0 = off, positive = safer, negative = full remove.
  676. pre_blobs : int
  677. Pre-sponginess reduction for KillBlobs. 0 = off, ±1 = 'both', ±2 = 'square'.
  678.  
  679. Returns
  680. -------
  681. vs.VideoNode
  682. GRAY8 binary mask (0 or 255), cropped to the loc area (or full frame).
  683. """
  684.  
  685. # ---- Step 1: optional spatial crop ----
  686. loc_left = loc_top = loc_right = loc_bottom = 0
  687.  
  688. if loc is not None:
  689. loc_left, loc_top, loc_right, loc_bottom = loc
  690. # loc uses the same convention as AVS Crop: each value is the number
  691. # of pixels to remove from that side. std.Crop takes exactly that.
  692. clip = core.std.Crop(clip,
  693. left=loc_left, top=loc_top,
  694. right=loc_right, bottom=loc_bottom)
  695.  
  696. # ---- Step 2: build mask per mode ----
  697.  
  698. if dyn_mask_mode == 6:
  699. # Pure luma binarise
  700. mask = _dyntune_mask(clip, dyn_tune, dyn_tune_inf)
  701. dyn_inflate = 0 # matches AVS hard-set
  702.  
  703. elif dyn_mask_mode == 5:
  704. # Pure colour-key
  705. if dyn_color is None:
  706. raise ValueError("dyn_mask_mode=5 requires dyn_color to be set.")
  707. mask = _dyncolor_mask(clip, dyn_color, dyn_color_tol, dyn_color_inf)
  708. dyn_inflate = 0 # matches AVS hard-set
  709.  
  710. elif dyn_mask_mode == 7:
  711. # Pure edge gradient
  712. mask = _dyntedge_mask(clip, dyn_tedge_th_y, dyn_tedge_inf)
  713. dyn_inflate = 0 # matches AVS hard-set
  714.  
  715. elif dyn_mask_mode == 2:
  716. # Combo: DynTune (required) + DynColor (optional AND) + DynTEdge (optional AND)
  717. mask = _dyntune_mask(clip, dyn_tune, dyn_tune_inf)
  718.  
  719. if dyn_color is not None:
  720. color_mask = _dyncolor_mask(clip, dyn_color, dyn_color_tol, dyn_color_inf)
  721. mask = _expr([mask, color_mask], "x y and")
  722.  
  723. if dyn_tedge:
  724. edge_mask = _dyntedge_mask(clip, dyn_tedge_th_y, dyn_tedge_inf)
  725. mask = _expr([mask, edge_mask], "x y and")
  726.  
  727. elif dyn_mask_mode == 4:
  728. # Subtitle-with-dark-outline mask.
  729. # text_mask : bright core of the subtitle (high threshold).
  730. # halo_mask : dark outline pixels inverted to white (low threshold).
  731. # Hysteresis grows outward from the bright text seed into the dark
  732. # outline region — keeping only outline pixels that are directly
  733. # adjacent/connected to the bright text core.
  734. # clip_weak = candidate pixels to keep (halo — the larger region)
  735. # clip_strong = seed pixels that must be present (bright text core)
  736. text_mask = _dyntune_mask(clip, dyn_tune, dyn_tune_inf)
  737. halo_mask = _dynmask4h_mask(clip, dynmask4h)
  738. # Inflate text_mask slightly so it touches the adjacent dark outline
  739. text_seed = _inflate(text_mask, 1)
  740. # Hysteresis: keep halo pixels connected to the inflated text seed
  741. mask = _hysteresis(halo_mask, text_seed)
  742. # Merge: text core + connected outline
  743. mask = _expr([mask, text_mask], "x y max")
  744.  
  745. else:
  746. raise ValueError(
  747. f"dyn_mask_mode={dyn_mask_mode} is not supported. "
  748. "Use 2, 4, 5, 6, or 7."
  749. )
  750.  
  751. # ---- Step 3: optional temporal diff AND ----
  752. if dyn_diff:
  753. diff_mask = _temporal_diff_mask(clip, dyn_diff_threshold, dyn_diff_inflate)
  754. mask = _expr([mask, diff_mask], "x y and")
  755.  
  756. # ---- Step 4: blob removal (before KillNoise, matches AVS chain) ----
  757. if kill_blobs != 0:
  758. mask = _kill_blobs(mask, kill_blobs, pre_blobs)
  759.  
  760. # ---- Step 5: noise/artifact removal ----
  761. mask = _kill_noise(mask, kill_noise)
  762.  
  763. # ---- Step 6: final inflate ----
  764. if dyn_inflate > 0:
  765. mask = _inflate(mask, dyn_inflate)
  766.  
  767. # ---- Step 7: pad back to original resolution ----
  768. # AddBorders fills the regions outside the loc area with black (0),
  769. # so the mask only marks pixels inside the subtitle region.
  770. if loc is not None:
  771. mask = core.std.AddBorders(mask,
  772. left=loc_left, top=loc_top,
  773. right=loc_right, bottom=loc_bottom)
  774.  
  775. return mask
  776.  
  777.  
  778. # ---------------------------------------------------------------------------
  779. # Convenience wrappers matching common AVS call patterns
  780. # ---------------------------------------------------------------------------
  781.  
  782. def subtitle_mask_simple(
  783. clip: vs.VideoNode,
  784. loc: Optional[tuple[int, int, int, int]] = None,
  785. dyn_tune: Union[int, str] = 200,
  786. dyn_inflate: int = 0,
  787. kill_noise: int = 0,
  788. ) -> vs.VideoNode:
  789. """
  790. Simplest one-liner wrapper: luma binarise only (DynMask=6 equivalent).
  791.  
  792. Example
  793. -------
  794. mask = subtitle_mask_simple(src, loc=(0, 900, 0, -0), dyn_tune=220)
  795. """
  796. return subtitle_mask(clip, loc=loc,
  797. dyn_mask_mode=6,
  798. dyn_tune=dyn_tune,
  799. dyn_inflate=dyn_inflate,
  800. kill_noise=kill_noise)
  801.  
  802.  
  803. def subtitle_mask_halo(
  804. clip: vs.VideoNode,
  805. loc: Optional[tuple[int, int, int, int]] = None,
  806. dyn_tune: Union[int, str] = 200,
  807. dynmask4h: int = 60,
  808. dyn_inflate: int = 0,
  809. kill_noise: int = 0,
  810. kill_blobs: int = 0,
  811. ) -> vs.VideoNode:
  812. """
  813. Subtitle mask for subs with a dark halo (DynMask=4 equivalent).
  814.  
  815. dyn_tune : threshold for the bright subtitle text core.
  816. dynmask4h : threshold for the surrounding dark halo (1–200).
  817.  
  818. Example
  819. -------
  820. mask = subtitle_mask_halo(src, loc=(0, 850, 0, -0),
  821. dyn_tune=200, dynmask4h=60)
  822. """
  823. return subtitle_mask(clip, loc=loc,
  824. dyn_mask_mode=4,
  825. dyn_tune=dyn_tune,
  826. dynmask4h=dynmask4h,
  827. dyn_inflate=dyn_inflate,
  828. kill_noise=kill_noise,
  829. kill_blobs=kill_blobs)
  830.  
Advertisement
Comments
  • User was banned
Add Comment
Please, Sign In to add comment