nativeres 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.
nativeres/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .funcs import get_dct_distribution, get_descale_error, getfnative, getfscaler, getnative, getscaler
2
+
3
+ __all__ = ["get_dct_distribution", "get_descale_error", "getfnative", "getfscaler", "getnative", "getscaler"]
@@ -0,0 +1,313 @@
1
+ """CLI module"""
2
+
3
+ import sys
4
+ import warnings
5
+ from itertools import zip_longest
6
+ from logging import INFO, basicConfig, captureWarnings, getLogger
7
+ from typing import Annotated, Any, Literal, assert_never, cast
8
+
9
+ from jetpytools import SPath
10
+ from rich.console import Console
11
+ from rich.logging import RichHandler
12
+ from rich.pretty import pretty_repr
13
+ from rich.progress import BarColumn, Progress, TextColumn
14
+ from rich.style import Style
15
+ from rich.table import Table
16
+ from vskernels import ComplexKernel
17
+ from vsmasktools import EdgeDetect
18
+ from vssource import Indexer
19
+
20
+ from .. import funcs
21
+ from ..constants import HIGH_RATE, LOW_RATE
22
+ from ..kernels import default_kernels
23
+ from .components import (
24
+ app,
25
+ base_dim_opt,
26
+ crop_opt,
27
+ cull_rate_opt,
28
+ debug_opt,
29
+ dim_mode_opt,
30
+ dim_opt,
31
+ frame_opt,
32
+ global_debug_opt,
33
+ indexer_opt,
34
+ input_file_arg,
35
+ kernel_opt,
36
+ mask_opt,
37
+ metric_mode_opt,
38
+ radius_opt,
39
+ range_dim_opt,
40
+ show_default_kernels_opt,
41
+ show_vskernels_opt,
42
+ step_opt,
43
+ )
44
+ from .helpers import get_progress, get_videonode_from_input
45
+
46
+ console = Console(stderr=True)
47
+
48
+ warnings.filterwarnings("always")
49
+ captureWarnings(True)
50
+ basicConfig(
51
+ level=INFO,
52
+ handlers=[RichHandler(console=console)],
53
+ format="{name}: {message}",
54
+ style="{",
55
+ )
56
+
57
+ logger = getLogger(__name__)
58
+
59
+
60
+ @app.callback()
61
+ def callback(
62
+ show_kernels: Annotated[bool, show_default_kernels_opt] = False,
63
+ show_vskernels: Annotated[bool, show_vskernels_opt] = False,
64
+ debug: Annotated[bool, debug_opt] = False,
65
+ global_debug: Annotated[bool, global_debug_opt] = False,
66
+ ) -> None:
67
+ """Descale analysis tools for VapourSynth."""
68
+ ...
69
+
70
+
71
+ @app.command(
72
+ help="[bold]Determine the native resolution of upscaled material.[/]\n\n"
73
+ "Analyzes a range of dimensions to find which one produces the lowest error when inverse scaled.\n"
74
+ "Primary use case is finding the native resolution of upscaled anime.",
75
+ no_args_is_help=True,
76
+ )
77
+ def getnative(
78
+ input_file: Annotated[SPath, input_file_arg],
79
+ range_dim: Annotated[tuple[int, int] | None, range_dim_opt] = None,
80
+ dim_mode: Annotated[Literal["height", "width"], dim_mode_opt] = "height",
81
+ kernel: Annotated[ComplexKernel, kernel_opt] = cast(ComplexKernel, "bilinear"),
82
+ frame: Annotated[int, frame_opt] = 0,
83
+ step: Annotated[float, step_opt] = 1,
84
+ crop: Annotated[tuple[int, int, int, int] | None, crop_opt] = None,
85
+ metric_mode: Annotated[funcs.MetricMode, metric_mode_opt] = "MAE",
86
+ indexer: Annotated[Indexer, indexer_opt] = cast(Indexer, "bs"),
87
+ ) -> None:
88
+ import numpy as np
89
+ from PySide6.QtWidgets import QApplication, QMainWindow, QStyle
90
+
91
+ from ..plotting import RescalePlotWidget
92
+
93
+ clip = get_videonode_from_input(input_file, indexer)
94
+
95
+ # Resolve dimension and the range of dimensions to check
96
+ if range_dim:
97
+ start, stop = range_dim
98
+ else:
99
+ match dim_mode:
100
+ case "height":
101
+ dim = clip.height
102
+ case "width":
103
+ dim = clip.width
104
+ case _:
105
+ assert_never(dim_mode)
106
+
107
+ start, stop = int(dim * LOW_RATE), int(dim * HIGH_RATE)
108
+
109
+ # Build the list of dims (int or fractional)
110
+ step_f = float(step)
111
+ if step_f.is_integer():
112
+ dims = range(start, stop + 1, int(step_f))
113
+ x_label_fmt = "%.0f"
114
+ else:
115
+ num = int((stop - start) / step_f) + 1
116
+ dims = np.linspace(start, start + step_f * (num - 1), num).tolist()
117
+ x_label_fmt = f"%.{str(step_f)[::-1].find('.') + 1}f"
118
+
119
+ # Pair with the fixed dimension
120
+ match dim_mode:
121
+ case "height":
122
+ dimensions = zip_longest([clip.width], dims, fillvalue=clip.width)
123
+ case "width":
124
+ dimensions = zip_longest(dims, [clip.height], fillvalue=clip.height)
125
+ case _:
126
+ assert_never(dim_mode)
127
+
128
+ # Pretty progress
129
+ progress = get_progress(console)
130
+ gtask_id = progress.add_task("Gathering data...", total=None)
131
+
132
+ logger.debug(kernel)
133
+
134
+ with progress:
135
+ results = funcs.getnative(
136
+ clip,
137
+ frame,
138
+ dimensions,
139
+ kernel,
140
+ crop,
141
+ metric_mode=metric_mode,
142
+ progress_cb=lambda curr, total: progress.update(gtask_id, advance=1, total=total, visible=True),
143
+ )
144
+ progress.update(gtask_id, total=100, completed=100, refresh=True)
145
+
146
+ dims, errors = zip(*results)
147
+
148
+ # Show the plot window
149
+ app = QApplication(sys.argv)
150
+ win = QMainWindow()
151
+ win.setWindowTitle("Native Resolution Analysis")
152
+ win.setWindowIcon(win.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView))
153
+ win.resize(1000, 600)
154
+
155
+ plot = RescalePlotWidget(
156
+ f"Error plot - {kernel.pretty_string} on {dim_mode}",
157
+ [getattr(d, dim_mode) for d in dims],
158
+ errors,
159
+ dim_mode.title(),
160
+ )
161
+ plot.axis_x.setLabelFormat(x_label_fmt)
162
+
163
+ win.setCentralWidget(plot)
164
+
165
+ win.show()
166
+ app.exec()
167
+
168
+
169
+ @app.command(
170
+ help="[bold]Identify the best inverse scaler for a given resolution.[/]\n\n"
171
+ "Compares multiple kernels against a specific target resolution to determine which one "
172
+ "was likely used for the original upscaling.",
173
+ epilog="""
174
+ [dim]Notes:
175
+
176
+ - getscaler gives heuristic results; it's not infallible.
177
+
178
+ - Always visually verify the suggested scaler and parameters on multiple frames before trusting them.[/dim]
179
+ """,
180
+ no_args_is_help=True,
181
+ )
182
+ def getscaler(
183
+ input_file: Annotated[SPath, input_file_arg],
184
+ dim: Annotated[float, dim_opt],
185
+ dim_mode: Annotated[Literal["height", "width"], dim_mode_opt] = "height",
186
+ base_dim_opt: Annotated[int | None, base_dim_opt] = None,
187
+ kernels: Annotated[list[ComplexKernel], kernel_opt] = [],
188
+ frame: Annotated[int, frame_opt] = 0,
189
+ crop: Annotated[tuple[int, int, int, int] | None, crop_opt] = None,
190
+ metric_mode: Annotated[funcs.MetricMode, metric_mode_opt] = "MAE",
191
+ mask: Annotated[type[EdgeDetect] | None, mask_opt] = None,
192
+ indexer: Annotated[Indexer, indexer_opt] = cast(Indexer, "bs"),
193
+ ) -> None:
194
+ clip = get_videonode_from_input(input_file, indexer)
195
+
196
+ # Resolve dimension to check
197
+ scaler_args: dict[str, Any] = {
198
+ "width": clip.width,
199
+ "height": clip.height,
200
+ dim_mode: dim,
201
+ f"base_{dim_mode}": base_dim_opt,
202
+ }
203
+
204
+ progress = Progress(
205
+ TextColumn("[progress.description]{task.description}"),
206
+ BarColumn(),
207
+ console=console,
208
+ transient=True,
209
+ )
210
+ task = progress.add_task("Gathering data...", total=None)
211
+
212
+ with progress:
213
+ ress = funcs.getscaler(
214
+ clip,
215
+ frame,
216
+ kernels=(*default_kernels, *kernels),
217
+ crop=crop,
218
+ metric_mode=metric_mode,
219
+ mask=mask,
220
+ **scaler_args,
221
+ )
222
+ progress.update(task, completed=100, total=100, visible=False, refresh=True)
223
+
224
+ # Results are sorted and displayed to the CLI for the user
225
+ sorted_ress = sorted(ress, key=lambda r: r.error)
226
+ best = sorted_ress[0]
227
+
228
+ logger.debug("%s", pretty_repr(sorted_ress, max_width=200, indent_size=2))
229
+
230
+ width, height = scaler_args["width"], scaler_args["height"]
231
+
232
+ dwidth = f"{width:.0f}" if float(width).is_integer() else f"{width:.3f}"
233
+ dheight = f"{height:.0f}" if float(height).is_integer() else f"{height:.3f}"
234
+
235
+ table = Table(
236
+ title=f"Results for frame {frame} — Resolution: {dwidth}x{dheight}",
237
+ title_style=Style(bold=True),
238
+ caption=f"Smallest error archieved by {best.kernel.pretty_string}: {best.error:.13f}",
239
+ caption_style=Style(bold=True, dim=True),
240
+ caption_justify="left",
241
+ min_width=80,
242
+ )
243
+ table.add_column("Kernel")
244
+ table.add_column("Error %", justify="center")
245
+ table.add_column(metric_mode, justify="right")
246
+
247
+ for res in sorted_ress:
248
+ table.add_row(
249
+ res.kernel.pretty_string, f"{res.error * 100 / best.error if best.error else 0:.2f} %", f"{res.error:.13f}"
250
+ )
251
+
252
+ console.rule()
253
+ console.print(table, new_line_start=True)
254
+ console.rule()
255
+ console.print(
256
+ "Getfscaler is not infallible!\n"
257
+ "Always visually verify the suggested scaler and parameters on multiple frames before trusting them.",
258
+ style=Style(color="yellow", dim=True),
259
+ )
260
+
261
+
262
+ @app.command(
263
+ help="[bold]Visualize the frequency distribution of a frame.[/]\n\n"
264
+ "Calculates the Discrete Cosine Transform (DCT) of the image rows/columns "
265
+ "to identify spikes that may indicate the native resolution or scaling artifacts.",
266
+ no_args_is_help=True,
267
+ )
268
+ def getfreq(
269
+ input_file: Annotated[SPath, input_file_arg],
270
+ frame: Annotated[int, frame_opt] = 0,
271
+ cull_rate: Annotated[float, cull_rate_opt] = 3.0,
272
+ radius: Annotated[int, radius_opt] = 50,
273
+ indexer: Annotated[Indexer, indexer_opt] = cast(Indexer, "bs"),
274
+ ) -> None:
275
+ from PySide6.QtWidgets import QApplication, QMainWindow, QStyle
276
+
277
+ from ..funcs import get_dct_distribution
278
+ from ..plotting import FrequencyPlotWidget
279
+
280
+ clip = get_videonode_from_input(input_file, indexer)
281
+
282
+ progress = get_progress(console)
283
+ task = progress.add_task("Calculating DCT distribution...", total=None)
284
+
285
+ with progress:
286
+ dct_h, dct_v = get_dct_distribution(clip, frame, cull_rate=cull_rate)
287
+ progress.update(task, completed=100, total=100, visible=False, refresh=True)
288
+
289
+ min_val_h, max_val_h = int(clip.width * LOW_RATE), int(clip.width * HIGH_RATE)
290
+ min_val_v, max_val_v = int(clip.height * LOW_RATE), int(clip.height * HIGH_RATE)
291
+
292
+ # Show the plot window
293
+ app = QApplication(sys.argv)
294
+ win = QMainWindow()
295
+ win.setWindowTitle("Frequency Analysis")
296
+ win.setWindowIcon(win.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView))
297
+ win.resize(1000, 600)
298
+
299
+ plot = FrequencyPlotWidget(
300
+ f"DCT Frequency - {input_file.name}",
301
+ dct_h,
302
+ dct_v,
303
+ min_val_h,
304
+ max_val_h,
305
+ min_val_v,
306
+ max_val_v,
307
+ check_radius=radius,
308
+ )
309
+
310
+ win.setCentralWidget(plot)
311
+
312
+ win.show()
313
+ app.exec()
@@ -0,0 +1,171 @@
1
+ from click import BadParameter
2
+ from jetpytools import SPath
3
+ from typer import Argument, Option, Typer
4
+ from vsmasktools import EdgeDetect
5
+
6
+ from ..funcs import resolve_kernel
7
+ from .helpers import (
8
+ resolve_dimension,
9
+ resolve_dimension_mode,
10
+ resolve_idx,
11
+ set_debug,
12
+ set_global_debug,
13
+ show_default_kernels,
14
+ show_vskernels,
15
+ )
16
+
17
+ # DEBUG
18
+ debug_opt = Option(
19
+ "--debug",
20
+ help="Enable debug output.",
21
+ hidden=True,
22
+ is_eager=True,
23
+ callback=set_debug,
24
+ )
25
+ global_debug_opt = Option(
26
+ "--global-debug",
27
+ help="Enable global debug output.",
28
+ hidden=True,
29
+ is_eager=True,
30
+ callback=set_global_debug,
31
+ )
32
+
33
+
34
+ # App
35
+ app = Typer(
36
+ name="nativeres",
37
+ rich_markup_mode="rich",
38
+ pretty_exceptions_enable=True,
39
+ add_completion=False,
40
+ no_args_is_help=True,
41
+ )
42
+
43
+ # Commons
44
+ input_file_arg = Argument(
45
+ help="Path to the source material to analyze. "
46
+ "Supports videos, images, or VapourSynth scripts. For scripts, the first output is used.",
47
+ metavar="INPUT",
48
+ resolve_path=True,
49
+ parser=SPath,
50
+ )
51
+ frame_opt = Option(
52
+ "--frame",
53
+ "-f",
54
+ help="The specific frame number to extract and analyze from video inputs. Ignored for images.",
55
+ metavar="INTEGER",
56
+ rich_help_panel="Common",
57
+ )
58
+ kernel_opt = Option(
59
+ "--kernel",
60
+ "-k",
61
+ help="The kernel(s) to use for inverse scaling.\n\n"
62
+ "Can be a kernel name or a class call with parameters (e.g., 'Bicubic(b=0, c=0.5)').\n\n"
63
+ "Use --show-kernels for a list of available kernels.",
64
+ metavar="Kernel|Kernel(arg0=..., arg1=...)",
65
+ parser=lambda v: resolve_kernel(v, BadParameter),
66
+ rich_help_panel="Common",
67
+ )
68
+
69
+ dim_mode_opt = Option(
70
+ "--dim-mode",
71
+ "-dm",
72
+ help="Specifies whether to analyze based on the [bold]height[/] or [bold]width[/] of the frame.",
73
+ metavar="height|width|h|w",
74
+ parser=resolve_dimension_mode,
75
+ rich_help_panel="Common",
76
+ )
77
+ crop_opt = Option(
78
+ "--crop",
79
+ "-c",
80
+ help="Crop the input frame before analysis to remove black bars.\n\n"
81
+ "Format: [bold]LEFT RIGHT TOP BOTTOM[/] (e.g., '0 0 240 240').",
82
+ metavar="L R T B",
83
+ rich_help_panel="Common",
84
+ )
85
+ metric_mode_opt = Option(
86
+ "--metric-mode",
87
+ "-mm",
88
+ help="The mathematical metric used to compare scaling results.\n\n"
89
+ "- [bold]MAE[/] (Mean Absolute Error)\n\n"
90
+ "- [bold]MSE[/] (Mean Squared Error)\n\n"
91
+ "- [bold]RMSE[/] (Root Mean Squared Error)",
92
+ metavar="MAE|MSE|RMSE",
93
+ parser=lambda value: value.upper(),
94
+ rich_help_panel="Common",
95
+ )
96
+ indexer_opt = Option(
97
+ "--indexer",
98
+ "-idx",
99
+ help="The VapourSynth indexer used to load files.\n\nSpecifying the plugin namespace is also allowed (e.g., 'bs').",
100
+ metavar="STRING",
101
+ parser=resolve_idx,
102
+ show_default="BestSource",
103
+ rich_help_panel="Common",
104
+ )
105
+
106
+ # Helpers
107
+ show_default_kernels_opt = Option(
108
+ "--show-kernels",
109
+ help="Show the default checked kernels for getscaler and exit.",
110
+ is_eager=True,
111
+ show_default=False,
112
+ callback=show_default_kernels,
113
+ rich_help_panel="Helpers",
114
+ )
115
+ show_vskernels_opt = Option(
116
+ "--show-vskernels",
117
+ help="Show the builtin supported kernels from vskernels and exit.",
118
+ is_eager=True,
119
+ show_default=False,
120
+ callback=show_vskernels,
121
+ rich_help_panel="Helpers",
122
+ )
123
+
124
+ # getnative exclusive
125
+ range_dim_opt = Option(
126
+ "--range-dim",
127
+ "-rd",
128
+ help="The inclusive range of resolutions to test.\n\nSpecify as [bold]START END[/] (e.g., '500 1080').",
129
+ metavar="INTEGER INTEGER",
130
+ show_default=False,
131
+ )
132
+ step_opt = Option("--step", "-s", help="The increment step between resolutions in the tested range.", metavar="NUMBER")
133
+
134
+
135
+ # getscaler exclusive
136
+ dim_opt = Argument(
137
+ help="The suspected native resolution to verify. "
138
+ "Use an integer for exact pixels (e.g., 720) or a float for sub-pixel dimensions (e.g., 719.8).",
139
+ metavar="NUMBER",
140
+ parser=resolve_dimension,
141
+ )
142
+ base_dim_opt = Option(
143
+ "--base-dim",
144
+ "-b",
145
+ help="Base integer dimension if checking for fractional resolution.",
146
+ )
147
+ mask_opt = Option(
148
+ "--mask",
149
+ "-m",
150
+ help="Edge-detection mask to reduce noise influence on the metric. "
151
+ "Pass a mask name (e.g., 'Prewitt') or a class name from vsmasktools.",
152
+ metavar="EDGEDETECT",
153
+ parser=EdgeDetect.from_param,
154
+ )
155
+
156
+
157
+ # Frequency exclusive
158
+ cull_rate_opt = Option(
159
+ "--cull-rate",
160
+ "-cr",
161
+ help="Cull the sides/top of the frame to focus on the center.",
162
+ metavar="NUMBER",
163
+ show_default=True,
164
+ )
165
+ radius_opt = Option(
166
+ "--radius",
167
+ "-r",
168
+ help="Radius for finding peaks/spikes in the frequency plot.",
169
+ metavar="INTEGER",
170
+ show_default=True,
171
+ )
@@ -0,0 +1,115 @@
1
+ from logging import DEBUG, getLogger
2
+ from typing import Any
3
+
4
+ from jetpytools import CustomValueError, SPath, get_subclasses
5
+ from rich.console import Console
6
+ from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn, TimeRemainingColumn
7
+ from typer import BadParameter, Exit
8
+ from vskernels import ComplexKernel
9
+ from vssource import BestSource, CacheIndexer, Indexer
10
+ from vstools import Matrix, vs
11
+
12
+
13
+ # Callbacks
14
+ def resolve_dimension(value: str) -> float:
15
+ nb = float(value)
16
+
17
+ if nb.is_integer():
18
+ return int(nb)
19
+
20
+ return nb
21
+
22
+
23
+ def resolve_idx(idx: str) -> Indexer:
24
+ indexer = Indexer.from_param(idx, BadParameter)
25
+
26
+ args = dict[str, Any]()
27
+
28
+ if issubclass(indexer, CacheIndexer):
29
+ # Set cache path to None
30
+ args[indexer._cache_arg_name] = None
31
+
32
+ if issubclass(indexer, BestSource):
33
+ args["show_pretty_progress"] = True
34
+
35
+ return indexer(**args)
36
+
37
+
38
+ def resolve_dimension_mode(mode: str) -> str:
39
+ match mode:
40
+ case "height" | "h":
41
+ return "height"
42
+ case "width" | "w":
43
+ return "width"
44
+ case _:
45
+ raise BadParameter("Unknown dimension passed")
46
+
47
+
48
+ def set_debug(value: bool) -> None:
49
+ if value:
50
+ getLogger((__package__ or "").split(".")[0]).setLevel(DEBUG)
51
+
52
+
53
+ def set_global_debug(value: bool) -> None:
54
+ if value:
55
+ getLogger().setLevel(DEBUG)
56
+
57
+
58
+ def show_default_kernels(value: bool) -> None:
59
+ if value:
60
+ from ..kernels import default_kernels
61
+
62
+ console = Console(stderr=True)
63
+
64
+ for kernel in default_kernels:
65
+ console.print(str(kernel))
66
+
67
+ raise Exit(0)
68
+
69
+
70
+ def show_vskernels(value: bool) -> None:
71
+ if value:
72
+ all_kernels = {k for k in get_subclasses(ComplexKernel) if not k.is_abstract}
73
+
74
+ console = Console(stderr=True)
75
+
76
+ for kernel in sorted(all_kernels, key=lambda k: k.__name__):
77
+ console.print(kernel.__name__)
78
+
79
+ raise Exit(0)
80
+
81
+
82
+ # Helpers
83
+ def get_videonode_from_input(path: SPath, indexer: Indexer) -> vs.VideoNode:
84
+ if not path.exists():
85
+ raise BadParameter(f"{path.to_str()!r} doesn't exist.")
86
+
87
+ if path.suffix in (".py", ".vpy"):
88
+ from vsengine import load_script
89
+
90
+ load_script(path, module="__nativeres__").result()
91
+ out = next(iter(vs.get_outputs().values()))
92
+
93
+ if not isinstance(out, vs.VideoOutputTuple):
94
+ raise CustomValueError("Unknown VapourSynth output", get_videonode_from_input, type(out))
95
+
96
+ return out.clip
97
+
98
+ if isinstance(indexer, BestSource):
99
+ from signal import SIG_DFL, SIGINT, signal
100
+
101
+ signal(SIGINT, SIG_DFL)
102
+
103
+ clip = indexer.source(path, 32, idx_props=False)
104
+ return clip.resize.Bilinear(format=vs.GRAYS, matrix=Matrix.BT709, matrix_in=Matrix.from_video(clip))
105
+
106
+
107
+ def get_progress(console: Console) -> Progress:
108
+ return Progress(
109
+ TextColumn("[progress.description]{task.description}"),
110
+ BarColumn(),
111
+ TextColumn("{task.percentage:>3.0f}%"),
112
+ TimeElapsedColumn(),
113
+ TimeRemainingColumn(),
114
+ console=console,
115
+ )
nativeres/constants.py ADDED
@@ -0,0 +1 @@
1
+ LOW_RATE, HIGH_RATE = 0.465, 0.925