Merge pull request #87 from nyanpasu64/qt-squash

Add PyQt GUI
pull/357/head
nyanpasu64 2018-12-18 20:56:10 -08:00 zatwierdzone przez GitHub
commit 5efd7a85f0
21 zmienionych plików z 2066 dodań i 84 usunięć

Wyświetl plik

@ -18,7 +18,7 @@ class ChannelConfig:
wav_path: str
# Supplying a dict inherits attributes from global trigger.
trigger: Union[ITriggerConfig, dict, None] = None # TODO test channel-specific triggers
trigger: Union[ITriggerConfig, dict, None] = attr.Factory(dict) # TODO test channel-specific triggers
# Multiplies how wide the window is, in milliseconds.
trigger_width: Optional[int] = None
render_width: Optional[int] = None

Wyświetl plik

@ -7,7 +7,7 @@ import click
from ovgenpy.channel import ChannelConfig
from ovgenpy.config import yaml
from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig
from ovgenpy.ovgenpy import default_config, Config, Ovgen
from ovgenpy.ovgenpy import default_config, Ovgen, Config, Arguments
Folder = click.Path(exists=True, file_okay=False)
@ -36,12 +36,18 @@ YAML_NAME = YAML_EXTS[0]
VIDEO_NAME = '.mp4'
def get_path(audio_file: Union[None, str, Path], ext: str) -> Path:
DEFAULT_NAME = 'ovgenpy'
def get_name(audio_file: Union[None, str, Path]) -> str:
# Write file to current working dir, not audio dir.
if audio_file:
name = Path(audio_file).name
name = Path(audio_file).stem
else:
name = 'ovgenpy'
name = DEFAULT_NAME
return name
def get_path(audio_file: Union[None, str, Path], ext: str) -> Path:
name = get_name(audio_file)
# Add extension
return Path(name).with_suffix(ext)
@ -103,7 +109,7 @@ def main(
# Create cfg: Config object.
cfg: Optional[Config] = None
cfg_path: Optional[Path] = None
cfg_dir: Optional[str] = None # Changing to Path will take a lot of refactoring.
cfg_dir: Optional[str] = None
wav_list: List[Path] = []
for name in files:
@ -145,6 +151,7 @@ def main(
wav_list += matches
if not cfg:
# cfg and cfg_dir are always initialized together.
channels = [ChannelConfig(str(wav_path)) for wav_path in wav_list]
cfg = default_config(
@ -157,7 +164,9 @@ def main(
cfg_dir = '.'
if show_gui:
raise click.UsageError('GUI not implemented')
from ovgenpy import gui
gui.gui_main(cfg, cfg_path)
else:
if not files:
raise click.UsageError('Must specify files or folders to play')
@ -176,7 +185,9 @@ def main(
if outputs:
assert Ovgen # to prevent PyCharm from deleting the import
command = 'Ovgen(cfg, cfg_dir, outputs).play()'
arg = Arguments(cfg_dir=cfg_dir, outputs=outputs)
# TODO make it a lambda
command = 'Ovgen(cfg, arg).play()'
if profile:
import cProfile

Wyświetl plik

@ -1,5 +1,6 @@
from io import StringIO
from typing import ClassVar, TYPE_CHECKING, Type
import pickle
from io import StringIO, BytesIO
from typing import ClassVar, TYPE_CHECKING, Type, TypeVar
import attr
from ruamel.yaml import yaml_object, YAML, Representer
@ -8,7 +9,7 @@ if TYPE_CHECKING:
from enum import Enum
__all__ = ['yaml',
__all__ = ['yaml', 'copy_config',
'register_config', 'kw_config', 'Alias', 'Ignored', 'register_enum',
'OvgenError', 'OvgenWarning']
@ -32,6 +33,38 @@ yaml = MyYAML()
_yaml_loadable = yaml_object(yaml)
"""
Speed of copying objects:
number = 100
print(timeit.timeit(lambda: f(cfg), number=number))
- pickle_copy 0.0566s
- deepcopy 0.0967s
- yaml_copy 0.4875s
pickle_copy is fastest.
According to https://stackoverflow.com/questions/1410615/ ,
pickle is faster, but less general (works fine for @register_config objects).
"""
T = TypeVar('T')
# Unused
# def yaml_copy(obj: T) -> T:
# with StringIO() as stream:
# yaml.dump(obj, stream)
# return yaml.load(stream)
# AKA pickle_copy
def copy_config(obj: T) -> T:
with BytesIO() as stream:
pickle.dump(obj, stream)
stream.seek(0)
return pickle.load(stream)
# Setup configuration load/dump infrastructure.
def register_config(cls=None, *, kw_only=False, always_dump: str = ''):
@ -77,7 +110,13 @@ class _ConfigMixin:
cls = type(self)
for field in attr.fields(cls):
# Skip deprecated fields with leading underscores.
# They have already been baked into other config fields.
name = field.name
if name[0] == '_':
continue
value = getattr(self, name)
if dump_all or name in always_dump:
@ -109,7 +148,7 @@ class _ConfigMixin:
if isinstance(class_var, Alias):
target = class_var.key
if target in state:
raise TypeError(
raise OvgenError(
f'{type(self).__name__} received both Alias {key} and '
f'equivalent {target}'
)
@ -160,4 +199,3 @@ class OvgenWarning(UserWarning):
""" Warning about deprecated end-user config (YAML/GUI).
(Should be) caught by GUI and displayed to user. """
pass

43
ovgenpy/gui/.gitignore vendored 100644
Wyświetl plik

@ -0,0 +1,43 @@
# C++ objects and libs
*.slo
*.lo
*.o
*.a
*.la
*.lai
*.so
*.dll
*.dylib
# Qt-es
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
*.pro.user.*
*.qbs.user
*.qbs.user.*
*.moc
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
# Qt unit tests
target_wrapper.*
# QtCreator
*.autosave
# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
# QtCreator CMake
CMakeLists.txt.user*

Wyświetl plik

@ -0,0 +1,775 @@
import os
import sys
from pathlib import Path
from typing import *
from typing import List, Any
import PyQt5.QtCore as qc
import PyQt5.QtWidgets as qw
import attr
from PyQt5 import uic
from PyQt5.QtCore import QModelIndex, Qt
from PyQt5.QtGui import QKeySequence, QFont, QCloseEvent
from PyQt5.QtWidgets import QShortcut
from ovgenpy import cli
from ovgenpy.channel import ChannelConfig
from ovgenpy.config import OvgenError, copy_config, yaml
from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr
from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext, find_ranges
from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig
from ovgenpy.ovgenpy import Ovgen, Config, Arguments, default_config
from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig
from ovgenpy.util import obj_name
FILTER_WAV_FILES = "WAV files (*.wav)"
APP_NAME = 'ovgenpy'
APP_DIR = Path(__file__).parent
def res(file: str) -> str:
return str(APP_DIR / file)
def gui_main(cfg: Config, cfg_path: Optional[Path]):
# TODO read config within MainWindow, and show popup if loading fails.
# qw.QApplication.setStyle('fusion')
QApp = qw.QApplication
QApp.setAttribute(qc.Qt.AA_EnableHighDpiScaling)
# Qt on Windows will finally switch default font to lfMessageFont=Segoe UI
# (Vista, 2006)... in 2020 (Qt 6.0).
if qc.QSysInfo.kernelType() == 'winnt':
# This will be wrong for non-English languages, but it's better than default?
font = QFont("Segoe UI", 9)
font.setStyleHint(QFont.SansSerif)
QApp.setFont(font)
app = qw.QApplication(sys.argv)
window = MainWindow(cfg, cfg_path)
sys.exit(app.exec_())
class MainWindow(qw.QMainWindow):
"""
Main window.
Control flow:
__init__
load_cfg
# Opening a document
load_cfg
"""
def __init__(self, cfg: Config, cfg_path: Optional[Path]):
super().__init__()
# Load UI.
uic.loadUi(res('mainwindow.ui'), self) # sets windowTitle
# Bind UI buttons, etc. Functions block main thread, avoiding race conditions.
self.master_audio_browse.clicked.connect(self.on_master_audio_browse)
self.channelUp.add_shortcut(self.channelsGroup, 'ctrl+shift+up')
self.channelDown.add_shortcut(self.channelsGroup, 'ctrl+shift+down')
self.channelUp.clicked.connect(self.channel_view.on_channel_up)
self.channelDown.clicked.connect(self.channel_view.on_channel_down)
self.channelAdd.clicked.connect(self.on_channel_add)
self.channelDelete.clicked.connect(self.on_channel_delete)
# Bind actions.
self.actionNew.triggered.connect(self.on_action_new)
self.actionOpen.triggered.connect(self.on_action_open)
self.actionSave.triggered.connect(self.on_action_save)
self.actionSaveAs.triggered.connect(self.on_action_save_as)
self.actionPlay.triggered.connect(self.on_action_play)
self.actionRender.triggered.connect(self.on_action_render)
self.actionExit.triggered.connect(qw.QApplication.closeAllWindows)
# Initialize ovgen-thread attribute.
self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None)
# Bind config to UI.
self.load_cfg(cfg, cfg_path)
self.show()
# Config models
_cfg_path: Optional[Path]
# Whether document is dirty, changed, has unsaved changes
_any_unsaved: bool
@property
def any_unsaved(self) -> bool:
return self._any_unsaved
@any_unsaved.setter
def any_unsaved(self, value: bool):
self._any_unsaved = value
self._update_unsaved_title()
model: Optional['ConfigModel'] = None
channel_model: 'ChannelModel'
channel_view: 'ChannelTableView'
channelsGroup: qw.QGroupBox
def closeEvent(self, event: QCloseEvent) -> None:
"""Called on closing window."""
if self.prompt_save():
event.accept()
else:
event.ignore()
def on_action_new(self):
if not self.prompt_save():
return
cfg = default_config()
self.load_cfg(cfg, None)
def on_action_open(self):
if not self.prompt_save():
return
name, file_type = qw.QFileDialog.getOpenFileName(
self, "Open config", self.cfg_dir, "YAML files (*.yaml)"
)
if name != '':
cfg_path = Path(name)
try:
# Raises YAML structural exceptions
cfg = yaml.load(cfg_path)
# Raises color getter exceptions
# ISSUE: catching an exception will leave UI in undefined state?
self.load_cfg(cfg, cfg_path)
except Exception as e:
qw.QMessageBox.critical(self, 'Error loading file', str(e))
return
def prompt_save(self) -> bool:
"""
Called when user is closing document
(when opening a new document or closing the app).
:return: False if user cancels close-document action.
"""
if not self.any_unsaved:
return True
Msg = qw.QMessageBox
save_message = f"Save changes to {self.title_cache}?"
should_close = Msg.question(
self, "Save Changes?", save_message,
Msg.Save | Msg.Discard | Msg.Cancel
)
if should_close == Msg.Cancel:
return False
elif should_close == Msg.Discard:
return True
else:
return self.on_action_save()
def load_cfg(self, cfg: Config, cfg_path: Optional[Path]):
self._cfg_path = cfg_path
self._any_unsaved = False
self.load_title()
if self.model is None:
self.model = ConfigModel(cfg)
# Calls self.on_gui_edited() whenever GUI widgets change.
map_gui(self, self.model)
else:
self.model.set_cfg(cfg)
self.channel_model = ChannelModel(cfg.channels)
# Calling setModel again disconnects previous model.
self.channel_view.setModel(self.channel_model)
self.channel_model.dataChanged.connect(self.on_gui_edited)
def on_gui_edited(self):
self.any_unsaved = True
title_cache: str
def load_title(self):
self.title_cache = self.title
self._update_unsaved_title()
def _update_unsaved_title(self):
if self.any_unsaved:
undo_str = '*'
else:
undo_str = ''
self.setWindowTitle(f'{self.title_cache}{undo_str} - {APP_NAME}')
# GUI actions, etc.
master_audio_browse: qw.QPushButton
channelAdd: 'ShortcutButton'
channelDelete: 'ShortcutButton'
channelUp: 'ShortcutButton'
channelDown: 'ShortcutButton'
# Loading mainwindow.ui changes menuBar from a getter to an attribute.
menuBar: qw.QMenuBar
actionNew: qw.QAction
actionOpen: qw.QAction
actionSave: qw.QAction
actionSaveAs: qw.QAction
actionPlay: qw.QAction
actionRender: qw.QAction
actionExit: qw.QAction
def on_master_audio_browse(self):
# TODO add default file-open dir, initialized to yaml path and remembers prev
# useless if people don't reopen old projects
name, file_type = qw.QFileDialog.getOpenFileName(
self, "Open master audio file", self.cfg_dir, FILTER_WAV_FILES
)
if name != '':
master_audio = 'master_audio'
self.model[master_audio] = name
self.model.update_widget[master_audio]()
def on_channel_add(self):
wavs, file_type = qw.QFileDialog.getOpenFileNames(
self, "Add audio channels", self.cfg_dir, FILTER_WAV_FILES
)
if wavs:
self.channel_view.append_channels(wavs)
def on_channel_delete(self):
self.channel_view.delete_selected()
def on_action_save(self) -> bool:
"""
:return: False if user cancels save action.
"""
if self._cfg_path is None:
return self.on_action_save_as()
yaml.dump(self.cfg, self._cfg_path)
self.any_unsaved = False
self._update_unsaved_title()
return True
def on_action_save_as(self) -> bool:
"""
:return: False if user cancels save action.
"""
cfg_path_default = os.path.join(self.cfg_dir, self.file_stem) + cli.YAML_NAME
filters = ["YAML files (*.yaml)", "All files (*)"]
path = get_save_with_ext(
self, "Save As", cfg_path_default, filters, cli.YAML_NAME
)
if path:
self._cfg_path = path
self.load_title()
self.on_action_save()
return True
else:
return False
def on_action_play(self):
""" Launch ovgen and ffplay. """
error_msg = 'Cannot play, another play/render is active'
with self.ovgen_thread as t:
if t is not None:
self.ovgen_thread.unlock()
qw.QMessageBox.critical(self, 'Error', error_msg)
return
outputs = [FFplayOutputConfig()]
self.play_thread(outputs, dlg=None)
def on_action_render(self):
""" Get file name. Then show a progress dialog while rendering to file. """
error_msg = 'Cannot render to file, another play/render is active'
with self.ovgen_thread as t:
if t is not None:
self.ovgen_thread.unlock()
qw.QMessageBox.critical(self, 'Error', error_msg)
return
video_path = os.path.join(self.cfg_dir, self.file_stem) + cli.VIDEO_NAME
filters = ["MP4 files (*.mp4)", "All files (*)"]
path = get_save_with_ext(self, "Render to Video", video_path, filters,
cli.VIDEO_NAME)
if path:
name = str(path)
# FIXME what if missing mp4?
dlg = OvgenProgressDialog(self, 'Rendering video')
outputs = [FFmpegOutputConfig(name)]
self.play_thread(outputs, dlg)
def play_thread(self, outputs: List[IOutputConfig],
dlg: Optional['OvgenProgressDialog']):
""" self.ovgen_thread MUST be locked. """
arg = self._get_args(outputs)
if dlg:
arg = attr.evolve(arg,
on_begin=dlg.on_begin,
progress=dlg.setValue,
is_aborted=dlg.wasCanceled,
on_end=dlg.reset, # TODO dlg.close
)
cfg = copy_config(self.model.cfg)
t = self.ovgen_thread.obj = OvgenThread(cfg, arg)
t.error.connect(self.on_play_thread_error)
t.finished.connect(self.on_play_thread_finished)
t.start()
def on_play_thread_error(self, exc: BaseException):
qw.QMessageBox.critical(self, 'Error rendering oscilloscope', str(exc))
def on_play_thread_finished(self):
self.ovgen_thread.set(None)
def _get_args(self, outputs: List[IOutputConfig]):
arg = Arguments(
cfg_dir=self.cfg_dir,
outputs=outputs,
)
return arg
# File paths
@property
def cfg_dir(self) -> str:
maybe_path = self._cfg_path or self.cfg.master_audio
if maybe_path:
return str(Path(maybe_path).resolve().parent)
return '.'
UNTITLED = 'Untitled'
@property
def title(self) -> str:
if self._cfg_path:
return self._cfg_path.name
return self.UNTITLED
@property
def file_stem(self) -> str:
return cli.get_name(self._cfg_path or self.cfg.master_audio)
@property
def cfg(self):
return self.model.cfg
class ShortcutButton(qw.QPushButton):
scoped_shortcut: QShortcut
def add_shortcut(self, scope: qw.QWidget, shortcut: str) -> None:
""" Adds shortcut and tooltip. """
keys = QKeySequence(shortcut, QKeySequence.PortableText)
self.scoped_shortcut = qw.QShortcut(keys, scope)
self.scoped_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
self.scoped_shortcut.activated.connect(self.click)
self.setToolTip(keys.toString(QKeySequence.NativeText))
class OvgenThread(qc.QThread):
def __init__(self, cfg: Config, arg: Arguments):
qc.QThread.__init__(self)
self.cfg = cfg
self.arg = arg
def run(self) -> None:
cfg = self.cfg
arg = self.arg
try:
Ovgen(cfg, arg).play()
except Exception as e:
arg.on_end()
self.error.emit(e)
else:
arg.on_end()
error = qc.pyqtSignal(Exception)
class OvgenProgressDialog(qw.QProgressDialog):
def __init__(self, parent: Optional[qw.QWidget], title: str):
super().__init__(parent)
self.setMinimumWidth(300)
self.setWindowTitle(title)
self.setLabelText('Progress:')
# If set to 0, the dialog is always shown as soon as any progress is set.
self.setMinimumDuration(0)
# Don't reset when rendering is approximately finished.
self.setAutoReset(False)
# Close after ovgen finishes.
self.setAutoClose(True)
def on_begin(self, begin_time, end_time):
self.setRange(int(round(begin_time)), int(round(end_time)))
# self.setValue is called by Ovgen, on the first frame.
def nrow_ncol_property(altered: str, unaltered: str) -> property:
def get(self: 'ConfigModel'):
val = getattr(self.cfg.layout, altered)
if val is None:
return 0
else:
return val
def set(self: 'ConfigModel', val: int):
if val > 0:
setattr(self.cfg.layout, altered, val)
setattr(self.cfg.layout, unaltered, None)
self.update_widget['layout__' + unaltered]()
elif val == 0:
setattr(self.cfg.layout, altered, None)
else:
raise OvgenError(f"invalid input: {altered} < 0, should never happen")
return property(get, set)
def default_property(path: str, default):
def getter(self: 'ConfigModel'):
val = rgetattr(self.cfg, path)
if val is None:
return default
else:
return val
def setter(self: 'ConfigModel', val):
rsetattr(self.cfg, path, val)
return property(getter, setter)
def color2hex_property(path: str):
def getter(self: 'ConfigModel'):
color_attr = rgetattr(self.cfg, path)
return color2hex(color_attr)
def setter(self: 'ConfigModel', val: str):
color = color2hex(val)
rsetattr(self.cfg, path, color)
return property(getter, setter)
class ConfigModel(PresentationModel):
cfg: Config
combo_symbols = {}
combo_text = {}
render__bg_color = color2hex_property('render__bg_color')
render__init_line_color = color2hex_property('render__init_line_color')
@property
def render_video_size(self) -> str:
render = self.cfg.render
w, h = render.width, render.height
return f'{w}x{h}'
@render_video_size.setter
def render_video_size(self, value: str):
error = OvgenError(f"invalid video size {value}, must be WxH")
for sep in 'x*,':
width_height = value.split(sep)
if len(width_height) == 2:
break
else:
raise error
render = self.cfg.render
width, height = width_height
try:
render.width = int(width)
render.height = int(height)
except ValueError:
raise error
layout__nrows = nrow_ncol_property('nrows', unaltered='ncols')
layout__ncols = nrow_ncol_property('ncols', unaltered='nrows')
combo_symbols['layout__orientation'] = ['h', 'v']
combo_text['layout__orientation'] = ['Horizontal', 'Vertical']
render__line_width = default_property('render__line_width', 1.5)
class ChannelTableView(qw.QTableView):
def append_channels(self, wavs: List[str]):
model: ChannelModel = self.model()
begin_row = model.rowCount()
count_rows = len(wavs)
col = model.idx_of_key['wav_path']
model.insertRows(begin_row, count_rows)
for row, wav_path in enumerate(wavs, begin_row):
index = model.index(row, col)
model.setData(index, wav_path)
def delete_selected(self):
model: 'ChannelModel' = self.model()
rows = self.selected_rows()
row_ranges = find_ranges(rows)
for first_row, nrow in reversed(list(row_ranges)):
model.removeRows(first_row, nrow)
def on_channel_up(self):
self.move_selection(-1)
def on_channel_down(self):
self.move_selection(1)
def move_selection(self, delta: int):
model: 'ChannelModel' = self.model()
rows = self.selected_rows()
row_ranges = find_ranges(rows)
# If we hit the end, cancel all other moves.
# If moving up, move top first.
if delta > 0:
# If moving down, move bottom first.
row_ranges = reversed(list(row_ranges))
parent = qc.QModelIndex()
for first_row, nrow in row_ranges:
if delta > 0:
dest_row = first_row + nrow + delta
else:
dest_row = first_row + delta
if not model.moveRows(parent, first_row, nrow, parent, dest_row):
break
def selected_rows(self) -> List[int]:
sel: qc.QItemSelectionModel = self.selectionModel()
inds: List[qc.QModelIndex] = sel.selectedIndexes()
rows: List[int] = sorted({ind.row() for ind in inds})
return rows
@attr.dataclass
class Column:
key: str
cls: Type
default: Any
def _display_name(self) -> str:
return (self.key
.replace('__', '\n')
.replace('_', ' ')
.title())
display_name: str = attr.Factory(_display_name, takes_self=True)
class ChannelModel(qc.QAbstractTableModel):
""" Design based off
https://doc.qt.io/qt-5/model-view-programming.html#a-read-only-example-model and
https://doc.qt.io/qt-5/model-view-programming.html#model-subclassing-reference
"""
def __init__(self, channels: List[ChannelConfig]):
""" Mutates `channels` and `line_color` for convenience. """
super().__init__()
self.channels = channels
line_color = 'line_color'
for cfg in self.channels:
t = cfg.trigger
if isinstance(t, ITriggerConfig):
if not isinstance(t, CorrelationTriggerConfig):
raise OvgenError(
f'Loading per-channel {obj_name(t)} not supported')
trigger_dict = attr.asdict(t)
else:
trigger_dict = dict(t or {})
if line_color in trigger_dict:
trigger_dict[line_color] = color2hex(trigger_dict[line_color])
cfg.trigger = trigger_dict
def triggers(self, row: int) -> dict:
trigger = self.channels[row].trigger
assert isinstance(trigger, dict)
return trigger
# columns
col_data = [
Column('wav_path', str, '', 'WAV Path'),
Column('trigger_width', int, None, 'Trigger Width ×'),
Column('render_width', int, None, 'Render Width ×'),
Column('line_color', str, None, 'Line Color'),
# TODO move from table view to sidebar QDataWidgetMapper?
Column('trigger__edge_strength', float, None),
Column('trigger__responsiveness', float, None),
Column('trigger__buffer_falloff', float, None),
]
@staticmethod
def _idx_of_key(col_data=col_data):
return {
col.key: idx
for idx, col in enumerate(col_data)
}
idx_of_key = _idx_of_key.__func__()
def columnCount(self, parent: QModelIndex = ...) -> int:
return len(self.col_data)
def headerData(self, section: int, orientation: Qt.Orientation,
role=Qt.DisplayRole):
if role == Qt.DisplayRole:
if orientation == Qt.Horizontal:
col = section
try:
return self.col_data[col].display_name
except IndexError:
return nope
else:
return str(section + 1)
return nope
# rows
def rowCount(self, parent: QModelIndex = ...) -> int:
return len(self.channels)
# data
TRIGGER = 'trigger__'
def data(self, index: QModelIndex, role=Qt.DisplayRole) -> qc.QVariant:
col = index.column()
row = index.row()
if role in [Qt.DisplayRole, Qt.EditRole] and index.isValid() and row < self.rowCount():
data = self.col_data[col]
key = data.key
if key.startswith(self.TRIGGER):
key = behead(key, self.TRIGGER)
value = self.triggers(row).get(key, '')
else:
value = getattr(self.channels[row], key)
if value == data.default:
return ''
if key == 'wav_path' and role == Qt.DisplayRole:
if Path(value).parent != Path():
return '...' + Path(value).name
return str(value)
return nope
def setData(self, index: QModelIndex, value: str, role=Qt.EditRole) -> bool:
col = index.column()
row = index.row()
if index.isValid() and role == Qt.EditRole:
# type(value) == str
data = self.col_data[col]
key = data.key
if value and not value.isspace():
try:
value = data.cls(value)
except ValueError:
return False
else:
value = data.default
if key.startswith(self.TRIGGER):
key = behead(key, self.TRIGGER)
trigger = self.triggers(row)
if value == data.default:
# Delete key if (key: value) present
trigger.pop(key, None)
else:
trigger[key] = value
else:
setattr(self.channels[row], key, value)
self.dataChanged.emit(index, index, [role])
return True
return False
"""So if I understood it correctly you want to reorder the columns by moving the
headers and then want to know how the view looks like. I believe ( 90% certain )
when you reorder the headers it does not trigger any change in the model! and
then if you just start printing the data of the model you will only see the data
in the order how it was initially before you swapper/reordered some column with
the header. """
def insertRows(self, row: int, count: int, parent=QModelIndex()) -> bool:
if not (count >= 1 and 0 <= row <= len(self.channels)):
return False
self.beginInsertRows(parent, row, row + count - 1)
self.channels[row:row] = [ChannelConfig('') for _ in range(count)]
self.endInsertRows()
return True
def removeRows(self, row: int, count: int, parent=QModelIndex()) -> bool:
nchan = len(self.channels)
# row <= nchan for consistency.
if not (count >= 1 and 0 <= row <= nchan and row + count <= nchan):
return False
self.beginRemoveRows(parent, row, row + count - 1)
del self.channels[row: row + count]
self.endRemoveRows()
return True
def moveRows(self,
_sourceParent: QModelIndex, src_row: int, count: int,
_destinationParent: QModelIndex, dest_row: int):
nchan = len(self.channels)
if not (count >= 1
and 0 <= src_row <= nchan and src_row + count <= nchan
and 0 <= dest_row <= nchan):
return False
# If source and destination overlap, beginMoveRows returns False.
if not self.beginMoveRows(
_sourceParent, src_row, src_row + count - 1,
_destinationParent, dest_row):
return False
# We know source and destination do not overlap.
src = slice(src_row, src_row + count)
dest = slice(dest_row, dest_row)
if dest_row > src_row:
# Move down: Insert dest, then remove src
self.channels[dest] = self.channels[src]
del self.channels[src]
else:
# Move up: Remove src, then insert dest.
rows = self.channels[src]
del self.channels[src]
self.channels[dest] = rows
self.endMoveRows()
return True
def flags(self, index: QModelIndex):
if not index.isValid():
return Qt.ItemIsEnabled
return (qc.QAbstractItemModel.flags(self, index)
| Qt.ItemIsEditable | Qt.ItemNeverHasChildren)
nope = qc.QVariant()

Wyświetl plik

@ -0,0 +1,273 @@
import functools
import operator
from typing import Optional, List, Callable, Dict, Any, ClassVar, TYPE_CHECKING
from PyQt5 import QtWidgets as qw, QtCore as qc
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtGui import QPalette, QColor
from PyQt5.QtWidgets import QWidget
from ovgenpy.config import OvgenError
from ovgenpy.triggers import lerp
from ovgenpy.util import obj_name, perr
if TYPE_CHECKING:
from ovgenpy.gui import MainWindow
__all__ = ['PresentationModel', 'map_gui', 'behead', 'rgetattr', 'rsetattr']
WidgetUpdater = Callable[[], None]
Attrs = Any
class PresentationModel:
""" Key-value MVP presentation-model.
Qt's built-in model-view framework expects all models to
take the form of a database-style numbered [row, column] structure,
whereas my model takes the form of a key-value struct exposed as a form.
"""
# These fields are specific to each subclass, and assigned there.
# Although less explicit, these can be assigned using __init_subclass__.
combo_symbols: Dict[str, List[str]]
combo_text: Dict[str, List[str]]
def __init__(self, cfg: Attrs):
self.cfg = cfg
self.update_widget: Dict[str, WidgetUpdater] = {}
def __getitem__(self, item):
try:
# Custom properties
return getattr(self, item)
except AttributeError:
return rgetattr(self.cfg, item)
def __setitem__(self, key, value):
# Custom properties
if hasattr(type(self), key):
setattr(self, key, value)
elif rhasattr(self.cfg, key):
rsetattr(self.cfg, key, value)
else:
raise AttributeError(f'cannot set attribute {key} on {obj_name(self)}()')
def set_cfg(self, cfg: Attrs):
self.cfg = cfg
for updater in self.update_widget.values():
updater()
# TODO add tests for recursive operations
def map_gui(view: 'MainWindow', model: PresentationModel):
"""
Binding:
- .ui <widget name="layout__nrows">
- view.layout__nrows
- pmodel['layout__nrows']
Only <widget>s subclassing BoundWidget will be bound.
"""
widgets: List[BoundWidget] = view.findChildren(BoundWidget) # dear pyqt, add generic mypy return types
for widget in widgets:
path = widget.objectName()
widget.bind_widget(model, path)
widget.gui_changed.connect(view.on_gui_edited)
Signal = Any
class BoundWidget(QWidget):
default_palette: QPalette
error_palette: QPalette
pmodel: PresentationModel
path: str
def bind_widget(self, model: PresentationModel, path: str) -> None:
try:
self.default_palette = self.palette()
self.error_palette = self.calc_error_palette()
self.pmodel = model
self.path = path
self.cfg2gui()
# Allow widget to be updated by other events.
model.update_widget[path] = self.cfg2gui
# Allow pmodel to be changed by widget.
self.gui_changed.connect(self.set_model)
except Exception:
perr(self)
perr(path)
raise
def calc_error_palette(self) -> QPalette:
""" Palette with red background, used for widgets with invalid input. """
error_palette = QPalette(self.palette())
bg = error_palette.color(QPalette.Base)
red = QColor(qc.Qt.red)
red_bg = blend_colors(bg, red, 0.5)
error_palette.setColor(QPalette.Base, red_bg)
return error_palette
def cfg2gui(self):
""" Update the widget without triggering signals.
When the presentation pmodel updates dependent widget 1,
the pmodel (not widget 1) is responsible for updating other
dependent widgets.
TODO add option to send signals
"""
with qc.QSignalBlocker(self):
self.set_gui(self.pmodel[self.path])
def set_gui(self, value): pass
gui_changed: ClassVar[Signal]
def set_model(self, value): pass
def blend_colors(color1: QColor, color2: QColor, ratio: float, gamma=2):
""" Blends two colors in linear color space.
Produces better results on both light and dark themes,
than integer blending (which is too dark).
"""
rgb1 = color1.getRgbF()[:3] # r,g,b, remove alpha
rgb2 = color2.getRgbF()[:3]
rgb_blend = []
for ch1, ch2 in zip(rgb1, rgb2):
blend = lerp(ch1 ** gamma, ch2 ** gamma, ratio) ** (1/gamma)
rgb_blend.append(blend)
return QColor.fromRgbF(*rgb_blend, 1.0)
def model_setter(value_type: type) -> Callable:
@pyqtSlot(value_type)
def set_model(self: BoundWidget, value):
assert isinstance(value, value_type)
try:
self.pmodel[self.path] = value
except OvgenError:
self.setPalette(self.error_palette)
else:
self.setPalette(self.default_palette)
return set_model
def alias(name: str):
return property(operator.attrgetter(name))
class BoundLineEdit(qw.QLineEdit, BoundWidget):
# PyQt complains when we assign unbound methods (`set_gui = qw.QLineEdit.setText`),
# but not if we call them indirectly.
set_gui = alias('setText')
gui_changed = alias('textChanged')
set_model = model_setter(str)
class BoundSpinBox(qw.QSpinBox, BoundWidget):
set_gui = alias('setValue')
gui_changed = alias('valueChanged')
set_model = model_setter(int)
class BoundDoubleSpinBox(qw.QDoubleSpinBox, BoundWidget):
set_gui = alias('setValue')
gui_changed = alias('valueChanged')
set_model = model_setter(float)
class BoundComboBox(qw.QComboBox, BoundWidget):
combo_symbols: List[str]
symbol2idx: Dict[str, int]
# noinspection PyAttributeOutsideInit
def bind_widget(self, model: PresentationModel, path: str) -> None:
# Effectively enum values.
self.combo_symbols = model.combo_symbols[path]
# symbol2idx[str] = int
self.symbol2idx = {}
# Pretty-printed text
combo_text = model.combo_text[path]
for i, symbol in enumerate(self.combo_symbols):
self.symbol2idx[symbol] = i
self.addItem(combo_text[i])
BoundWidget.bind_widget(self, model, path)
# combobox.index = pmodel.attr
def set_gui(self, symbol: str):
combo_index = self.symbol2idx[symbol]
self.setCurrentIndex(combo_index)
gui_changed = alias('currentIndexChanged')
# pmodel.attr = combobox.index
@pyqtSlot(int)
def set_model(self, combo_index: int):
assert isinstance(combo_index, int)
self.pmodel[self.path] = self.combo_symbols[combo_index]
# Unused
def try_behead(string: str, header: str) -> Optional[str]:
if not string.startswith(header):
return None
return string[len(header):]
def behead(string: str, header: str) -> str:
if not string.startswith(header):
raise ValueError(f'{string} does not start with {header}')
return string[len(header):]
DUNDER = '__'
# https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
def rgetattr(obj, dunder_delim_path: str, *default):
"""
:param obj: Object
:param dunder_delim_path: 'attr1__attr2__etc'
:param default: Optional default value, at any point in the path
:return: obj.attr1.attr2.etc
"""
def _getattr(obj, attr):
return getattr(obj, attr, *default)
attrs = dunder_delim_path.split(DUNDER)
return functools.reduce(_getattr, [obj] + attrs)
def rhasattr(obj, dunder_delim_path: str):
try:
rgetattr(obj, dunder_delim_path)
return True
except AttributeError:
return False
# https://stackoverflow.com/a/31174427/2683842
def rsetattr(obj, dunder_delim_path: str, val):
"""
:param obj: Object
:param dunder_delim_path: 'attr1__attr2__etc'
:param val: obj.attr1.attr2.etc = val
"""
parent, _, name = dunder_delim_path.rpartition(DUNDER)
parent_obj = rgetattr(obj, parent) if parent else obj
return setattr(parent_obj, name, val)

Wyświetl plik

@ -0,0 +1,583 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1080</width>
<height>0</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralWidget">
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,1">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tabGeneral">
<attribute name="title">
<string>&amp;General</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="optionGlobal">
<property name="title">
<string>Global</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="fpsL">
<property name="text">
<string>FPS</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="BoundSpinBox" name="fps">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>999</number>
</property>
<property name="singleStep">
<number>10</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="trigger_msL">
<property name="text">
<string>Trigger Width (ms)</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="BoundSpinBox" name="trigger_ms">
<property name="minimum">
<number>5</number>
</property>
<property name="singleStep">
<number>5</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="render_msL">
<property name="text">
<string>Render Width (ms)</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="BoundSpinBox" name="render_ms">
<property name="minimum">
<number>5</number>
</property>
<property name="singleStep">
<number>5</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="amplificationL">
<property name="text">
<string>Amplification</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="BoundDoubleSpinBox" name="amplification">
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="begin_timeL">
<property name="text">
<string>Begin Time (s)</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="BoundDoubleSpinBox" name="begin_time">
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="optionAppear">
<property name="title">
<string>Appearance</string>
</property>
<layout class="QFormLayout" name="formLayout_4">
<item row="0" column="0">
<widget class="QLabel" name="render__bg_colorL">
<property name="text">
<string>Background</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="BoundLineEdit" name="render__bg_color">
<property name="text">
<string>bg</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="render__init_line_colorL">
<property name="text">
<string>Line Color</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="BoundLineEdit" name="render__init_line_color">
<property name="text">
<string>fg</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="render__line_widthL">
<property name="text">
<string>Line Width</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="BoundDoubleSpinBox" name="render__line_width">
<property name="minimum">
<double>0.500000000000000</double>
</property>
<property name="singleStep">
<double>0.500000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="optionLayout">
<property name="title">
<string>Layout</string>
</property>
<layout class="QFormLayout" name="formLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="layout__orientationL">
<property name="text">
<string>Orientation</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="BoundComboBox" name="layout__orientation"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="layout__ncolsL">
<property name="text">
<string>Columns</string>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="layoutDims">
<item>
<widget class="BoundSpinBox" name="layout__ncols">
<property name="specialValueText">
<string notr="true"> </string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="layout__nrowsL">
<property name="text">
<string>Rows</string>
</property>
</widget>
</item>
<item>
<widget class="BoundSpinBox" name="layout__nrows">
<property name="specialValueText">
<string notr="true"> </string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="render_video_sizeL">
<property name="text">
<string>Video Size</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="BoundLineEdit" name="render_video_size">
<property name="text">
<string>vs</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="optionChannel">
<property name="title">
<string>Performance</string>
</property>
<layout class="QFormLayout" name="formLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="render_subfpsL">
<property name="text">
<string>Render FPS Divisor</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="BoundSpinBox" name="render_subfps">
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="trigger_subsamplingL">
<property name="text">
<string>Trigger Subsampling</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="BoundSpinBox" name="trigger_subsampling">
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="render_subsamplingL">
<property name="text">
<string>Render Subsampling</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="BoundSpinBox" name="render_subsampling">
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="audioColumn">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QGroupBox" name="audioGroup">
<property name="title">
<string>Master Audio</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="BoundLineEdit" name="master_audio">
<property name="text">
<string>/</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="master_audio_browse">
<property name="text">
<string>&amp;Browse...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="optionAudio">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Trigger</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="trigger__edge_strengthL">
<property name="text">
<string>Edge Strength</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="trigger__responsivenessL">
<property name="text">
<string>Responsiveness</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLabel" name="trigger__buffer_falloffL">
<property name="text">
<string>Buffer Falloff</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="BoundDoubleSpinBox" name="trigger__edge_strength">
<property name="minimum">
<double>-99.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="BoundDoubleSpinBox" name="trigger__responsiveness">
<property name="maximum">
<double>1.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="BoundDoubleSpinBox" name="trigger__buffer_falloff">
<property name="singleStep">
<double>0.500000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="channelsGroup">
<property name="title">
<string>Oscilloscope Channels</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<layout class="QHBoxLayout" name="channelBar">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="ShortcutButton" name="channelAdd">
<property name="text">
<string>&amp;Add...</string>
</property>
</widget>
</item>
<item>
<widget class="ShortcutButton" name="channelDelete">
<property name="text">
<string>&amp;Delete</string>
</property>
</widget>
</item>
<item>
<widget class="ShortcutButton" name="channelUp">
<property name="text">
<string>Up</string>
</property>
</widget>
</item>
<item>
<widget class="ShortcutButton" name="channelDown">
<property name="text">
<string>Down</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="ChannelTableView" name="channel_view"/>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menuBar">
<widget class="QMenu" name="menuFile">
<property name="title">
<string>&amp;File</string>
</property>
<addaction name="actionNew"/>
<addaction name="actionOpen"/>
<addaction name="actionSave"/>
<addaction name="actionSaveAs"/>
<addaction name="separator"/>
<addaction name="actionPlay"/>
<addaction name="actionRender"/>
<addaction name="separator"/>
<addaction name="actionExit"/>
</widget>
<addaction name="menuFile"/>
</widget>
<widget class="QToolBar" name="toolBar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionNew"/>
<addaction name="actionOpen"/>
<addaction name="actionSave"/>
<addaction name="actionSaveAs"/>
<addaction name="separator"/>
<addaction name="actionPlay"/>
<addaction name="actionRender"/>
</widget>
<action name="actionOpen">
<property name="text">
<string>&amp;Open</string>
</property>
<property name="shortcut">
<string>Ctrl+O</string>
</property>
</action>
<action name="actionSave">
<property name="text">
<string>&amp;Save</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="actionNew">
<property name="text">
<string>&amp;New</string>
</property>
<property name="shortcut">
<string>Ctrl+N</string>
</property>
</action>
<action name="actionSaveAs">
<property name="text">
<string>Save &amp;As</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
</action>
<action name="actionExit">
<property name="text">
<string>E&amp;xit</string>
</property>
<property name="shortcut">
<string>Ctrl+Q</string>
</property>
</action>
<action name="actionPlay">
<property name="text">
<string>&amp;Play</string>
</property>
<property name="shortcut">
<string>Ctrl+P</string>
</property>
</action>
<action name="actionRender">
<property name="text">
<string>&amp;Render to Video</string>
</property>
<property name="shortcut">
<string>Ctrl+R</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<customwidgets>
<customwidget>
<class>BoundLineEdit</class>
<extends>QLineEdit</extends>
<header>ovgenpy/gui/data_bind.h</header>
</customwidget>
<customwidget>
<class>BoundSpinBox</class>
<extends>QSpinBox</extends>
<header>ovgenpy/gui/data_bind.h</header>
</customwidget>
<customwidget>
<class>BoundDoubleSpinBox</class>
<extends>QDoubleSpinBox</extends>
<header>ovgenpy/gui/data_bind.h</header>
</customwidget>
<customwidget>
<class>BoundComboBox</class>
<extends>QComboBox</extends>
<header>ovgenpy/gui/data_bind.h</header>
</customwidget>
<customwidget>
<class>ShortcutButton</class>
<extends>QPushButton</extends>
<header>ovgenpy/gui/__init__.h</header>
</customwidget>
<customwidget>
<class>ChannelTableView</class>
<extends>QTableView</extends>
<header>ovgenpy/gui/__init__.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

Wyświetl plik

@ -0,0 +1,83 @@
from pathlib import Path
from typing import *
from typing import Iterable, Tuple
import matplotlib.colors
import more_itertools
from PyQt5.QtCore import QMutex
from PyQt5.QtWidgets import QWidget, QFileDialog
from ovgenpy.config import OvgenError
def color2hex(color):
try:
return matplotlib.colors.to_hex(color, keep_alpha=False)
except ValueError:
raise OvgenError(f'invalid color {color}')
except Exception as e:
raise OvgenError(
f'doubly invalid color {color}, raises {e} (report bug!)')
T = TypeVar('T')
class Locked(Generic[T]):
""" Based off https://stackoverflow.com/a/37606669 """
def __init__(self, obj: T):
super().__init__()
self.obj = obj
self.lock = QMutex(QMutex.Recursive)
self.skip_exit = False
def __enter__(self) -> T:
self.lock.lock()
return self.obj
def unlock(self):
# FIXME does it work? i was not thinking clearly when i wrote this
if not self.skip_exit:
self.skip_exit = True
self.lock.unlock()
def __exit__(self, *args, **kwargs):
if self.skip_exit:
self.skip_exit = False
else:
self.lock.unlock()
def set(self, value: T) -> T:
with self:
self.obj = value
return value
def get_save_with_ext(
parent: QWidget, caption: str, dir_or_file: str,
filters: List[str], default_suffix: str
) -> Optional[Path]:
""" On KDE, getSaveFileName does not append extension. This is a workaround. """
name, sel_filter = QFileDialog.getSaveFileName(
parent, caption, dir_or_file, ';;'.join(filters)
)
if name == '':
return None
path = Path(name)
if sel_filter == filters[0] and path.suffix == '':
path = path.with_suffix(default_suffix)
return path
def find_ranges(iterable: Iterable[T]) -> Iterable[Tuple[T, int]]:
"""Extracts consecutive runs from a list of items.
:param iterable: List of items.
:return: Iterable of (first elem, length).
"""
for group in more_itertools.consecutive_groups(iterable):
group = list(group)
yield group[0], len(group)

Wyświetl plik

@ -2,7 +2,7 @@ from typing import Optional, TypeVar, Callable, List
import numpy as np
from ovgenpy.config import register_config
from ovgenpy.config import register_config, OvgenError
from ovgenpy.util import ceildiv
@ -19,7 +19,7 @@ class LayoutConfig:
self.ncols = None
if self.nrows and self.ncols:
raise ValueError('cannot manually assign both nrows and ncols')
raise OvgenError('cannot manually assign both nrows and ncols')
if not self.nrows and not self.ncols:
self.ncols = 1
@ -41,7 +41,7 @@ class RendererLayout:
self.orientation = cfg.orientation
if self.orientation not in self.VALID_ORIENTATIONS:
raise ValueError(f'Invalid orientation {self.orientation} not in '
raise OvgenError(f'Invalid orientation {self.orientation} not in '
f'{self.VALID_ORIENTATIONS}')
def _calc_layout(self):
@ -54,12 +54,13 @@ class RendererLayout:
if cfg.nrows:
nrows = cfg.nrows
if nrows is None:
raise ValueError('invalid cfg: rows_first is True and nrows is None')
raise ValueError('impossible cfg: nrows is None and true')
ncols = ceildiv(self.nplots, nrows)
else:
ncols = cfg.ncols
if ncols is None:
raise ValueError('invalid cfg: rows_first is False and ncols is None')
raise ValueError('invalid LayoutConfig: nrows,ncols is None '
'(__attrs_post_init__ not called?)')
nrows = ceildiv(self.nplots, ncols)
return nrows, ncols

Wyświetl plik

@ -1,4 +1,5 @@
# https://ffmpeg.org/ffplay.html
import numpy as np
import shlex
import subprocess
from abc import ABC, abstractmethod
@ -11,6 +12,7 @@ if TYPE_CHECKING:
from ovgenpy.ovgenpy import Config
ByteBuffer = Union[bytes, np.ndarray]
RGB_DEPTH = 3
PIXEL_FORMAT = 'rgb24'
@ -24,6 +26,13 @@ class IOutputConfig:
return self.cls(ovgen_cfg, cfg=self)
class _Stop:
pass
Stop = _Stop()
class Output(ABC):
def __init__(self, ovgen_cfg: 'Config', cfg: IOutputConfig):
self.ovgen_cfg = ovgen_cfg
@ -38,12 +47,15 @@ class Output(ABC):
return self
@abstractmethod
def write_frame(self, frame: bytes) -> None:
def write_frame(self, frame: ByteBuffer) -> Optional[_Stop]:
""" Output a Numpy ndarray. """
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def terminate(self):
pass
# Glue logic
def register_output(config_t: Type[IOutputConfig]):
@ -117,11 +129,21 @@ class PipeOutput(Output):
def __enter__(self):
return self
def write_frame(self, frame: bytes) -> None:
self._stream.write(frame)
def write_frame(self, frame: ByteBuffer) -> Optional[_Stop]:
try:
self._stream.write(frame)
return None
except BrokenPipeError:
return Stop
def close(self) -> int:
self._stream.close()
def close(self, wait=True) -> int:
try:
self._stream.close()
except (BrokenPipeError, OSError): # BrokenPipeError is a OSError
pass
if not wait:
return 0
retval = 0
for popen in self._pipeline:
@ -132,24 +154,27 @@ class PipeOutput(Output):
if exc_type is None:
self.close()
else:
# Calling self.close() is bad.
# If exception occurred but ffplay continues running.
# popen.wait() will prevent stack trace from showing up.
self._stream.close()
self.terminate()
exc = None
for popen in self._pipeline:
popen.terminate()
# https://stackoverflow.com/a/49038779/2683842
try:
popen.wait(1) # timeout=seconds
except subprocess.TimeoutExpired as e:
# gee thanks Python, https://stackoverflow.com/questions/45292479/
exc = e
popen.kill()
def terminate(self):
# Calling self.close() is bad.
# If exception occurred but ffplay continues running,
# popen.wait() will prevent stack trace from showing up.
self.close(wait=False)
if exc:
raise exc
exc = None
for popen in self._pipeline:
popen.terminate()
# https://stackoverflow.com/a/49038779/2683842
try:
popen.wait(1) # timeout=seconds
except subprocess.TimeoutExpired as e:
# gee thanks Python, https://stackoverflow.com/questions/45292479/
exc = e
popen.kill()
if exc:
raise exc
# FFmpegOutput
@ -198,7 +223,7 @@ class FFplayOutput(PipeOutput):
def __init__(self, ovgen_cfg: 'Config', cfg: FFplayOutputConfig):
super().__init__(ovgen_cfg, cfg)
ffmpeg = _FFmpegProcess([FFMPEG], ovgen_cfg)
ffmpeg = _FFmpegProcess([FFMPEG, '-nostats'], ovgen_cfg)
ffmpeg.add_output(cfg)
ffmpeg.templates.append('-f nut')
@ -211,15 +236,3 @@ class FFplayOutput(PipeOutput):
# assert p2.stdin is None # True unless Popen is being mocked (test_output).
self.open(p1, p2)
# ImageOutput
@register_config
class ImageOutputConfig(IOutputConfig):
path_prefix: str
@register_output(ImageOutputConfig)
class ImageOutput(Output):
pass

Wyświetl plik

@ -5,7 +5,7 @@ from contextlib import ExitStack, contextmanager
from enum import unique, IntEnum
from fractions import Fraction
from types import SimpleNamespace
from typing import Optional, List, Union, TYPE_CHECKING
from typing import Optional, List, Union, TYPE_CHECKING, Callable
import attr
@ -97,7 +97,7 @@ class Config:
subsampling = self._subsampling
self.trigger_subsampling = coalesce(self.trigger_subsampling, subsampling)
self.render_subsampling = coalesce(self.render_subsampling, subsampling)
# Compute trigger_ms and render_ms.
width_ms = self._width_ms
try:
@ -147,24 +147,38 @@ def default_config(**kwargs) -> Config:
return attr.evolve(cfg, **kwargs)
BeginFunc = Callable[[float, float], None]
ProgressFunc = Callable[[int], None]
IsAborted = Callable[[], bool]
@attr.dataclass
class Arguments:
cfg_dir: str
outputs: List[outputs_.IOutputConfig]
on_begin: BeginFunc = lambda begin_time, end_time: None
progress: ProgressFunc = print
is_aborted: IsAborted = lambda: False
on_end: Callable[[], None] = lambda: None
class Ovgen:
def __init__(self, cfg: Config, cfg_dir: str,
outputs: List[outputs_.IOutputConfig]):
def __init__(self, cfg: Config, arg: Arguments):
self.cfg = cfg
self.cfg_dir = cfg_dir
self.arg = arg
self.has_played = False
# TODO test progress and is_aborted
# TODO benchmark_mode/not_benchmarking == code duplication.
benchmark_mode = self.cfg.benchmark_mode
not_benchmarking = not benchmark_mode
if not_benchmarking or benchmark_mode == BenchmarkMode.OUTPUT:
self.output_cfgs = outputs
self.output_cfgs = arg.outputs
else:
self.output_cfgs = []
if len(self.cfg.channels) == 0:
raise ValueError('Config.channels is empty')
raise OvgenError('Config.channels is empty')
waves: List[Wave]
channels: List[Channel]
@ -172,7 +186,7 @@ class Ovgen:
nchan: int
def _load_channels(self):
with pushd(self.cfg_dir):
with pushd(self.arg.cfg_dir):
self.channels = [Channel(ccfg, self.cfg) for ccfg in self.cfg.channels]
self.waves = [channel.wave for channel in self.channels]
self.triggers = [channel.trigger for channel in self.channels]
@ -180,7 +194,7 @@ class Ovgen:
@contextmanager
def _load_outputs(self):
with pushd(self.cfg_dir):
with pushd(self.arg.cfg_dir):
with ExitStack() as stack:
self.outputs = [
stack.enter_context(output_cfg(self.cfg))
@ -204,9 +218,12 @@ class Ovgen:
begin_frame = round(fps * self.cfg.begin_time)
end_frame = fps * coalesce(self.cfg.end_time, self.waves[0].get_s())
end_time = coalesce(self.cfg.end_time, self.waves[0].get_s())
end_frame = fps * end_time
end_frame = int(end_frame) + 1
self.arg.on_begin(self.cfg.begin_time, end_time)
renderer = self._load_renderer()
# region show_internals
@ -256,12 +273,20 @@ class Ovgen:
# For each frame, render each wave
for frame in range(begin_frame, end_frame):
if self.arg.is_aborted():
# Used for FPS calculation
end_frame = frame
for output in self.outputs:
output.terminate()
break
time_seconds = frame / fps
should_render = (frame - begin_frame) % render_subfps == ahead
rounded = int(time_seconds)
if PRINT_TIMESTAMP and rounded != prev:
print(rounded)
self.arg.progress(rounded)
prev = rounded
render_datas = []
@ -296,12 +321,19 @@ class Ovgen:
if not_benchmarking or benchmark_mode >= BenchmarkMode.RENDER:
# Render frame
renderer.render_frame(render_datas)
frame = renderer.get_frame()
frame_data = renderer.get_frame()
if not_benchmarking or benchmark_mode == BenchmarkMode.OUTPUT:
# Output frame
aborted = False
for output in self.outputs:
output.write_frame(frame)
if output.write_frame(frame_data) is outputs_.Stop:
aborted = True
break
if aborted:
# Outputting frame happens after most computation finished.
end_frame = frame + 1
break
if self.raise_on_teardown:
raise self.raise_on_teardown

Wyświetl plik

@ -7,7 +7,7 @@ import attr
from ovgenpy.config import register_config
from ovgenpy.layout import RendererLayout, LayoutConfig
from ovgenpy.outputs import RGB_DEPTH
from ovgenpy.outputs import RGB_DEPTH, ByteBuffer
from ovgenpy.util import coalesce
matplotlib.use('agg')
@ -48,6 +48,7 @@ class LineParam:
color: str
# TODO rename to Plotter
class Renderer(ABC):
def __init__(self, cfg: RendererConfig, lcfg: 'LayoutConfig', nplots: int,
channel_cfgs: Optional[List['ChannelConfig']]):
@ -73,7 +74,7 @@ class Renderer(ABC):
def render_frame(self, datas: List[np.ndarray]) -> None: ...
@abstractmethod
def get_frame(self) -> bytes: ...
def get_frame(self) -> ByteBuffer: ...
class MatplotlibRenderer(Renderer):
@ -183,7 +184,7 @@ class MatplotlibRenderer(Renderer):
self._fig.canvas.draw()
self._fig.canvas.flush_events()
def get_frame(self) -> bytes:
def get_frame(self) -> ByteBuffer:
""" Returns ndarray of shape w,h,3. """
canvas = self._fig.canvas

Wyświetl plik

@ -141,7 +141,7 @@ class CorrelationTriggerConfig(ITriggerConfig):
def _validate_param(self, key: str, begin, end):
value = getattr(self, key)
if not begin <= value <= end:
raise ValueError(
raise OvgenError(
f'Invalid {key}={value} (should be within [{begin}, {end}])')
@ -457,7 +457,7 @@ class LocalPostTrigger(PostTrigger):
# Window data
if cache.period is None:
raise ValueError(
raise OvgenError(
"Missing 'cache.period', try stacking CorrelationTrigger "
"before LocalPostTrigger")

Wyświetl plik

@ -1,4 +1,5 @@
import os
import sys
from contextlib import contextmanager
from itertools import chain
from pathlib import Path
@ -96,3 +97,6 @@ def pushd(new_dir: Union[Path, str]):
finally:
os.chdir(previous_dir)
def perr(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)

Wyświetl plik

@ -6,6 +6,9 @@ from scipy.io import wavfile
# Internal class, not exposed via YAML
from ovgenpy.config import OvgenError
@attr.dataclass
class _WaveConfig:
amplification: float = 1
@ -51,7 +54,7 @@ class Wave:
self.max_val = 1
else:
raise ValueError(f'unexpected wavfile dtype {dtype}')
raise OvgenError(f'unexpected wavfile dtype {dtype}')
def __getitem__(self, index: Union[int, slice]) -> 'np.ndarray[FLOAT]':
""" Copies self.data[item], converted to a FLOAT within range [-1, 1). """

Wyświetl plik

@ -11,8 +11,10 @@ setup(
description='',
tests_require=['pytest>=3.2.0', 'pytest-pycharm', 'hypothesis', 'delayed-assert'],
install_requires=[
'numpy', 'scipy', 'click', 'ruamel.yaml',
'ruamel.yaml>=0.15.70', # See test_config.py to pick a suitable minimum version
'numpy', 'scipy', 'click', 'more_itertools',
'matplotlib',
'attrs>=18.2.0',
'PyQt5',
]
)

Wyświetl plik

@ -10,7 +10,7 @@ import ovgenpy.channel
import ovgenpy.ovgenpy
from ovgenpy.channel import ChannelConfig, Channel
from ovgenpy.config import OvgenError
from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode
from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode, Arguments
from ovgenpy.triggers import NullTriggerConfig
from ovgenpy.util import coalesce
@ -124,7 +124,7 @@ def test_config_channel_width_stride(
assert trigger._stride == channel.trigger_stride
## Ensure ovgenpy calls render using channel.render_samp and render_stride.
ovgen = Ovgen(cfg, '.', outputs=[])
ovgen = Ovgen(cfg, Arguments(cfg_dir='.', outputs=[]))
renderer = mocker.patch.object(Ovgen, '_load_renderer').return_value
ovgen.play()

Wyświetl plik

@ -12,7 +12,7 @@ from ovgenpy import cli
from ovgenpy.cli import YAML_NAME
from ovgenpy.config import yaml
from ovgenpy.outputs import FFmpegOutputConfig
from ovgenpy.ovgenpy import Config, Ovgen
from ovgenpy.ovgenpy import Config, Ovgen, Arguments
from ovgenpy.util import pushd
if TYPE_CHECKING:
@ -132,7 +132,7 @@ def test_load_yaml_another_dir(yaml_sink, mocker, Popen):
# Issue: this test does not use cli.main() to compute output path.
# Possible solution: Call cli.main() via Click runner.
output = FFmpegOutputConfig(cli.get_path(cfg.master_audio, cli.VIDEO_NAME))
ovgen = Ovgen(cfg, subdir, [output])
ovgen = Ovgen(cfg, Arguments(subdir, [output]))
ovgen.play()
# Compute absolute paths

Wyświetl plik

@ -1,7 +1,7 @@
import pytest
from ruamel.yaml import yaml_object
from ovgenpy.config import register_config, yaml, Alias, Ignored, kw_config
from ovgenpy.config import register_config, yaml, Alias, Ignored, kw_config, OvgenError
# YAML Idiosyncrasies: https://docs.saltstack.com/en/develop/topics/troubleshooting/yaml_idiosyncrasies.html
@ -163,7 +163,7 @@ xx: 1
x: 1
xx: 1
'''
with pytest.raises(TypeError):
with pytest.raises(OvgenError):
yaml.load(s)
@ -229,3 +229,68 @@ def test_load_post_init():
foo: 0
'''
assert yaml.load(s) == Foo(99)
# ruamel.yaml has a unstable and shape-shifting API.
# Test which version numbers have properties we want.
def test_dump_dataclass_order():
@register_config(always_dump='*')
class Config:
a: int = 1
b: int = 1
c: int = 1
d: int = 1
e: int = 1
z: int = 1
y: int = 1
x: int = 1
w: int = 1
v: int = 1
assert yaml.dump(Config()) == '''\
!Config
a: 1
b: 1
c: 1
d: 1
e: 1
z: 1
y: 1
x: 1
w: 1
v: 1
'''
def test_load_dump_dict_order():
s = '''\
a: 1
b: 1
c: 1
d: 1
e: 1
z: 1
y: 1
x: 1
w: 1
v: 1
'''
dic = yaml.load(s)
assert yaml.dump(dic) == s, yaml.dump(dic)
def test_load_list_dict_type():
"""Fails on ruamel.yaml<0.15.70 (CommentedMap/CommentedSeq)."""
dic = yaml.load('{}')
assert isinstance(dic, dict)
lis = yaml.load('[]')
assert isinstance(lis, list)
def test_list_slice_assign():
"""Crashes on ruamel.yaml<0.15.55 (CommentedSeq)."""
lis = yaml.load('[]')
lis[0:0] = list(range(5))
lis[2:5] = []

Wyświetl plik

@ -0,0 +1,55 @@
import pytest
from ovgenpy.gui.data_bind import rgetattr, rsetattr, rhasattr
def test_rgetattr():
""" Test to ensure recursive model access works.
GUI elements are named "prefix__" "recursive__attr" and bind to recursive.attr.
https://stackoverflow__com/a/31174427/
"""
class Person(object):
def __init__(self):
self.pet = Pet()
self.residence = Residence()
class Pet(object):
def __init__(self, name='Fido', species='Dog'):
self.name = name
self.species = species
class Residence(object):
def __init__(self, type='House', sqft=None):
self.type = type
self.sqft = sqft
p = Person()
# Test rgetattr(present)
assert rgetattr(p, 'pet__species') == 'Dog'
assert rgetattr(p, 'pet__species', object()) == 'Dog'
# Test rgetattr(missing)
assert rgetattr(p, 'pet__ghost__species', 'calico') == 'calico'
with pytest.raises(AttributeError):
# Without a default argument, `rgetattr`, like `getattr`, raises
# AttributeError when the dotted attribute is missing
print(rgetattr(p, 'pet__ghost__species'))
# Test rsetattr()
rsetattr(p, 'pet__name', 'Sparky')
rsetattr(p, 'residence__type', 'Apartment')
assert p.pet.name == 'Sparky'
assert p.residence.type == 'Apartment'
# Test rhasattr()
assert rhasattr(p, 'pet')
assert rhasattr(p, 'pet__name')
# Test rhasattr(levels of missing)
assert not rhasattr(p, 'pet__ghost')
assert not rhasattr(p, 'pet__ghost__species')
assert not rhasattr(p, 'ghost')
assert not rhasattr(p, 'ghost__species')

Wyświetl plik

@ -7,7 +7,7 @@ import pytest
from ovgenpy.channel import ChannelConfig
from ovgenpy.outputs import RGB_DEPTH, \
FFmpegOutput, FFmpegOutputConfig, FFplayOutput, FFplayOutputConfig
from ovgenpy.ovgenpy import default_config, Ovgen
from ovgenpy.ovgenpy import default_config, Ovgen, Arguments
from ovgenpy.renderer import RendererConfig, MatplotlibRenderer
from tests.test_renderer import WIDTH, HEIGHT, ALL_ZEROS
@ -90,7 +90,7 @@ def test_ovgen_terminate_ffplay(Popen, mocker: 'pytest_mock.MockFixture'):
Python exceptions occur. """
cfg = sine440_config()
ovgen = Ovgen(cfg, '.', outputs=[FFplayOutputConfig()])
ovgen = Ovgen(cfg, Arguments('.', [FFplayOutputConfig()]))
render_frame = mocker.patch.object(MatplotlibRenderer, 'render_frame')
render_frame.side_effect = DummyException()
@ -110,7 +110,7 @@ def test_ovgen_terminate_works():
`popen.terminate()` is called. """
cfg = sine440_config()
ovgen = Ovgen(cfg, '.', outputs=[FFplayOutputConfig()])
ovgen = Ovgen(cfg, Arguments('.', [FFplayOutputConfig()]))
ovgen.raise_on_teardown = DummyException
with pytest.raises(DummyException):
@ -127,7 +127,7 @@ def test_ovgen_output_without_audio():
cfg = sine440_config()
cfg.master_audio = None
ovgen = Ovgen(cfg, '.', outputs=[NULL_OUTPUT])
ovgen = Ovgen(cfg, Arguments('.', [NULL_OUTPUT]))
# Should not raise exception.
ovgen.play()
@ -157,7 +157,7 @@ def test_render_subfps_one():
cfg = sine440_config()
cfg.render_subfps = 1
ovgen = Ovgen(cfg, '.', outputs=[DummyOutputConfig()])
ovgen = Ovgen(cfg, Arguments('.', [DummyOutputConfig()]))
ovgen.play()
assert DummyOutput.frames_written >= 2
@ -177,7 +177,7 @@ def test_render_subfps_non_integer(mocker: 'pytest_mock.MockFixture'):
assert cfg.render_fps != int(cfg.render_fps)
assert Fraction(1) == int(1)
ovgen = Ovgen(cfg, '.', outputs=[NULL_OUTPUT])
ovgen = Ovgen(cfg, Arguments('.', [NULL_OUTPUT]))
ovgen.play()
# But it seems FFmpeg actually allows decimal -framerate (although a bad idea).