From dd810ed41fa672973eb7a6bd2f4347253fdeefb2 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Mon, 22 Jun 2026 12:57:29 +0200 Subject: [PATCH] Adds deinterlacer after-filters --- src/ffx/cli.py | 22 ++++++- src/ffx/filter/deinterlace_filter.py | 84 +++++++++++++++++++-------- tests/unit/test_deinterlace_filter.py | 71 ++++++++++++++++++++-- 3 files changed, 146 insertions(+), 31 deletions(-) diff --git a/src/ffx/cli.py b/src/ffx/cli.py index bf7679b..da1aefb 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -100,6 +100,19 @@ def normalizeCpuOption(ctx, param, value): raise click.BadParameter(str(ex)) from ex +def normalizeDeinterlaceOption(ctx, param, value): + if value == 'none': + return value + + from ffx.filter.deinterlace_filter import DeinterlaceFilter + + try: + DeinterlaceFilter.getModeTokens(value) + except ValueError as ex: + raise click.BadParameter(str(ex)) from ex + return value + + def normalizeSubtitleExtension(ctx, param, value): normalizedExtension = str(value).strip().lower() if normalizedExtension.startswith('.'): @@ -1196,12 +1209,17 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor): @click.option( "--deinterlace", - type=click.Choice(("none", "default", "up_down", "temp", "up_down_temp")), + type=str, + metavar="SPEC", is_flag=False, flag_value="default", default="none", show_default=True, - help="Deinterlace with bwdif, optionally followed by a named filter profile.", + callback=normalizeDeinterlaceOption, + help=( + "Deinterlace with bwdif followed by ordered upN, downN, and tempNNNN " + "components, for example up2_temp3366_down2." + ), ) @click.option("--denoise", is_flag=False, flag_value="default", default="none") diff --git a/src/ffx/filter/deinterlace_filter.py b/src/ffx/filter/deinterlace_filter.py index 0dd2fe2..1552d77 100644 --- a/src/ffx/filter/deinterlace_filter.py +++ b/src/ffx/filter/deinterlace_filter.py @@ -1,4 +1,4 @@ -import itertools +import re from .filter import Filter @@ -9,28 +9,11 @@ class DeinterlaceFilter(Filter): MODE_KEY = 'mode' DEFAULT_MODE = 'default' - UP_DOWN_MODE = 'up_down' - TEMP_MODE = 'temp' - UP_DOWN_TEMP_MODE = 'up_down_temp' - MODE_TOKENS = { - DEFAULT_MODE: ['bwdif=mode=1'], - UP_DOWN_MODE: [ - 'bwdif=mode=1', - 'scale=iw*2:ih*2:flags=lanczos', - 'scale=iw/2:ih/2:flags=lanczos', - ], - TEMP_MODE: [ - 'bwdif=mode=1', - 'hqdn3d=1.5:1.5:3:3', - ], - UP_DOWN_TEMP_MODE: [ - 'bwdif=mode=1', - 'scale=iw*2:ih*2:flags=lanczos', - 'scale=iw/2:ih/2:flags=lanczos', - 'hqdn3d=1.5:1.5:3:3', - ], - } + DEFAULT_SCALE_FACTOR = 2 + MIN_SCALE_FACTOR = 2 + MAX_SCALE_FACTOR = 4 + DEFAULT_TEMP_PARAMETERS = '3366' # DEFAULT_STRENGTH: float = 2.8 # DEFAULT_PATCH_SIZE: int = 13 @@ -50,8 +33,7 @@ class DeinterlaceFilter(Filter): DeinterlaceFilter.MODE_KEY, DeinterlaceFilter.DEFAULT_MODE, ) - if self.__mode not in DeinterlaceFilter.MODE_TOKENS: - raise ValueError(f"Unknown deinterlace mode: {self.__mode}") + self.__tokens = DeinterlaceFilter.getModeTokens(self.__mode) # self.__useHardware = kwargs.get('use_hardware', False) @@ -140,6 +122,58 @@ class DeinterlaceFilter(Filter): super().__init__(self) + @staticmethod + def getModeTokens(mode): + if mode == DeinterlaceFilter.DEFAULT_MODE: + return ['bwdif=mode=1'] + + if not isinstance(mode, str) or not mode: + raise ValueError("Deinterlace mode must not be empty") + + tokens = ['bwdif=mode=1'] + for component in mode.split('_'): + scaleMatch = re.fullmatch(r'(up|down)(\d*)', component) + if scaleMatch: + direction, parameter = scaleMatch.groups() + factor = ( + int(parameter) + if parameter + else DeinterlaceFilter.DEFAULT_SCALE_FACTOR + ) + if not ( + DeinterlaceFilter.MIN_SCALE_FACTOR + <= factor + <= DeinterlaceFilter.MAX_SCALE_FACTOR + ): + raise ValueError( + f"Scale factor in '{component}' must be between " + + f"{DeinterlaceFilter.MIN_SCALE_FACTOR} and " + + f"{DeinterlaceFilter.MAX_SCALE_FACTOR}" + ) + operator = '*' if direction == 'up' else '/' + tokens.append( + f"scale=iw{operator}{factor}:ih{operator}{factor}:flags=lanczos" + ) + continue + + tempMatch = re.fullmatch(r'temp(\d*)', component) + if tempMatch: + parameters = ( + tempMatch.group(1) + or DeinterlaceFilter.DEFAULT_TEMP_PARAMETERS + ) + if len(parameters) != 4: + raise ValueError( + f"Temporal parameters in '{component}' must contain four digits" + ) + strengths = [f"{int(digit) / 2:g}" for digit in parameters] + tokens.append(f"hqdn3d={':'.join(strengths)}") + continue + + raise ValueError(f"Unknown deinterlace component: '{component}'") + + return tokens + def getPayload(self): @@ -159,7 +193,7 @@ class DeinterlaceFilter(Filter): }, 'suffices': suffices, 'variant': f"DEINT", - 'tokens': list(DeinterlaceFilter.MODE_TOKENS[self.__mode])} + 'tokens': list(self.__tokens)} return payload diff --git a/tests/unit/test_deinterlace_filter.py b/tests/unit/test_deinterlace_filter.py index 9cb8011..d1b0c58 100644 --- a/tests/unit/test_deinterlace_filter.py +++ b/tests/unit/test_deinterlace_filter.py @@ -1,5 +1,7 @@ import unittest +import click + from ffx.cli import convert from ffx.filter.deinterlace_filter import DeinterlaceFilter from ffx.filter.filter import Filter @@ -24,7 +26,7 @@ class DeinterlaceFilterTest(unittest.TestCase): self.assertEqual(['bwdif=mode=1'], payload['tokens']) def test_up_down_mode_yields_lanczos_scales_after_bwdif(self): - payload = self.getPayload(DeinterlaceFilter.UP_DOWN_MODE) + payload = self.getPayload('up_down') self.assertEqual( [ @@ -36,7 +38,7 @@ class DeinterlaceFilterTest(unittest.TestCase): ) def test_temp_mode_yields_hqdn3d_after_bwdif(self): - payload = self.getPayload(DeinterlaceFilter.TEMP_MODE) + payload = self.getPayload('temp') self.assertEqual( ['bwdif=mode=1', 'hqdn3d=1.5:1.5:3:3'], @@ -44,7 +46,7 @@ class DeinterlaceFilterTest(unittest.TestCase): ) def test_up_down_temp_mode_yields_combined_filters_after_bwdif(self): - payload = self.getPayload(DeinterlaceFilter.UP_DOWN_TEMP_MODE) + payload = self.getPayload('up_down_temp') self.assertEqual( [ @@ -56,10 +58,59 @@ class DeinterlaceFilterTest(unittest.TestCase): payload['tokens'], ) + def test_explicit_scale_factors_and_filter_order_are_preserved(self): + payload = self.getPayload('up4_temp3366_down4') + + self.assertEqual( + [ + 'bwdif=mode=1', + 'scale=iw*4:ih*4:flags=lanczos', + 'hqdn3d=1.5:1.5:3:3', + 'scale=iw/4:ih/4:flags=lanczos', + ], + payload['tokens'], + ) + + def test_temporal_digits_are_divided_by_two(self): + payload = self.getPayload('temp0044_up2_down2') + + self.assertEqual( + [ + 'bwdif=mode=1', + 'hqdn3d=0:0:2:2', + 'scale=iw*2:ih*2:flags=lanczos', + 'scale=iw/2:ih/2:flags=lanczos', + ], + payload['tokens'], + ) + + def test_each_component_uses_its_default_when_parameters_are_omitted(self): + payload = self.getPayload('up_temp_down') + + self.assertEqual( + [ + 'bwdif=mode=1', + 'scale=iw*2:ih*2:flags=lanczos', + 'hqdn3d=1.5:1.5:3:3', + 'scale=iw/2:ih/2:flags=lanczos', + ], + payload['tokens'], + ) + def test_unknown_mode_is_rejected(self): - with self.assertRaisesRegex(ValueError, 'Unknown deinterlace mode: other'): + with self.assertRaisesRegex( + ValueError, + "Unknown deinterlace component: 'other'", + ): DeinterlaceFilter(mode='other') + def test_invalid_parameters_are_rejected(self): + invalidModes = ('up0', 'down5', 'temp123', 'up__down') + + for mode in invalidModes: + with self.subTest(mode=mode), self.assertRaises(ValueError): + DeinterlaceFilter(mode=mode) + def test_cli_accepts_named_modes_and_preserves_bare_flag_behavior(self): upDownContext = convert.make_context( 'convert', @@ -73,15 +124,27 @@ class DeinterlaceFilterTest(unittest.TestCase): 'convert', ['--deinterlace', 'up_down_temp'], ) + complexContext = convert.make_context( + 'convert', + ['--deinterlace', 'up4_temp3366_down4'], + ) bareFlagContext = convert.make_context('convert', ['--deinterlace']) omittedContext = convert.make_context('convert', []) self.assertEqual('up_down', upDownContext.params['deinterlace']) self.assertEqual('temp', tempContext.params['deinterlace']) self.assertEqual('up_down_temp', combinedContext.params['deinterlace']) + self.assertEqual( + 'up4_temp3366_down4', + complexContext.params['deinterlace'], + ) self.assertEqual('default', bareFlagContext.params['deinterlace']) self.assertEqual('none', omittedContext.params['deinterlace']) + def test_cli_rejects_an_invalid_specification(self): + with self.assertRaises(click.BadParameter): + convert.make_context('convert', ['--deinterlace', 'temp123']) + if __name__ == '__main__': unittest.main()