diff --git a/app/InterlacedRenderer.py b/app/InterlacedRenderer.py index 744465a..a44dc1e 100755 --- a/app/InterlacedRenderer.py +++ b/app/InterlacedRenderer.py @@ -10,16 +10,16 @@ class InterlacedRenderer(DefaultRenderer): interlaced = True @staticmethod - def apply_main_effect(nt: Ntsc, frame1, frame2=None, frameId=0): + def apply_main_effect(nt: Ntsc, frame1, frame2, frameno: int): #raise NotImplementedError() if frame2 is None: frame2 = frame1 - frame1 = nt.composite_layer(frame1, frame1, field=0, fieldno=0, frame=frameId) + frame1 = nt.composite_layer(frame1, frame1, field=0, fieldno=0, frameno=frameno) frame1 = cv2.convertScaleAbs(frame1) frame2 = cv2.copyMakeBorder(frame2,1,0,0,0,cv2.BORDER_CONSTANT) - frame2 = nt.composite_layer(frame2, frame2, field=2, fieldno=2, frame=frameId) + frame2 = nt.composite_layer(frame2, frame2, field=2, fieldno=2, frameno=frameno) frame2 = cv2.convertScaleAbs(frame2) frame = frame1 frame[1::2,:] = frame2[2::2,:] diff --git a/app/NtscApp.py b/app/NtscApp.py index f589bcb..5f753e3 100644 --- a/app/NtscApp.py +++ b/app/NtscApp.py @@ -1,25 +1,268 @@ import json from pathlib import Path from random import randint -from typing import Tuple, Union, List, Dict +from typing import Tuple, Union, List, Dict, Set, Any, TypeVar, Generic, Callable, Type import requests import cv2 import numpy from PyQt5 import QtWidgets, QtCore, QtGui -from PyQt5.QtWidgets import QSlider, QHBoxLayout, QVBoxLayout, QLabel, QCheckBox, QInputDialog, QPushButton +from PyQt5.QtWidgets import ( + QHBoxLayout, QGridLayout, + QWidget, QSlider, QLabel, QCheckBox, QInputDialog, QPushButton, QSpinBox, QDoubleSpinBox, QComboBox, + QFrame, QGroupBox +) from numpy import ndarray +from abc import abstractmethod from app.InterlacedRenderer import InterlacedRenderer from app.config_dialog import ConfigDialog from app.logs import logger from app.Renderer import DefaultRenderer -from app.funcs import resize_to_height, pick_save_file, trim_to_4width, set_ui_element -from app.ntsc import random_ntsc, Ntsc +from app.funcs import resize_to_height, pick_save_file, trim_to_4width +from app.ntsc import random_ntsc, Ntsc, VHSSpeed from ui import mainWindow from ui.DoubleSlider import DoubleSlider +T = TypeVar("T") + +class Control(Generic[T]): + param_name: str + _on_change: Callable[[str, T], Any] + widget: QWidget + + def __init__(self, param_name: str, on_change: Callable[[str, T], Any]): + self.param_name = param_name + self._on_change = on_change + + def _on_value_change(self): + self._on_change(self.param_name, self.get_value()) + + @abstractmethod + def get_value(self) -> T: + pass + + @abstractmethod + def set_value(self, value: Any, block_signals=False) -> None: + pass + +class CheckboxControl(Control[bool]): + widget: QCheckBox + + def __init__(self, param_name: str, text: str, on_change: Callable[[str, bool], Any]): + super().__init__(param_name, on_change) + + self.widget = QCheckBox() + self.widget.setText(text) + self.widget.setObjectName(param_name) + self.widget.stateChanged.connect(self._on_value_change) + + def get_value(self): + return self.widget.isChecked() + + def set_value(self, value, block_signals=False): + if block_signals: + self.widget.blockSignals(True) + self.widget.setChecked(bool(value)) + self.widget.blockSignals(False) + +Num = TypeVar("Num", bound=Union[int, float]) + +class SliderControl(Control[Num]): + _slider_value_type: Type[Num] + + slider: Union[QSlider, DoubleSlider] + box: Union[QSpinBox, QDoubleSpinBox] + widget: QFrame + + def __init__( + self, + param_name: str, + on_change: Callable[[str, Num], Any], + min_val: Num, + max_val: Num, + slider_value_type: Type[Num] + ): + super().__init__(param_name, on_change) + + self._slider_value_type = slider_value_type + + ly = QHBoxLayout() + ly.setContentsMargins(0, 0, 0, 0) + self.widget = QFrame() + self.widget.setLayout(ly) + + if slider_value_type is int: + self.slider = QSlider() + self.box = QSpinBox() + elif slider_value_type is float: + self.box = QDoubleSpinBox() + self.box.setSingleStep(0.1) + self.slider = DoubleSlider() + else: + raise ValueError(f"Invalid slider value type: {slider_value_type.__name__}") + + self.slider.blockSignals(True) + self.box.blockSignals(True) + + self.box.setMinimum(min_val) + self.box.setMaximum(max_val) + self.box.setFixedWidth(64) + self.slider.valueChanged.connect(self.box.setValue) + self.box.valueChanged.connect(self.slider.setValue) + self.slider.valueChanged.connect(self._on_value_change) + + self.slider.setMaximum(max_val) + self.slider.setMinimum(min_val) + self.slider.setMouseTracking(False) + if max_val < 100 and slider_value_type == int: + self.slider.setTickPosition(QSlider.TicksLeft) + self.slider.setTickInterval(1) + self.slider.setOrientation(QtCore.Qt.Horizontal) + self.slider.setObjectName(param_name) + + self.slider.blockSignals(False) + self.box.blockSignals(False) + + ly.addWidget(self.slider) + ly.addWidget(self.box) + + def get_value(self): + return self._slider_value_type(self.slider.value()) + + def set_value(self, value, block_signals=False): + if block_signals: + self.slider.blockSignals(True) + self.box.blockSignals(True) + self.slider.setValue(self._slider_value_type(value)) + self.box.setValue(self._slider_value_type(value)) + self.slider.blockSignals(False) + self.box.blockSignals(False) + +class ComboBoxControl(Control[T]): + _values: List[T] + _values_to_indices: Dict[T, int] + + widget: QComboBox + + def __init__( + self, + param_name: str, + on_change: Callable[[str, T], Any], + values: List[Tuple[str, T]] + ): + super().__init__(param_name, on_change) + + self._values = [value[1] for value in values] + + self.widget = QComboBox() + + # map values back to indices for use in sync_nt_to_sliders + self._values_to_indices = {} + + for index, (text, data) in enumerate(values): + self.widget.addItem(text, data) + self._values_to_indices[data] = index + self.widget.setObjectName(param_name) + + self.widget.currentIndexChanged.connect(self._on_value_change) + + def get_value(self): + index = self.widget.currentIndex() + return None if index == -1 else self._values[index] + + def set_value(self, value, block_signals=False): + if block_signals: + self.widget.blockSignals(True) + self.widget.setCurrentIndex(self._values_to_indices.get(value, -1)) + self.widget.blockSignals(False) + +class GroupBoxControl(Control[bool]): + widget: QGroupBox + + def __init__(self, param_name: str, text: str, on_change: Callable[[str, bool], Any], controls: Union["ControlForm", "ControlGrid"]): + super().__init__(param_name, on_change) + + self.widget = QGroupBox(text) + self.widget.setCheckable(True) + self.widget.setObjectName(param_name) + self.widget.toggled.connect(self._on_value_change) + self.widget.setLayout(controls.layout) + + def get_value(self): + return self.widget.isChecked() + + def set_value(self, value, block_signals=False): + print("set_value on groupbox") + if block_signals: + self.widget.blockSignals(True) + self.widget.setChecked(bool(value)) + self.widget.blockSignals(False) + +class ControlForm: + """Helper class for laying out controls in a form style (label on the left, control on the right.)""" + layout: QGridLayout + _next_row: int + _rows: List[Tuple[QLabel, Control]] + + def __init__(self, layout: Union[None, QGridLayout] = None): + self.layout = QGridLayout() if layout is None else layout + self.layout.setColumnStretch(1, 1) + self.layout.setVerticalSpacing(0) + self._next_row = 0 + self._rows = [] + + def add_control(self, text: str, control: Control): + label = QLabel(text) + label.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.MinimumExpanding) + label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + + self.layout.addWidget(label, self._next_row, 0) + self.layout.addWidget(control.widget, self._next_row, 1) + self._rows.append((label, control)) + self._next_row += 1 + + def add_group(self, child: GroupBoxControl): + # span both columns + self.layout.addWidget(child.widget, self._next_row, 0, 1, 2) + self._next_row += 1 + + def update_visibility(self, filter: Callable[[Control], bool]): + for label, control in self._rows: + is_visible = filter(control) + label.setVisible(is_visible) + control.widget.setVisible(is_visible) + +class ControlGrid: + """Helper class for laying out controls in a grid style, with a given number of columns before wrapping.""" + layout: QGridLayout + _next_row: int + _next_column: int + _num_columns: int + _controls: List[Control] + + def __init__(self, num_columns: int, layout: Union[None, QGridLayout] = None): + self.layout = QGridLayout() if layout is None else layout + self._num_columns = num_columns + self._next_row = 0 + self._next_column = 0 + self._controls = [] + + def add_control(self, control: Control): + self.layout.addWidget(control.widget, self._next_row, self._next_column) + self._next_column += 1 + if self._next_column >= self._num_columns: + self._next_row += 1 + self._next_column = 0 + self._controls.append(control) + + def update_visibility(self, filter: Callable[[Control], bool]): + for control in self._controls: + is_visible = filter(control) + control.widget.setVisible(is_visible) + # TODO: redo layout class NtscApp(QtWidgets.QMainWindow, mainWindow.Ui_MainWindow): + render_thread: QtCore.QThread def __init__(self): self.videoRenderer: DefaultRenderer = None self.current_frame: numpy.ndarray = False @@ -32,14 +275,15 @@ def __init__(self): self.isRenderActive: bool = False self.mainEffect: bool = True self.videoMode: bool = False - self.loss_less_mode: bool = False + self.lossless_mode: bool = False self.interlaced: bool = True self.framecount: int = 0 self.__video_output_suffix = ".mp4" # or .mkv for FFV1 self.ProcessAudio: bool = False - self.nt_controls = {} + self.nt_controls: Dict[str, Control] = {} self.nt: Ntsc = None - self.pro_mode_elements = [] + self.pro_mode = False + self.pro_mode_params: Set[str] = set() # Это здесь нужно для доступа к переменным, методам # и т.д. в файле design.py super().__init__() @@ -52,6 +296,9 @@ def __init__(self): "_vhs_edge_wave": self.tr("Edge wave"), "_vhs_tracking_noise": self.tr("VHS tracking noise"), "_output_vhs_tape_speed": self.tr("VHS tape speed"), + "_output_vhs_tape_speed_sp": self.tr("SP (Standard Play)"), + "_output_vhs_tape_speed_lp": self.tr("LP (Long Play)"), + "_output_vhs_tape_speed_ep": self.tr("EP (Extended Play)"), "_ringing": self.tr("Ringing"), "_ringing_power": self.tr("Ringing power"), "_ringing_shift": self.tr("Ringing shift"), @@ -79,11 +326,20 @@ def __init__(self): "_output_ntsc": self.tr("NTSC output"), "_black_line_cut": self.tr("Cut 2% black line"), } + self.sliders = ControlForm(self.slidersLayout) + self.checkboxes = ControlGrid(2, self.checkboxesLayout) + + vhs_controls = self.add_group("_emulating_vhs") + self.add_slider("_vhs_out_sharpen", 1, 5, parent=vhs_controls) + self.add_slider("_vhs_edge_wave", 0, 10, parent=vhs_controls) + self.add_menu("_output_vhs_tape_speed", [ + (self.strings["_output_vhs_tape_speed_sp"], VHSSpeed.VHS_SP), + (self.strings["_output_vhs_tape_speed_lp"], VHSSpeed.VHS_LP), + (self.strings["_output_vhs_tape_speed_ep"], VHSSpeed.VHS_EP) + ], parent=vhs_controls) + self.add_slider("_composite_preemphasis", 0, 10, float) - self.add_slider("_vhs_out_sharpen", 1, 5) - self.add_slider("_vhs_edge_wave", 0, 10) self.add_slider("_vhs_tracking_noise", 0, 800) - # self.add_slider("_output_vhs_tape_speed", 0, 10) self.add_slider("_ringing", 0, 1, float, pro=True) self.add_slider("_ringing_power", 0, 10) self.add_slider("_ringing_shift", 0, 3, float, pro=True) @@ -100,18 +356,19 @@ def __init__(self): self.add_slider("_head_switching_speed", 0, 100) - self.add_checkbox("_vhs_head_switching", (1, 1)) - self.add_checkbox("_color_bleed_before", (2, 2), pro=True) - self.add_checkbox("_enable_ringing2", (2, 1), pro=True) - self.add_checkbox("_composite_in_chroma_lowpass", (3, 2), pro=True) - self.add_checkbox("_composite_out_chroma_lowpass", (3, 1), pro=True) - self.add_checkbox("_composite_out_chroma_lowpass_lite", (4, 2), pro=True) - self.add_checkbox("_emulating_vhs", (4, 1)) - self.add_checkbox("_nocolor_subcarrier", (5, 2), pro=True) - self.add_checkbox("_vhs_chroma_vert_blend", (5, 1), pro=True) - self.add_checkbox("_vhs_svideo_out", (6, 2), pro=True) - self.add_checkbox("_output_ntsc", (6, 1), pro=True) - self.add_checkbox("_black_line_cut", (1, 2), pro=False) + self.add_checkbox("_vhs_head_switching") + self.add_checkbox("_black_line_cut", pro=False) + self.add_checkbox("_enable_ringing2", pro=True) + self.add_checkbox("_composite_in_chroma_lowpass", pro=True) + self.add_checkbox("_composite_out_chroma_lowpass", pro=True) + self.add_checkbox("_composite_out_chroma_lowpass_lite", pro=True) + self.add_checkbox("_nocolor_subcarrier", pro=True) + self.add_checkbox("_vhs_chroma_vert_blend", pro=True) + self.add_checkbox("_vhs_svideo_out", pro=True) + self.add_checkbox("_output_ntsc", pro=True) + self.add_checkbox("_color_bleed_before", pro=True) + + self.set_pro_mode(False) self.renderHeightBox.valueChanged.connect( lambda: self.set_current_frames(*self.get_current_video_frames()) @@ -138,9 +395,12 @@ def __init__(self): lambda: self.set_pro_mode(self.ProMode.isChecked()) ) - self.seedSpinBox.valueChanged.connect(self.update_seed) + self.presetFromSeedButton.clicked.connect(self.update_preset_seed) presets = [18, 31, 38, 44] - self.seedSpinBox.setValue(presets[randint(0, len(presets) - 1)]) + self.presetSeedSpinBox.setValue(presets[randint(0, len(presets) - 1)]) + self.update_preset_seed() + + self.noiseSeedSpinBox.valueChanged.connect(self.update_noise_seed) self.renderingLabel.hide() @@ -152,10 +412,10 @@ def __init__(self): self.videoTrackSlider.hide() self.livePreviewCheckbox.hide() - self.label_2.hide() - self.renderHeightBox.hide() - self.seedLabel.hide() - self.seedSpinBox.hide() + #self.label_2.hide() + #self.renderHeightBox.hide() + #self.seedLabel.hide() + #self.seedSpinBox.hide() self.add_builtin_templates() @@ -190,19 +450,19 @@ def setup_renderer(self): try: self.update_status("Terminating prev renderer") logger.debug("Terminating prev renderer") - self.thread.quit() + self.render_thread.quit() self.update_status("Waiting prev renderer") logger.debug("Waiting prev renderer") - self.thread.wait() + self.render_thread.wait() except AttributeError: logger.debug("Setup first renderer") # создадим поток - self.thread = QtCore.QThread() + self.render_thread = QtCore.QThread() # создадим объект для выполнения кода в другом потоке RendererClass = self.get_render_class() self.videoRenderer = RendererClass() # перенесём объект в другой поток - self.videoRenderer.moveToThread(self.thread) + self.videoRenderer.moveToThread(self.render_thread) # после чего подключим все сигналы и слоты self.videoRenderer.newFrame.connect(self.render_preview) self.videoRenderer.frameMoved.connect(self.videoTrackSlider.setValue) @@ -210,7 +470,7 @@ def setup_renderer(self): self.videoRenderer.sendStatus.connect(self.update_status) self.videoRenderer.increment_progress.connect(self.increment_progress) # подключим сигнал старта потока к методу run у объекта, который должен выполнять код в другом потоке - self.thread.started.connect(self.videoRenderer.run) + self.render_thread.started.connect(self.videoRenderer.run) @QtCore.pyqtSlot() def stop_render(self): @@ -258,10 +518,10 @@ def toggle_main_effect(self): def lossless_exporting(self): lossless_state = self.LossLessCheckBox.isChecked() - self.loss_less_mode = lossless_state + self.lossless_mode = lossless_state self.__video_output_suffix = '.mkv' if lossless_state else '.mp4' try: - self.videoRenderer.lossless = lossless_state + self.videoRenderer.config['lossless'] = lossless_state logger.debug(f"Lossless: {str(lossless_state)}") except AttributeError: pass @@ -271,17 +531,22 @@ def audio_filtering(self): state = False # Workaround self.ProcessAudio = state try: - self.videoRenderer.audio_process = state + self.videoRenderer.config['audio_process'] = state logger.debug(f"Process audio: {str(state)}") except AttributeError: pass - @QtCore.pyqtSlot(int) - def update_seed(self, seed): - self.nt = random_ntsc(seed) + @QtCore.pyqtSlot() + def update_preset_seed(self): + self.nt = random_ntsc(self.presetSeedSpinBox.value()) self.nt._enable_ringing2 = True self.sync_nt_to_sliders() + @QtCore.pyqtSlot(int) + def update_noise_seed(self): + self.nt._noise_seed = self.noiseSeedSpinBox.value() + self.nt_update_preview() + @QtCore.pyqtSlot(str) def update_status(self, string): logger.info('[GUI STATUS] ' + string) @@ -298,135 +563,110 @@ def set_render_state(self, is_render_active): self.stopRenderButton.setEnabled(is_render_active) # todo: сделать реассигн параметров во время рендера - self.seedSpinBox.setEnabled(not is_render_active) + self.presetSeedSpinBox.setEnabled(not is_render_active) + self.progressBar.setVisible(is_render_active) if is_render_active: - self.progressBar.show() self.renderingLabel.show() if self.videoMode: self.videoTrackSlider.hide() - self.label_2.hide() - self.renderHeightBox.hide() - self.seedLabel.hide() - self.seedSpinBox.hide() + #self.label_2.hide() + #self.renderHeightBox.hide() + #self.seedLabel.hide() + #self.seedSpinBox.hide() else: - self.progressBar.hide() self.renderingLabel.hide() if self.videoMode: self.videoTrackSlider.show() - self.label_2.show() - self.renderHeightBox.show() - self.seedLabel.show() - self.seedSpinBox.show() + #self.label_2.show() + #self.renderHeightBox.show() + #self.seedLabel.show() + #self.seedSpinBox.show() self.NearestUpScale.setEnabled(not is_render_active) def sync_nt_to_sliders(self): - for parameter_name, element in self.nt_controls.items(): + for parameter_name, control in self.nt_controls.items(): value = getattr(self.nt, parameter_name) - # This is necessary because some parameters that have a real float type, but in the interface, - # the slide is simplified to int. However, when setting the initial parameters that occur here, - # you need to set from the initial parameters that float - if isinstance(element, QSlider) and isinstance(value, float): - value = int(value) - - set_ui_element(element, value) - - related_label = element.parent().findChild(QLabel, parameter_name) - if related_label: - related_label.setText(str(value)[:7]) - - logger.debug(f"set {type(value)} {parameter_name} slider to {value}") + control.set_value(value, block_signals=True) + logger.debug(f"set {type(value)} {parameter_name} {type(control).__name__} to {value}") self.nt_update_preview() - def value_changed_slot(self): - element = self.sender() - parameter_name = element.objectName() - if isinstance(element, (QSlider, DoubleSlider)): - value = element.value() - related_label = element.parent().findChild(QLabel, parameter_name) - if related_label: - related_label.setText(str(value)[:7]) - elif isinstance(element, QCheckBox): - value = element.isChecked() - + def value_changed(self, parameter_name, value): logger.debug(f"Set {parameter_name} to {value}") setattr(self.nt, parameter_name, value) self.nt_update_preview() - def add_checkbox(self, param_name, pos, pro=False): - checkbox = QCheckBox() - checkbox.setText(self.strings[param_name]) - checkbox.setObjectName(param_name) - checkbox.stateChanged.connect(self.value_changed_slot) - # checkbox.mouseReleaseEvent(lambda: self.controls_set()) - self.nt_controls[param_name] = checkbox - self.checkboxesLayout.addWidget(checkbox, pos[0], pos[1]) - - if pro: - self.pro_mode_elements.append(checkbox) - checkbox.hide() + def pro_mode_filter(self, item: Control): + if self.pro_mode: + return True + return item.param_name not in self.pro_mode_params @QtCore.pyqtSlot(bool) def set_pro_mode(self, state): - for frame in self.pro_mode_elements: - if state: - frame.show() - else: - frame.hide() + self.pro_mode = state - def add_slider(self, param_name, min_val, max_val, slider_value_type: Union[int, float] = int, pro=False): - ly = QVBoxLayout() - ly.setSpacing(0) - slider_frame = QtWidgets.QFrame() - slider_frame.setLayout(ly) + self.checkboxes.update_visibility(self.pro_mode_filter) + self.sliders.update_visibility(self.pro_mode_filter) + + def add_checkbox(self, param_name, pro=False, parent: Union[None, ControlGrid]=None): + checkbox = CheckboxControl(param_name, self.strings[param_name], self.value_changed) + self.nt_controls[param_name] = checkbox + + if parent is None: + parent = self.checkboxes + parent.add_control(checkbox) if pro: - self.pro_mode_elements.append(slider_frame) - slider_frame.hide() + self.pro_mode_params.add(param_name) + + def add_menu(self, param_name, values: List[Tuple[str, Any]], pro=False, parent: Union[None, ControlForm]=None): + menu = ComboBoxControl(param_name, self.value_changed, values) + self.nt_controls[param_name] = menu - if slider_value_type is int: - slider = QSlider() - # box = QSpinBox() - slider.valueChanged.connect(self.value_changed_slot) - elif slider_value_type is float: - # box = QDoubleSpinBox() - # box.setSingleStep(0.1) - slider = DoubleSlider() - slider.mouseRelease.connect(self.value_changed_slot) - - slider.blockSignals(True) - slider.setEnabled(True) - slider.setMaximum(max_val) - slider.setMinimum(min_val) - slider.setMouseTracking(False) - if max_val < 100 and slider_value_type == int: - slider.setTickPosition(QSlider.TicksLeft) - slider.setOrientation(QtCore.Qt.Horizontal) - slider.setObjectName(f"{param_name}") - slider.blockSignals(False) - - label = QLabel() - # label.setText(description or name) - label.setText(self.strings[param_name]) - - # todo: сделать рандомайзер вместо бокса - # box.setMinimum(min_val) - # box.setMaximum(max_val) - # box.valueChanged.connect(slider.setValue) - # slider.valueChanged.connect(box.setValue) - value_label = QLabel() - value_label.setObjectName(param_name) - # slider.valueChanged.connect(lambda intval: value_label.setText(str(intval))) - - ly.addWidget(label) - ly.addWidget(slider) - # slider_layout.addWidget(box) - ly.addWidget(value_label) + if parent is None: + parent = self.sliders + parent.add_control(self.strings[param_name], menu) + if pro: + self.pro_mode_params.add(param_name) + + def add_slider( + self, + param_name, + min_val, + max_val, + slider_value_type: Union[Type[int], Type[float]] = int, + pro=False, + parent: Union[None, ControlForm]=None + ): + slider = SliderControl( + param_name, + self.value_changed, + min_val, + max_val, + slider_value_type + ) self.nt_controls[param_name] = slider - self.slidersLayoutLay.addWidget(slider_frame) + + if parent is None: + parent = self.sliders + parent.add_control(self.strings[param_name], slider) + + if pro: + self.pro_mode_params.add(param_name) + + def add_group(self, param_name, pro=False, parent: Union[None, ControlForm]=None) -> ControlForm: + controls = ControlForm() + group = GroupBoxControl(param_name, self.strings[param_name], self.value_changed, controls) + self.nt_controls[param_name] = group + + if parent is None: + parent = self.sliders + parent.add_group(group) + + return controls def get_current_video_frames(self): preview_h = self.renderHeightBox.value() @@ -494,7 +734,13 @@ def open_image_by_url(self): return None def open_file(self): - file = QtWidgets.QFileDialog.getOpenFileName(self, "Select File") + image_filters, video_filters = ( + " ".join(f"*{extension}" for extension in extension_list) + for extension_list + in (self.supported_image_type, self.supported_video_type) + ) + filters = f"All supported files ({image_filters} {video_filters});;Images ({image_filters});;Videos ({video_filters});;All files (*)" + file = QtWidgets.QFileDialog.getOpenFileName(self, "Select File", filter=filters) if file: if file[0] != "": path = Path(file[0]) @@ -514,10 +760,10 @@ def open_file(self): self.update_status(f"Unsupported file type {file_suffix}") def prepare_input_blocks(self): - self.label_2.show() - self.renderHeightBox.show() - self.seedLabel.show() - self.seedSpinBox.show() + #self.label_2.show() + #self.renderHeightBox.show() + #self.seedLabel.show() + #self.seedSpinBox.show() self.livePreviewCheckbox.show() def set_video_mode(self): @@ -559,18 +805,12 @@ def open_image(self, img: numpy.ndarray): def nt_get_config(self): values = {} - element: Union[QCheckBox, QSlider, DoubleSlider] - for parameter_name, element in self.nt_controls.items(): - if isinstance(element, QCheckBox): - value = element.isChecked() - elif isinstance(element, (QSlider, DoubleSlider)): - value = element.value() - - values[parameter_name] = value + for parameter_name, control in self.nt_controls.items(): + values[parameter_name] = control.get_value() return values - def nt_set_config(self, values: List[Dict[str, Union[int, float]]]): + def nt_set_config(self, values: Dict[str, Union[int, float]]): for parameter_name, value in values.items(): setattr(self.nt, parameter_name, value) @@ -586,6 +826,11 @@ def round_framecount(self, framecount: int): framed += 1 return framed + + def check_interlaced(self, fr: float): + self.interlaced = True + if(fr < 50): + self.interlaced = False def open_video(self, path: Path): self.setup_renderer() @@ -601,6 +846,8 @@ def open_video(self, path: Path): "path": path, "suffix": path.suffix.lower(), } + + self.check_interlaced(self.input_video["orig_fps"]) if(self.interlaced): self.videoTrackSlider.setSingleStep(2) @@ -611,6 +858,7 @@ def open_video(self, path: Path): self.videoRenderer.framecount = self.framecount + logger.debug(f"Is interlaced: {self.interlaced}") logger.debug(f"Framecount: {self.videoRenderer.framecount}") logger.debug(f"selfinput: {self.input_video}") @@ -633,7 +881,7 @@ def render_image(self): image = cv2.resize(self.current_frame, crop_wh) if image.shape[1] % 4 != 0: image = trim_to_4width(image) - image = self.videoRenderer.apply_main_effect(self.nt, frame1=image) + image = self.videoRenderer.apply_main_effect(self.nt, image, image, self.videoTrackSlider.value()) is_success, im_buf_arr = cv2.imencode(".png", image) if not is_success: self.update_status("Error while saving (!is_success)") @@ -644,7 +892,7 @@ def render_video(self): if self.input_video['suffix'] == ".gif": suffix = self.input_video['suffix'] else: - suffix = self.__video_output_suffix + suffix = '.mkv' if self.lossless_mode else '.mp4' target_file = pick_save_file(self, title='Render video as', suffix=suffix) if not target_file: return None @@ -652,7 +900,7 @@ def render_video(self): "target_file": target_file, "nt": self.nt, "input_video": self.input_video, - "input_heigth": self.renderHeightBox.value(), + "input_height": self.renderHeightBox.value(), "upscale_2x": self.NearestUpScale.isChecked(), "lossless": self.videoRenderer.lossless, "framecount": self.framecount @@ -663,13 +911,7 @@ def render_video(self): #self.audio_filtering() self.progressBar.setValue(1) self.videoRenderer.render_data = render_data - self.thread.start() - - def nt_process(self, frame) -> ndarray: - _ = self.nt.composite_layer(frame, frame, field=0, fieldno=1) - ntsc_out_image = cv2.convertScaleAbs(_) - ntsc_out_image[1:-1:2] = ntsc_out_image[0:-2:2] / 2 + ntsc_out_image[2::2] / 2 - return ntsc_out_image + self.render_thread.start() def nt_update_preview(self): current_frame_valid = isinstance(self.current_frame, ndarray) @@ -681,7 +923,7 @@ def nt_update_preview(self): self.render_preview(self.current_frame) return None - ntsc_out_image = self.videoRenderer.apply_main_effect(self.nt, self.current_frame, self.next_frame) + ntsc_out_image = self.videoRenderer.apply_main_effect(self.nt, self.current_frame, self.next_frame, self.videoTrackSlider.value()) if self.compareMode: ntsc_out_image = numpy.concatenate( diff --git a/app/Renderer.py b/app/Renderer.py index cfd6e44..6e16e03 100755 --- a/app/Renderer.py +++ b/app/Renderer.py @@ -35,7 +35,7 @@ class AbstractRenderer(QtCore.QObject): @staticmethod @abc.abstractmethod - def apply_main_effect(nt: Ntsc, frame1, frame2=None): + def apply_main_effect(nt: Ntsc, frame1, frame2, frameno: int): raise NotImplementedError() @@ -60,19 +60,18 @@ class DefaultRenderer(AbstractRenderer): buffer: dict[int, ndarray] = defaultdict(lambda: None) @staticmethod - def apply_main_effect(nt: Ntsc, frame1, frame2=None, frameId=0): + def apply_main_effect(nt: Ntsc, frame1, frame2, frameno: int): #raise NotImplementedError() if frame2 is None: frame2 = frame1 - frame1 = nt.composite_layer(frame1, frame2, field=0, fieldno=2, frame=frameId) + frame1 = nt.composite_layer(frame1, frame1, field=0, fieldno=0, frameno=frameno) frame1 = cv2.convertScaleAbs(frame1) - # Using warpAffine temporary while finding another fix - #frame2 = cv2.warpAffine(frame2, numpy.float32([[1, 0, 0], [0, 1, 1]]), (frame2.shape[1], frame2.shape[0]+2)) - frame2 = cv2.copyMakeBorder(frame1,1,0,0,0,cv2.BORDER_CONSTANT) - #frame2 = nt.composite_layer(frame2, frame2, field=2, fieldno=2) - #frame2 = cv2.convertScaleAbs(frame2) + frame2 = cv2.copyMakeBorder(frame2,1,0,0,0,cv2.BORDER_CONSTANT) + frame2 = nt.composite_layer(frame2, frame2, field=2, fieldno=2, frameno=frameno) + frame2 = cv2.convertScaleAbs(frame2) + frame = frame1 frame[1::2,:] = frame2[2::2,:] return frame @@ -161,10 +160,10 @@ def produce_frame(self): if self.mainEffect: frame = self.apply_main_effect( - nt=self.render_data.get("nt"), - frame1=frame1, - frame2=frame2, - frameId=self.show_frame_index + self.render_data.get("nt"), + frame1, + frame2, + self.show_frame_index ) else: frame = frame1 @@ -186,7 +185,7 @@ def set_up(self): self.render_data["input_video"]["width"], self.render_data["input_video"]["height"] ) - render_wh = resize_to_height(orig_wh, self.render_data["input_heigth"]) + render_wh = resize_to_height(orig_wh, self.render_data["input_height"]) container_wh = render_wh upscale_2x = self.render_data["upscale_2x"] @@ -238,7 +237,7 @@ def run(self): suffix = '.mkv' - print(self.config.get("lossless")) + #print(self.config.get("lossless")) tmp_output = self.render_data['target_file'].parent / f'tmp_{self.render_data["target_file"].stem}{suffix}' @@ -260,7 +259,7 @@ def run(self): framerate = self.render_data["input_video"]["orig_fps"] self.framecount = self.config.get("framecount") - print(self.framecount) + #print(self.framecount) video = cv2.VideoWriter() @@ -278,6 +277,7 @@ def run(self): logger.debug(f'Temp output: {str(tmp_output.resolve())}') logger.debug(f'Output video: {str(self.render_data["target_file"].resolve())}') #logger.debug(f'Process audio: {self.process_audio}') + logger.debug(f'Process audio: {str(self.config.get("audio_process"))}') self.current_frame_index = 0 self.show_frame_index = 0 @@ -348,7 +348,7 @@ def run(self): final_audio = orig.audio - if(self.config.get("audio_process") == True): + if(self.config.get('audio_process')): self.sendStatus.emit(f'[FFMPEG] Preparing audio filtering') #tmp_audio = self.render_data['target_file'].parent / f'tmp_audio_{self.render_data["target_file"].stem}.wav' @@ -366,10 +366,10 @@ def run(self): aud_ff_noise = ffmpeg.input(f'aevalsrc=-2+random(0):sample_rate={aud_ff_srate}:channel_layout=mono',f="lavfi",t=aud_ff_duration) aud_ff_noise = ffmpeg.filter((aud_ff_noise, aud_ff_noise), 'join', inputs=2, channel_layout='stereo') - aud_ff_noise = aud_ff_noise.filter('volume', self.audio_noise_volume) + aud_ff_noise = aud_ff_noise.filter('volume', self.config.get('audio_noise_volume')) - aud_ff_fx = final_audio.filter("volume",self.audio_sat_beforevol).filter("alimiter",limit="0.5").filter("volume",0.8) - aud_ff_fx = aud_ff_fx.filter("firequalizer",gain=f'if(lt(f,{self.audio_lowpass}), 0, -INF)') + aud_ff_fx = final_audio.filter("volume",self.config.get('audio_sat_beforevol')).filter("alimiter",limit="0.5").filter("volume",0.8) + aud_ff_fx = aud_ff_fx.filter("firequalizer",gain=f'if(lt(f,{self.config.get("audio_lowpass")}), 0, -INF)') aud_ff_mix = ffmpeg.filter([aud_ff_fx, aud_ff_noise], 'amix').filter("firequalizer",gain='if(lt(f,13301), 0, -INF)') diff --git a/app/funcs.py b/app/funcs.py index 29172c8..a85ce86 100755 --- a/app/funcs.py +++ b/app/funcs.py @@ -51,12 +51,3 @@ def expand_to_4width(img: numpy.ndarray) -> numpy.ndarray: height, width, channels = img.shape logger.debug(f"┗FIX to wh: {width}x{height} w%4={width % 4}") return img - - -def set_ui_element(element, value): - element.blockSignals(True) - if isinstance(value, bool): - element.setChecked(value) - elif isinstance(value, (int, float)): - element.setValue(value) - element.blockSignals(False) diff --git a/app/ntsc.py b/app/ntsc.py index 6732595..74bc441 100755 --- a/app/ntsc.py +++ b/app/ntsc.py @@ -1,16 +1,14 @@ import math import random import sys -from enum import Enum +from enum import IntEnum from pathlib import Path -from typing import List +from typing import List, Union import numpy -import scipy -from scipy.signal import lfilter +from scipy.signal import lfilter, lfiltic from scipy.ndimage.interpolation import shift -import numpy as np import cv2 M_PI = math.pi @@ -23,7 +21,7 @@ ring_pattern_real_path = f'{sys._MEIPASS}/app/ringPattern.npy' ring_pattern_path = Path(ring_pattern_real_path) -RingPattern = np.load(str(ring_pattern_path.resolve())) +RingPattern = numpy.load(str(ring_pattern_path.resolve())) def ringing(img2d, alpha=0.5, noiseSize=0, noiseValue=2, clip=True, seed=None): @@ -35,28 +33,28 @@ def ringing(img2d, alpha=0.5, noiseSize=0, noiseValue=2, clip=True, seed=None): :param noiseValue: float, noise amplitude (0-5) optimal values is 0.5-2 :return: 2d image """ - dft = cv2.dft(np.float32(img2d), flags=cv2.DFT_COMPLEX_OUTPUT) - dft_shift = np.fft.fftshift(dft) + dft = cv2.dft(numpy.float32(img2d), flags=cv2.DFT_COMPLEX_OUTPUT) + dft_shift = numpy.fft.fftshift(dft) rows, cols = img2d.shape crow, ccol = int(rows / 2), int(cols / 2) - mask = np.zeros((rows, cols, 2), np.uint8) + mask = numpy.zeros((rows, cols, 2), numpy.uint8) maskH = min(crow, int(1 + alpha * crow)) mask[:, ccol - maskH:ccol + maskH] = 1 if noiseSize > 0: - noise = np.ones((mask.shape[0], mask.shape[1], mask.shape[2])) * noiseValue - noiseValue / 2. + noise = numpy.ones((mask.shape[0], mask.shape[1], mask.shape[2])) * noiseValue - noiseValue / 2. start = int(ccol - ((1 - noiseSize) * ccol)) stop = int(ccol + ((1 - noiseSize) * ccol)) noise[:, start:stop, :] = 0 - rnd = np.random.RandomState(seed) + rnd = numpy.random.RandomState(seed) mask = mask.astype(float) + rnd.rand(mask.shape[0], mask.shape[1], mask.shape[2]) * noise - noise / 2. - img_back = cv2.idft(np.fft.ifftshift(dft_shift * mask), flags=cv2.DFT_SCALE) + img_back = cv2.idft(numpy.fft.ifftshift(dft_shift * mask), flags=cv2.DFT_SCALE) if clip: _min, _max = img2d.min(), img2d.max() - return np.clip(img_back[:, :, 0], _min, _max) + return numpy.clip(img_back[:, :, 0], _min, _max) else: return img_back[:, :, 0] @@ -68,20 +66,20 @@ def ringing2(img2d, power=4, shift=0, clip=True): :param power: int, ringing parrern poser (optimal 2 - 6) :return: 2d image """ - dft = cv2.dft(np.float32(img2d), flags=cv2.DFT_COMPLEX_OUTPUT) - dft_shift = np.fft.fftshift(dft) + dft = cv2.dft(numpy.float32(img2d), flags=cv2.DFT_COMPLEX_OUTPUT) + dft_shift = numpy.fft.fftshift(dft) rows, cols = img2d.shape scalecols = int(cols * (1 + shift)) - mask = cv2.resize(RingPattern[np.newaxis, :], (scalecols, 1), interpolation=cv2.INTER_LINEAR)[0] + mask = cv2.resize(RingPattern[numpy.newaxis, :], (scalecols, 1), interpolation=cv2.INTER_LINEAR)[0] mask = mask[(scalecols // 2) - (cols // 2):(scalecols // 2) + (cols // 2)] mask = mask ** power - img_back = cv2.idft(np.fft.ifftshift(dft_shift * mask[None, :, None]), flags=cv2.DFT_SCALE) + img_back = cv2.idft(numpy.fft.ifftshift(dft_shift * mask[None, :, None]), flags=cv2.DFT_SCALE) if clip: _min, _max = img2d.min(), img2d.max() - return np.clip(img_back[:, :, 0], _min, _max) + return numpy.clip(img_back[:, :, 0], _min, _max) else: return img_back[:, :, 0] @@ -93,10 +91,13 @@ def fmod(x: float, y: float) -> float: def clamp(n, smallest, largest): return max(smallest, min(n, largest)) - +# TODO: update to use new numpy Generator class NumpyRandom: def __init__(self, seed=None): self.rnd = numpy.random.RandomState(seed) + + def seed(self, seed: int): + self.rnd.seed(seed) def nextInt(self, _from: int = Int_MIN_VALUE, until: int = Int_MAX_VALUE) -> int: return self.rnd.randint(_from, until) @@ -104,57 +105,6 @@ def nextInt(self, _from: int = Int_MIN_VALUE, until: int = Int_MAX_VALUE) -> int def nextIntArray(self, size: int, _from: int = Int_MIN_VALUE, until: int = Int_MAX_VALUE) -> numpy.ndarray: return self.rnd.randint(_from, until, size, dtype=numpy.int32) - -class XorWowRandom: - def __init__(self, seed1: int, seed2: int): - self.x: int = numpy.int32(seed1) - self.y: int = numpy.int32(seed2) - self.z: int = numpy.int32(0) - self.w: int = numpy.int32(0) - self.v: int = -numpy.int32(seed1) - 1 - self.addend: int = numpy.int32((numpy.int32(seed1) << 10) ^ (numpy.uint32(seed2) >> 4)) - [self._nextInt() for _ in range(0, 64)] - - def _nextInt(self) -> int: - t = self.x - t = numpy.int32(t ^ (numpy.uint32(t) >> 2)) - self.x = numpy.int32(self.y) - self.y = numpy.int32(self.z) - self.z = numpy.int32(self.w) - v0 = numpy.int32(self.v) - self.w = numpy.int32(v0) - t = (t ^ (t << 1)) ^ v0 ^ (v0 << 4) - self.v = numpy.int32(t) - self.addend += 362437 - return t + numpy.int32(self.addend) - - def nextInt(self, _from: int = Int_MIN_VALUE, until: int = Int_MAX_VALUE) -> numpy.int32: - n = until - _from - if n > 0 or n == Int_MIN_VALUE: - if (n & -n) == n: - assert False, "not implemented" - else: - v: int = 0 - while True: - bits = numpy.uint32(self._nextInt()) >> 1 - v = bits % n - if bits - v + (n - 1) >= 0: - break - return numpy.int32(_from + v) - else: - r = range(_from, until) - while True: - rnd = self._nextInt() - if rnd in r: - return numpy.int32(rnd) - - def nextIntArray(self, size: int, _from: int = Int_MIN_VALUE, until: int = Int_MAX_VALUE) -> numpy.ndarray: - zeros = numpy.zeros(size, dtype=numpy.int32) - for i in range(0, size): - zeros[i] = self.nextInt(_from=_from, until=until) - return zeros - - # interleaved uint8 HWC BGR to -> planar int32 CHW YIQ def bgr2yiq(bgrimg: numpy.ndarray) -> numpy.ndarray: planar = numpy.transpose(bgrimg, (2, 0, 1)) @@ -168,7 +118,7 @@ def bgr2yiq(bgrimg: numpy.ndarray) -> numpy.ndarray: # one field of planar int32 CHW YIQ -> one field of interleaved uint8 HWC BGR to -def yiq2bgr(yiq: numpy.ndarray, dst_bgr: numpy.ndarray = None, field: int = 0) -> numpy.ndarray: +def yiq2bgr(yiq: numpy.ndarray, dst_bgr: Union[numpy.ndarray, None] = None, field: int = 0) -> numpy.ndarray: c, h, w = yiq.shape dst_bgr = dst_bgr if dst_bgr is not None else numpy.zeros((h, w, c)) Y, I, Q = yiq @@ -191,39 +141,7 @@ def yiq2bgr(yiq: numpy.ndarray, dst_bgr: numpy.ndarray = None, field: int = 0) - dst_bgr[1::2] = interleavedBGR return dst_bgr - -class LowpassFilter: - def __init__(self, rate: float, hz: float, value: float = 0.0): - self.timeInterval: float = 1.0 / rate - self.tau: float = 1 / (hz * 2.0 * M_PI) - self.alpha: float = self.timeInterval / (self.tau + self.timeInterval) - self.prev: float = value - - def lowpass(self, sample: float) -> float: - stage1 = sample * self.alpha - stage2 = self.prev - self.prev * self.alpha - self.prev = stage1 + stage2 - return self.prev - - def highpass(self, sample: float) -> float: - stage1 = sample * self.alpha - stage2 = self.prev - self.prev * self.alpha - self.prev = stage1 + stage2 - return sample - self.prev - - def lowpass_array(self, samples: numpy.ndarray) -> numpy.ndarray: - if self.prev == 0.0: - return lfilter([self.alpha], [1, -(1.0 - self.alpha)], samples) - else: - ic = scipy.signal.lfiltic([self.alpha], [1, -(1.0 - self.alpha)], [self.prev]) - return lfilter([self.alpha], [1, -(1.0 - self.alpha)], samples, zi=ic)[0] - - def highpass_array(self, samples: numpy.ndarray) -> numpy.ndarray: - f = self.lowpass_array(samples) - return samples - f - - -def cut_black_line_border(image: numpy.ndarray, bordersize: int = None) -> None: +def cut_black_line_border(image: numpy.ndarray, bordersize: int = None) -> numpy.ndarray: h, w, _ = image.shape if bordersize is None: line_width = int(w * 0.017) # 1.7% @@ -231,6 +149,7 @@ def cut_black_line_border(image: numpy.ndarray, bordersize: int = None) -> None: line_width = bordersize # TODO: value to settings image[:, -1*line_width:] = 0 # 0 set to black + return image def composite_lowpass(yiq: numpy.ndarray, field: int, fieldno: int): @@ -241,11 +160,10 @@ def composite_lowpass(yiq: numpy.ndarray, field: int, fieldno: int): delay = 2 if (p == 1) else 4 P = fI if (p == 1) else fQ P = P[field::2] - lp = lowpassFilters(cutoff, reset=0.0) for i, f in enumerate(P): - f = lp[0].lowpass_array(f) - f = lp[1].lowpass_array(f) - f = lp[2].lowpass_array(f) + f = lowpassFilter(f, cutoff, reset=0.0) + f = lowpassFilter(f, cutoff, reset=0.0) + f = lowpassFilter(f, cutoff, reset=0.0) P[i, 0:width - delay] = f.astype(numpy.int32)[delay:] @@ -257,33 +175,35 @@ def composite_lowpass_tv(yiq: numpy.ndarray, field: int, fieldno: int): delay = 1 P = fI if (p == 1) else fQ P = P[field::2] - lp = lowpassFilters(2600000.0, reset=0.0) for i, f in enumerate(P): - f = lp[0].lowpass_array(f) - f = lp[1].lowpass_array(f) - f = lp[2].lowpass_array(f) + f = lowpassFilter(f, 2600000.0, reset=0.0) + f = lowpassFilter(f, 2600000.0, reset=0.0) + f = lowpassFilter(f, 2600000.0, reset=0.0) P[i, 0:width - delay] = f.astype(numpy.int32)[delay:] def composite_preemphasis(yiq: numpy.ndarray, field: int, composite_preemphasis: float, composite_preemphasis_cut: float): fY, fI, fQ = yiq - pre = LowpassFilter(Ntsc.NTSC_RATE, composite_preemphasis_cut, 16.0) fields = fY[field::2] for i, samples in enumerate(fields): - filtered = samples + pre.highpass_array(samples) * composite_preemphasis + filtered = samples + highpassFilter(samples, composite_preemphasis_cut, 16.0) * composite_preemphasis fields[i] = filtered.astype(numpy.int32) -class VHSSpeed(Enum): - VHS_SP = (2400000.0, 320000.0, 9) - VHS_LP = (1900000.0, 300000.0, 12) - VHS_EP = (1400000.0, 280000.0, 14) +# Needs to be an IntEnum so it can be saved in JSON +class VHSSpeed(IntEnum): + VHS_SP = 0 + VHS_LP = 1 + VHS_EP = 2 - def __init__(self, luma_cut: float, chroma_cut: float, chroma_delay: int): - self.luma_cut = luma_cut - self.chroma_cut = chroma_cut - self.chroma_delay = chroma_delay + def __init__(self, num: int): + print(num) + self.luma_cut, self.chroma_cut, self.chroma_delay = [ + (2400000.0, 320000.0, 9), + (1900000.0, 300000.0, 12), + (1400000.0, 280000.0, 14) + ][num] class Ntsc: @@ -296,9 +216,14 @@ class Ntsc: BLACK_LEVEL = (7.5/100.0) WHITE_LEVEL = (100.0/100.0) - def __init__(self, precise=False, random=None): + def __init__(self, precise: bool, random: NumpyRandom): self.precise = precise - self.random = random if random is not None else XorWowRandom(31374242, 0) + + self.random = random + + # Seed to use when generating random noise + self._noise_seed = 0 + self._composite_preemphasis_cut = 1000000.0 # analog artifacts related to anything that affects the raw composite signal i.e. CATV modulation self._composite_preemphasis = 0.0 # values 0..8 look realistic @@ -311,7 +236,6 @@ def __init__(self, precise=False, random=None): self._vhs_head_switching = False # turn this on only on frames height 486 pixels or more self._head_switching_speed = 0 # 0..100 this is /1000 increment for _vhs_head_switching_point 0 is static - self._vhs_head_switching_point = 1.0 - (4.5 + 0.01) / 262.5 # 4 scanlines NTSC up from vsync self._vhs_head_switching_phase = (1.0 - 0.01) / 262.5 # 4 scanlines NTSC up from vsync self._vhs_head_switching_phase_noise = 1.0 / 500 / 262.5 # 1/500th of a scanline @@ -361,10 +285,8 @@ def video_noise(self, yiq: numpy.ndarray, field: int, video_noise: int): fields = fY[field::2] fh, fw = fields.shape if not self.precise: # this one works FAST - lp = LowpassFilter(1, 1, 0) - lp.alpha = 0.5 rnds = self.rand_array(fw * fh) % noise_mod - video_noise - noises = shift(lp.lowpass_array(rnds).astype(numpy.int32), 1) + noises = shift(lfilter([0.5], [1, -0.5], rnds).astype(numpy.int32), 1) fields += noises.reshape(fields.shape) else: # this one works EXACTLY like original code noise = 0 @@ -377,7 +299,6 @@ def video_noise(self, yiq: numpy.ndarray, field: int, video_noise: int): # https://bavc.github.io/avaa/artifacts/chrominance_noise.html def video_chroma_noise(self, yiq: numpy.ndarray, field: int, video_chroma_noise: int): - _, height, width = yiq.shape fY, fI, fQ = yiq noise_mod = video_chroma_noise * 2 + 1 @@ -385,13 +306,11 @@ def video_chroma_noise(self, yiq: numpy.ndarray, field: int, video_chroma_noise: V = fQ[field::2] fh, fw = U.shape if not self.precise: - lp = LowpassFilter(1, 1, 0) - lp.alpha = 0.5 rndsU = self.rand_array(fw * fh) % noise_mod - video_chroma_noise - noisesU = shift(lp.lowpass_array(rndsU).astype(numpy.int32), 1) + noisesU = shift(lfilter([0.5], [1, -0.5], rndsU).astype(numpy.int32), 1) rndsV = self.rand_array(fw * fh) % noise_mod - video_chroma_noise - noisesV = shift(lp.lowpass_array(rndsV).astype(numpy.int32), 1) + noisesV = shift(lfilter([0.5], [1, -0.5], rndsV).astype(numpy.int32), 1) U += noisesU.reshape(U.shape) V += noisesV.reshape(V.shape) @@ -427,20 +346,21 @@ def video_chroma_phase_noise(self, yiq: numpy.ndarray, field: int, video_chroma_ U[y, :] = u V[y, :] = v - def vhs_head_switching(self, yiq: numpy.ndarray, field: int = 0): + def vhs_head_switching(self, yiq: numpy.ndarray, field: int, frameno: int): _, height, width = yiq.shape fY, fI, fQ = yiq twidth = width + width // 10 shy = 0 noise = 0.0 if self._vhs_head_switching_phase_noise != 0.0: - x = numpy.int32(random.randint(1, 2000000000)) + x = numpy.int32(self.random.nextInt(1, 2000000000)) noise = x / 1000000000.0 - 1.0 noise *= self._vhs_head_switching_phase_noise + vhs_head_switching_point = (self._head_switching_speed / 1000) * frameno + t = twidth * (262.5 if self._output_ntsc else 312.5) - p = int(fmod(self._vhs_head_switching_point + noise, 1.0) * t) - self._vhs_head_switching_point += self._head_switching_speed/1000 + p = int(fmod(vhs_head_switching_point + noise, 1.0) * t) y = int(p // twidth * 2) + field p = int(fmod(self._vhs_head_switching_phase + noise, 1.0) * t) x = p % twidth @@ -490,12 +410,12 @@ def _chroma_luma_xi(self, fieldno: int, y: int): def encode_composite_level(self, array: numpy.ndarray): arrayMax = (256.0 * 256.0) ar = array.astype(numpy.float32) / arrayMax - interp = np.interp(ar, (0.0, 1.0), (Ntsc.BLACK_LEVEL, 1.0)) + interp = numpy.interp(ar, (0.0, 1.0), (Ntsc.BLACK_LEVEL, 1.0)) return (interp * arrayMax).astype(numpy.int32) def decode_composite_level(self, array: numpy.ndarray): arrayMax = (256.0 * 256.0) ar = array.astype(numpy.float32) / arrayMax - interp = np.interp(ar, (Ntsc.BLACK_LEVEL, 1.0), (0.0, 1.0)) + interp = numpy.interp(ar, (Ntsc.BLACK_LEVEL, 1.0), (0.0, 1.0)) return (interp * arrayMax).astype(numpy.int32) def chroma_into_luma(self, yiq: numpy.ndarray, field: int, fieldno: int, subcarrier_amplitude: int, frame: int = 0): @@ -525,6 +445,7 @@ def chroma_into_luma(self, yiq: numpy.ndarray, field: int, fieldno: int, subcarr y += 2 def chroma_from_luma(self, yiq: numpy.ndarray, field: int, fieldno: int, subcarrier_amplitude: int): + _, height, width = yiq.shape fY, fI, fQ = yiq @@ -586,29 +507,27 @@ def vhs_luma_lowpass(self, yiq: numpy.ndarray, field: int, luma_cut: float): _, height, width = yiq.shape fY, fI, fQ = yiq for Y in fY[field::2]: - pre = LowpassFilter(Ntsc.NTSC_RATE, luma_cut, 16.0) - lp = lowpassFilters(cutoff=luma_cut, reset=16.0) - f0 = lp[0].lowpass_array(Y) - f1 = lp[1].lowpass_array(f0) - f2 = lp[2].lowpass_array(f1) - f3 = f2 + pre.highpass_array(f2) * 1.6 + f0 = lowpassFilter(Y, cutoff=luma_cut, reset=16.0) + f1 = lowpassFilter(f0, cutoff=luma_cut, reset=16.0) + f2 = lowpassFilter(f1, cutoff=luma_cut, reset=16.0) + f3 = f2 + highpassFilter(f2, luma_cut, 16.0) * 1.6 Y[:] = f3 def vhs_chroma_lowpass(self, yiq: numpy.ndarray, field: int, chroma_cut: float, chroma_delay: int): _, height, width = yiq.shape fY, fI, fQ = yiq for U in fI[field::2]: - lpU = lowpassFilters(cutoff=chroma_cut, reset=0.0) - f0 = lpU[0].lowpass_array(U) - f1 = lpU[1].lowpass_array(f0) - f2 = lpU[2].lowpass_array(f1) + f0 = lowpassFilter(U, cutoff=chroma_cut, reset=0.0) + f1 = lowpassFilter(f0, cutoff=chroma_cut, reset=0.0) + f2 = lowpassFilter(f1, cutoff=chroma_cut, reset=0.0) + U[:width - chroma_delay] = f2[chroma_delay:] for V in fQ[field::2]: - lpV = lowpassFilters(cutoff=chroma_cut, reset=0.0) - f0 = lpV[0].lowpass_array(V) - f1 = lpV[1].lowpass_array(f0) - f2 = lpV[2].lowpass_array(f1) + f0 = lowpassFilter(V, cutoff=chroma_cut, reset=0.0) + f1 = lowpassFilter(f0, cutoff=chroma_cut, reset=0.0) + f2 = lowpassFilter(f1, cutoff=chroma_cut, reset=0.0) + V[:width - chroma_delay] = f2[chroma_delay:] # VHS decks also vertically smear the chroma subcarrier using a delay line @@ -630,11 +549,10 @@ def vhs_sharpen(self, yiq: numpy.ndarray, field: int, luma_cut: float): _, height, width = yiq.shape fY, fI, fQ = yiq for Y in fY[field::2]: - lp = lowpassFilters(cutoff=luma_cut * 4, reset=0.0) s = Y - ts = lp[0].lowpass_array(Y) - ts = lp[1].lowpass_array(ts) - ts = lp[2].lowpass_array(ts) + ts = lowpassFilter(Y, cutoff=luma_cut * 4, reset=0.0) + ts = lowpassFilter(ts, cutoff=luma_cut * 4, reset=0.0) + ts = lowpassFilter(ts, cutoff=luma_cut * 4, reset=0.0) Y[:] = (s + (s - ts) * self._vhs_out_sharpen * 2.0) # http://www.michaeldvd.com.au/Articles/VideoArtefacts/VideoArtefactsColourBleeding.html @@ -655,9 +573,7 @@ def vhs_edge_wave(self, yiq: numpy.ndarray, field: int): _, height, width = yiq.shape fY, fI, fQ = yiq rnds = self.random.nextIntArray(height // 2, 0, self._vhs_edge_wave) - lp = LowpassFilter(Ntsc.NTSC_RATE, self._output_vhs_tape_speed.luma_cut, - 0) # no real purpose to initialize it with ntsc values - rnds = lp.lowpass_array(rnds).astype(numpy.int32) + rnds = lowpassFilter(rnds, self._output_vhs_tape_speed.luma_cut, 0.0).astype(numpy.int32) for y, Y in enumerate(fY[field::2]): if rnds[y] != 0: @@ -754,15 +670,21 @@ def emulate_vhs(self, yiq: numpy.ndarray, field: int, fieldno: int): self.chroma_into_luma(yiq, field, fieldno, self._subcarrier_amplitude) self.chroma_from_luma(yiq, field, fieldno, self._subcarrier_amplitude) - def composite_layer(self, dst: numpy.ndarray, src: numpy.ndarray, field: int, fieldno: int, frame: int = 0): + def composite_layer(self, dst: numpy.ndarray, src: numpy.ndarray, field: int, fieldno: int, frameno: int): assert dst.shape == src.shape, "dst and src images must be of same shape" + # Set random seed based on the noise seed and frame number + self.random.seed(frameno) + frame_dependent = self.random.nextInt() + seed = (frame_dependent ^ self._noise_seed) & 0x7fffffff + self.random.seed(seed) + ogw, ogh, channel = src.shape self.fs = (30000.0 / 1001.0) * float(525) * float(ogw) * (858.0 / 760.0) if self._black_line_cut: - cut_black_line_border(src) + src = cut_black_line_border(src.copy()) yiq = bgr2yiq(src) @@ -773,7 +695,7 @@ def composite_layer(self, dst: numpy.ndarray, src: numpy.ndarray, field: int, fi composite_lowpass(yiq, field, fieldno) if self._ringing != 1.0: - self.ringing(yiq, field) + self.ringing(yiq, field, seed) self.chroma_into_luma(yiq, field, fieldno, self._subcarrier_amplitude) @@ -784,7 +706,7 @@ def composite_layer(self, dst: numpy.ndarray, src: numpy.ndarray, field: int, fi self.video_noise(yiq, field, self._video_noise) if self._vhs_head_switching: - self.vhs_head_switching(yiq, field) + self.vhs_head_switching(yiq, field, frameno) if not self._nocolor_subcarrier: self.chroma_from_luma(yiq, field, fieldno, self._subcarrier_amplitude_back) @@ -811,7 +733,7 @@ def composite_layer(self, dst: numpy.ndarray, src: numpy.ndarray, field: int, fi self.color_bleed(yiq, field) #if self._ringing != 1.0: - # self.ringing(yiq, field) + # self.ringing(yiq, field, seed) Y, I, Q = yiq @@ -826,15 +748,15 @@ def _blur_chroma(self, chroma: numpy.ndarray) -> numpy.ndarray: down2 = cv2.resize(chroma.astype(numpy.float32), (w // 2, h // 2), interpolation=cv2.INTER_LANCZOS4) return cv2.resize(down2, (w, h), interpolation=cv2.INTER_LANCZOS4).astype(numpy.int32) - def ringing(self, yiq: numpy.ndarray, field: int): + def ringing(self, yiq: numpy.ndarray, field: int, seed: int): Y, I, Q = yiq sz = self._freq_noise_size amp = self._freq_noise_amplitude shift = self._ringing_shift if not self._enable_ringing2: - Y[field::2] = ringing(Y[field::2], self._ringing, noiseSize=sz, noiseValue=amp, clip=False) - I[field::2] = ringing(I[field::2], self._ringing, noiseSize=sz, noiseValue=amp, clip=False) - Q[field::2] = ringing(Q[field::2], self._ringing, noiseSize=sz, noiseValue=amp, clip=False) + Y[field::2] = ringing(Y[field::2], self._ringing, noiseSize=sz, noiseValue=amp, clip=False, seed=seed) + I[field::2] = ringing(I[field::2], self._ringing, noiseSize=sz, noiseValue=amp, clip=False, seed=seed) + Q[field::2] = ringing(Q[field::2], self._ringing, noiseSize=sz, noiseValue=amp, clip=False, seed=seed) else: Y[field::2] = ringing2(Y[field::2], power=self._ringing_power, shift=shift, clip=False) I[field::2] = ringing2(I[field::2], power=self._ringing_power, shift=shift, clip=False) @@ -843,7 +765,7 @@ def ringing(self, yiq: numpy.ndarray, field: int): def random_ntsc(seed=None) -> Ntsc: rnd = random.Random(seed) - ntsc = Ntsc(random=NumpyRandom(seed)) + ntsc = Ntsc(precise=False, random=NumpyRandom(seed)) ntsc._composite_preemphasis = rnd.triangular(0, 8, 0) ntsc._vhs_out_sharpen = rnd.triangular(1, 5, 1.5) ntsc._composite_in_chroma_lowpass = rnd.random() < 0.8 # lean towards default value @@ -873,6 +795,17 @@ def random_ntsc(seed=None) -> Ntsc: ntsc._color_bleed_vert = int(rnd.triangular(0, 8, 0)) return ntsc +def lowpassFilter(samples: numpy.ndarray, cutoff: float, reset: float, rate: float = Ntsc.NTSC_RATE) -> numpy.ndarray: + timeInterval = 1.0 / rate + tau = 1 / (cutoff * 2.0 * M_PI) + alpha = timeInterval / (tau + timeInterval) + + if reset == 0.0: + return lfilter([alpha], [1, -(1.0 - alpha)], samples) + else: + ic = lfiltic([alpha], [1, -(1.0 - alpha)], [reset]) + return lfilter([alpha], [1, -(1.0 - alpha)], samples, zi=ic)[0] -def lowpassFilters(cutoff: float, reset: float, rate: float = Ntsc.NTSC_RATE) -> List[LowpassFilter]: - return [LowpassFilter(rate, cutoff, reset) for x in range(0, 3)] \ No newline at end of file +def highpassFilter(samples: numpy.ndarray, cutoff: float, reset: float, rate: float = Ntsc.NTSC_RATE) -> numpy.ndarray: + f = lowpassFilter(samples, cutoff, reset, rate) + return samples - f \ No newline at end of file diff --git a/ntscQT.py b/ntscQT.py index 0c9fcba..1e30fc9 100755 --- a/ntscQT.py +++ b/ntscQT.py @@ -1,6 +1,7 @@ import os import sys from pathlib import Path +import traceback from PyQt5 import QtCore, QtWidgets from PyQt5.QtCore import QLibraryInfo @@ -24,7 +25,7 @@ def crash_handler(etype, value, tb): logger.trace(value) traceback.print_exception(etype, value, tb) - logger.exception("Uncaught exception: {0}".format(str(value))) + logger.error("Uncaught exception: {0}\n{1}".format(str(value), "\n".join(traceback.format_tb(tb)))) sys.exit(1) def cls(): @@ -38,11 +39,11 @@ def main(): locale = QtCore.QLocale.system().name() cls() - print(f"{colorama.Back.BLUE + colorama.Fore.BLACK}--- ntscQT+ ---{colorama.Style.RESET_ALL}") - print(f"by RGM, based on JargeZ's "+'\x1B[3m'+"ntscQT"+'\x1B[0m') - print("") + print("📼 ntscQT+") + #print(f"by RGM, based on JargeZ's "+'\x1B[3m'+"ntscQT"+'\x1B[0m') + #print("") - spinner = Halo(text='Loading...',color='white') + spinner = Halo(text='',color='white') spinner.start() # if run by pyinstaller executable, frozen attr will be true @@ -60,7 +61,7 @@ def main(): app.setWindowIcon(QtGui.QIcon(QtGui.QPixmap("./icon.png"))) app.installTranslator(translator) - qdarktheme.setup_theme("dark") + qdarktheme.setup_theme("dark",corner_shape="sharp") spinner.stop() print("Loaded.") @@ -72,7 +73,7 @@ def main(): print("Using default translation") window = NtscApp() - window.show() + window.showMaximized() sys.exit(app.exec_()) diff --git a/ntscQT.spec b/ntscQT.spec index 78c481e..4f97839 100755 --- a/ntscQT.spec +++ b/ntscQT.spec @@ -8,7 +8,7 @@ a = Analysis( ['ntscQT.py'], pathex=['C:/hostedtoolcache/windows/python/3.10.11/x64/lib/site-packages'], binaries=[('C:/hostedtoolcache/windows/python/3.10.11/x64/lib/site-packages/cv2/opencv_videoio_ffmpeg*.dll', '.'), ('./ffmpeg.exe', '.')], - datas=[('./app/ringPattern.npy', './app'), ('translate/*.qm', 'translate/'), ('./icon.png', '.')], + datas=[('./app/ringPattern.npy', './app'), ('translate/*.qm', 'translate/'), ('./icon.png', '.'), ('./ui/img/logo32px.png', './ui/img')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/ui/DoubleSlider.py b/ui/DoubleSlider.py index a8f011e..469f25b 100755 --- a/ui/DoubleSlider.py +++ b/ui/DoubleSlider.py @@ -3,6 +3,7 @@ class DoubleSlider(QSlider): mouseRelease = QtCore.pyqtSignal(object) + valueChanged = QtCore.pyqtSignal(float) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -15,6 +16,10 @@ def __init__(self, *args, **kwargs): self._min_value = 0.0 self._max_value = 1.0 + super().valueChanged.connect(self._value_changed_adapter) + + def _value_changed_adapter(self): + self.valueChanged.emit(self.value()) @property def _value_range(self): diff --git a/ui/configExportDialog.py b/ui/configExportDialog.py index be34f4c..737f250 100644 --- a/ui/configExportDialog.py +++ b/ui/configExportDialog.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'ui/configExportDialog.ui' # -# Created by: PyQt5 UI code generator 5.14.1 +# Created by: PyQt5 UI code generator 5.15.9 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -36,8 +37,8 @@ def setupUi(self, TemplateConfigDialog): self.openConfigButton.setObjectName("openConfigButton") self.retranslateUi(TemplateConfigDialog) - self.buttonBox.rejected.connect(TemplateConfigDialog.reject) - self.buttonBox.accepted.connect(TemplateConfigDialog.accept) + self.buttonBox.rejected.connect(TemplateConfigDialog.reject) # type: ignore + self.buttonBox.accepted.connect(TemplateConfigDialog.accept) # type: ignore QtCore.QMetaObject.connectSlotsByName(TemplateConfigDialog) def retranslateUi(self, TemplateConfigDialog): diff --git a/ui/img/logo32px.png b/ui/img/logo32px.png new file mode 100644 index 0000000..1227d2a Binary files /dev/null and b/ui/img/logo32px.png differ diff --git a/ui/mainWindow.py b/ui/mainWindow.py index b56a04f..3e3beff 100755 --- a/ui/mainWindow.py +++ b/ui/mainWindow.py @@ -23,26 +23,51 @@ def setupUi(self, MainWindow): self.controlLayout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize) self.controlLayout.setObjectName("controlLayout") self.label = QtWidgets.QLabel(self.centralwidget) - self.label.setMaximumSize(QtCore.QSize(16777215, 29)) + self.label.setMaximumSize(QtCore.QSize(16777215, 34)) self.label.setOpenExternalLinks(True) + + logoPixmap = QtGui.QPixmap("ui/img/logo32px.png") + self.label.setPixmap(logoPixmap) + self.label.setObjectName("label") self.controlLayout.addWidget(self.label) + self.ProMode = QtWidgets.QCheckBox(self.centralwidget) + self.ProMode.setObjectName("ProMode") + self.controlLayout.addWidget(self.ProMode) + self.line_3 = QtWidgets.QFrame(self.centralwidget) + self.line_3.setFrameShape(QtWidgets.QFrame.HLine) + self.line_3.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_3.setObjectName("line_3") + self.controlLayout.addWidget(self.line_3) self.checkboxesLayout = QtWidgets.QGridLayout() self.checkboxesLayout.setObjectName("checkboxesLayout") self.controlLayout.addLayout(self.checkboxesLayout) - self.slidersLayoutWid = QtWidgets.QWidget() - self.slidersLayout = QtWidgets.QScrollArea() - - self.slidersLayoutLay = QtWidgets.QVBoxLayout() - self.slidersLayoutWid.setLayout(self.slidersLayoutLay) - - self.slidersLayout.setWidget(self.slidersLayoutWid) - self.slidersLayout.setWidgetResizable(True) - + self.scrollArea = QtWidgets.QScrollArea(self.centralwidget) + self.scrollArea.setWidgetResizable(True) + self.scrollArea.setObjectName("scrollArea") + self.scrollAreaWidgetContents_2 = QtWidgets.QFrame() + self.scrollAreaWidgetContents_2.setGeometry(QtCore.QRect(0, 0, 461, 716)) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.scrollAreaWidgetContents_2.sizePolicy().hasHeightForWidth()) + self.scrollAreaWidgetContents_2.setSizePolicy(sizePolicy) + self.scrollAreaWidgetContents_2.setObjectName("scrollAreaWidgetContents_2") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.scrollAreaWidgetContents_2) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.verticalLayout_2 = QtWidgets.QVBoxLayout() + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.slidersLayout = QtWidgets.QGridLayout() + self.slidersLayout.setVerticalSpacing(0) self.slidersLayout.setObjectName("slidersLayout") - self.slidersLayoutLay.setObjectName("slidersLayoutLay") - self.controlLayout.addWidget(self.slidersLayout) + + self.verticalLayout_2.addLayout(self.slidersLayout) + self.verticalLayout_3.addLayout(self.verticalLayout_2) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_3.addItem(spacerItem) + self.scrollArea.setWidget(self.scrollAreaWidgetContents_2) + self.controlLayout.addWidget(self.scrollArea) self.templatesLayout = QtWidgets.QHBoxLayout() self.templatesLayout.setObjectName("templatesLayout") @@ -96,58 +121,99 @@ def setupUi(self, MainWindow): self.videoTrackSlider.setInvertedControls(True) self.videoTrackSlider.setObjectName("videoTrackSlider") self.positionControlLayout.addWidget(self.videoTrackSlider) - self.livePreviewCheckbox = QtWidgets.QCheckBox(self.centralwidget) - self.livePreviewCheckbox.setMaximumSize(QtCore.QSize(136, 16777215)) - self.livePreviewCheckbox.setToolTip("") - self.livePreviewCheckbox.setObjectName("livePreviewCheckbox") - self.positionControlLayout.addWidget(self.livePreviewCheckbox) + self.verticalLayout.addLayout(self.positionControlLayout) self.gridLayout_2 = QtWidgets.QGridLayout() self.gridLayout_2.setObjectName("gridLayout_2") - self.label_2 = QtWidgets.QLabel(self.centralwidget) - self.label_2.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.label_2.setObjectName("label_2") - self.gridLayout_2.addWidget(self.label_2, 1, 1, 1, 1) - self.renderHeightBox = QtWidgets.QSpinBox(self.centralwidget) - self.renderHeightBox.setMaximum(3000) - self.renderHeightBox.setSingleStep(120) - self.renderHeightBox.setObjectName("renderHeightBox") - self.gridLayout_2.addWidget(self.renderHeightBox, 1, 2, 1, 1) - self.NearestUpScale = QtWidgets.QCheckBox(self.centralwidget) - self.NearestUpScale.setObjectName("NearestUpScale") - self.gridLayout_2.addWidget(self.NearestUpScale, 2, 1, 1, 1) + self.compareModeButton = QtWidgets.QCheckBox(self.centralwidget) self.compareModeButton.setObjectName("compareModeButton") - self.gridLayout_2.addWidget(self.compareModeButton, 2, 4, 1, 1) - self.seedSpinBox = QtWidgets.QSpinBox(self.centralwidget) - font = QtGui.QFont() - font.setPointSize(13) - self.seedSpinBox.setFont(font) - self.seedSpinBox.setObjectName("seedSpinBox") - self.gridLayout_2.addWidget(self.seedSpinBox, 1, 4, 1, 1) + + self.gridLayout_2.addWidget(self.compareModeButton, 2, 5, 1, 1) + self.NearestUpScale = QtWidgets.QCheckBox(self.centralwidget) + self.NearestUpScale.setObjectName("NearestUpScale") + self.gridLayout_2.addWidget(self.NearestUpScale, 2, 2, 1, 1) + self.toggleMainEffect = QtWidgets.QCheckBox(self.centralwidget) self.toggleMainEffect.setChecked(True) self.toggleMainEffect.setObjectName("toggleMainEffect") - self.gridLayout_2.addWidget(self.toggleMainEffect, 2, 3, 1, 1) - self.ProMode = QtWidgets.QCheckBox(self.centralwidget) - self.ProMode.setObjectName("ProMode") - self.gridLayout_2.addWidget(self.ProMode, 2, 2, 1, 1) - self.seedLabel = QtWidgets.QLabel(self.centralwidget) - self.seedLabel.setMaximumSize(QtCore.QSize(85, 16777215)) - font = QtGui.QFont() - font.setPointSize(15) - font.setBold(True) - font.setUnderline(False) - font.setWeight(75) - self.seedLabel.setFont(font) - self.seedLabel.setAutoFillBackground(False) - self.seedLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.seedLabel.setObjectName("seedLabel") - self.gridLayout_2.addWidget(self.seedLabel, 1, 3, 1, 1) + + self.gridLayout_2.addWidget(self.toggleMainEffect, 2, 4, 1, 1) + self.LossLessCheckBox = QtWidgets.QCheckBox(self.centralwidget) self.LossLessCheckBox.setObjectName("LossLessCheckBox") - self.gridLayout_2.addWidget(self.LossLessCheckBox, 2, 5, 1, 1) + self.gridLayout_2.addWidget(self.LossLessCheckBox, 2, 6, 1, 1) self.verticalLayout.addLayout(self.gridLayout_2) + + self.horizontalLayout_2 = QtWidgets.QHBoxLayout() + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.horizontalLayout_5 = QtWidgets.QHBoxLayout() + self.horizontalLayout_5.setObjectName("horizontalLayout_5") + self.renderHeightLabel = QtWidgets.QLabel(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.renderHeightLabel.sizePolicy().hasHeightForWidth()) + self.renderHeightLabel.setSizePolicy(sizePolicy) + self.renderHeightLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.renderHeightLabel.setObjectName("renderHeightLabel") + self.horizontalLayout_5.addWidget(self.renderHeightLabel) + self.renderHeightBox = QtWidgets.QSpinBox(self.centralwidget) + self.renderHeightBox.setMaximum(3000) + self.renderHeightBox.setSingleStep(120) + self.renderHeightBox.setObjectName("renderHeightBox") + self.horizontalLayout_5.addWidget(self.renderHeightBox) + self.horizontalLayout_2.addLayout(self.horizontalLayout_5) + self.line = QtWidgets.QFrame(self.centralwidget) + self.line.setFrameShape(QtWidgets.QFrame.VLine) + self.line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line.setObjectName("line") + self.horizontalLayout_2.addWidget(self.line) + self.horizontalLayout_7 = QtWidgets.QHBoxLayout() + self.horizontalLayout_7.setObjectName("horizontalLayout_7") + self.noiseSeedLabel = QtWidgets.QLabel(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.noiseSeedLabel.sizePolicy().hasHeightForWidth()) + self.noiseSeedLabel.setSizePolicy(sizePolicy) + self.noiseSeedLabel.setObjectName("noiseSeedLabel") + self.horizontalLayout_7.addWidget(self.noiseSeedLabel) + self.noiseSeedSpinBox = QtWidgets.QSpinBox(self.centralwidget) + self.noiseSeedSpinBox.setObjectName("noiseSeedSpinBox") + self.horizontalLayout_7.addWidget(self.noiseSeedSpinBox) + self.horizontalLayout_2.addLayout(self.horizontalLayout_7) + self.line_2 = QtWidgets.QFrame(self.centralwidget) + self.line_2.setFrameShape(QtWidgets.QFrame.VLine) + self.line_2.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_2.setObjectName("line_2") + self.horizontalLayout_2.addWidget(self.line_2) + self.horizontalLayout_6 = QtWidgets.QHBoxLayout() + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.presetSeedLabel = QtWidgets.QLabel(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.presetSeedLabel.sizePolicy().hasHeightForWidth()) + self.presetSeedLabel.setSizePolicy(sizePolicy) + self.presetSeedLabel.setAutoFillBackground(False) + self.presetSeedLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.presetSeedLabel.setObjectName("presetSeedLabel") + self.horizontalLayout_6.addWidget(self.presetSeedLabel) + self.presetSeedSpinBox = QtWidgets.QSpinBox(self.centralwidget) + self.presetSeedSpinBox.setObjectName("presetSeedSpinBox") + self.horizontalLayout_6.addWidget(self.presetSeedSpinBox) + self.presetFromSeedButton = QtWidgets.QPushButton(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.presetFromSeedButton.sizePolicy().hasHeightForWidth()) + self.presetFromSeedButton.setSizePolicy(sizePolicy) + self.presetFromSeedButton.setObjectName("presetFromSeedButton") + self.horizontalLayout_6.addWidget(self.presetFromSeedButton) + self.horizontalLayout_2.addLayout(self.horizontalLayout_6) + self.verticalLayout.addLayout(self.horizontalLayout_2) + self.statusLabel = QtWidgets.QLabel(self.centralwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) sizePolicy.setHorizontalStretch(0) @@ -163,7 +229,12 @@ def setupUi(self, MainWindow): self.openFile = QtWidgets.QPushButton(self.centralwidget) self.openFile.setObjectName("openFile") self.horizontalLayout_4.addWidget(self.openFile) - self.openImageUrlButton = QtWidgets.QToolButton(self.centralwidget) + self.openImageUrlButton = QtWidgets.QPushButton(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.openImageUrlButton.sizePolicy().hasHeightForWidth()) + self.openImageUrlButton.setSizePolicy(sizePolicy) self.openImageUrlButton.setObjectName("openImageUrlButton") self.horizontalLayout_4.addWidget(self.openImageUrlButton) self.verticalLayout.addLayout(self.horizontalLayout_4) @@ -212,6 +283,17 @@ def setupUi(self, MainWindow): self.stopRenderButton.setMaximumSize(QtCore.QSize(138, 16777215)) self.stopRenderButton.setObjectName("stopRenderButton") self.horizontalLayout.addWidget(self.stopRenderButton) + + self.livePreviewCheckbox = QtWidgets.QCheckBox(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.livePreviewCheckbox.sizePolicy().hasHeightForWidth()) + self.livePreviewCheckbox.setSizePolicy(sizePolicy) + self.livePreviewCheckbox.setMaximumSize(QtCore.QSize(136, 16777215)) + self.livePreviewCheckbox.setObjectName("livePreviewCheckbox") + self.horizontalLayout.addWidget(self.livePreviewCheckbox) + self.verticalLayout.addLayout(self.horizontalLayout) self.horizontalLayout_3.addLayout(self.verticalLayout) MainWindow.setCentralWidget(self.centralwidget) @@ -223,26 +305,35 @@ def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate("MainWindow", "ntscQT+")) MainWindow.setWindowIcon(QtGui.QIcon("./icon.png")) - self.label.setText(_translate("MainWindow", "\n" -"
\n" -"ntscQT+ by RGM. Based on JargeZ's work.
")) + #self.label.setText(_translate("MainWindow", "\n" +#"\n" +#"ntscQT+ by RGM. Based on JargeZ's work.
")) + self.ProMode.setText(_translate("MainWindow", "Pro mode")) self.exportImportConfigButton.setText(_translate("MainWindow", "📝")) self.image_frame.setText(_translate("MainWindow", "No image/video/GIF selected. ❓")) self.refreshFrameButton.setText(_translate("MainWindow", "🔄")) - self.livePreviewCheckbox.setText(_translate("MainWindow", "Live Preview")) self.renderingLabel.setText(_translate("MainWindow", "RENDERING")) - self.label_2.setText(_translate("MainWindow", "Render height")) - self.NearestUpScale.setText(_translate("MainWindow", "x2 Output by nearest-neighbor")) + self.compareModeButton.setToolTip(_translate("MainWindow", "Split-screen between the original image and one with effects applied.
")) self.compareModeButton.setText(_translate("MainWindow", "Compare mode")) + self.NearestUpScale.setText(_translate("MainWindow", "x2 Output by nearest-neighbor")) self.toggleMainEffect.setText(_translate("MainWindow", "ON/OFF")) - self.ProMode.setText(_translate("MainWindow", "Pro mode")) - self.seedLabel.setText(_translate("MainWindow", "Seed")) + + self.LossLessCheckBox.setToolTip(_translate("MainWindow", "Export using the lossless FFV1 codec.
")) self.LossLessCheckBox.setText(_translate("MainWindow", "Lossless .mkv export")) + self.renderHeightLabel.setText(_translate("MainWindow", "Render height")) + self.renderHeightBox.setWhatsThis(_translate("MainWindow", "Defaults to the height of the input video. Decreasing this will decrease the output resolution, meaning the video will be processed faster.
")) + self.noiseSeedLabel.setText(_translate("MainWindow", "Noise seed")) + self.noiseSeedSpinBox.setToolTip(_translate("MainWindow", "Change the random seed used for video noise.
")) + self.presetSeedLabel.setText(_translate("MainWindow", "Preset seed")) + self.presetFromSeedButton.setToolTip(_translate("MainWindow", "Randomize the parameters from the given seed.
")) + self.presetFromSeedButton.setText(_translate("MainWindow", "Preset from seed")) self.openFile.setText(_translate("MainWindow", "Open file (video or image)")) self.openImageUrlButton.setText(_translate("MainWindow", "Open image url")) self.renderVideoButton.setText(_translate("MainWindow", "Render video as")) self.saveImageButton.setText(_translate("MainWindow", "Save image")) self.pauseRenderButton.setText(_translate("MainWindow", "Pause Render")) self.stopRenderButton.setText(_translate("MainWindow", "Stop Render")) + self.livePreviewCheckbox.setToolTip(_translate("MainWindow", "When enabled, display every frame during the rendering process. When disabled, only every 10th frame is shown.
", "When rendering, every frame is displayed. Otherwise, only every 10th")) + self.livePreviewCheckbox.setText(_translate("MainWindow", "Live preview")) diff --git a/ui/mainWindow.ui b/ui/mainWindow.ui index 49223ce..ec8dfab 100755 --- a/ui/mainWindow.ui +++ b/ui/mainWindow.ui @@ -43,11 +43,71 @@ p, li { white-space: pre-wrap; } +