vsdirty 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vsdirty/admask.py ADDED
@@ -0,0 +1,480 @@
1
+ import vapoursynth as vs
2
+ from typing import Optional
3
+
4
+ core = vs.core
5
+
6
+ if not (hasattr(vs.core, 'cas') or hasattr(vs.core, 'fmtc') or hasattr(vs.core, 'akarin')):
7
+ raise ImportError("'cas', 'fmtc' and 'akarin' are mandatory. Make sure the DLLs are present in the plugins folder.")
8
+
9
+
10
+ def _get_stdev(avg: float, sq_avg: float) -> float:
11
+ return abs(sq_avg - avg ** 2) ** 0.5
12
+
13
+ #fatta dalla IA e anche male, ma fa il suo la cambierò in futuro forse
14
+ def _soft_threshold(clip: vs.VideoNode, thr: float, steepness: float = 20.0) -> vs.VideoNode:
15
+ """
16
+ Applies a soft threshold to the clip.
17
+ Values below thr accept a penalty that decays exponentially based on the distance from thr.
18
+ formula: x < thr ? x * exp((x - thr) * steepness) : x
19
+ """
20
+ from .adutils import scale_binary_value
21
+
22
+ core = vs.core
23
+
24
+ thr_scaled = scale_binary_value(clip, thr, return_int=False)
25
+
26
+ if clip.format.sample_type == vs.INTEGER:
27
+ max_val = (1 << clip.format.bits_per_sample) - 1
28
+ diff_expr = f"x {thr_scaled} - {max_val} /"
29
+ else:
30
+ diff_expr = f"x {thr_scaled} -"
31
+
32
+ return core.akarin.Expr(
33
+ [clip],
34
+ f"x {thr_scaled} >= x "
35
+ f"{diff_expr} {steepness} * exp x * "
36
+ f"?"
37
+ )
38
+
39
+ def auto_thr_high(stddev):
40
+ if stddev >0.900:
41
+ stdev_min, stdev_max = 0.800, 1.000
42
+ else:
43
+ stdev_min, stdev_max = 0.400, 1.000
44
+ thr_high_min, thr_high_max = 0.005, 1.000
45
+ norm = min(max((stddev - stdev_min) / (stdev_max - stdev_min), 0.000), 1.000)
46
+ return (thr_high_max * ((thr_high_min / thr_high_max) ** norm))
47
+
48
+ def luma_mask (
49
+ clip: vs.VideoNode,
50
+ min_value: float = 17500,
51
+ sthmax: float = 0.95,
52
+ sthmin: float = 1.4,
53
+ )-> vs.VideoNode :
54
+ from .adutils import plane
55
+
56
+ luma = plane(clip, 0)
57
+ lumamask = core.std.Expr(
58
+ [luma],
59
+ "x {0} < x {2} * x {0} - 0.0001 + log {1} * exp x + ?".format(min_value, sthmax, sthmin)
60
+ )
61
+ lumamask = lumamask.std.Invert()
62
+
63
+ return lumamask
64
+
65
+ def luma_mask_man (
66
+ clip: vs.VideoNode,
67
+ t: float = 0.3,
68
+ s: float = 5,
69
+ a: float = 0.3,
70
+ )-> vs.VideoNode :
71
+ """
72
+ Custom luma mask that uses a different approach to calculate the mask (Made By Mhanz).
73
+ This mask is sensitive to the brightness of the image producing a smooth transition between dark and bright areas of th clip based on brightness levels.
74
+ The mask exalt bright areas and darkens dark areas, inverting them.
75
+
76
+ Curve graph https://www.geogebra.org/calculator/cqnfnqyk
77
+
78
+ :param clip: Clip to process (only the first plane will be processed).
79
+ :param s:
80
+ :param t: Threshold that determines what is considered light and what is dark.
81
+ :param a:
82
+ :return: Luma mask.
83
+ """
84
+ from .adutils import plane
85
+
86
+ luma = plane(clip, 0)
87
+ f=1/3
88
+
89
+ maxvalue = (1 << clip.format.bits_per_sample) - 1
90
+ normx = f"x 2 * {maxvalue} / "
91
+
92
+ lumamask = core.std.Expr(
93
+ [luma],
94
+ f"x " # Mettiamo x sullo stack per la moltiplicazione finale
95
+ f"{normx} {t} < " # x < b ?
96
+ # - Ramo TRUE → f(x):
97
+ f"{normx} {t} - {normx} {t} - 2 pow {s} * {a} + {f} pow / 1 + "
98
+ # - Ramo FALSE → h(x):
99
+ f"{normx} {t} - {normx} {t} - 2 pow {s} * 5 * {a} + {f} pow / 1 + "
100
+ # - Operatore ternario:
101
+ f"? "
102
+ # - moltiplica per x:
103
+ f"*"
104
+ )
105
+
106
+ lumamask = lumamask.std.Invert()
107
+
108
+ return lumamask
109
+
110
+ def luma_mask_ping(
111
+ clip: vs.VideoNode,
112
+ low_amp: float = 0.8,
113
+ thr: float = 0.196,
114
+ ) -> vs.VideoNode:
115
+ """
116
+ Custom luma mask that uses a different approach to calculate the mask (Made By PingWer).
117
+ This mask is sensitive to the brightness of the image, producing a constant dark mask for bright areas,
118
+ a constant white mask for very dark areas, and a exponential transition between these extremes based on brightness levels.
119
+
120
+ Curve graph https://www.geogebra.org/calculator/fxbrx4s4
121
+
122
+ :param clip: Clip to process (only the first plane will be processed).
123
+ :param low_amp: General preamplification value, but more sensitive for values lower than thr.
124
+ :param thr: Threshold that determines what is considered bright and what is dark.
125
+ :return: Luma mask.
126
+ """
127
+
128
+ core = vs.core
129
+ import math
130
+ from .adutils import scale_binary_value, plane
131
+
132
+ bit_depth = clip.format.bits_per_sample
133
+ max_val = (1 << bit_depth) - 1
134
+
135
+ thr_scaled = scale_binary_value(clip, value=thr, bit=bit_depth, return_int=False)
136
+
137
+ high_amp = (math.exp(low_amp - 1) + low_amp * math.exp(low_amp)) / (math.exp(low_amp) - 1)
138
+
139
+ expr = (
140
+ f"x {max_val} / "
141
+ f"dup {thr_scaled} < "
142
+ f"{thr_scaled} 1 + - exp {low_amp} + "
143
+ f"{high_amp} {high_amp} dup {thr_scaled} 1 - - log {high_amp} * exp {low_amp} + / - "
144
+ f"? "
145
+ f"x *"
146
+ )
147
+
148
+ cc = core.akarin.Expr([plane(clip, 0)], expr)
149
+
150
+ cc = core.std.Invert(cc)
151
+
152
+ return cc
153
+
154
+ def unbloat_retinex(
155
+ clip: vs.VideoNode,
156
+ sigma: list[float] = [25, 80, 250],
157
+ lower_thr: float = 0.001,
158
+ upper_thr: float = 0.005,
159
+ fast: bool = True
160
+ ) -> vs.VideoNode:
161
+ """
162
+ Multi-Scale Retinex (MSR) optimized for edge enhancement and dynamic range compression.
163
+
164
+ Uses Gaussian blur (via vsrgtools.gauss_blur) with optional downscaling optimization
165
+ for large sigma values. The output is a normalized [0, 1] Float32 grayscale clip
166
+ suitable for edge detection or as a preprocessing step for masking.
167
+
168
+ :param clip: Input clip. Must be Grayscale (GRAY format).
169
+ :param sigma: List of sigma values for multi-scale blur. Default: [25, 80, 250].
170
+ Larger values capture coarser illumination variations.
171
+ :param lower_thr: Quantile threshold for black level (0-1). Default: 0.001 (0.1%).
172
+ Used to ignore dark outliers during final normalization.
173
+ :param upper_thr: Quantile threshold for white level (0-1). Default: 0.001 (0.1%).
174
+ Used to ignore bright outliers during final normalization.
175
+ :param fast: Enable downscaling optimization for large sigmas. Default: True.
176
+ Significantly faster with minimal quality loss.
177
+ :return: Processed Float32 Grayscale clip with enhanced local contrast.
178
+
179
+ :raises ValueError: If input clip is not Grayscale.
180
+ """
181
+ from vstools import depth
182
+ from vsrgtools import gauss_blur
183
+
184
+ if clip.format.color_family != vs.GRAY:
185
+ raise ValueError("unbloat_retinex: Input must be a Grayscale clip.")
186
+
187
+ if clip.format.sample_type != vs.FLOAT:
188
+ luma = depth(clip, 32)
189
+ else:
190
+ luma = clip
191
+
192
+ stats = luma.std.PlaneStats()
193
+ luma_norm = core.akarin.Expr([luma, stats], "x y.PlaneStatsMin - y.PlaneStatsMax y.PlaneStatsMin - 0.000001 max /")
194
+
195
+ sigmas_sorted = sorted(sigma)
196
+ sigmas_to_blur = sigmas_sorted[:-1] if fast else sigmas_sorted
197
+
198
+ blurs = []
199
+ w, h = luma_norm.width, luma_norm.height
200
+
201
+ for s in sigmas_to_blur:
202
+ if fast and s > 6:
203
+ ds_ratio = max(1, s / 3)
204
+ ds_w, ds_h = max(1, int(w / ds_ratio)), max(1, int(h / ds_ratio))
205
+
206
+ if ds_ratio > 2:
207
+ down = luma_norm.resize.Bicubic(ds_w, ds_h)
208
+
209
+ s_down = s / ds_ratio
210
+ blurred_down = gauss_blur(down, sigma=s_down)
211
+
212
+ blurred = blurred_down.resize.Bicubic(w, h)
213
+ else:
214
+ blurred = gauss_blur(luma_norm, sigma=s)
215
+ else:
216
+ blurred = gauss_blur(luma_norm, sigma=s)
217
+ blurs.append(blurred)
218
+
219
+ inputs = [luma_norm] + blurs
220
+
221
+ def get_char(i):
222
+ if i == 0: return 'x'
223
+ if i == 1: return 'y'
224
+ if i == 2: return 'z'
225
+ if i == 3: return 'a'
226
+ return chr(ord('a') + (i - 3))
227
+
228
+ terms = []
229
+ for i in range(1, len(inputs)):
230
+ c = get_char(i)
231
+ terms.append(f"{c} 0 <= 1 x {c} / 1 + ?")
232
+
233
+ if fast:
234
+ terms.append("x 1 +")
235
+
236
+ expr_code = " ".join(terms)
237
+ if len(terms) > 1:
238
+ expr_code += " " + " ".join(["+"] * (len(terms) - 1))
239
+
240
+ slen = len(sigma)
241
+ expr_code += f" log {slen} /"
242
+
243
+ msr = core.akarin.Expr(inputs, expr_code)
244
+
245
+ if hasattr(core, 'vszip') and (lower_thr > 0 or upper_thr > 0):
246
+ msr_stats = core.vszip.PlaneMinMax(msr, lower_thr, upper_thr)
247
+ min_key, max_key = 'psmMin', 'psmMax'
248
+ else:
249
+ msr_stats = msr.std.PlaneStats()
250
+ min_key, max_key = 'PlaneStatsMin', 'PlaneStatsMax'
251
+
252
+ balanced = core.akarin.Expr([msr, msr_stats], f"x y.{min_key} - y.{max_key} y.{min_key} - 0.000001 max /")
253
+
254
+ return balanced
255
+
256
+
257
+ def advanced_edgemask(
258
+ clip: vs.VideoNode,
259
+ ref: Optional[vs.VideoNode] = None,
260
+ sigma1: float = 3,
261
+ retinex_sigma: list[float] = [50, 200, 350],
262
+ sigma2: float = 1,
263
+ sharpness: float = 0.8,
264
+ kirsch_weight: float = 0.5,
265
+ kirsch_thr: float = 0.35,
266
+ edge_thr: float = 0.02,
267
+ **kwargs
268
+ ) -> vs.VideoNode:
269
+ """
270
+ Advanced edge mask combining Retinex preprocessing with multiple edge detectors.
271
+
272
+ This mask uses BM3D denoising + Multi-Scale Retinex to enhance edges before detection,
273
+ then combines Sobel, Prewitt, TCanny and Kirsch edge detectors for robust edge detection.
274
+
275
+ :param clip: Clip to process (YUV or Gray).
276
+ :param ref: Optional reference clip for denoising.
277
+ :param sigma1: BM3D sigma for initial denoising. Default: 3.
278
+ :param retinex_sigma: Sigma values for Multi-Scale Retinex. Default: [50, 200, 350].
279
+ :param sigma2: Nlmeans strength for post-Retinex denoising. Default: 1.
280
+ :param sharpness: CAS sharpening amount (0-1). Default: 0.8.
281
+ :param kirsch_weight: Weight for Kirsch edges in final blend (0-1). Default: 0.7.
282
+ :param kirsch_thr: Kirsch threshold. Default: 0.25.
283
+ :param edge_thr: Threshold for edge combination logic (0-1). Default: 0.02.
284
+ :param kwargs: Additional arguments for Retinex.
285
+ :return: Edge mask (Gray clip).
286
+ """
287
+ from vstools import get_y, depth
288
+ from vsdenoise import nl_means
289
+ from vsmasktools import Morpho, Kirsch
290
+ from .adfunc import mini_BM3D
291
+ from .adutils import scale_binary_value
292
+
293
+ core = vs.core
294
+
295
+ if clip.format.color_family == vs.RGB:
296
+ raise ValueError("advanced_edgemask: RGB clips are not supported.")
297
+
298
+ if clip.format.color_family == vs.GRAY:
299
+ luma = clip
300
+ else:
301
+ luma = get_y(clip)
302
+
303
+ luma = depth(luma, 16)
304
+
305
+ if ref is not None:
306
+ if ref.format.color_family == vs.RGB:
307
+ raise ValueError("advanced_edgemask: RGB reference clips are not supported.")
308
+
309
+ if ref.format.color_family == vs.GRAY:
310
+ ref_y = ref
311
+ else:
312
+ ref_y = get_y(ref)
313
+
314
+ if ref_y.format.bits_per_sample != 16:
315
+ ref_y = depth(ref_y, 16)
316
+ clipd = mini_BM3D(luma, sigma=sigma1, ref=ref_y, radius=1, profile="HIGH", planes=0)
317
+ else:
318
+ clipd = mini_BM3D(luma, sigma=sigma1, radius=1, profile="HIGH", planes=0)
319
+
320
+ msrcpa = depth(unbloat_retinex(
321
+ depth(clipd, 32),
322
+ sigma=retinex_sigma,
323
+ fast=True,
324
+ **kwargs
325
+ ), 16, dither_type="none")
326
+
327
+ msrcp = nl_means(msrcpa, h=sigma2, a=2)
328
+
329
+ if sharpness > 0:
330
+ msrcp = core.cas.CAS(msrcp, sharpness=sharpness, opt=0, planes=0)
331
+ clipd = core.cas.CAS(clipd, sharpness=sharpness, opt=0, planes=0)
332
+
333
+ preSobel = core.akarin.Expr([
334
+ get_y(msrcp).std.Sobel(),
335
+ get_y(clipd).std.Sobel(),
336
+ ], "x y max")
337
+
338
+ prePrewitt = core.akarin.Expr([
339
+ get_y(msrcp).std.Prewitt(),
340
+ get_y(clipd).std.Prewitt(),
341
+ ], "x y max")
342
+
343
+ edges = core.akarin.Expr([preSobel, prePrewitt], "x y +")
344
+
345
+ tcanny = core.akarin.Expr([
346
+ core.tcanny.TCanny(get_y(msrcp), mode=1, sigma=0),
347
+ core.tcanny.TCanny(get_y(clipd), mode=1, sigma=0)
348
+ ], "x y max")
349
+
350
+ kirco = core.akarin.Expr([
351
+ Kirsch.edgemask(get_y(msrcp), clamp=False, lthr=kirsch_thr),
352
+ Kirsch.edgemask(get_y(clipd), clamp=False, lthr=kirsch_thr)
353
+ ], "x y max")
354
+
355
+ edge_thr_scaled = scale_binary_value(edges, edge_thr, return_int=True)
356
+ mask = core.akarin.Expr(
357
+ [edges, tcanny, kirco],
358
+ f"x y + {edge_thr_scaled} < x y + z {kirsch_weight} * + x y + ?"
359
+ )
360
+
361
+ return mask
362
+
363
+
364
+ def hd_flatmask(
365
+ clip: vs.VideoNode,
366
+ ref: Optional[vs.VideoNode] = None,
367
+ sigma1: float = 3,
368
+ retinex_sigma: list[float] = [50, 200, 350],
369
+ sigma2: float = 1,
370
+ sharpness: float = 0.8,
371
+ edge_thr: float = 0.55,
372
+ texture_strength: float = 2,
373
+ edges_strength: float = 0.02,
374
+ blur: float = 2,
375
+ expand: int = 3,
376
+ **kwargs
377
+ ) -> vs.VideoNode:
378
+ """
379
+ Advanced edge mask combining Retinex preprocessing with multiple edge detectors.
380
+
381
+ This mask uses BM3D denoising + Multi-Scale Retinex to enhance edges before detection,
382
+ then combines Sobel, Prewitt, TCanny and Kirsch edge detectors for robust edge detection.
383
+
384
+ :param clip: Clip to process (YUV or Gray).
385
+ :param ref: Optional reference clip for denoising.
386
+ :param sigma1: BM3D sigma for initial denoising. Default: 3.
387
+ :param retinex_sigma: Sigma values for Multi-Scale Retinex. Default: [50, 200, 350].
388
+ :param sigma2: Nlmeans strength for post-Retinex denoising. Default: 1.
389
+ :param sharpness: CAS sharpening amount (0-1). Default: 0.8.
390
+ :param edge_thr: Threshold for edge combination logic (0-1). This allows to separate edges from texture. Default: 0.55.
391
+ :param texture_strength: Texture strength for mask (0-inf). Values above 1 decrese the strength of the texture in the mask, lower values increase it. The max value is theoretical infinite, but there is no gain after some point. Default: 0.8.
392
+ :param edges_strength: Edges strength for mask (0-1). Basic multiplier for edges strength. Default: 0.03.
393
+ :param blur: Blur amount for mask (0-1). Default: 2.
394
+ :param expand: Expand amount for mask (0-1). Higher value increases the size of the texture in the mask. Default: 3.
395
+ :param kwargs: Additional arguments for Retinex.
396
+ :return: Edge mask (Gray clip) where dark values are texture and edges, bright values are flat areas.
397
+ """
398
+
399
+ from vstools import depth
400
+ from vsdenoise import nl_means
401
+ from vsmasktools import Morpho, Kirsch, XxpandMode
402
+ from .adfunc import mini_BM3D
403
+ from .adutils import scale_binary_value, plane
404
+ from vsrgtools import gauss_blur
405
+
406
+ core = vs.core
407
+
408
+ if clip.format.color_family == vs.RGB:
409
+ raise ValueError("advanced_edgemask: RGB clips are not supported.")
410
+
411
+ if clip.format.color_family == vs.GRAY:
412
+ luma = clip
413
+ else:
414
+ luma = plane(clip, 0)
415
+
416
+ luma = depth(luma, 16)
417
+
418
+ if ref is not None:
419
+ if ref.format.color_family == vs.RGB:
420
+ raise ValueError("advanced_edgemask: RGB reference clips are not supported.")
421
+
422
+ if ref.format.color_family == vs.GRAY:
423
+ ref_y = ref
424
+ else:
425
+ ref_y = plane(ref, 0)
426
+
427
+ if ref_y.format.bits_per_sample != 16:
428
+ ref_y = depth(ref_y, 16)
429
+ clipd = mini_BM3D(luma, sigma=sigma1, ref=ref_y, radius=1, profile="HIGH", planes=0)
430
+ else:
431
+ clipd = mini_BM3D(luma, sigma=sigma1, radius=1, profile="HIGH", planes=0)
432
+
433
+ msrcpa = depth(unbloat_retinex(
434
+ depth(clipd, 32),
435
+ sigma=retinex_sigma,
436
+ fast=True,
437
+ **kwargs
438
+ ), 16, dither_type="none")
439
+
440
+ msrcp = nl_means(msrcpa, h=sigma2, a=2)
441
+
442
+ if sharpness > 0:
443
+ msrcp = core.cas.CAS(msrcp, sharpness=sharpness, opt=0, planes=0)
444
+ clipd = core.cas.CAS(clipd, sharpness=sharpness, opt=0, planes=0)
445
+
446
+ edges = core.akarin.Expr([
447
+ msrcp.std.Sobel(),
448
+ clipd.std.Sobel(),
449
+ msrcp.std.Prewitt(),
450
+ clipd.std.Prewitt()
451
+ ], "x y max z a max +")
452
+
453
+ if edge_thr > 0:
454
+ edges = _soft_threshold(edges, edge_thr, 10)
455
+
456
+ tcanny = core.akarin.Expr([
457
+ core.tcanny.TCanny(msrcp, mode=1, sigma=0),
458
+ core.tcanny.TCanny(clipd, mode=1, sigma=0)
459
+ ], "x y max")
460
+ tcanny = core.std.Minimum(tcanny)
461
+
462
+ edgescombo = Morpho.inflate(core.akarin.Expr([edges, tcanny], "x y +"), iterations=2)
463
+
464
+ kirco = core.akarin.Expr([
465
+ Kirsch.edgemask(msrcp),
466
+ Kirsch.edgemask(clipd)
467
+ ], "x y +")
468
+
469
+ edges_expanded = Morpho.expand(edgescombo, mode=XxpandMode.ELLIPSE, sw=1, sh=1)
470
+ kirco_diff = core.akarin.Expr([kirco, edges_expanded], "x y -")
471
+ kirco_expanded = Morpho.expand(kirco_diff, mode=XxpandMode.ELLIPSE, sw=expand, sh=expand)
472
+
473
+ edgescombo = core.akarin.Expr(edgescombo.std.Invert(), f"x {scale_binary_value(luma, edges_strength, return_int=True)} +")
474
+ kirco_expanded = luma_mask_man(kirco_expanded, t=0.001, s=texture_strength, a=0.5)
475
+
476
+ mask = core.akarin.Expr([edgescombo.std.Invert(), kirco_expanded.std.Invert()], "x y +")
477
+
478
+ mask = gauss_blur(mask, blur)
479
+
480
+ return mask
vsdirty/adutils.py ADDED
@@ -0,0 +1,208 @@
1
+ import vapoursynth as vs
2
+ from typing import SupportsIndex, Optional, List, Tuple, Union
3
+
4
+ def plane(
5
+ clip: vs.VideoNode,
6
+ index: SupportsIndex
7
+ ) -> vs.VideoNode:
8
+ """
9
+ Returns a plane from the given clip at the specified index.
10
+
11
+ :param clip: The input video clip.
12
+ :param index: The index of the plane to return.
13
+ :return: A new video clip containing only the specified plane.
14
+ """
15
+ return vs.core.std.ShufflePlanes(clip, index.__index__(), vs.GRAY)
16
+
17
+ def scale_binary_value(
18
+ clip: vs.VideoNode,
19
+ value: float,
20
+ return_int: bool = True,
21
+ bit: Optional[int] = None,
22
+ )-> float:
23
+ """
24
+ Scales a value based on the bit depth of the clip.
25
+
26
+ :param clip: Clip to process.
27
+ :param value: Value to scale (0.0 - 1.0).
28
+ :param return_int: Whether to return an integer value. Default is True (will be ignore if the input clip is Float).
29
+ :param bit: Bit depth of the clip. If None, the bit depth of the clip will be used. Default is None.
30
+ :return: Scaled value.
31
+ """
32
+ if bit is None and clip is not None:
33
+ if clip.format is None:
34
+ raise ValueError("scale_binary_value: Clip must have a defined format.")
35
+
36
+ if clip.format.bits_per_sample is None:
37
+ raise ValueError("scale_binary_value: Clip must have a defined bit depth.")
38
+
39
+ bit = clip.format.bits_per_sample
40
+
41
+ if not (0.0 <= value <= 1.0):
42
+ raise ValueError("scale_binary_value: Value must be between 0.0 and 1.0.")
43
+
44
+ if clip.format.sample_type == vs.FLOAT or bit==32:
45
+ # For float clips, return the value as is
46
+ return value
47
+
48
+ max_val = (1 << bit) - 1
49
+
50
+ if return_int:
51
+ return int(value * max_val)
52
+ else:
53
+ return value * max_val
54
+
55
+ def _detect_ranges(
56
+ diff: vs.VideoNode,
57
+ thr: float
58
+ ) -> List[Tuple[int, int]]:
59
+ core = vs.core
60
+ stats = core.std.PlaneStats(diff)
61
+
62
+ indices = []
63
+ warned_missing = False
64
+ for n in range(diff.num_frames):
65
+ f = stats.get_frame(n)
66
+ pmax = f.props.get('PlaneStatsMax')
67
+
68
+ if pmax is None:
69
+ if not warned_missing:
70
+ print(f"Warning: PlaneStatsMax missing at frame {n}")
71
+ warned_missing = True
72
+ continue
73
+
74
+ if pmax > thr:
75
+ indices.append(n)
76
+
77
+ detected_ranges = []
78
+ if indices:
79
+ start = prev = indices[0]
80
+ for i in indices[1:]:
81
+ if i == prev + 1:
82
+ prev = i
83
+ else:
84
+ detected_ranges.append((start, prev))
85
+ start = prev = i
86
+ detected_ranges.append((start, prev))
87
+
88
+ return detected_ranges
89
+
90
+ def diff_and_swap(
91
+ clipa: vs.VideoNode,
92
+ clipb: vs.VideoNode,
93
+ thr: float = 35000.0,
94
+ discard_first: int = 0,
95
+ discard_last: int = 0,
96
+ ranges: Optional[Union[List[int], List[Tuple[int, int]]]] = None,
97
+ precise_swapping: bool = False
98
+ ):
99
+ """
100
+ Compares two clips (clipa and clipb) frame by frame based on Luma.
101
+ Identifies ranges of frames where the maximum absolute difference exceeds a certain threshold.
102
+
103
+ This function is useful for "patching" a source (clipb) with frames from
104
+ another source (clipa) where differences are significant (e.g. artifacts,
105
+ corruption, or different versions).
106
+
107
+ :param clipa: The "correction" clip (e.g. WEB).
108
+ :param clipb: The "base" clip (e.g. BD).
109
+ :param thr: 16-bit threshold applied to PlaneStatsMax. Default 35000.
110
+ :param discard_first: Number of detected ranges to discard from the beginning (ignored if explicit ranges are used).
111
+ :param discard_last: Number of detected ranges to discard from the end (ignored if explicit ranges are used).
112
+ :param ranges: Manual range control.
113
+ - None: Detects ranges, prints the map and uses ALL of them.
114
+ - [-1]: Discards ALL ranges (returns original clipb, no calculation).
115
+ - [int, ...]: Detects ranges, prints the map and uses only those at the specified indices.
116
+ - [(start, end), ...]: Uses ONLY these explicit ranges, SKIPS DETECTION.
117
+ :param precise_swapping: If True, replaces only the differing pixels (diff > threshold -> expand -> blur -> mask).
118
+ If False (default), replaces the entire frame in the detected ranges.
119
+
120
+ :return: A tuple `(merged, selected)` where `merged` is clipb with replacements from clipa, and
121
+ `selected` is a clip containing only the frames from clipa used for replacement
122
+ (or None if no frames were swapped).
123
+ """
124
+ from vstools import depth, replace_ranges
125
+ core = vs.core
126
+
127
+ if not isinstance(clipa, vs.VideoNode) or not isinstance(clipb, vs.VideoNode):
128
+ raise vs.Error("diff_and_swap: both inputs must be VideoNode")
129
+
130
+ if not isinstance(discard_first, int) or not isinstance(discard_last, int) or discard_first < 0 or discard_last < 0:
131
+ raise vs.Error('diff_and_swap: discard_first/discard_last must be non-negative integers')
132
+
133
+ if clipa.format.id != clipb.format.id:
134
+ raise vs.Error("diff_and_swap: both clips must have the same format")
135
+
136
+ if clipa.format.color_family != vs.YUV or clipb.format.color_family != vs.YUV:
137
+ raise vs.Error("diff_and_swap: only YUV clips are supported")
138
+
139
+ clipa = depth(clipa, 16)
140
+ clipb = depth(clipb, 16)
141
+
142
+ min_frames = min(clipa.num_frames, clipb.num_frames)
143
+ clipa_t = clipa.std.Trim(0, min_frames - 1) if clipa.num_frames != min_frames else clipa
144
+ clipb_t = clipb.std.Trim(0, min_frames - 1) if clipb.num_frames != min_frames else clipb
145
+
146
+ final_ranges = []
147
+ explicit_ranges = False
148
+
149
+ if ranges is not None and len(ranges) == 1 and ranges[0] == -1:
150
+ return clipb, None
151
+
152
+ if ranges is not None and len(ranges) > 0 and isinstance(ranges[0], tuple):
153
+ explicit_ranges = True
154
+ final_ranges = ranges
155
+ print(f"diff_and_swap: Using explicit ranges: {final_ranges}")
156
+
157
+ if precise_swapping:
158
+ diff_clip = core.std.Expr([clipa_t, clipb_t], "x y - abs")
159
+
160
+ binary_thr = scale_binary_value(diff_clip, 4/255, bit=diff_clip.format.bits_per_sample)
161
+ mask = diff_clip.std.Binarize(binary_thr)
162
+ mask = mask.std.Maximum().std.BoxBlur(hradius=1, vradius=1)
163
+
164
+ replacement = core.std.MaskedMerge(clipb_t, clipa_t, mask)
165
+ else:
166
+ diff_clip = core.std.Expr([plane(clipa_t, 0), plane(clipb_t, 0)], "x y - abs")
167
+ replacement = clipa_t
168
+
169
+ if not explicit_ranges:
170
+ detected_ranges = _detect_ranges(diff_clip, thr)
171
+
172
+ if detected_ranges:
173
+ if discard_first:
174
+ detected_ranges = detected_ranges[discard_first:]
175
+ if discard_last:
176
+ if discard_last >= len(detected_ranges):
177
+ detected_ranges = []
178
+ else:
179
+ detected_ranges = detected_ranges[:len(detected_ranges) - discard_last]
180
+
181
+ if detected_ranges:
182
+ print("diff_and_swap Detected Range Map:")
183
+ for i, (s, e) in enumerate(detected_ranges):
184
+ print(f"Range {i}: {s} - {e} (Frames: {e-s+1})")
185
+ else:
186
+ print("diff_and_swap: No ranges detected.")
187
+
188
+ if ranges is None:
189
+ final_ranges = detected_ranges
190
+ else:
191
+ for idx in ranges:
192
+ if not isinstance(idx, int):
193
+ print(f"diff_and_swap Warning: Invalid index '{idx}' ignored.")
194
+ continue
195
+ if 0 <= idx < len(detected_ranges):
196
+ final_ranges.append(detected_ranges[idx])
197
+ else:
198
+ print(f"diff_and_swap Warning: Index {idx} out of bounds of detected ranges.")
199
+
200
+ if final_ranges:
201
+ merged = replace_ranges(clipb_t, replacement, final_ranges)
202
+ segments = [replacement.std.Trim(a, b) for a, b in final_ranges]
203
+ selected = core.std.Splice(segments)
204
+ else:
205
+ merged = clipb_t
206
+ selected = None
207
+
208
+ return merged, selected