Last active
December 15, 2023 15:57
-
-
Save LightArrowsEXE/787e036bbe22357a69efee4f82bf4f17 to your computer and use it in GitHub Desktop.
getfscaler: getscaler with frac descaling support
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Minimum Python version required: 3.10. | |
Minimum required VapourSynth version: R63 | |
This is an unofficial "companion" script to getfnative <https://github.com/YomikoR/GetFnative>. | |
Note that the results of this are **NOT** conclusive on their own! | |
When using this script, be extra mindful that results may be inaccurate, | |
and that you should always double-check yourself! | |
TRUST YOUR EYES OVER THIS SCRIPT! | |
Example usages: | |
Regular usage, using an image file: | |
python getfscaler "image.png" -nh 810 | |
Fractional native resolution, using a video file: | |
python getfscaler "input.mkv" -nh 719.8 -bh 720 | |
Cross-conversion, using a vpy file: | |
python getfscaler "input.vpy" -nh 720 -fb 2 | |
HDCAM master, but you're stubborn and want to try to descale it vertically anyway: | |
python getfscaler "input.png" -nh 720 -nw 1920 | |
Requirements: | |
- vs-jet (pip install vsjet) | |
If you prefer installing the required components individually: | |
- vs-tools (pip install vstools) | |
- vs-kernels (pip install vskernels) (MUST BE GIT LATEST!) | |
- vs-masktools (pip install vsmasktools) | |
- vs-scale (pip install vsscale) | |
- vs-source (pip install vssource) | |
- rich (pip install rich) | |
This is a rewrite of the original getscaler <https://gist.github.com/cN3rd/51077b6abf45b684bf9a3c657d859b43> | |
and features the following changes: | |
- Use more robust IEW tooling | |
- Only use kernels used in professional software + Point by default | |
- Add additional post-filtering methods to reduce error caused by dithering and dirty edges | |
- Add fractional support (see: getfnative) | |
- Add support for descaling cross-converted video | |
- Support many more types of images and don't rely on ffms2 (known to cause issues) | |
- Print additional information and warnings | |
- (Optional) One-dimensional scaling (horizontal/vertical only, or both) | |
- (Optional) Set output nodes for every single image (UNTESTED!) | |
- (Optional) Check a bunch of additional kernels (NOT RECOMMENDED!) | |
- (Optional) More verbose output to give the user a better idea of what's going on internally (--debug) | |
Note that the errors may appear higher on average than with the original getscaler. | |
This script will warn you if the error is likely too high to be reliable, but again, use your eyes. | |
""" | |
import argparse | |
import logging | |
import operator | |
import time | |
from math import ceil | |
from random import randint | |
from typing import Any | |
from rich.logging import RichHandler | |
from vskernels import (Bessel, Bicubic, BicubicSharp, Bilinear, BlackHarris, | |
BlackMan, BlackManMinLobe, BlackNuttall, Bohman, Box, | |
BSpline, Catrom, Cosine, Descaler, FlatTop, Gaussian, | |
Ginseng, Hamming, Hann, Hermite, Kaiser, Kernel, | |
KernelT, Lanczos, MinSide, Mitchell, Parzen, Point, | |
Quadratic, Robidoux, RobidouxSharp, RobidouxSoft, Sinc, | |
Spline16, Spline36, Spline64, Welch, Wiener, FFmpegBicubic, AdobeBicubic) | |
from vsmasktools import Sobel, replace_squaremask | |
from vsscale import fdescale_args | |
from vssource import source | |
from vstools import (FieldBased, FieldBasedT, FileWasNotFoundError, SPath, | |
core, get_prop, get_w, plane, set_output, vs) | |
# Logging stolen from vsmuxtools | |
FORMAT = "%(message)s" | |
logging.basicConfig( | |
format=FORMAT, datefmt="[%X]", handlers=[RichHandler(markup=True, omit_repeated_times=False, show_path=False)] | |
) | |
logger = logging.getLogger("getfscaler") | |
def _format_msg(msg: str, caller: Any) -> str: | |
if caller and not isinstance(caller, str): | |
caller = caller.__class__.__qualname__ if hasattr(caller, "__class__") and \ | |
caller.__class__.__name__ not in ["function", "method"] else caller | |
caller = caller.__name__ if not isinstance(caller, str) else caller | |
return msg if caller is None else f"[bold]{caller}:[/] {msg}" | |
def debug(msg: str, caller: Any = None) -> None: | |
if not logger.level == logging.DEBUG: | |
return | |
message = _format_msg(msg, caller) | |
logger.debug(message) | |
def warn(msg: str, caller: Any = None, sleep: int = 0) -> None: | |
message = _format_msg(msg, caller) | |
logger.warning(message) | |
if sleep: | |
time.sleep(sleep) | |
caller_name = SPath(__file__).stem | |
def get_kernel_name(kernel: KernelT) -> tuple[str, str]: | |
kernel = Kernel.ensure_obj(kernel) | |
kernel_name = kernel.__class__.__name__ | |
if isinstance(kernel, Bicubic): | |
kernel_name = f"Bicubic (b={kernel.b:.2f}, c={kernel.c:.2f})" | |
elif isinstance(kernel, Lanczos): | |
kernel_name += f" (taps={kernel.taps})" | |
debug(f"Checking error for {kernel.__class__.__name__} ({kernel_name})...", get_kernel_name) | |
return kernel_name, kernel.__class__.__name__ | |
def get_error( | |
clip: vs.VideoNode, | |
width: float = 1280.0, height: float = 720.0, | |
line_mask: vs.VideoNode | None = None, crop: int = 8, | |
kernel: KernelT | None = None, | |
) -> dict[str, float]: | |
"""Get the descale error.""" | |
debug(kernel, get_error) | |
if not issubclass(kernel if isinstance(kernel, type) else type(kernel), Descaler): | |
if args.debug: | |
warn(f"Kernel \"{kernel}\" is not a subclass of Descaler! Skipping...", get_error) | |
return {} | |
kernel = Kernel.ensure_obj(kernel) | |
kernel_name, kernel_class = get_kernel_name(kernel) | |
kernel_out = kernel_name if args.swap else kernel_class | |
ceil_bh = ceil(height) & ~1 | |
ceil_bw = ceil(width) & ~1 | |
de_args, up_args = fdescale_args(clip, height, ceil_bh, ceil_bw, up_rate=1.0, src_width=width) | |
debug(f"Descaling using the following parameters: {de_args}", get_error) | |
debug(f"Upscaling using the following parameters: {up_args}", get_error) | |
if not args.fields: | |
descaled = kernel.scale(kernel.descale(clip, **de_args), clip.width, clip.height, **up_args) | |
if args.out: | |
set_output(descaled, name=f"{kernel_name} (rescaled)") | |
descaled = post_descale(clip, descaled, line_mask, crop) | |
err = get_prop(descaled.std.PlaneStats(clip), "PlaneStatsDiff", float) | |
return {kernel_out: err} | |
descaled_reg, shifts_reg = descale_fields(clip, de_args.get("height", 720), kernel, args.fields, False) | |
descaled_reg = post_descale(clip, descaled_reg, line_mask, crop) | |
err_reg = get_prop(descaled_reg.std.PlaneStats(clip), "PlaneStatsDiff", float) | |
debug(f"Error for {kernel_class} [{kernel_name}, {shifts_reg[-1]}]: {err_reg:.13f}", get_error) | |
descaled_neg, shifts_neg = descale_fields(clip, de_args.get("height", 720), kernel, args.fields, True) | |
descaled_neg = post_descale(clip, descaled_neg, line_mask, crop) | |
err_neg = get_prop(descaled_neg.std.PlaneStats(clip), "PlaneStatsDiff", float) | |
debug(f"Error for {kernel_class} [{kernel_name}, {shifts_neg[-1]}]: {err_neg:.13f}", get_error) | |
return { | |
f"{kernel_out} [{shifts_reg[0]:.3f}, {shifts_reg[1]:.3f}]": err_reg, | |
f"{kernel_out} [{shifts_neg[0]:.3f}, {shifts_neg[1]:.3f}]": err_neg, | |
} | |
def post_descale( | |
og_clip: vs.VideoNode, descaled_clip: vs.VideoNode, | |
line_mask: vs.VideoNode | None = None, crop: int = 8 | |
) -> vs.VideoNode: | |
# Reduce error by applying the descale to only the lineart and removing edges from the equation. | |
if line_mask: | |
descaled_clip = og_clip.std.MaskedMerge(descaled_clip, line_mask) | |
if crop: | |
descaled_clip = replace_squaremask( | |
descaled_clip, og_clip, (og_clip.width - crop * 2, og_clip.height - crop * 2, crop, crop), invert=True | |
) | |
return descaled_clip | |
def descale_fields( | |
clip: vs.VideoNode, | |
height: int, | |
kernel: Kernel, | |
tff: FieldBasedT = FieldBased.TFF, | |
shift_negative: bool = False | |
) -> tuple[vs.VideoNode, tuple[float, float]]: | |
""" | |
Descale the frame per-field. | |
This is used to descale cross conversions. | |
""" | |
neg = -1 if shift_negative else 1 | |
target_shift = (height / clip.height) * 0.25 | |
clip_y = plane(clip, 0) | |
to_descale = FieldBased.ensure_presence(clip_y, tff) | |
to_descale = to_descale.std.SeparateFields() | |
descaled_1 = kernel.descale( | |
to_descale[0::2], get_w(height, clip), | |
height // 2, (target_shift, 0.0) | |
) | |
descaled_2 = kernel.descale( | |
to_descale[1::2], get_w(height, clip), | |
height // 2, (target_shift * neg, 0.0) | |
) | |
upscaled = FieldBased.PROGRESSIVE.apply(core.std.Interleave([ | |
kernel.scale(descaled_1, clip.width, to_descale.height, (target_shift, 0)), | |
kernel.scale(descaled_2, clip.width, to_descale.height, (target_shift * neg, 0)) | |
]).std.DoubleWeave(tff=False))[::2] | |
if args.out: | |
set_output(upscaled, name=f"{kernel.__class__.__name__} [{target_shift, target_shift * neg}] (rescaled)") | |
return upscaled, (target_shift, target_shift * neg) | |
def get_kernels() -> list[KernelT]: | |
kernels: list[KernelT] = [ | |
# Bicubic-based | |
Hermite, # Bicubic b=0.0, c=0.0 | |
Catrom, # Bicubic b=0.0, c=0.5 | |
Mitchell, # Bicubic b=0.333, c=0.333 | |
BicubicSharp, # Bicubic b=0.0, c=1.0 | |
# Bicubic-based but from specific applications | |
FFmpegBicubic, # Bicubic b=0.0, c=0.6. FFmpeg's swscale | |
AdobeBicubic, # Bicubic b=0.0, c=0.75. Adobe's "Bicubic" interpolation | |
# Bilinear-based | |
Bilinear, | |
# Lanczos-based | |
Lanczos(taps=3), | |
Lanczos(taps=4), | |
# Point-based | |
Point, | |
# Impulse-based | |
Hamming, | |
Quadratic, | |
# Misc kernels | |
BlackMan, | |
] | |
if not args.extensive: | |
return kernels | |
warn( | |
"Extensive kernel checking enabled. Note that the original set of kernels were chosen because they are used " | |
"in professional software, and as such are astronomically more likely to be used in real productions. Please " | |
"be extra careful when using non-default kernels!" | |
) | |
kernels += [ | |
# Bicubic-based | |
BSpline, | |
Robidoux, | |
RobidouxSoft, | |
RobidouxSharp, | |
# Lanczos-based | |
Lanczos(taps=5), | |
# Spline-based | |
Spline16, | |
Spline36, | |
Spline64, | |
# Impulse-based | |
Wiener, | |
Hann, | |
BlackHarris, | |
BlackNuttall, | |
FlatTop, | |
MinSide, | |
Ginseng, | |
Welch, | |
Cosine, | |
Bessel, | |
Parzen, | |
Kaiser, | |
Bohman, | |
# Misc kernels | |
Box, | |
BlackManMinLobe, | |
Sinc, | |
Gaussian | |
] | |
return kernels | |
def print_results(clip: vs.VideoNode, errors: dict[str, float], framenum: int = 0) -> None: | |
errors_sorted: list[tuple[str, float]] = sorted(errors.items(), key=operator.itemgetter(1)) | |
if not errors_sorted: | |
warn("Could not get any values!", print_results) | |
return | |
best = errors_sorted[0] | |
clip = FieldBased.ensure_presence(clip, args.fields) | |
height = f"{args.native_height:.3f}" if not float(args.native_height).is_integer() else int(args.native_height) | |
width = f"{args.native_width:.3f}" if not float(args.native_width).is_integer() else int(args.native_width) | |
header = f"\nResults for frame {framenum} (resolution: {width}/{height}, " \ | |
f"AR: {args.native_width / args.native_height:.3f}, " \ | |
f"field-based: {FieldBased.from_video(clip).pretty_string}):" | |
print(header) | |
print("-" * max(80, len(header))) | |
print(f'{"Scaler":<44}\t{"Error%":>7}\t{"Abs. Error":>18}') | |
for name, abserr in errors_sorted: | |
relerr = abserr / best[1] if best[1] != 0 else 0 | |
print(f"{name:<44}\t{relerr:>8.1%}\t{abserr:.13f}") | |
print("-" * max(80, len(header))) | |
print(f"Smallest error achieved by \"{best[0]}\" ({best[1]:.10f})\n") | |
if best[1] > 0.008: | |
warn( | |
"The error rates for this frame are on the low end of acceptable errors. " | |
"This can be happen if you have the wrong native resolution or there are FHD elements in the image. " | |
"Be extra careful when trying to descale this frame using these results!" | |
) | |
if any(x in best[0].lower() for x in ("mitchell", "0.33")): | |
warn( | |
"Note that Mitchell is a common false-positive. " | |
"Carefully compare your descaling results with Catrom or Lanczos!" | |
) | |
if "spline" in best[0].lower(): | |
warn( | |
"Note that Spline is an EXTREMELY uncommon custom kernel. " | |
"Carefully compare your descaling results with Catrom or Lanczos!" | |
) | |
warn("getscaler is not perfect! Please don't blindly trust these results " | |
"and carefully verify them for yourself!") | |
def main() -> None: | |
if not (p := SPath(args.input_file)).exists(): | |
raise FileWasNotFoundError(f"Could not find the file, \"{p}\"!", main) | |
clip = source(p) | |
if args.native_height == -1 and args.native_width == -1: | |
warn(f"You cannot set both \"--native-height\" and \"--native-width\" to \"-1\"!", main) | |
return | |
if args.native_height == -1: | |
args.native_height = clip.height | |
if args.native_width == -1: | |
args.native_width = clip.width | |
if args.fields and not float(args.native_height).is_integer(): | |
warn("Float values are not currently supported when scaling per-field! Rounding...") | |
if args.native_width is not None: | |
args.native_width = int(args.native_width) | |
args.native_height = int(args.native_height) | |
if args.native_width is None: | |
args.native_width = args.native_height * clip.width / clip.height | |
framenum = args.frame or randint(0, clip.num_frames - 1) | |
frame = clip[framenum] | |
if args.frame is None: | |
debug(f"No frame number given. Grabbing random frame ({framenum}/{clip.num_frames-1})...") | |
frame_y = plane(frame, 0) | |
mask = Sobel.edgemask(frame_y) | |
if args.out: | |
set_output(frame_y, name="original frame (luma)") | |
kernels = get_kernels() | |
errors: dict[str, float] = dict() | |
for kernel in kernels: | |
err = get_error(frame_y, args.native_width, args.native_height, mask, args.crop, kernel) | |
if err: | |
errors |= err | |
print_results(clip, errors, framenum) | |
if __name__ == "__main__": | |
parser = argparse.ArgumentParser(description="Find the best inverse scaler for a given frame") | |
parser.add_argument( | |
dest="input_file", | |
type=str, | |
help="Absolute or relative path to the input file (video/script/image)", | |
) | |
parser.add_argument( | |
"--native-height", | |
"-nh", | |
dest="native_height", | |
type=float, | |
default=720.0, | |
help="Approximated native height. Passing \"-1\" will use the input's height. " | |
"Default is 720.0. Accepts both int and float", | |
) | |
parser.add_argument( | |
"--native_width", | |
"-nw", | |
dest="native_width", | |
type=float, | |
default=None, | |
help="Approximated native width. Passing \"-1\" will use the input's width. " | |
"Default is None (auto-calculate from input and height)", | |
) | |
parser.add_argument( | |
"--crop", | |
"-c", | |
dest="crop", | |
type=int, | |
default=8, | |
help="Number of pixels to crop the edges of the result by, reducing error caused by dirty edges", | |
) | |
parser.add_argument( | |
"--frame", | |
"-f", | |
dest="frame", | |
type=int, | |
default=None, | |
help="Specify a frame for the analysis. Random if unspecified", | |
) | |
parser.add_argument( | |
"--field-based", | |
"-fb", | |
dest="fields", | |
type=int, | |
default=0, | |
help="How to treat the field properties of the frame. " | |
"0 = Progressive, 1 = Bottom-Field-First, 2 = Top-Field-First. " | |
"The shifts that were applied will be added to the Scaler in square brackets. " | |
"Defaults to 0 (Progressive)" | |
) | |
parser.add_argument( | |
"--swap", | |
"-s", | |
default=False, | |
action="store_true", | |
help="Swap the kernel names to use more descriptive names, i.e. Catrom => Bicubic (b=0.00, c=0.50)", | |
) | |
parser.add_argument( | |
"--extensive", | |
"-e", | |
action="store_true", | |
help="Perform a more extensive check using headcrafted kernels and parameters", | |
) | |
parser.add_argument( | |
"--out", | |
"-o", | |
action="store_true", | |
help="Set an output node for the clips", | |
) | |
parser.add_argument( | |
"--debug", | |
action="store_true", | |
help="Enable debugger logging", | |
) | |
args = parser.parse_args() | |
if args.debug: | |
logger.setLevel(logging.DEBUG) | |
debug("Debug logging enabled") | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment