Adds deinterlacer after-filters
This commit is contained in:
@@ -100,6 +100,19 @@ def normalizeCpuOption(ctx, param, value):
|
|||||||
raise click.BadParameter(str(ex)) from ex
|
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):
|
def normalizeSubtitleExtension(ctx, param, value):
|
||||||
normalizedExtension = str(value).strip().lower()
|
normalizedExtension = str(value).strip().lower()
|
||||||
if normalizedExtension.startswith('.'):
|
if normalizedExtension.startswith('.'):
|
||||||
@@ -1196,12 +1209,17 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--deinterlace",
|
"--deinterlace",
|
||||||
type=click.Choice(("none", "default", "up_down", "temp", "up_down_temp")),
|
type=str,
|
||||||
|
metavar="SPEC",
|
||||||
is_flag=False,
|
is_flag=False,
|
||||||
flag_value="default",
|
flag_value="default",
|
||||||
default="none",
|
default="none",
|
||||||
show_default=True,
|
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")
|
@click.option("--denoise", is_flag=False, flag_value="default", default="none")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import itertools
|
import re
|
||||||
|
|
||||||
from .filter import Filter
|
from .filter import Filter
|
||||||
|
|
||||||
@@ -9,28 +9,11 @@ class DeinterlaceFilter(Filter):
|
|||||||
|
|
||||||
MODE_KEY = 'mode'
|
MODE_KEY = 'mode'
|
||||||
DEFAULT_MODE = 'default'
|
DEFAULT_MODE = 'default'
|
||||||
UP_DOWN_MODE = 'up_down'
|
|
||||||
TEMP_MODE = 'temp'
|
|
||||||
UP_DOWN_TEMP_MODE = 'up_down_temp'
|
|
||||||
|
|
||||||
MODE_TOKENS = {
|
DEFAULT_SCALE_FACTOR = 2
|
||||||
DEFAULT_MODE: ['bwdif=mode=1'],
|
MIN_SCALE_FACTOR = 2
|
||||||
UP_DOWN_MODE: [
|
MAX_SCALE_FACTOR = 4
|
||||||
'bwdif=mode=1',
|
DEFAULT_TEMP_PARAMETERS = '3366'
|
||||||
'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_STRENGTH: float = 2.8
|
# DEFAULT_STRENGTH: float = 2.8
|
||||||
# DEFAULT_PATCH_SIZE: int = 13
|
# DEFAULT_PATCH_SIZE: int = 13
|
||||||
@@ -50,8 +33,7 @@ class DeinterlaceFilter(Filter):
|
|||||||
DeinterlaceFilter.MODE_KEY,
|
DeinterlaceFilter.MODE_KEY,
|
||||||
DeinterlaceFilter.DEFAULT_MODE,
|
DeinterlaceFilter.DEFAULT_MODE,
|
||||||
)
|
)
|
||||||
if self.__mode not in DeinterlaceFilter.MODE_TOKENS:
|
self.__tokens = DeinterlaceFilter.getModeTokens(self.__mode)
|
||||||
raise ValueError(f"Unknown deinterlace mode: {self.__mode}")
|
|
||||||
|
|
||||||
# self.__useHardware = kwargs.get('use_hardware', False)
|
# self.__useHardware = kwargs.get('use_hardware', False)
|
||||||
|
|
||||||
@@ -140,6 +122,58 @@ class DeinterlaceFilter(Filter):
|
|||||||
|
|
||||||
super().__init__(self)
|
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):
|
def getPayload(self):
|
||||||
|
|
||||||
@@ -159,7 +193,7 @@ class DeinterlaceFilter(Filter):
|
|||||||
},
|
},
|
||||||
'suffices': suffices,
|
'suffices': suffices,
|
||||||
'variant': f"DEINT",
|
'variant': f"DEINT",
|
||||||
'tokens': list(DeinterlaceFilter.MODE_TOKENS[self.__mode])}
|
'tokens': list(self.__tokens)}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
from ffx.cli import convert
|
from ffx.cli import convert
|
||||||
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||||
from ffx.filter.filter import Filter
|
from ffx.filter.filter import Filter
|
||||||
@@ -24,7 +26,7 @@ class DeinterlaceFilterTest(unittest.TestCase):
|
|||||||
self.assertEqual(['bwdif=mode=1'], payload['tokens'])
|
self.assertEqual(['bwdif=mode=1'], payload['tokens'])
|
||||||
|
|
||||||
def test_up_down_mode_yields_lanczos_scales_after_bwdif(self):
|
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(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
@@ -36,7 +38,7 @@ class DeinterlaceFilterTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_temp_mode_yields_hqdn3d_after_bwdif(self):
|
def test_temp_mode_yields_hqdn3d_after_bwdif(self):
|
||||||
payload = self.getPayload(DeinterlaceFilter.TEMP_MODE)
|
payload = self.getPayload('temp')
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
['bwdif=mode=1', 'hqdn3d=1.5:1.5:3:3'],
|
['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):
|
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(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
@@ -56,10 +58,59 @@ class DeinterlaceFilterTest(unittest.TestCase):
|
|||||||
payload['tokens'],
|
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):
|
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')
|
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):
|
def test_cli_accepts_named_modes_and_preserves_bare_flag_behavior(self):
|
||||||
upDownContext = convert.make_context(
|
upDownContext = convert.make_context(
|
||||||
'convert',
|
'convert',
|
||||||
@@ -73,15 +124,27 @@ class DeinterlaceFilterTest(unittest.TestCase):
|
|||||||
'convert',
|
'convert',
|
||||||
['--deinterlace', 'up_down_temp'],
|
['--deinterlace', 'up_down_temp'],
|
||||||
)
|
)
|
||||||
|
complexContext = convert.make_context(
|
||||||
|
'convert',
|
||||||
|
['--deinterlace', 'up4_temp3366_down4'],
|
||||||
|
)
|
||||||
bareFlagContext = convert.make_context('convert', ['--deinterlace'])
|
bareFlagContext = convert.make_context('convert', ['--deinterlace'])
|
||||||
omittedContext = convert.make_context('convert', [])
|
omittedContext = convert.make_context('convert', [])
|
||||||
|
|
||||||
self.assertEqual('up_down', upDownContext.params['deinterlace'])
|
self.assertEqual('up_down', upDownContext.params['deinterlace'])
|
||||||
self.assertEqual('temp', tempContext.params['deinterlace'])
|
self.assertEqual('temp', tempContext.params['deinterlace'])
|
||||||
self.assertEqual('up_down_temp', combinedContext.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('default', bareFlagContext.params['deinterlace'])
|
||||||
self.assertEqual('none', omittedContext.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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user