diff --git a/.gitignore b/.gitignore index 30ae261..d97f36b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__ .mypy_cache .nox .coverage +*.egg-info # local environment setup .env diff --git a/requirements.txt b/requirements.txt index 8bc75c2..924e62c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,10 @@ +Click==7.0 cycler==0.10.0 +Flask==1.1.1 +itsdangerous==1.1.0 +Jinja2==2.10.3 kiwisolver==1.1.0 +MarkupSafe==1.1.1 matplotlib==3.1.2 numpy==1.17.4 Pillow==6.2.1 @@ -13,4 +18,5 @@ six==1.13.0 TextGrid==1.5 tqdm==4.40.2 ttkthemes==2.4.0 +Werkzeug==0.16.0 xparser==0.0.4 diff --git a/setup.py b/setup.py index f7fd524..bd29eef 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ def get_requirement(line): "pytest-mock", "boto3", "mypy-boto3-s3", + "typeshed", # flask-stubs ] }, ) diff --git a/ultratrace2/__main__.py b/ultratrace2/__main__.py index a5138f5..6583811 100644 --- a/ultratrace2/__main__.py +++ b/ultratrace2/__main__.py @@ -6,7 +6,7 @@ logging.basicConfig(level=logging.DEBUG) -def main(): +def main() -> None: parser = argparse.ArgumentParser(prog="ultratrace") @@ -67,7 +67,7 @@ def main(): args = parser.parse_args() - app = initialize_app(headless=args.headless, path=args.path, theme=args.theme) + app = initialize_app(args.path, headless=args.headless, theme=args.theme) app.main() diff --git a/ultratrace2/app.py b/ultratrace2/app.py index 32fae47..6e7b9e0 100644 --- a/ultratrace2/app.py +++ b/ultratrace2/app.py @@ -1,28 +1,16 @@ from tkinter.filedialog import askdirectory as choose_dir from typing import Optional -from .gui import GUI from .model.project import Project class App: def __init__( - self, - headless: bool = False, - path: Optional[str] = None, - theme: Optional[str] = None, + self, path: str, headless: bool = False, theme: Optional[str] = None, ): - if path is None and not headless: - path = choose_dir() - if not path: - raise ValueError("You must choose a directory to open") - self.project: Project = Project.get_by_path(path) - if not headless: - self.gui = GUI(theme=theme) - def main(self) -> None: pass @@ -32,9 +20,9 @@ def main(self) -> None: def initialize_app( - headless: bool = False, path: Optional[str] = None, theme: Optional[str] = None + path: str, headless: bool = False, theme: Optional[str] = None ) -> App: global app - app = App(headless=headless, path=path, theme=theme,) + app = App(path, headless=headless, theme=theme,) return app diff --git a/ultratrace2/gui/__init__.py b/ultratrace2/gui/__init__.py deleted file mode 100644 index bda78d3..0000000 --- a/ultratrace2/gui/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -import logging - -from typing import Optional - -from .themes import ThemedTk, get_theme -from .widgets import ALIGN_HORIZONTAL, ALIGN_VERTICAL -from .widgets.audio import Audio -from .widgets.container import Container -from .widgets.control import Control -from .widgets.dicom import Dicom -from .widgets.spectrogram import Spectrogram -from .widgets.textgrid import TextGrid -from .widgets.trace import Trace -from .widgets.undo import Undo -from .widgets.video import Video - - -logger = logging.getLogger(__name__) - - -class GUI(ThemedTk): - def __init__(self, theme: Optional[str] = None): - - self.audio = Audio() - self.control = Control() - self.dicom = Dicom() - self.spectrogram = Spectrogram() - self.textgrid = TextGrid() - self.trace = Trace() - self.undo = Undo() - self.video = Video() - - self.root = Container( - ALIGN_VERTICAL, - Container( - ALIGN_HORIZONTAL, - Container(ALIGN_VERTICAL, self.control, self.trace, self.undo,), - self.dicom, - ), - Container(ALIGN_VERTICAL, self.spectrogram, self.textgrid,), - ) - - if hasattr(super(), "set_theme"): - theme = get_theme(theme) - if theme is not None: - logger.info("Using TtkTheme: " + theme) - super().__init__(theme=theme) - else: - super().__init__() - else: - super().__init__() diff --git a/ultratrace2/gui/themes.py b/ultratrace2/gui/themes.py deleted file mode 100644 index 233045a..0000000 --- a/ultratrace2/gui/themes.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging -import os -import platform - -from typing import Optional - -logger = logging.getLogger(__name__) - -try: - from ttkthemes import ThemedTk # type: ignore -except ImportError: - logger.warning("Unable to load themes") - from tkinter import Tk as ThemedTk # noqa: F401 - - -def get_theme(name: Optional[str]) -> Optional[str]: - if name is not None: - return name - - if platform.system() == "Linux": - try: - - import xrp # type: ignore - - Xresources_path = os.path.join(os.environ["HOME"], ".Xresources") - if os.path.exists(Xresources_path): - Xresources = xrp.parse_file(Xresources_path) - if "*TtkTheme" in Xresources.resources: - return Xresources.resources["*TtkTheme"] - if "*TkTheme" in Xresources.resources: - return Xresources.resources["*TkTheme"] - return "clam" - - except Exception as e: - logger.warning("Error loading themes: " + str(e)) - - return None diff --git a/ultratrace2/gui/widgets/__init__.py b/ultratrace2/gui/widgets/__init__.py deleted file mode 100644 index 2a1d74a..0000000 --- a/ultratrace2/gui/widgets/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -ALIGN_HORIZONTAL = "horiz" -ALIGN_VERTICAL = "vert" - - -class Widget: - def __init__(self, align=ALIGN_VERTICAL, children=[]): - - if align not in (ALIGN_HORIZONTAL, ALIGN_VERTICAL): - raise ValueError("Unknown alignment: " + align) - self.align = align - self.children = children - - def is_loaded(self): - return True - - def __iter__(self): - for child in self.children: - yield child - - def __len__(self): - return len(self.children) - - -class OptionalWidget(Widget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.is_imported = False - - def is_loaded(self): - return self.is_imported diff --git a/ultratrace2/gui/widgets/audio.py b/ultratrace2/gui/widgets/audio.py deleted file mode 100644 index 6fac49f..0000000 --- a/ultratrace2/gui/widgets/audio.py +++ /dev/null @@ -1,22 +0,0 @@ -import logging - -from . import OptionalWidget - - -logger = logging.getLogger(__name__) - - -class Audio(OptionalWidget): - def __init__(self): - super().__init__() - - # FIXME: allow disabling of widgets via command line args - - try: - import pyaudio # type: ignore # noqa: F401 - from pydub import AudioSegment # type: ignore # noqa: F401 - - self.is_imported = True - except ImportError: - logger.warning("Audio Widget failed to load") - return diff --git a/ultratrace2/gui/widgets/container.py b/ultratrace2/gui/widgets/container.py deleted file mode 100644 index 04c0571..0000000 --- a/ultratrace2/gui/widgets/container.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import Widget - - -class Container(Widget): - def __init__(self, align, *children): - super().__init__(align=align, children=children) diff --git a/ultratrace2/gui/widgets/control.py b/ultratrace2/gui/widgets/control.py deleted file mode 100644 index 9c4c397..0000000 --- a/ultratrace2/gui/widgets/control.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import Widget - - -class Control(Widget): - def __init__(self): - super().__init__() diff --git a/ultratrace2/gui/widgets/dicom.py b/ultratrace2/gui/widgets/dicom.py deleted file mode 100644 index 0d39b0f..0000000 --- a/ultratrace2/gui/widgets/dicom.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging - -from . import OptionalWidget - - -logger = logging.getLogger(__name__) - - -class Dicom(OptionalWidget): - def __init__(self): - super().__init__() - - # FIXME: allow passing command line arg to turn off DICOM viewer - - try: - import numpy as np # noqa: F401 - import pydicom as dicom # type: ignore # noqa: F401 - from PIL import Image, ImageTk, ImageEnhance # type: ignore # noqa: F401 - - self.is_imported = True - except ImportError: - logger.warning("Dicom Widget failed to load") - return diff --git a/ultratrace2/gui/widgets/div.py b/ultratrace2/gui/widgets/div.py deleted file mode 100644 index a07ebbf..0000000 --- a/ultratrace2/gui/widgets/div.py +++ /dev/null @@ -1,12 +0,0 @@ -import tkinter as tk - - -class Div(tk.Frame): - def __init__(self, parent, children=[], sticky=""): - super().__init__(parent) - - def grid(self, *args, **kwargs): - super().grid(*args, **kwargs) - - def grid_remove(self): - super().grid_remove() diff --git a/ultratrace2/gui/widgets/filedialog.py b/ultratrace2/gui/widgets/filedialog.py deleted file mode 100644 index 47fcdf9..0000000 --- a/ultratrace2/gui/widgets/filedialog.py +++ /dev/null @@ -1,11 +0,0 @@ -from tkinter import filedialog as TkFileDialog - -from . import Widget - - -class FileDialog(Widget): - def __init__(self): - super().__init__() - - def ask(self, *args, **kwargs): - return TkFileDialog.askdirectory(*args, **kwargs) diff --git a/ultratrace2/gui/widgets/frame.py b/ultratrace2/gui/widgets/frame.py deleted file mode 100644 index fda4e43..0000000 --- a/ultratrace2/gui/widgets/frame.py +++ /dev/null @@ -1,5 +0,0 @@ -import tkinter as tk - - -class Frame(tk.Frame): - pass diff --git a/ultratrace2/gui/widgets/spectrogram.py b/ultratrace2/gui/widgets/spectrogram.py deleted file mode 100644 index 5c58566..0000000 --- a/ultratrace2/gui/widgets/spectrogram.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging - -from . import OptionalWidget - - -logger = logging.getLogger(__name__) - - -class Spectrogram(OptionalWidget): - def __init__(self): - super().__init__() - - # FIXME: allow passing command line arg to turn off spectrogram viewer - - try: - self.is_imported = True - except ImportError: - logger.warning("Spectrogram Widget failed to load") - return diff --git a/ultratrace2/gui/widgets/textgrid.py b/ultratrace2/gui/widgets/textgrid.py deleted file mode 100644 index 79b8316..0000000 --- a/ultratrace2/gui/widgets/textgrid.py +++ /dev/null @@ -1,21 +0,0 @@ -import logging - -from . import OptionalWidget - - -logger = logging.getLogger(__name__) - - -class TextGrid(OptionalWidget): - def __init__(self): - super().__init__() - - # FIXME: allow passing command line arg to turn off textgrid viewer - - try: - from textgrid import TextGrid # type: ignore # noqa: F401 - - self.is_imported = True - except ImportError: - logger.warning("TextGrid Widget failed to load") - return diff --git a/ultratrace2/gui/widgets/trace.py b/ultratrace2/gui/widgets/trace.py deleted file mode 100644 index bfcad64..0000000 --- a/ultratrace2/gui/widgets/trace.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import Widget - - -class Trace(Widget): - def __init__(self): - super().__init__() diff --git a/ultratrace2/gui/widgets/undo.py b/ultratrace2/gui/widgets/undo.py deleted file mode 100644 index 9c27c0f..0000000 --- a/ultratrace2/gui/widgets/undo.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import Widget - - -class Undo(Widget): - def __init__(self): - super().__init__() diff --git a/ultratrace2/gui/widgets/video.py b/ultratrace2/gui/widgets/video.py deleted file mode 100644 index 0d67b3a..0000000 --- a/ultratrace2/gui/widgets/video.py +++ /dev/null @@ -1,22 +0,0 @@ -import logging - -from . import OptionalWidget - - -logger = logging.getLogger(__name__) - - -class Video(OptionalWidget): - def __init__(self): - super().__init__() - - # FIXME: allow passing command line arg to turn off video viewer - - try: - import threading # noqa: F401 - import queue # noqa: F401 - - self.is_imported = True - except ImportError: - logger.warning("Video Widget failed to load") - return diff --git a/ultratrace2/gui/widgets/xhair.py b/ultratrace2/gui/widgets/xhair.py deleted file mode 100644 index 86d5eff..0000000 --- a/ultratrace2/gui/widgets/xhair.py +++ /dev/null @@ -1,95 +0,0 @@ -from uuid import uuid4 - - -class XHair: - SELECTED_COLOR = "blue" - SELECTED_WIDTH = 1.5 - UNSELECTED_WIDTH = 1 - RADIUS = 10 - - def __init__(self, canvas, trace, x, y, **kwargs): - - self.id = uuid4() - self.canvas = canvas - self.trace = trace - self.x = x - self.y = y - - self.is_selected = False - self.is_hidden = False - - self.h_line = self.canvas.create_line( - x - 10, - y, - x + 10, - y, - tag=self.id, - width=self.get_width(), - fill=self.get_color(), - ) - self.v_line = self.canvas.create_line( - x, - y - 10, - x, - y + 10, - tag=self.id, - width=self.get_width(), - fill=self.get_color(), - ) - - def sq_dist_from(self, other): - # Euclidean distance squared, since sqrt() is relatively slow :^) - if isinstance(other, XHair): - dx = self.x - other.x - dy = self.y - other.y - elif isinstance(other, tuple): - dx = self.x - other[0] - dy = self.y - other[1] - return (dx ** 2) + (dy ** 2) - - def __repr__(self): - return f"XHair(id={self.id}, x={self.x}, y={self.y})" - - def get_width(self): - return self.SELECTED_WIDTH if self.is_selected else self.UNSELECTED_WIDTH - - def get_color(self): - return self.SELECTED_COLOR if self.is_selected else self.trace.get_color() - - def get_state(self): - return "hidden" if self.is_hidden else "normal" - - def toggle_select(self): - self.is_selected = not self.is_selected - self.redraw() - - def select(self): - self.is_selected = True - self.redraw() - - def unselect(self): - self.is_selected = False - self.redraw() - - def show(self): - self.is_hidden = False - self.redraw() - - def hide(self): - self.is_hidden = True - self.redraw() - - def move(self, x, y): - dx = x - self.x - dy = y - self.y - self.canvas.move(self.id, dx, dy) - self.x = x - self.y = y - - def redraw(self): - self.canvas.itemconfig( - self.id, - width=self.get_width(), - fill=self.get_color(), - state=self.get_state(), - ) diff --git a/ultratrace2/gui/widgets/zoom_frame.py b/ultratrace2/gui/widgets/zoom_frame.py deleted file mode 100644 index 08bcdfe..0000000 --- a/ultratrace2/gui/widgets/zoom_frame.py +++ /dev/null @@ -1,146 +0,0 @@ -import tkinter as tk -from tkinter import ttk - -from PIL import Image, ImageTk # type: ignore - -from .xhair import XHair - - -class Trace: - def get_color(self): - return "black" - - -class ZoomFrame(tk.Frame): - """ - Valid **kwargs for tk.Frame: - - background (bg) - - borderwidth (bd) - - colormap - - container - - cursor - - height - - highlightbackground - - highlightcolor - - highlightthickness - - relief - - takefocus - - visual - - width - """ - - DEFAULT_WIDTH = 800 - DEFAULT_HEIGHT = 600 - MIN_ZOOM = -5 - MAX_ZOOM = 5 - - def __init__(self, master, **kwargs): - - super().__init__(master, **kwargs) - self.canvas = tk.Canvas( - self, - bg="grey", - width=self.DEFAULT_WIDTH, - height=self.DEFAULT_HEIGHT, - highlightthickness=0, - ) - self.canvas.bind("", self.on_click) - self.canvas.bind("", self.on_mousemove) - self.canvas.bind("", self.on_release) - - self.image_container = None - self.image = None - self.zoom = 0 - - ### - self.delta = 1.3 - self.canvas_width = 800 - self.width = 0 - self.canvas_height = 600 - self.height = 0 - self.shown = False - self.aspect_ratio = 4 / 3 - self.orig_x = self.canvas.xview()[0] - 1 - self.orig_y = self.canvas.yview()[0] - 150 - self.image_scale = 1.0 - self.pan_start_x = 0 - self.pan_x = 0 - self.pan_start_y = 0 - self.pan_y = 0 - - self.zoom_in_button = ttk.Button(master, text="zoom in", command=self.zoom_in) - self.zoom_in_button.grid(column=1, row=0) - - self.zoom_out_button = ttk.Button( - master, text="zoom out", command=self.zoom_out - ) - self.zoom_out_button.grid(column=1, row=1) - ### - - self.set_image( - Image.open( - "/Users/user/Pictures/Photo Booth Library/Pictures/Photo on 6-28-19 at 10.10 AM.jpg" - ) - ) - self.is_dragging = False - - self.xhairs = {} # Dict - - def set_image(self, image): - - self.image_container = self.canvas.create_rectangle( - 0, 0, 0, 0, tags="container" - ) - container_x0, container_y0, container_x1, container_y1 = self.canvas.bbox( - self.image_container - ) - - self.image = image.resize( - (container_x1 - container_x0, container_y1 - container_y0) - ) - - # We need to keep a reference around to prevent Python from garbage-collecting - # it from underneath us. - self.image_tk = ImageTk.PhotoImage(image) - - self.canvas.create_image(0, 0, image=self.image_tk, tags="image") - self.canvas.lower("image") - - def on_click(self, event): - # FIXME: handle Shift+Click - print(event) - click_position = (self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)) - closest_xhair = None - closest_xhair_sq_dist = float("inf") - for xhair in self.xhairs.values(): - sq_dist = xhair.sq_dist_from(click_position) - print(xhair, sq_dist) - if sq_dist < (XHair.RADIUS ** 2) and sq_dist < closest_xhair_sq_dist: - closest_xhair = xhair - closest_xhair_sq_dist = sq_dist - - if closest_xhair is None: - xhair = XHair(self.canvas, Trace(), event.x, event.y) - self.xhairs[xhair.id] = xhair - else: - closest_xhair.toggle_select() - - def on_mousemove(self, event): - print(event) - - def on_release(self, event): - print(event) - - def zoom_in(self): - pass - - def zoom_out(self): - pass - - def grid(self, **kwargs): - super(ZoomFrame, self).grid(**kwargs) - self.canvas.grid(sticky="news", column=0, row=0, rowspan=2) - - def grid_remove(self): - super().grid_remove() - self.canvas.grid_remove() diff --git a/ultratrace2/model/files/__init__.py b/ultratrace2/model/files/__init__.py index bf613f7..4cbbe39 100644 --- a/ultratrace2/model/files/__init__.py +++ b/ultratrace2/model/files/__init__.py @@ -7,7 +7,7 @@ try: - from .loaders import DICOMLoader + from .loaders.dicom import DICOMLoader __register( [".dicom", ".dcm"], ["application/dicom"], DICOMLoader, @@ -16,7 +16,7 @@ logger.warning(e) try: - from .loaders import FLACLoader + from .loaders.flac import FLACLoader __register( [".flac"], ["audio/flac"], FLACLoader, @@ -25,7 +25,7 @@ logger.warning(e) try: - from .loaders import MeasurementLoader + from .loaders.measurement import MeasurementLoader __register( [], [], MeasurementLoader, @@ -34,7 +34,7 @@ logger.warning(e) try: - from .loaders import MP3Loader + from .loaders.mp3 import MP3Loader __register( [".mp3"], ["audio/mp3", "audio/MPA", "audio/mpa-robust"], MP3Loader, @@ -43,7 +43,7 @@ logger.warning(e) try: - from .loaders import OggLoader + from .loaders.ogg import OggLoader __register( [".ogg", ".oga", ".spx"], ["audio/ogg"], OggLoader, @@ -52,7 +52,7 @@ logger.warning(e) try: - from .loaders import TextGridLoader + from .loaders.textgrid import TextGridLoader __register( [".textgrid"], ["text/plain"], TextGridLoader, @@ -61,7 +61,7 @@ logger.warning(e) try: - from .loaders import WAVLoader + from .loaders.wav import WAVLoader __register( [".wav"], ["audio/x-wav", "audio/wav"], WAVLoader, diff --git a/ultratrace2/model/files/bundle.py b/ultratrace2/model/files/bundle.py index 7bf73ae..b157e4c 100644 --- a/ultratrace2/model/files/bundle.py +++ b/ultratrace2/model/files/bundle.py @@ -50,7 +50,7 @@ def set_sound_file(self, sound_file: SoundFileLoader) -> None: logger.warning("Overwriting existing sound file") self.sound_file = sound_file - def __repr__(self): + def __repr__(self) -> str: return f'Bundle("{self.name}",{self.alignment_file},{self.image_set_file},{self.sound_file})' diff --git a/ultratrace2/model/files/loaders/__init__.py b/ultratrace2/model/files/loaders/__init__.py index c27a3dd..e69de29 100644 --- a/ultratrace2/model/files/loaders/__init__.py +++ b/ultratrace2/model/files/loaders/__init__.py @@ -1,12 +0,0 @@ -# alignment files -from .measurement import MeasurementLoader # noqa: F401 -from .textgrid import TextGridLoader # noqa: F401 - -# imageset files -from .dicom import DICOMLoader # noqa: F401 - -# sound files -from .flac import FLACLoader # noqa: F401 -from .mp3 import MP3Loader # noqa: F401 -from .ogg import OggLoader # noqa: F401 -from .wav import WAVLoader # noqa: F401 diff --git a/ultratrace2/model/files/loaders/base.py b/ultratrace2/model/files/loaders/base.py index 99b4b6e..caf5670 100644 --- a/ultratrace2/model/files/loaders/base.py +++ b/ultratrace2/model/files/loaders/base.py @@ -22,10 +22,10 @@ def get_path(self) -> str: ... @abstractmethod - def set_path(self, path) -> None: + def set_path(self, path: str) -> None: ... - def __repr__(self): + def __repr__(self) -> str: return f"{type(self).__name__}({self.get_path()})" @classmethod diff --git a/ultratrace2/model/files/loaders/dicom.py b/ultratrace2/model/files/loaders/dicom.py index 8887a09..573a5e7 100644 --- a/ultratrace2/model/files/loaders/dicom.py +++ b/ultratrace2/model/files/loaders/dicom.py @@ -16,7 +16,7 @@ class DICOMLoader(ImageSetFileLoader): def get_path(self) -> str: return self._path - def set_path(self, path) -> None: + def set_path(self, path: str) -> None: self._path = path def __init__(self, path: str, pixels: np.ndarray): @@ -98,6 +98,6 @@ def from_file(cls, path: str) -> "DICOMLoader": f"Invalid DICOM ({path}), unable to read: {str(e)}" ) from e - def convert_to_png(self, *args, **kwargs): + def convert_to_png(self) -> None: # FIXME: implement this as a helper function raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/measurement.py b/ultratrace2/model/files/loaders/measurement.py index af63979..e3f7858 100644 --- a/ultratrace2/model/files/loaders/measurement.py +++ b/ultratrace2/model/files/loaders/measurement.py @@ -9,7 +9,7 @@ class MeasurementLoader(AlignmentFileLoader): def get_path(self) -> str: return self._path - def set_path(self, path) -> None: + def set_path(self, path: str) -> None: self._path = path def __init__(self, path: str): diff --git a/ultratrace2/model/files/loaders/pydub.py b/ultratrace2/model/files/loaders/pydub.py index 470878c..e2a0006 100644 --- a/ultratrace2/model/files/loaders/pydub.py +++ b/ultratrace2/model/files/loaders/pydub.py @@ -7,7 +7,7 @@ class PydubLoader(SoundFileLoader): def get_path(self) -> str: return self._path - def set_path(self, path) -> None: + def set_path(self, path: str) -> None: self._path = path def __init__(self, path: str, audio_segment: pydub.AudioSegment): diff --git a/ultratrace2/model/files/loaders/textgrid.py b/ultratrace2/model/files/loaders/textgrid.py index ca10d15..ad45e59 100644 --- a/ultratrace2/model/files/loaders/textgrid.py +++ b/ultratrace2/model/files/loaders/textgrid.py @@ -16,13 +16,13 @@ def __init__(self, tg_interval: textgrid.Interval): self.tg_interval = tg_interval def get_start(self) -> float: - return self.tg_interval.minTime + return float(self.tg_interval.minTime) def get_end(self) -> float: - return self.tg_interval.maxTime + return float(self.tg_interval.maxTime) def get_contents(self) -> str: - return self.tg_interval.mark + return str(self.tg_interval.mark) def __bool__(self) -> bool: return bool(self.get_contents()) @@ -32,7 +32,7 @@ class TextGridLoader(AlignmentFileLoader): def get_path(self) -> str: return self._path - def set_path(self, path) -> None: + def set_path(self, path: str) -> None: self._path = path def __init__(self, path: str, tg_data: textgrid.TextGrid): @@ -41,7 +41,7 @@ def __init__(self, path: str, tg_data: textgrid.TextGrid): self.offset = 0.0 def get_tier_names(self) -> Sequence[str]: - return self.tg_data.getNames() + return [str(n) for n in self.tg_data.getNames()] def get_intervals(self) -> Intervals: all_intervals = [] @@ -58,10 +58,10 @@ def get_intervals(self) -> Intervals: return all_intervals def get_start(self) -> float: - return self.tg_data.minTime + self.offset + return float(self.tg_data.minTime + self.offset) def get_end(self) -> float: - return self.tg_data.maxTime + self.offset + return float(self.tg_data.maxTime + self.offset) def get_offset(self) -> float: return self.offset diff --git a/ultratrace2/model/project.py b/ultratrace2/model/project.py index 9345ab9..c4e61e2 100644 --- a/ultratrace2/model/project.py +++ b/ultratrace2/model/project.py @@ -16,7 +16,7 @@ def __init__(self, traces: TraceList, files: FileBundleList): self.traces = traces self.files = files - def save(self): + def save(self) -> None: raise NotImplementedError() @classmethod @@ -66,13 +66,13 @@ def get_save_file(path: str) -> str: save_dir = Project.get_save_dir(path) return os.path.join(save_dir, "project.pkl") - def filepath(self): + def filepath(self) -> None: raise NotImplementedError() - def current_trace(self): + def current_trace(self) -> None: raise NotImplementedError() - def current_frame(self): + def current_frame(self) -> None: raise NotImplementedError() def has_alignment_impl(self) -> bool: diff --git a/ultratrace2/model/trace.py b/ultratrace2/model/trace.py index f2f4d29..f39e1e4 100644 --- a/ultratrace2/model/trace.py +++ b/ultratrace2/model/trace.py @@ -55,7 +55,7 @@ def add_xhair(self, bundle: "FileBundle", frame: int, x: float, y: float) -> Non class TraceList: - def __init__(self): + def __init__(self) -> None: self.traces: OrderedDict[UUID, Trace] = OrderedDict() default_trace = self.add_trace( Trace.DEFAULT_TRACE_NAME, Trace.DEFAULT_TRACE_COLOR, diff --git a/ultratrace2/model/xhair.py b/ultratrace2/model/xhair.py index 87c3064..e7a79c9 100644 --- a/ultratrace2/model/xhair.py +++ b/ultratrace2/model/xhair.py @@ -25,7 +25,7 @@ def sq_dist_from(self, other: Union["XHair", Tuple[float, float]]) -> float: dy = self.y - other[1] return (dx ** 2) + (dy ** 2) - def __repr__(self): + def __repr__(self) -> str: return f"XHair(id={self.id}, x={self.x}, y={self.y})" def toggle_select(self) -> None: @@ -37,10 +37,10 @@ def select(self) -> None: def unselect(self) -> None: self.is_selected = False - def show(self): + def show(self) -> None: self.is_hidden = False - def hide(self): + def hide(self) -> None: self.is_hidden = True def move(self, x: float, y: float) -> None: diff --git a/ultratrace2/server/__init__.py b/ultratrace2/server/__init__.py new file mode 100644 index 0000000..3d4cd3f --- /dev/null +++ b/ultratrace2/server/__init__.py @@ -0,0 +1,9 @@ +from flask import Flask + + +app = Flask(__name__) + +# server config goes here +print(__name__) + +from . import views diff --git a/ultratrace2/server/project_cache.py b/ultratrace2/server/project_cache.py new file mode 100644 index 0000000..7d0eba2 --- /dev/null +++ b/ultratrace2/server/project_cache.py @@ -0,0 +1,8 @@ +from functools import lru_cache + +from ..model.project import Project + + +@lru_cache(maxsize=16) +def get_project_by_path(path: str) -> Project: + return Project.get_by_path(path) diff --git a/ultratrace2/server/validate.py b/ultratrace2/server/validate.py new file mode 100644 index 0000000..89c3121 --- /dev/null +++ b/ultratrace2/server/validate.py @@ -0,0 +1,78 @@ +from typing import Any, Sequence, Type, TYPE_CHECKING, TypeVar, Union +from werkzeug.datastructures import ImmutableMultiDict + +from .project_cache import get_project_by_path +from ..model.color import Color +from ..model.project import Project +from ..model.trace import Trace +from ..model.xhair import XHair + + +if TYPE_CHECKING: + RequestArgs = ImmutableMultiDict[Any, Any] +else: + RequestArgs = ImmutableMultiDict + + +class ValidationError(TypeError): + def __init__(self, name: str, cls: type, args: RequestArgs): + super().__init__( + f"Invalid request param: '{name}': got '{type(args.get(name, None))}', expecting '{cls}'" + ) + + +def project(args: RequestArgs) -> Project: + if "path" not in args: + raise ValidationError("path", str, args) + return get_project_by_path(args["path"]) + + +def filename(args: RequestArgs, project: Project) -> str: + if "filename" not in args: + raise ValidationError("filename", str, args) + raise NotImplementedError() + + +def filenames(args: RequestArgs, project: Project) -> Sequence[str]: + if "filenames" not in args: + raise ValidationError("filenames", list, args) + raise NotImplementedError() + + +def frame(args: RequestArgs, project: Project, filename: str) -> int: + if "frame" not in args: + raise ValidationError("frame", int, args) + try: + frame = int(args["frame"]) + raise NotImplementedError() + except TypeError as e: + raise ValidationError("frame", int, args) from e + + +def trace(args: RequestArgs, project: Project) -> Trace: + raise NotImplementedError() + + +def xhair(args: RequestArgs, project: Project) -> XHair: + raise NotImplementedError() + + +def color(args: RequestArgs, name: str) -> Color: + raise NotImplementedError() + + +def tier_names(args: RequestArgs, project: Project) -> Sequence[str]: + raise NotImplementedError() + + +# _ValidateType = TypeVar("_ValidateType", bound=Union[Type[int], Type[float], Type[str]]) +_ValidateType = TypeVar("_ValidateType", int, float, str) + + +def primitive(args: RequestArgs, name: str, cls: Type[_ValidateType]) -> _ValidateType: + if name not in args: + raise ValidationError(name, cls, args) + try: + return cls(args[name]) + except TypeError as e: + raise ValidationError(name, cls, args) from e diff --git a/ultratrace2/server/views.py b/ultratrace2/server/views.py new file mode 100644 index 0000000..833e192 --- /dev/null +++ b/ultratrace2/server/views.py @@ -0,0 +1,368 @@ +from flask import Flask, request +from typing import Tuple + +import logging + +from . import app +from . import validate + + +logger = logging.getLogger(__name__) + +Response = Tuple[str, int] + + +@app.route("/project", methods=["PUT"]) +def load_project() -> Response: + """Main entrypoint. + + NB: To get the actual filename-specific data for a filetype, call + appropriate / endpoint. + + PUT /project?path= + { + "bundles": [ + { + "$filename": { + "has_alignment": boolean + "has_images": boolean + "has_sound": boolean + "has_spectrogram": boolean + }, + ... + }, + ... + ], + "traces": [ + { + "$trace_id": { + "name": str, + "color": str + }, + ... + }, + ... + ] + } + """ + try: + project = validate.project(request.args) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/image", methods=["GET"]) +def load_image() -> Response: + """Load *.dicom, *.png, etc. + + NB: "data" is serialized as a base64-encoded PNG file + + GET /image?path=&filename=&frame= + { + "data": str, + "xhairs": { + "$xhair_id": { + "trace_id": int, + "x": float, + "y": float + }, + ... + } + } + """ + try: + project = validate.project(request.args) + filename = validate.filename(request.args, project) + frame = validate.frame(request.args, project, filename) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/spectrogram", methods=["GET"]) +def get_spectrogram() -> Response: + """Load *.pmpkl, etc. + + NB: "data" is serialized as a base64-encoded PNG file + + GET /spectrogram?path=&filename=&start_time_ms=&stop_time_ms=\ + &window_length=&max_frequency=&dynamic_range=&n_slices= + { + "data": str + } + """ + try: + project = validate.project(request.args) + filename = validate.filename(request.args, project) + start_time_ms = validate.primitive(request.args, "start_time_ms", int) + stop_time_ms = validate.primitive(request.args, "stop_time_ms", int) + window_length = validate.primitive(request.args, "window_length", float) + max_frequency = validate.primitive(request.args, "max_frequency", float) + dynamic_range = validate.primitive(request.args, "dynamic_range", float) + n_slices = validate.primitive(request.args, "n_slices", int) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/audio", methods=["GET"]) +def get_audio() -> Response: + """Load *.wav, etc. + + #FIXME: How do we want to serialize this data? Should it be sent all at once + as a single base64-encoded string or requested in chunks? If in chunks, + then we'll need to require a "&frame=" parameter and the Accept-Ranges + header. + + GET /audio?path=&filename=[&frame=] + { + "data": str + } + """ + try: + project = validate.project(request.args) + filename = validate.filename(request.args, project) + frame = validate.frame(request.args, project, filename) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/alignment", methods=["GET"]) +def get_alignment() -> Response: + """Load *.TextGrid, etc. + + NB: We'll have to do something different for Point/Interval tiers -- maybe set + start_time == stop_time? + + GET /textgrid&path=&filename= + { + "offset": float, + "tiers": [ + { + "name": str, + "intervals": [ + { + "start_ms": int, + "stop_ms": int, + "label": str + }, + ... + ] + }, + ... + ] + } + """ + try: + project = validate.project(request.args) + filename = validate.filename(request.args, project) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/alignment/offset", methods=["PUT"]) +def set_alignment_offset() -> Response: + """Set the offset for a given filename + + PUT /alignment/offset?path=&filename=&offset= + null + """ + try: + project = validate.project(request.args) + filename = validate.filename(request.args, project) + offset = validate.primitive(request.args, "offset", int) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/traces/default", methods=["PUT"]) +def set_default_trace() -> Response: + """Set the default trace + + PUT /traces/default?path=&trace_id= + null + """ + try: + project = validate.project(request.args) + trace = validate.trace(request.args, project) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/traces/name", methods=["PUT"]) +def set_trace_name() -> Response: + """Set the trace's name + + PUT /traces/name?path=&trace_id=&new_name= + null + """ + try: + project = validate.project(request.args) + trace = validate.trace(request.args, project) + new_name = validate.primitive(request.args, "new_name", str) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/traces/color", methods=["PUT"]) +def set_trace_color() -> Response: + """Set the trace's color + + NB: should be given as a six-digit hex string + (e.g., 00ff00). + + PUT /traces/color?path=&trace_id=&new_color= + null + """ + try: + project = validate.project(request.args) + trace = validate.trace(request.args, project) + new_color = validate.color(request.args, "new_color") + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/traces/create", methods=["POST"]) +def create_trace() -> Response: + """Add a new trace + + NB: should be given as a six-digit hex string + (e.g. 00ff00). + + PUT /traces/create?path=&name=&color= + { + "trace_id": int + } + """ + try: + project = validate.project(request.args) + name = validate.primitive(request.args, "name", str) + color = validate.color(request.args, "color") + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/traces/delete", methods=["POST"]) +def delete_trace() -> Response: + """Delete a trace + + POST /traces/delete?path=&trace_id= + null + """ + try: + project = validate.project(request.args) + trace = validate.trace(request.args, project) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/xhairs/move", methods=["PUT"]) +def move_xhair() -> Response: + """Update an xhair's (x,y) position + + PUT /xhairs/move?path=&xhair_id=&x=&y= + null + """ + try: + project = validate.project(request.args) + xhair = validate.xhair(request.args, project) + x = validate.primitive(request.args, "x", float) + y = validate.primitive(request.args, "y", float) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/xhairs/create", methods=["POST"]) +def create_xhair() -> Response: + """Create a new xhair with given parameters + + POST /xhairs/create?path=&filename=&frame=&trace_id=&x=&y= + { + "xhair_id": int + } + """ + try: + project = validate.project(request.args) + filename = validate.filename(request.args, project) + frame = validate.frame(request.args, project, filename) + trace = validate.trace(request.args, project) + x = validate.primitive(request.args, "x", float) + y = validate.primitive(request.args, "y", float) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/xhairs/delete", methods=["POST"]) +def delete_xhair() -> Response: + """Delete an xhair + + POST /xhairs/delete?path=&xhair_id= + null + """ + try: + project = validate.project(request.args) + xhair = validate.xhair(request.args, project) + raise NotImplementedError() + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/search", methods=["GET"]) +def search() -> Response: + """Search query across project + + GET /search?path=&filenames=>&tier_names=>&query= + { + "matches": [ + { + "filename": str, + "tier_name": str, + "interval": { + "start_ms": float, + "stop_ms": float, + "label": str + } + }, + ... + ] + } + """ + try: + project = validate.project(request.args) + filenames = validate.filenames(request.args, project) + tier_names = validate.tier_names(request.args, project) + query = validate.primitive(request.args, "query", str) + raise NotImplementedError + except validate.ValidationError as e: + logger.error(e) + return str(e), 400 + + +@app.route("/export", methods=["POST"]) +def export() -> Response: + raise NotImplementedError()