diff --git a/.circleci/config.yml b/.circleci/config.yml index f63a72d..5685ef8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,11 +1,96 @@ -version: 2 +version: 2.1 + +commands: + install-and-check: + parameters: + python: + type: string + + steps: + - run: + name: upgrade pip and friends + command: | + << parameters.python >> -m pip install \ + pip==22.3 \ + setuptools==65.5.0 \ + wheel==0.38.0 + + - run: + name: install ultratrace + command: | + << parameters.python >> -m pip install \ + --requirement ./requirements-dev.txt + + - run: nox + jobs: - build: + ubuntu: docker: - - image: circleci/python:3.7.2 + - image: ubuntu:18.04 steps: - checkout - - run: ./install.sh - - run: sudo pip install boto3 mypy_boto3_s3 nox - - run: ./scripts/download-test-data - - run: nox + + - run: + name: install system packages + command: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install --yes \ + portaudio19-dev \ + python3.8 \ + python3.8-dev \ + python3.8-venv \ + python3-pip \ + python3-tk + + - install-and-check: + python: python3.8 + + fedora: + docker: + - image: fedora:35 + steps: + - checkout + + - run: + name: install system packages + command: | + dnf --assumeyes install \ + https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \ + https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm + dnf --assumeyes update + dnf --assumeyes install \ + gcc \ + portaudio-devel \ + python3-devel \ + python3-tkinter \ + python3-pip + + - install-and-check: + python: python3.10 + + macos: + macos: + xcode: 13.2.1 # latest for Big Sur + environment: + # https://circleci.com/docs/using-macos/#optimizing-homebrew + HOMEBREW_NO_AUTO_UPDATE: 1 + steps: + - checkout + + - run: + name: install system packages + command: | + brew install \ + portaudio \ + python-tk@3.9 + + - install-and-check: + python: python3.9 + +workflows: + main: + jobs: + - ubuntu + - fedora + - macos diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 4365c1c..0000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -# W503 Line break before binary operator -# E501 Line too long -[flake8] -max-line-length=88 -ignore=E501,W503 -exclude=ultratrace2/__main__.py diff --git a/.gitignore b/.gitignore index 30ae261..9b904bb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ data # or virtualenv/pipenv files Pipfile* +venv # python caches __pycache__ @@ -17,5 +18,9 @@ __pycache__ .nox .coverage +# build artifacts +build/ +*.egg-info + # local environment setup .env diff --git a/README.md b/README.md index c6d2422..d09aee8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # UltraTrace + This is a tool for [currently] manual annotation of 2D UTI (Ultrasound Tongue Imaging) data. You can have a look at our [UltraFest IX presentation](https://swatphonlab.github.io/2020-UltraTrace-presentation/presentation.html) for some details on featureset and functionality as of October, 2020. @@ -8,39 +9,89 @@ You can have a look at our [UltraFest IX presentation](https://swatphonlab.githu ## Installation -### Linux (apt or dnf) +This tool requires the following system packages to be installed: + +* [`portaudio`](http://www.portaudio.com/) +* [`python3`](https://www.python.org/) (3.7 or later) + +Additionally, you'll need the following Python components (which are sometimes distributed separately): + +* [Python development headers](https://devguide.python.org/getting-started/setup-building/#install-dependencies) +* [`pip`](https://pypi.org/project/pip/) +* [`tkinter`](https://docs.python.org/3/library/tkinter.html) +* [`venv`](https://docs.python.org/3/library/venv.html) -1. Download the UltraTrace source code, e.g. using `git`. -2. Run setup in the UltraTrace directory: -```bash -$ ./install.sh -$ python3 setup.py install +See below for platform-specific installation instructions: +* [Ubuntu](#ubuntu) +* [Fedora](#fedora) +* [macOS](#macos) +* [Windows](#windows) + +Once these libraries are installed, you can just `pip install` the package with + +```sh +$ python3 -m pip install -r ./requirements.txt ``` -### macOS +NOTE: For hacking on `ultratrace` itself, see [Development](#development) below. -1. Install [Homebrew](https://brew.sh). -2. Download the UltraTrace source code, e.g. using `git`. -3. Use Homebrew to install python3: -```bash -$ brew install python3 +NOTE: You probably want to install into a [virtual environment](https://docs.python.org/3/tutorial/venv.html) to avoid conflicts with system packages. Alternatively, you can do a [`--user` installation](https://pip.pypa.io/en/latest/user_guide/#user-installs). + +### Ubuntu + +Supported versions: 18.04, 20.04 + +```sh +$ apt-get update +$ apt-get install --yes \ + portaudio19-dev \ + python3.8 \ + python3.8-dev \ + python3.8-venv \ + python3-pip \ + python3-tk ``` -4. Run setup in the UltraTrace directory: -```bash -$ python3 setup.py install + +### Fedora + +Supported versions: 35, 36, 37 + +```sh +$ dnf --assumeyes install \ + https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \ + https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm +$ dnf --assumeyes update +$ dnf --assumeyes install \ + gcc \ + portaudio-devel \ + python3-devel \ + python3-tkinter \ + python3-pip +``` + +### macOS + +Supported versions: Big Sur, Monterey + +These instructions use the [Homebrew](https://brew.sh) package manager. + +```sh +$ brew update +$ brew install \ + portaudio \ + python-tk@3.9 ``` ### Windows -1. Install python3 -2. Make sure you have pip -3. Install ffmpeg and add to PATH -4. Run `setup.py` +TODO + +## Usage -## Use +Once [installed](#installation), you can just run -```bash -$ python3 -m ultratrace /path/to/data +```sh +$ ultratrace path/to/data ``` ### Data format @@ -72,10 +123,9 @@ The theme should just look right on Mac. #### Setting the theme on linux -Use pip3 to install ttkthemes. +We use [`ttkthemes`](https://ttkthemes.readthedocs.io/en/latest/). Add the following line to your `~/.Xresources` file: - ``` *TtkTheme: arc ``` @@ -83,3 +133,21 @@ Add the following line to your `~/.Xresources` file: You may select something other than clam for your theme. Currently, the main options are something like this: `arc`, `plastik`, `clearlooks`, `elegance`, `radiance`, `equilux`, `black`, `smog`, `scidblue`, etc. See the [ttkthemes documentation](https://ttkthemes.readthedocs.io/) for more information. Otherwise it'll fall back to `*TtkTheme`, and if that's not specified in `~/.Xresources`, it'll fall back to `clam`. + +## Development + +To hack on `ultratrace`, you should first [install all required system libraries](#installation). Then, you can set up a development environment by running + +```sh +$ source dev/env.sh +``` + +To lint/test `ultratrace`, use [`nox`](https://nox.thea.codes/en/stable/): +```sh +$ nox --help +``` + +To exit the development environment, just run +```sh +$ deactivate +``` diff --git a/bin/install b/bin/install deleted file mode 100755 index 4c8afe1..0000000 --- a/bin/install +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# -# Utility script to symbolically link the bin/ultratrace script into a user's -# path. By default, this installs the symbolic link into /usr/local/bin, but -# this can be overridden by specifying a --path (or -p) command line arg. -# -# As with bin/ultratrace, this is expected to work on Linux and MacOS, but not -# on Windows. - -set -euo pipefail - -usage() { - echo "usage: $0 [ [-h|--help] | [-p|--prefix] /path/to/install/to ]" - exit 1 -} - -# FIXME: This should maybe do something about system dependencies (i.e. things -# that need to be installed with a package manager) AND python dependencies -# (i.e. things that need to be installed with pip/conda/etc). - -prefix=/usr/local/bin - -if [[ $# -eq 0 ]]; then - true # do nothing -elif [[ $# -eq 1 ]]; then - # calling with -h|--help should just print usage anyway - usage -elif [[ $# -eq 2 ]]; then - if [[ $1 == "-p" || $1 == "--prefix" ]]; then - prefix="$2" - else - usage - fi -else - usage -fi - -# probably need superuser permissions -ln -sf "$(realpath "$(dirname $0)")/ultratrace" "$prefix/ultratrace" - diff --git a/bin/ultratrace b/bin/ultratrace deleted file mode 100755 index f54119e..0000000 --- a/bin/ultratrace +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# -# Executable wrapper around the ultratrace module so -# that it can be executed from anywhere on the system. -# This _should_ support both MacOS and Linux, but Windows -# has not been tested. - -# Exit early if any of our commands fail. -set -euo pipefail - -__realpath() { - # It looks like getting readlink(1) and/or realpath(1) to work in - # a reliable cross-platform way is probably difficult ... so let's - # just let Python do it for us :^) - python3 -c "import os; print(os.path.realpath('$1'))" -} - -# We want the script to be able to invoke our module without actually `cd`ing -# to it, since that would screw up any relative paths we pass as arguments. To -# get around this, we can set the python module search path explicitly via the -# $PYTHONPATH environment variable (see below). This line just makes sure that -# the invocation via `/path/to/bin/ultratrace` works the same as `ultratrace` -# (i.e., after running `bin/install`. -ULTRATRACE_DIR="$(dirname "$(__realpath "$0")")/.." - -# Pass all arguments through to `ultratrace`. We should be letting the -# argparse section of `__main__.py` be handling errors and generating nice -# user-facing text. -PYTHONPATH="$ULTRATRACE_DIR:${PYTHONPATH:-}" python3 -m ultratrace "$@" diff --git a/dev/env.sh b/dev/env.sh new file mode 100644 index 0000000..db7cbe8 --- /dev/null +++ b/dev/env.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# +# Set up a development environment for 'ultratrace'. +# +# Note that since this script is meant to be "source"d, we can't use +# the "set -euo pipefail" idiom (since that means that we would set +# those shell options in our host environment (this would cause bash +# to exit if we ran any command returning a non-zero exit code)). + +export ULTRATRACE_ROOT="$(git rev-parse --show-toplevel)" +export ULTRATRACE_VENV="$ULTRATRACE_ROOT/venv/ultratrace" +export ULTRATRACE_PYTHON="${ULTRATRACE_PYTHON:-python3}" + +# Create a virtual environment if we don't already have one. +if [ ! -e "$ULTRATRACE_VENV/bin/activate" ]; then + echo "Creating a virtual environment at '$ULTRATRACE_VENV'" >&2 + rm -rf "$ULTRATRACE_VENV" + "$ULTRATRACE_PYTHON" -m venv "$ULTRATRACE_VENV" +fi + +# Activate the virtual environment. +source "$ULTRATRACE_VENV/bin/activate" + +pip install --upgrade \ + pip==22.3 \ + setuptools==65.5.0 \ + wheel==0.38.0 + +pip install \ + --requirement "$ULTRATRACE_ROOT/requirements-dev.txt" diff --git a/install.sh b/install.sh deleted file mode 100755 index e5321c7..0000000 --- a/install.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash - -if which brew &> /dev/null; then - brew install portaudio ffmpeg libav -elif which apt-get &> /dev/null; then - sudo apt-get update - sudo apt-get install python3 \ - portaudio19-dev libportaudio2 \ - ffmpeg \ - libav-tools -elif which dnf &> /dev/null; then - sudo dnf up - sudo dnf in python3 portaudio ffmpeg -else - echo "Your package manager is not currently supported." - echo "Currently supported managers are: brew, apt, dnf." - exit 1 -fi diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 2ea754c..0000000 --- a/mypy.ini +++ /dev/null @@ -1,5 +0,0 @@ -[mypy] -check_untyped_defs=True - -[mypy-pytest,pytest.*] -ignore_missing_imports=True diff --git a/noxfile.py b/noxfile.py index c7dd2ae..6d96557 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,32 +1,14 @@ import nox -@nox.session -def format(session): - session.install("black") - session.run("black", "--check", "ultratrace2") - - @nox.session def install(session): - """Install ultratrace2. Assumes required system libraries are installed.""" - session.install(".") + """Install ultratrace. Assumes required system libraries are installed.""" + session.install("--requirement", "./requirements.txt") @nox.session -def typecheck(session): - """Run static analyzer against source files.""" - session.install(".[dev]") - session.run("mypy", "ultratrace2") - - -@nox.session -def lint(session): - session.install(".[dev]") - session.run("flake8", "ultratrace2") - - -@nox.session -def tests(session): - session.install(".[dev]") - session.run("pytest", "ultratrace2") +def format(session): + """Check code formatting.""" + session.install("--requirement", "./requirements-dev.txt") + session.run("black", "--check", "./ultratrace") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a5ebe0e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +# Configuration file for how to "build" our package. +# +# See also: https://setuptools.readthedocs.io/en/latest/build_meta.html +# See also: https://www.python.org/dev/peps/pep-0517/ + +[build-system] +requires = [ + "setuptools==65.5.0", + "wheel==0.38.0", +] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 + +[tool.mypy] +check_untyped_defs = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6e53540 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +# We want to pull in all of the "runtime" dependencies/versions ... +--requirement ./requirements.txt + +# ... and also our "development" dependencies/versions ... +flake8==5.0.4 +black==22.10.0 +mypy==0.982 +nox==2022.8.7 +pytest==7.1.3 + +# ... but also install our package in "editable" mode, which means +# that our changes will be visible immediately (without needing to +# reinstall). +--editable .[dev] diff --git a/requirements.txt b/requirements.txt index 2f38940..83494aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,19 @@ -cycler>=0.10.0 -kiwisolver>=1.1.0 -matplotlib>=3.1.2 -numpy>=1.17.4 -Pillow>=6.2.1 -praat-parselmouth>=0.4.0 -PyAudio==0.2.11 -pydicom>=1.3.0 -pydub==0.23.1 -pyparsing>=2.4.5 -python-dateutil>=2.8.1 -six>=1.13.0 +contourpy==1.0.5 +cycler==0.11.0 +fonttools==4.38.0 +kiwisolver==1.4.4 +matplotlib==3.6.1 +numpy==1.23.4 +packaging==21.3 +Pillow==9.2.0 +praat-parselmouth==0.4.1 +PyAudio==0.2.12 +pydicom==2.3.0 +pydub==0.25.1 +pyparsing==3.0.9 +python-dateutil==2.8.2 +python-magic==0.4.27 +six==1.16.0 TextGrid==1.5 -tqdm==4.40.2 -ttkthemes==2.4.0 +ttkthemes==3.2.2 xparser==0.0.4 -python-magic>=0.4.13 diff --git a/scripts/convert-dicom-to-png b/scripts/convert-dicom-to-png deleted file mode 100755 index eb66010..0000000 --- a/scripts/convert-dicom-to-png +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import sys - -sys.path.insert(0, '../ultratrace') - -from ultratrace.model2.files.impls import ImageSet - -if __name__ == '__main__': - - parser = argparse.ArgumentParser() - parser.add_argument('paths', nargs='+') - args = parser.parse_args() - - for path in args.paths: - im = ImageSet() - im.interpret(path) - im.data() diff --git a/scripts/download-test-data b/scripts/download-test-data deleted file mode 100755 index 18f92c8..0000000 --- a/scripts/download-test-data +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -""" -Kevin Murphy -keggsmurph21 at gmail dot com -""" - -from mypy_boto3_s3 import Client as S3Client -from typing import NoReturn - -import boto3 # type: ignore -import botocore # type: ignore -import json -import os -import shutil -import subprocess -import sys -import tarfile - - -BUCKET = "swatphonlab" -PREFIX = "ultratrace-test-data" - - -def usage() -> NoReturn: - print( - f""" -usage: {sys.argv[0]} - - This script downloads and extracts tarballs from an AWS S3 - bucket into the ./test-data folder. In order to run this - script, the following environment variables must be set and - exported: - - - AWS_ACCESS_KEY_ID - - AWS_SECRET_ACCESS_KEY - - To set and export these variables, you can run the following - command from the root of the project repository: - - $ source .env - - If that file does not exist on your system, please contact - one of the project maintainers for a copy. - - Further, the IAM role associated with the credentials must have - the correct permissions (READ and/or WRITE) on the S3 bucket. -""", - file=sys.stderr, - ) - sys.exit(1) - - -def hash_path(path: str) -> str: - assert os.path.exists(path) - return subprocess.check_output( - ["./scripts/hash-path", path], encoding="utf-8" - ).strip() - - -def download_file(s3: S3Client, key: str, local_path: str) -> None: - print(f"downloading s3://{BUCKET}/{key} ...", file=sys.stderr) - try: - s3.download_file( - Bucket=BUCKET, Key=key, Filename=local_path, - ) - except botocore.exceptions.ClientError as e: - print(repr(e), file=sys.stderr) - usage() - - -def check_hash(local_path: str, expected_hash: str) -> bool: - return os.path.exists(local_path) and hash_path(local_path) == expected_hash - - -if __name__ == "__main__": - - if os.environ.get("AWS_ACCESS_KEY_ID", None) is None: - usage() - if os.environ.get("AWS_SECRET_ACCESS_KEY", None) is None: - usage() - - repo_root = os.path.realpath(os.path.join(os.path.dirname(sys.argv[0]), "..",)) - - os.chdir(repo_root) - - s3 = boto3.client("s3") - - os.makedirs("./test-data", exist_ok=True) - - download_file(s3, f"{PREFIX}/manifest.json", "./test-data/manifest.json") - with open("./test-data/manifest.json") as fp: - manifest = json.load(fp) - - for dataset_name, dataset_metadata in manifest["datasets"].items(): - local_path = f"./test-data/{dataset_name}" - dataset_version = int(dataset_metadata["version"]) - dataset_hash = dataset_metadata["hash"] - if os.path.exists(local_path): - if check_hash(local_path, dataset_hash): - print("hash matches expected value, skipping ...", file=sys.stderr) - continue - print( - "hash doesn't match expected value, pulling latest archive ...", - file=sys.stderr, - ) - shutil.rmtree(local_path) - local_archive_path = f"{local_path}.tar.gz" - download_file( - s3, - f"{PREFIX}/{dataset_name}/v{dataset_version}.tar.gz", - local_archive_path, - ) - with tarfile.open(local_archive_path, "r:gz") as tf: - tf.extractall("./test-data") - assert check_hash(local_path, dataset_hash) - os.unlink(local_archive_path) - - print("done!", file=sys.stderr) diff --git a/scripts/hash-path b/scripts/hash-path deleted file mode 100755 index ed8d131..0000000 --- a/scripts/hash-path +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Helper function to hash an entire directory, so that we can check if -# contents match some known expected value (see below). -get_hash() { - hash_func=md5sum # not sure how portable this is - echo "$@" | md5sum | cut -d' ' -f1 -} - -# Helper function to normalize the order that files are listed across diff -# operating systems (currently tested on MacOS 10.14 and Ubuntu 18.04). This -# function just chomps all "problematic" characters (based on the data it's -# been tested on ... it's possible there might be other discrepancies). Doing -# this means files like `hello.txt` and `h e l l o.txt` both map to `hellotxt`, -# which may be problematic for `sort`. -normalize_filenames() { - while read filename; do - echo "$filename" \ - | sed -E 's,[. _],,g' \ - | sed -E 's,^\.*/*,,g' \ - | sed -E 's,//+,/,g' - done | sort --ignore-case -} - -# For each file under the path, use `get_hash` to compute a hash of its -# contents, then concat that hash to the `running_hash` string. Then, when -# each of the individual files has been hashed, use `get_hash` to echo out -# the hash of the `running_hash` string. -hash_path() { - echo "computing hash for $1 ..." >&2 - filenames="$(find "$1" -type f | normalize_filenames)" - running_hash="" - for file in $filenames; do - running_hash="${running_hash}$(get_hash "$file")" - done - get_hash $running_hash -} - -if [[ $# -ne 1 ]]; then - cat <&2 - -usage: $0 - - This script takes a single path (file, dir, symlink, etc.) as a parameter - and recursively hashes the contents of that path, echoing as a single string - to stdout. - -EOM - exit 1 -fi - -hash_path "$1" diff --git a/scripts/upload-test-data b/scripts/upload-test-data deleted file mode 100755 index f0d3074..0000000 --- a/scripts/upload-test-data +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" -Kevin Murphy -keggsmurph21 at gmail dot com -""" - -from mypy_boto3_s3 import Client as S3Client -from typing import Dict, Mapping, NoReturn, Union - -import boto3 # type: ignore -import botocore # type: ignore -import json -import os -import subprocess -import sys -import tarfile - - -BUCKET = "swatphonlab" -PREFIX = "ultratrace-test-data" - - -def usage() -> NoReturn: - print( - f""" -usage: {sys.argv[0]} - - This script packages and uploads tarballs to an AWS S3 bucket - from the ./test-data folder. In order to run this script, the - following environment variables must be set and exported: - - - AWS_ACCESS_KEY_ID - - AWS_SECRET_ACCESS_KEY - - To set and export these variables, you can run the following - command from the root of the project repository: - - $ source .env - - If that file does not exist on your system, please contact - one of the project maintainers for a copy. - - Further, the IAM role associated with the credentials must have - the correct permissions (READ and/or WRITE) on the S3 bucket. -""", - file=sys.stderr, - ) - sys.exit(1) - - -def hash_path(path: str) -> str: - assert os.path.exists(path) - return subprocess.check_output( - ["./scripts/hash-path", path], encoding="utf-8" - ).strip() - - -def download_file(s3: S3Client, key: str, local_path: str) -> None: - print(f"downloading s3://{BUCKET}/{key} ...", file=sys.stderr) - try: - s3.download_file( - Bucket=BUCKET, Key=key, Filename=local_path, - ) - except botocore.exceptions.ClientError as e: - print(repr(e), file=sys.stderr) - usage() - - -def upload_file(s3: S3Client, key: str, local_path: str) -> None: - print(f"uploading s3://{BUCKET}/{key} ...", file=sys.stderr) - try: - s3.upload_file(Bucket=BUCKET, Key=key, Filename=local_path) - except botocore.exceptions.ClientError as e: - print(repr(e), file=sys.stderr) - usage() - - -if __name__ == "__main__": - - if os.environ.get("AWS_ACCESS_KEY_ID", None) is None: - usage() - if os.environ.get("AWS_SECRET_ACCESS_KEY", None) is None: - usage() - - repo_root = os.path.realpath(os.path.join(os.path.dirname(sys.argv[0]), "..",)) - - os.chdir(repo_root) - - s3 = boto3.client("s3") - - download_file(s3, f"{PREFIX}/manifest.json", "./test-data/manifest.json") - with open("./test-data/manifest.json") as fp: - old_manifest = json.load(fp) - - assert os.path.exists("./test-data") - - for dataset_name in old_manifest["datasets"]: - assert os.path.exists(f"./test-data/{dataset_name}") - - new_manifest: Mapping[str, Dict[str, Dict[str, Union[str, int]]]] = {"datasets": {}} - - for dataset_name in os.listdir("./test-data"): - if dataset_name == "manifest.json": - continue - - assert not dataset_name.endswith(".tar.gz") - - local_path = f"./test-data/{dataset_name}" - local_hash = hash_path(local_path) - if dataset_name in old_manifest["datasets"]: - old_metadata = old_manifest["datasets"][dataset_name] - old_hash = old_metadata["hash"] - if local_hash == old_hash: - print("hash matches expected value, skipping ...", file=sys.stderr) - new_manifest["datasets"][dataset_name] = old_metadata - continue - print( - "hash doesn't match expected value, pushing latest archive ...", - file=sys.stderr, - ) - old_version = old_metadata["version"] - new_version = old_version + 1 - else: - print("new dataset detected, pushing latest archive ...", file=sys.stderr) - new_version = 1 - - new_manifest["datasets"][dataset_name] = { - "hash": local_hash, - "version": new_version, - } - - os.chdir("./test-data") - local_archive_path = f"{dataset_name}.tar.gz" - assert not os.path.exists(local_archive_path) - with tarfile.open(local_archive_path, "w:gz") as tf: - tf.add(dataset_name) - s3_archive_path = f"{PREFIX}/{dataset_name}/v{new_version}.tar.gz" - upload_file(s3, s3_archive_path, local_archive_path) - os.unlink(local_archive_path) - os.chdir("..") - - with open("./test-data/manifest.json", "w") as fp: - json.dump(new_manifest, fp) - upload_file(s3, f"{PREFIX}/manifest.json", "./test-data/manifest.json") - - print("done!", file=sys.stderr) diff --git a/setup.cfg b/setup.cfg index 6921bec..9304606 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,46 @@ -[tool:pytest] -addopts = - --cov=ultratrace2 +[metadata] +name = ultratrace +version = 0.9.1 +description = A tool for manually annotating ultrasound tongue imaging (UTI) data +long_description = file: README.md +url = https://github.com/SwatPhonLab/UltraTrace +maintainer = Jonathan Washington +maintainer_email = jwashin1@swarthmore.edu +license = GPL v3 License +license_files = + LICENSE + +[options] +package_dir = + =. +packages = find_namespace: +zip_safe = false + +python_requires = >= 3.7 +install_requires = + matplotlib + numpy + Pillow + praat-parselmouth + PyAudio + pydicom + pydub + TextGrid + ttkthemes + xparser + python-magic + +[options.extras_require] +dev = + flake8 + black + mypy + nox + pytest + +[options.entry_points] +console_scripts = + ultratrace = ultratrace.__main__:main + +[flake8] +max-line-length = 100 diff --git a/setup.py b/setup.py deleted file mode 100644 index 7e438b2..0000000 --- a/setup.py +++ /dev/null @@ -1,43 +0,0 @@ -from setuptools import setup, find_namespace_packages - - -def get_requirements(path): - with open(path) as f: - return list(filter(lambda s: len(s) > 0, (get_requirement(l) for l in f))) - - -def get_requirement(line): - r, *_ = line.split("#") - return r.strip().split() - - -setup( - name="ultratrace", - author="Jonathan Washington", - author_email="jwashin1@swarthmore.edu", - version="0.9.1", - packages=find_namespace_packages( - #include=["ultratrace2.*"], exclude=["ultratrace.*"] - include=["ultratrace.*"], exclude=["ultratrace2.*"] - ), - description="A tool for manually annotating ultrasound tongue imaging (UTI) data", - install_requires=get_requirements("requirements.txt"), - entry_points={ - "console_scripts": [ - "ultratrace = ultratrace.__main__:main" - ] - }, - extras_require={ - "dev": [ - "flake8", - "black", - "mypy", - "pytest", - "numpy-stubs @ git+https://github.com/numpy/numpy-stubs.git@master", - "pytest-cov", - "pytest-mock", - "boto3", - "mypy-boto3-s3", - ] - }, -) diff --git a/ultratrace/__main__.py b/ultratrace/__main__.py index 5aaf0e6..5e3d846 100755 --- a/ultratrace/__main__.py +++ b/ultratrace/__main__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -#import modules +# import modules from . import modules from . import util from .util.logging import * @@ -19,619 +19,691 @@ ThemedTk = Tk warn(e) + class App(ThemedTk): - ''' - This class is neatly wraps all the functionality of our application. By itself, - it's is responsible for handling command line input, navigating between files, - navigating between frames, handling of some events, and coordinating other core - functionality (which is handled by individual `modules`). - - Note: - - when we change files, modules should execute MODULE.reset() methods - - when we change frames, modules should execute MODULE.update() methods - - modules that are responsible for managing their own widgets should have - MODULE.grid() and MODULE.grid_remove() methods to wrap corresponding - functionality for their widgets - ''' - def __init__(self): - - info( 'initializing UltraTrace' ) - - default_ttktheme = "clam" # alt, clam, classic, default - # do the normal Tk init stuff - if util.get_platform()=='Linux': - try: - info(' - loading platform-specific enhancements for Linux') - import xrp # pip3 install xparser - from pathlib import Path - XresPath = os.path.join(str(Path.home()), '.Xresources') - if os.path.isfile(XresPath) or os.path.islink(XresPath): - info(" - found .Xresources file: {}".format(XresPath)) - Xresources = xrp.parse_file(XresPath, encoding="utf8") - #info("Opened .Xresources file {}".format(XresPath)) - if '*TtkTheme' in Xresources.resources: - ttktheme = Xresources.resources['*TtkTheme'] - if ttktheme in THEMES: - info(" - setting Linux Ttk theme to {}".format(ttktheme)) - else: - warn(" - Ttk theme {} specified ~/.Xresources not available, defaulting to {}".format(ttktheme, default_ttktheme)) - ttktheme = default_ttktheme - elif '*TkTheme' in Xresources.resources: - ttktheme = Xresources.resources['*TkTheme'] - info(" - setting Linux Tk theme to {}".format(ttktheme)) - else: - ttktheme = default_ttktheme - info(" - falling back to default Linux Tk theme: {}. You can set your theme to something else by adding a line like \"*TkTheme: alt\" or \"*TtkTheme: arc\" to ~/.Xresources".format(ttktheme)) - else: - ttktheme = "clam" # alt, clam, classic, default - info(" - no ~/.Xresources file found; falling back to default Linux Tk theme: {}. You can set your theme to something else by adding a line like \"*TkTheme: alt\" or \"*TtkTheme: arc\" to ~/.Xresources".format(ttktheme)) - super().__init__(theme=ttktheme) - except Exception as e: - error("Crash while loading .Xresources file or initialising T(t)k theme", e) - super().__init__() - else: - super().__init__() - self.title('UltraTrace') - - # check if we were passed a command line argument - parser = argparse.ArgumentParser(prog='UltraTrace') - parser.add_argument('path', help='path (unique to a participant) where subdirectories contain raw data', default=None, nargs='?') - args = parser.parse_args() - - # initialize data module - self.Data = modules.Metadata( self, args.path ) - - # initialize the main app widgets - self.setWidgetDefaults() - self.buildWidgetSkeleton() - - # initialize other modules - self.Control = modules.Control(self) - self.Trace = modules.Trace(self) - self.Dicom = modules.Dicom(self) - self.Audio = modules.Playback(self) - self.TextGrid = modules.TextGrid(self) - self.Spectrogram = modules.Spectrogram(self) - self.Search = modules.Search(self) - - info( ' - loading widgets' ) - - self.filesUpdate() - # self.framesUpdate() - # self.TextGrid.startup() #NOTE why does modules.TextGrid have to reset a second time? Is there a more economical way to do this? - - - # to deal with resize handler being called multiple times - # in a single window resize - self.isResizing = False - - self.oldwidth = self.winfo_width() - - self.after(300,self.afterstartup) - - def setWidgetDefaults(self): - ''' - Need to set up some defaults here before building Tk widgets (this is specifically - true w/r/t the StringVars) - ''' - self.currentFID = 0 # file index w/in list of sorted files - self.frame = 0 # current frame of dicom file - self.isClicked = False # used in handling of canvas click events - self.isDragging = False # used in handling of canvas click events - # self.resized = False #for changing widgets after window resize - self.selectBoxX = False - self.selectBoxY = False - - # some styling - self.fontStyle = Style() - if util.get_platform() == 'Darwin': - self.fontStyle.configure('symbol.TButton', font=('DejaVu Serif', 26)) - else: - self.fontStyle.configure('symbol.TButton', font=('DejaVu Serif', 19)) - - # declare string variables - self.currentFileSV = StringVar(self) - self.frameSV = StringVar(self) - - # initialize string variables - self.currentFileSV.set( self.Data.files[ self.currentFID ] ) - self.frameSV.set( '1' ) - def buildWidgetSkeleton(self): - ''' - Builds the basic skeleton of our app widgets. - - items marked with (*) are built directly in this function - - items marked with (~) are built by the individual modules - # WARNING: out of date diagram - .________________________________________. - | ROOT | - | .____________________________________. | - | | TOP* | | - | | ._______________. .______________. | | - | | | LEFT* | | RIGHT* | | | - | | | - file nav* | | - dicom~ | | | - | | | - frame nav*| | | | | - | | | - traces~ | | | | | - | | | - undo~ | | | | | - | | \_______________/ \______________/ | | - | \____________________________________/ | - | | - | .____________________________________. | - | | BOTTOM* | | - | | - spectrogram~ | | - | | - textgrid~ | | - | \____________________________________/ | - \________________________________________/ - ''' - # main Frame skeleton - self.TOP = Frame(self) - self.TOP.columnconfigure(1,weight=1, minsize=320) - self.TOP.rowconfigure(0,weight=1, minsize=240) - self.LEFT = Frame(self.TOP) - # self.LEFT.rowconfigure(0,weight=1) - # self.LEFT.columnconfigure(0,weight=1) - self.RIGHT = Frame(self.TOP) - self.RIGHT.rowconfigure(0,weight=1) - self.RIGHT.columnconfigure(0,weight=1) - self.BOTTOM = Frame(self) - # self.BOTTOM.columnconfigure(0,weight=1) - self.BOTTOM.columnconfigure(1,weight=1) - # self.BOTTOM.rowconfigure(0,weight=1) - # self.TOP.grid( row=0, column=0, sticky='nw') - # self.LEFT.grid( row=0, sticky='n' ) - # self.RIGHT.grid( row=0, column=1) - # self.BOTTOM.grid( row=1, column=0, sticky='e') - self.TOP.grid( row=0, column=0, sticky='nesw') - self.LEFT.grid( row=0, sticky='nesw' ) - self.RIGHT.grid( row=0, column=1, sticky='nesw') - self.BOTTOM.grid( row=1, column=0, sticky='nesw') - self.pady=3 - self.columnconfigure(0,weight=1) - self.rowconfigure(0,weight=1) - - # navigate between all available filenames in this directory - self.filesFrame = Frame(self.LEFT)#, pady=7) - self.filesPrevBtn = Button(self.filesFrame, text='<', command=self.filesPrev, takefocus=0, width="1.5") - self.filesJumpToMenu = OptionMenu(self.filesFrame, self.currentFileSV, self.Data.files[0], *self.Data.files, command=self.filesJumpTo) - self.filesNextBtn= Button(self.filesFrame, text='>', command=self.filesNext, takefocus=0, width="1.5") - self.filesFrame.grid( row=1 ) - self.filesPrevBtn.grid( row=1, column=0 ) - self.filesJumpToMenu.grid( row=1, column=1 ) - self.filesNextBtn.grid(row=1, column=2 ) - Header(self.filesFrame, text="Recording").grid( row=0, column=0, columnspan=3 ) - - # navigate between frames - self.framesFrame = Frame(self.LEFT)#, pady=7) - self.framesSubframe = Frame(self.framesFrame) - self.framesPrevBtn = Button(self.framesSubframe, text='<', command=self.framesPrev, takefocus=0, width="1.5") - self.framesEntryText = Entry(self.framesSubframe, width=5, textvariable=self.frameSV) - self.framesEntryBtn = Button(self.framesSubframe, text='Go', command=self.framesJumpTo, takefocus=0, width="3") - self.framesNextBtn= Button(self.framesSubframe, text='>', command=self.framesNext, takefocus=0, width="1.5") - self.framesHeader = Header(self.framesFrame, text="Frame") - self.framesFrame.grid( row=3 ) - self.framesSubframe.grid( row=1 ) - - # non-module-specific bindings - if util.get_platform() == 'Linux': - self.bind('', self.filesPrev ) - self.bind('', self.filesNext ) - else: - self.bind('', self.filesPrev ) - self.bind('', self.filesNext ) - self.bind('', self.framesPrev ) - self.bind('', self.framesNext ) - self.bind('', self.onBackspace ) - self.bind('', self.getWinSize) - self.bind('', self.onRelease) - self.bind('', self.onDoubleClick) - self.bind('', self.onEscape ) - # self.count = 0 - - self.framesEntryText.bind('', self.unfocusAndJump) - self.framesEntryText.bind('', lambda ev: self.framesFrame.focus()) - - # force window to front - self.lift() - - def lift(self): - ''' - Bring window to front (doesn't shift focus to window) - ''' - self.attributes('-topmost', 1) - self.attributes('-topmost', 0) - def afterstartup(self): - ''' - - ''' - self.bind('', self.alignBottomLeftWrapper ) - self.alignBottomLeft() - self.getWinSize() - self.alignBottomRight(self.oldwidth-self.leftwidth) - if self.Dicom.zframe.image: - self.Dicom.zframe.setImage(self.Dicom.zframe.image) - - def alignBottomLeftWrapper(self, event=None): - if self.isResizing: return - self.isResizing = True - self.after(100, lambda: self.alignBottomLeft(event)) - - def alignBottomLeft(self, event=None): - ''' - Makes the length of the canvases on the lower left the same length as the pane of controls in self.LEFT - ''' - self.leftwidth = self.LEFT.winfo_width() - for t in range(len(self.TextGrid.TkWidgets)): - tierWidgets = self.TextGrid.TkWidgets[t] - if 'frames' in tierWidgets: - tierWidgets['frames-label'].config(width=self.leftwidth) - tierWidgets['frames-label'].coords('all',(self.leftwidth,tierWidgets['frames-label'].coords(1)[1])) - if 'canvas' in tierWidgets: - tierWidgets['canvas-label'].config(width=self.leftwidth) - tierWidgets['canvas-label'].coords('all',(self.leftwidth,tierWidgets['canvas-label'].coords(1)[1])) - if event == None or event.widget == self: - self.alignBottomRight(self.winfo_width() - self.leftwidth) - if self.Dicom.zframe.image: - self.Dicom.zframe.setImage(self.Dicom.zframe.image) - self.isResizing = False - def alignBottomRight(self,x): - ''' ''' - self.Spectrogram.canvas_width = x - self.Spectrogram.canvas.config(width=x) - self.TextGrid.canvas_width = x - for t in range(len(self.TextGrid.TkWidgets)): - tierWidgets = self.TextGrid.TkWidgets[t] - canvas = None - if 'frames' in tierWidgets: - tierWidgets['frames'].config(width=x) - elif 'canvas' in tierWidgets: - tierWidgets['canvas'].config(width=x) - if 'times' in tierWidgets: - tierWidgets['times'].config(width=x) - tierWidgets['times'].coords(2,(x,tierWidgets['times'].coords(2)[1])) #move end time - tierWidgets['times'].coords(3,(x/2,tierWidgets['times'].coords(3)[1])) - self.TextGrid.fillCanvases() #calls Spectrogram.reset - - # def onWindowResize(self, event): - # ''' - # Handle moving or resizing the app window - # ''' - # self.alignBottomLeft() - # # self.resized=True - - def getWinSize(self, event=None): - self.oldwidth = self.winfo_width() - def onDoubleClick(self, event): - ''' select only crosshairs that's double clicked''' - nearby = self.Trace.getNearClickAllTraces( (event.x, event.y) ) - if nearby != None: - self.Trace.unselectAll() - self.Trace.select( nearby ) - def onClickZoom(self, event): - ''' - Handle clicking within the zoomframe canvas - ''' - if self.Dicom.isLoaded(): - self.click = (event.x, event.y) - self.isDragging = False - - # get nearby crosshairs from this trace - nearby = self.Trace.getNearClickAllTraces( self.click ) - - # if we didn't click near anything ... - if nearby == None: - self.Trace.unselectAll() - if event.state != 17: - # unselect crosshairs - self.isClicked = True - ch = self.Trace.add( *self.click ) - self.Control.push({ 'type':'add', 'chs':[ch] }) - else: - self.selectBoxX = self.Dicom.zframe.canvas.canvasx(event.x) - self.selectBoxY = self.Dicom.zframe.canvas.canvasy(event.y) - return - - # NOTE: only get here if we clicked near something - - # if holding option key, unselect the guy we clicked on - # if event.state == 16: - # if holding shift key (event.state==17), and crosshair is selected, unselect it - #debug(event.state) - if event.state == 17 and nearby in self.Trace.selected: - nearby.unselect() - if nearby in self.Trace.selected: - self.Trace.selected.remove( nearby ) - - # otherwise, if not selected, add it to our selection - elif nearby not in self.Trace.selected: - #debug("event.state", event.state) - if event.state != 1: #and nearby.isSelected == False: - self.Trace.unselectAll() - - # add this guy to our current selection - self.Trace.select( nearby ) - #through all of these operations, if clicked ch is selected, is ready to be dragged - if nearby in self.Trace.selected: - # set dragging variables - self.isDragging = True - self.dragClick = self.click - - def onReleaseZoom(self, event): - ''' - Handle releasing a click within the zoomframe canvas - ''' - if self.Dicom.isLoaded(): - - # select multiple crosshairs - if self.selectBoxX!=False: - canvas = self.Dicom.zframe.canvas - x1=self.selectBoxX - x2=canvas.canvasx(event.x) - y1=self.selectBoxY - y2=canvas.canvasy(event.y) - self.selectBoxX = False - self.selectBoxY = False - - trace = self.Trace.getCurrentTraceName() - coords = [] - x1True = None - - if trace in self.Trace.crosshairs: - for ch in self.Trace.crosshairs[ trace ]: - if x1True == None: - x1True, y1True = ch.transformCoordsToTrue(x1,y1) - x2True, y2True = ch.transformCoordsToTrue(x2,y2) - if ch.isVisible: - x,y = ch.getTrueCoords() - if min(x1True,x2True) < x < max(x1True,x2True) and min(y1True,y2True) < y < max(y1True,y2True): - self.Trace.select(ch) - - self.isDragging = False - self.isClicked = False - self.Trace.write() - def onReleaseSpec(self,event): - '''shift + release zooms textgrid & spectrogram to selected interval''' - if self.Spectrogram.specClick==True: - # if event.state==257: - canvas = self.Spectrogram.canvas - t1 = self.Spectrogram.clicktime - t2 = self.Spectrogram.xToTime(canvas.canvasx(event.x)) - # self.TextGrid.start = float(min(t1,t2)) - # self.TextGrid.end = float(max(t1,t2)) - # for itm in canvas.find_all()[0]: - # for tag in canvas.gettags(itm): #canvas.dtag() does not seem to work with one argument - if max(t1,t2) - min(t1,t2) > self.Spectrogram.ts: #if selected area is larger than one strip of Spectrogram - #gets rid of previous tags - for tag in canvas.gettags(canvas.find_all()[0]): - canvas.dtag(canvas.find_all()[0],tag) - a = self.Spectrogram.timeToX(self.Spectrogram.clicktime) - b = event.x - x1 = min(a,b) - x2 = max(a,b) - #find all frames within range, and add them as tags - frame_i = self.TextGrid.frames_canvas.find_all()[0] - current_loc = self.TextGrid.frames_canvas.coords(frame_i)[0] - while current_loc < x2: - if current_loc > x1: - tag = self.TextGrid.frames_canvas.gettags(frame_i)[0] - canvas.addtag_all(tag) - frame_i += 1 - current_loc = self.TextGrid.frames_canvas.coords(frame_i)[0] - canvas.addtag_all('minTime'+str(self.Spectrogram.xToTime(x1))) - canvas.addtag_all('maxTime'+str(self.Spectrogram.xToTime(x2))) - self.TextGrid.selectedItem = (canvas, canvas.find_all()[0]) - self.TextGrid.setSelectedIntvlFrames(self.TextGrid.selectedItem) - # self.TextGrid.paintCanvases() - # self.Spectrogram.drawInterval(l_loc=x1,r_loc=x2) - # debug(canvas.gettags('all')) - # specgram = self.Spectrogram.canvas.find_all()[0] - # self.TextGrid.fillCanvases() - self.TextGrid.genFrameList(widg=canvas,x_loc=x2, SI=True) - self.Spectrogram.specClick = False - self.Spectrogram.clicktime = -1 - - def onRelease(self,event): - ''' - - ''' - #runs if click happened on specific canvases - self.onReleaseZoom(event) - self.onReleaseSpec(event) - #runs if window resized - # if self.resized == True and self.Dicom.zframe.shown == True: #shouldn't trigger when frame not displayed - if self.winfo_width() != self.oldwidth and self.Dicom.zframe.shown == True: #shouldn't trigger when frame not displayed - # self.resized = False - #resize dicom image - png_loc = self.Data.getPreprocessedDicom(self.frame) - image = PIL.Image.open( png_loc ) - self.Dicom.zframe.setImage(image) - # x = self.Dicom.zframe.width - x = self.winfo_width() - self.LEFT.winfo_width() - # y = self.Dicom.zframe.height - #resize TextGrid tiers and spectrogram - self.alignBottomRight(x) - #move Traces - self.Trace.move() - - #save layout ot geometry manager - geometry = self.geometry() - self.Data.setTopLevel( 'geometry', geometry ) - - def onMotion(self, event): - ''' - Handle mouse movement within the zoomframe canvas - ''' - if self.Dicom.isLoaded(): - - if self.isDragging: # dragging selection - thisClick = (event.x, event.y) - selected = list(self.Trace.selected) - coords = [] - # move all currently selected crosshairs - for sch in selected: - # keep their relative distance constant - center = ( sch.x, sch.y ) # canvas coordinates not true coordinates - newX = event.x + center[0] - self.dragClick[0] - newY = event.y + center[1] - self.dragClick[1] - sch.dragTo( (newX,newY) ) - coords.append( center ) - - self.dragClick = thisClick - self.Control.push({ 'type':'move', 'chs':selected, 'coords':coords }) - - elif self.isClicked: # no selection, mouse clicked - lastClick = self.click - thisClick = (event.x, event.y) - # enforce minimum distance b/w new crosshairs - dx = abs(thisClick[0] - lastClick[0]) / self.Dicom.zframe.imgscale - dy = abs(thisClick[1] - lastClick[1]) / self.Dicom.zframe.imgscale - if dx > util.CROSSHAIR_DRAG_BUFFER or dy > util.CROSSHAIR_DRAG_BUFFER: - self.click = thisClick - ch = self.Trace.add( *self.click ) - self.Control.push({ 'type':'add', 'chs':[ch] }) - def onEscape(self, event): - ''' - Handle key : empties the current selection - ''' - self.isDragging = False - self.isClicked = False - self.Trace.unselectAll() - def onBackspace(self, event): - ''' - Handle key : removes current selection - ''' - for sch in self.Trace.selected: - self.Trace.remove( sch ) - self.Control.push({ 'type':'delete', 'chs':self.Trace.selected }) - self.Trace.unselectAll() - - def filesUpdate(self): - ''' - Changes to be executed every time we change files - ''' - # update variables - self.currentFileSV.set( self.Data.files[ self.currentFID ] ) - self.frame = 1 - self.frames= 1 - - # reset modules - self.Control.reset() - self.Trace.reset() - self.Dicom.reset() # need this after Trace.reset() #NOTE is this still true? - self.Audio.reset() - self.TextGrid.reset() - self.Spectrogram.reset() - - # check if we can pan left/right - self.filesPrevBtn['state'] = 'disabled' if self.Data.getFileLevel('_prev')==None else 'normal' - self.filesNextBtn['state'] = 'disabled' if self.Data.getFileLevel('_next')==None else 'normal' - #load first frame - self.framesUpdate() - - def filesPrev(self, event=None): - ''' - controls self.filesPrevBtn for panning between available files - ''' - if self.Data.getFileLevel( '_prev' ) != None: - # change the index of the current file - self.currentFID -= 1 - # update - self.filesUpdate() - def filesNext(self, event=None): - ''' - controls self.filesNextBtn for panning between available files - ''' - if self.Data.getFileLevel( '_next' ) != None: - # change the index of the current file - self.currentFID += 1 - # update - self.filesUpdate() - def filesJumpTo(self, choice): - ''' - jump directly to an available file (from the OptionMenu widget) - ''' - self.currentFID = self.Data.files.index( choice ) - self.filesUpdate() - - def framesUpdate(self): - ''' - Changes to be executed every time we change frames - ''' - # frameTier = self.TextGrid.TextGrid.getFirst(self.TextGrid.frameTierName) - # if - - # update variables - self.frameSV.set( str(self.frame) ) - - # update modules - self.Control.update() - self.Dicom.update() - self.Trace.update() - self.Audio.update() - self.TextGrid.update() - self.Spectrogram.update() - - # check if we can pan left/right - self.framesPrevBtn['state'] = 'disabled' if self.frame==self.TextGrid.startFrame else 'normal' - self.framesNextBtn['state'] = 'disabled' if self.frame==self.TextGrid.endFrame else 'normal' - - def framesPrev(self, event=None): - ''' - controls self.framesPrevBtn for panning between frames - ''' - # if self.Dicom.isLoaded and self.frame > self.TextGrid.startFrame: - if isinstance(self.focus_get(), (Entry, Spinbox)): return - if self.frame > self.TextGrid.startFrame: - self.frame -= 1 - # if len(self.TextGrid.selectedIntvlFrames) != 0: - # while str(self.frame) not in self.TextGrid.selectedIntvlFrames or self.frame > self.TextGrid.last_frame: - # if self.frame <= int(self.TextGrid.selectedIntvlFrames[0]): - # self.frame = int(self.TextGrid.selectedIntvlFrames[0]) - # break - # self.frame -= 1 - self.framesUpdate() - def framesNext(self, event=None): - ''' - controls self.framesNextBtn for panning between frames - ''' - # if self.Dicom.isLoaded and self.frame < self.TextGrid.endFrame: - if isinstance(self.focus_get(), (Entry, Spinbox)): return - if self.frame < self.TextGrid.endFrame: - self.frame += 1 - # if len(self.TextGrid.selectedIntvlFrames) != 0: - # while str(self.frame) not in self.TextGrid.selectedIntvlFrames or self.frame < self.TextGrid.first_frame: - # if self.frame >= int(self.TextGrid.selectedIntvlFrames[-1]): - # self.frame = int(self.TextGrid.selectedIntvlFrames[-1]) - # break - # self.frame += 1 - self.framesUpdate() - def unfocusAndJump(self, event): - self.framesJumpTo() - self.framesFrame.focus() - def framesJumpTo(self): - ''' - jump directly to a frame (from the Entry widget) - ''' - try: - - choice = int( self.frameSV.get() ) - - if choice<1: - self.frame = 1 - elif choice>self.frames: - self.frame = self.frames - else: - self.frame = choice - - self.framesUpdate() - - except ValueError: - error( 'Please enter an integer!' ) - -if __name__=='__main__': - app = App() - # app.mainloop() - while True: - try: - app.mainloop() - break - except UnicodeDecodeError as e: - error(e) + """ + This class is neatly wraps all the functionality of our application. By itself, + it's is responsible for handling command line input, navigating between files, + navigating between frames, handling of some events, and coordinating other core + functionality (which is handled by individual `modules`). + + Note: + - when we change files, modules should execute MODULE.reset() methods + - when we change frames, modules should execute MODULE.update() methods + - modules that are responsible for managing their own widgets should have + MODULE.grid() and MODULE.grid_remove() methods to wrap corresponding + functionality for their widgets + """ + + def __init__(self): + + info("initializing UltraTrace") + + default_ttktheme = "clam" # alt, clam, classic, default + # do the normal Tk init stuff + if util.get_platform() == "Linux": + try: + info(" - loading platform-specific enhancements for Linux") + import xrp # pip3 install xparser + from pathlib import Path + + XresPath = os.path.join(str(Path.home()), ".Xresources") + if os.path.isfile(XresPath) or os.path.islink(XresPath): + info(" - found .Xresources file: {}".format(XresPath)) + Xresources = xrp.parse_file(XresPath, encoding="utf8") + # info("Opened .Xresources file {}".format(XresPath)) + if "*TtkTheme" in Xresources.resources: + ttktheme = Xresources.resources["*TtkTheme"] + if ttktheme in THEMES: + info(" - setting Linux Ttk theme to {}".format(ttktheme)) + else: + warn( + " - Ttk theme {} specified ~/.Xresources not available, defaulting to {}".format( + ttktheme, default_ttktheme + ) + ) + ttktheme = default_ttktheme + elif "*TkTheme" in Xresources.resources: + ttktheme = Xresources.resources["*TkTheme"] + info(" - setting Linux Tk theme to {}".format(ttktheme)) + else: + ttktheme = default_ttktheme + info( + ' - falling back to default Linux Tk theme: {}. You can set your theme to something else by adding a line like "*TkTheme: alt" or "*TtkTheme: arc" to ~/.Xresources'.format( + ttktheme + ) + ) + else: + ttktheme = "clam" # alt, clam, classic, default + info( + ' - no ~/.Xresources file found; falling back to default Linux Tk theme: {}. You can set your theme to something else by adding a line like "*TkTheme: alt" or "*TtkTheme: arc" to ~/.Xresources'.format( + ttktheme + ) + ) + super().__init__(theme=ttktheme) + except Exception as e: + error("Crash while loading .Xresources file or initialising T(t)k theme", e) + super().__init__() + else: + super().__init__() + self.title("UltraTrace") + + # check if we were passed a command line argument + parser = argparse.ArgumentParser(prog="UltraTrace") + parser.add_argument( + "path", + help="path (unique to a participant) where subdirectories contain raw data", + default=None, + nargs="?", + ) + args = parser.parse_args() + + # initialize data module + self.Data = modules.Metadata(self, args.path) + + # initialize the main app widgets + self.setWidgetDefaults() + self.buildWidgetSkeleton() + + # initialize other modules + self.Control = modules.Control(self) + self.Trace = modules.Trace(self) + self.Dicom = modules.Dicom(self) + self.Audio = modules.Playback(self) + self.TextGrid = modules.TextGrid(self) + self.Spectrogram = modules.Spectrogram(self) + self.Search = modules.Search(self) + + info(" - loading widgets") + + self.filesUpdate() + # self.framesUpdate() + # self.TextGrid.startup() #NOTE why does modules.TextGrid have to reset a second time? Is there a more economical way to do this? + + # to deal with resize handler being called multiple times + # in a single window resize + self.isResizing = False + + self.oldwidth = self.winfo_width() + + self.after(300, self.afterstartup) + + def setWidgetDefaults(self): + """ + Need to set up some defaults here before building Tk widgets (this is specifically + true w/r/t the StringVars) + """ + self.currentFID = 0 # file index w/in list of sorted files + self.frame = 0 # current frame of dicom file + self.isClicked = False # used in handling of canvas click events + self.isDragging = False # used in handling of canvas click events + # self.resized = False #for changing widgets after window resize + self.selectBoxX = False + self.selectBoxY = False + + # some styling + self.fontStyle = Style() + if util.get_platform() == "Darwin": + self.fontStyle.configure("symbol.TButton", font=("DejaVu Serif", 26)) + else: + self.fontStyle.configure("symbol.TButton", font=("DejaVu Serif", 19)) + + # declare string variables + self.currentFileSV = StringVar(self) + self.frameSV = StringVar(self) + + # initialize string variables + self.currentFileSV.set(self.Data.files[self.currentFID]) + self.frameSV.set("1") + + def buildWidgetSkeleton(self): + """ + Builds the basic skeleton of our app widgets. + - items marked with (*) are built directly in this function + - items marked with (~) are built by the individual modules + # WARNING: out of date diagram + .________________________________________. + | ROOT | + | .____________________________________. | + | | TOP* | | + | | ._______________. .______________. | | + | | | LEFT* | | RIGHT* | | | + | | | - file nav* | | - dicom~ | | | + | | | - frame nav*| | | | | + | | | - traces~ | | | | | + | | | - undo~ | | | | | + | | \_______________/ \______________/ | | + | \____________________________________/ | + | | + | .____________________________________. | + | | BOTTOM* | | + | | - spectrogram~ | | + | | - textgrid~ | | + | \____________________________________/ | + \________________________________________/ + """ + # main Frame skeleton + self.TOP = Frame(self) + self.TOP.columnconfigure(1, weight=1, minsize=320) + self.TOP.rowconfigure(0, weight=1, minsize=240) + self.LEFT = Frame(self.TOP) + # self.LEFT.rowconfigure(0,weight=1) + # self.LEFT.columnconfigure(0,weight=1) + self.RIGHT = Frame(self.TOP) + self.RIGHT.rowconfigure(0, weight=1) + self.RIGHT.columnconfigure(0, weight=1) + self.BOTTOM = Frame(self) + # self.BOTTOM.columnconfigure(0,weight=1) + self.BOTTOM.columnconfigure(1, weight=1) + # self.BOTTOM.rowconfigure(0,weight=1) + # self.TOP.grid( row=0, column=0, sticky='nw') + # self.LEFT.grid( row=0, sticky='n' ) + # self.RIGHT.grid( row=0, column=1) + # self.BOTTOM.grid( row=1, column=0, sticky='e') + self.TOP.grid(row=0, column=0, sticky="nesw") + self.LEFT.grid(row=0, sticky="nesw") + self.RIGHT.grid(row=0, column=1, sticky="nesw") + self.BOTTOM.grid(row=1, column=0, sticky="nesw") + self.pady = 3 + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # navigate between all available filenames in this directory + self.filesFrame = Frame(self.LEFT) # , pady=7) + self.filesPrevBtn = Button( + self.filesFrame, text="<", command=self.filesPrev, takefocus=0, width="1.5" + ) + self.filesJumpToMenu = OptionMenu( + self.filesFrame, + self.currentFileSV, + self.Data.files[0], + *self.Data.files, + command=self.filesJumpTo + ) + self.filesNextBtn = Button( + self.filesFrame, text=">", command=self.filesNext, takefocus=0, width="1.5" + ) + self.filesFrame.grid(row=1) + self.filesPrevBtn.grid(row=1, column=0) + self.filesJumpToMenu.grid(row=1, column=1) + self.filesNextBtn.grid(row=1, column=2) + Header(self.filesFrame, text="Recording").grid(row=0, column=0, columnspan=3) + + # navigate between frames + self.framesFrame = Frame(self.LEFT) # , pady=7) + self.framesSubframe = Frame(self.framesFrame) + self.framesPrevBtn = Button( + self.framesSubframe, text="<", command=self.framesPrev, takefocus=0, width="1.5" + ) + self.framesEntryText = Entry(self.framesSubframe, width=5, textvariable=self.frameSV) + self.framesEntryBtn = Button( + self.framesSubframe, text="Go", command=self.framesJumpTo, takefocus=0, width="3" + ) + self.framesNextBtn = Button( + self.framesSubframe, text=">", command=self.framesNext, takefocus=0, width="1.5" + ) + self.framesHeader = Header(self.framesFrame, text="Frame") + self.framesFrame.grid(row=3) + self.framesSubframe.grid(row=1) + + # non-module-specific bindings + if util.get_platform() == "Linux": + self.bind("", self.filesPrev) + self.bind("", self.filesNext) + else: + self.bind("", self.filesPrev) + self.bind("", self.filesNext) + self.bind("", self.framesPrev) + self.bind("", self.framesNext) + self.bind("", self.onBackspace) + self.bind("", self.getWinSize) + self.bind("", self.onRelease) + self.bind("", self.onDoubleClick) + self.bind("", self.onEscape) + # self.count = 0 + + self.framesEntryText.bind("", self.unfocusAndJump) + self.framesEntryText.bind("", lambda ev: self.framesFrame.focus()) + + # force window to front + self.lift() + + def lift(self): + """ + Bring window to front (doesn't shift focus to window) + """ + self.attributes("-topmost", 1) + self.attributes("-topmost", 0) + + def afterstartup(self): + """ """ + self.bind("", self.alignBottomLeftWrapper) + self.alignBottomLeft() + self.getWinSize() + self.alignBottomRight(self.oldwidth - self.leftwidth) + if self.Dicom.zframe.image: + self.Dicom.zframe.setImage(self.Dicom.zframe.image) + + def alignBottomLeftWrapper(self, event=None): + if self.isResizing: + return + self.isResizing = True + self.after(100, lambda: self.alignBottomLeft(event)) + + def alignBottomLeft(self, event=None): + """ + Makes the length of the canvases on the lower left the same length as the pane of controls in self.LEFT + """ + self.leftwidth = self.LEFT.winfo_width() + for t in range(len(self.TextGrid.TkWidgets)): + tierWidgets = self.TextGrid.TkWidgets[t] + if "frames" in tierWidgets: + tierWidgets["frames-label"].config(width=self.leftwidth) + tierWidgets["frames-label"].coords( + "all", (self.leftwidth, tierWidgets["frames-label"].coords(1)[1]) + ) + if "canvas" in tierWidgets: + tierWidgets["canvas-label"].config(width=self.leftwidth) + tierWidgets["canvas-label"].coords( + "all", (self.leftwidth, tierWidgets["canvas-label"].coords(1)[1]) + ) + if event == None or event.widget == self: + self.alignBottomRight(self.winfo_width() - self.leftwidth) + if self.Dicom.zframe.image: + self.Dicom.zframe.setImage(self.Dicom.zframe.image) + self.isResizing = False + + def alignBottomRight(self, x): + """ """ + self.Spectrogram.canvas_width = x + self.Spectrogram.canvas.config(width=x) + self.TextGrid.canvas_width = x + for t in range(len(self.TextGrid.TkWidgets)): + tierWidgets = self.TextGrid.TkWidgets[t] + canvas = None + if "frames" in tierWidgets: + tierWidgets["frames"].config(width=x) + elif "canvas" in tierWidgets: + tierWidgets["canvas"].config(width=x) + if "times" in tierWidgets: + tierWidgets["times"].config(width=x) + tierWidgets["times"].coords( + 2, (x, tierWidgets["times"].coords(2)[1]) + ) # move end time + tierWidgets["times"].coords(3, (x / 2, tierWidgets["times"].coords(3)[1])) + self.TextGrid.fillCanvases() # calls Spectrogram.reset + + # def onWindowResize(self, event): + # ''' + # Handle moving or resizing the app window + # ''' + # self.alignBottomLeft() + # # self.resized=True + + def getWinSize(self, event=None): + self.oldwidth = self.winfo_width() + + def onDoubleClick(self, event): + """select only crosshairs that's double clicked""" + nearby = self.Trace.getNearClickAllTraces((event.x, event.y)) + if nearby != None: + self.Trace.unselectAll() + self.Trace.select(nearby) + + def onClickZoom(self, event): + """ + Handle clicking within the zoomframe canvas + """ + if self.Dicom.isLoaded(): + self.click = (event.x, event.y) + self.isDragging = False + + # get nearby crosshairs from this trace + nearby = self.Trace.getNearClickAllTraces(self.click) + + # if we didn't click near anything ... + if nearby == None: + self.Trace.unselectAll() + if event.state != 17: + # unselect crosshairs + self.isClicked = True + ch = self.Trace.add(*self.click) + self.Control.push({"type": "add", "chs": [ch]}) + else: + self.selectBoxX = self.Dicom.zframe.canvas.canvasx(event.x) + self.selectBoxY = self.Dicom.zframe.canvas.canvasy(event.y) + return + + # NOTE: only get here if we clicked near something + + # if holding option key, unselect the guy we clicked on + # if event.state == 16: + # if holding shift key (event.state==17), and crosshair is selected, unselect it + # debug(event.state) + if event.state == 17 and nearby in self.Trace.selected: + nearby.unselect() + if nearby in self.Trace.selected: + self.Trace.selected.remove(nearby) + + # otherwise, if not selected, add it to our selection + elif nearby not in self.Trace.selected: + # debug("event.state", event.state) + if event.state != 1: # and nearby.isSelected == False: + self.Trace.unselectAll() + + # add this guy to our current selection + self.Trace.select(nearby) + # through all of these operations, if clicked ch is selected, is ready to be dragged + if nearby in self.Trace.selected: + # set dragging variables + self.isDragging = True + self.dragClick = self.click + + def onReleaseZoom(self, event): + """ + Handle releasing a click within the zoomframe canvas + """ + if self.Dicom.isLoaded(): + + # select multiple crosshairs + if self.selectBoxX != False: + canvas = self.Dicom.zframe.canvas + x1 = self.selectBoxX + x2 = canvas.canvasx(event.x) + y1 = self.selectBoxY + y2 = canvas.canvasy(event.y) + self.selectBoxX = False + self.selectBoxY = False + + trace = self.Trace.getCurrentTraceName() + coords = [] + x1True = None + + if trace in self.Trace.crosshairs: + for ch in self.Trace.crosshairs[trace]: + if x1True == None: + x1True, y1True = ch.transformCoordsToTrue(x1, y1) + x2True, y2True = ch.transformCoordsToTrue(x2, y2) + if ch.isVisible: + x, y = ch.getTrueCoords() + if min(x1True, x2True) < x < max(x1True, x2True) and min( + y1True, y2True + ) < y < max(y1True, y2True): + self.Trace.select(ch) + + self.isDragging = False + self.isClicked = False + self.Trace.write() + + def onReleaseSpec(self, event): + """shift + release zooms textgrid & spectrogram to selected interval""" + if self.Spectrogram.specClick == True: + # if event.state==257: + canvas = self.Spectrogram.canvas + t1 = self.Spectrogram.clicktime + t2 = self.Spectrogram.xToTime(canvas.canvasx(event.x)) + # self.TextGrid.start = float(min(t1,t2)) + # self.TextGrid.end = float(max(t1,t2)) + # for itm in canvas.find_all()[0]: + # for tag in canvas.gettags(itm): #canvas.dtag() does not seem to work with one argument + if ( + max(t1, t2) - min(t1, t2) > self.Spectrogram.ts + ): # if selected area is larger than one strip of Spectrogram + # gets rid of previous tags + for tag in canvas.gettags(canvas.find_all()[0]): + canvas.dtag(canvas.find_all()[0], tag) + a = self.Spectrogram.timeToX(self.Spectrogram.clicktime) + b = event.x + x1 = min(a, b) + x2 = max(a, b) + # find all frames within range, and add them as tags + frame_i = self.TextGrid.frames_canvas.find_all()[0] + current_loc = self.TextGrid.frames_canvas.coords(frame_i)[0] + while current_loc < x2: + if current_loc > x1: + tag = self.TextGrid.frames_canvas.gettags(frame_i)[0] + canvas.addtag_all(tag) + frame_i += 1 + current_loc = self.TextGrid.frames_canvas.coords(frame_i)[0] + canvas.addtag_all("minTime" + str(self.Spectrogram.xToTime(x1))) + canvas.addtag_all("maxTime" + str(self.Spectrogram.xToTime(x2))) + self.TextGrid.selectedItem = (canvas, canvas.find_all()[0]) + self.TextGrid.setSelectedIntvlFrames(self.TextGrid.selectedItem) + # self.TextGrid.paintCanvases() + # self.Spectrogram.drawInterval(l_loc=x1,r_loc=x2) + # debug(canvas.gettags('all')) + # specgram = self.Spectrogram.canvas.find_all()[0] + # self.TextGrid.fillCanvases() + self.TextGrid.genFrameList(widg=canvas, x_loc=x2, SI=True) + self.Spectrogram.specClick = False + self.Spectrogram.clicktime = -1 + + def onRelease(self, event): + """ """ + # runs if click happened on specific canvases + self.onReleaseZoom(event) + self.onReleaseSpec(event) + # runs if window resized + # if self.resized == True and self.Dicom.zframe.shown == True: #shouldn't trigger when frame not displayed + if ( + self.winfo_width() != self.oldwidth and self.Dicom.zframe.shown == True + ): # shouldn't trigger when frame not displayed + # self.resized = False + # resize dicom image + png_loc = self.Data.getPreprocessedDicom(self.frame) + image = PIL.Image.open(png_loc) + self.Dicom.zframe.setImage(image) + # x = self.Dicom.zframe.width + x = self.winfo_width() - self.LEFT.winfo_width() + # y = self.Dicom.zframe.height + # resize TextGrid tiers and spectrogram + self.alignBottomRight(x) + # move Traces + self.Trace.move() + + # save layout ot geometry manager + geometry = self.geometry() + self.Data.setTopLevel("geometry", geometry) + + def onMotion(self, event): + """ + Handle mouse movement within the zoomframe canvas + """ + if self.Dicom.isLoaded(): + + if self.isDragging: # dragging selection + thisClick = (event.x, event.y) + selected = list(self.Trace.selected) + coords = [] + # move all currently selected crosshairs + for sch in selected: + # keep their relative distance constant + center = (sch.x, sch.y) # canvas coordinates not true coordinates + newX = event.x + center[0] - self.dragClick[0] + newY = event.y + center[1] - self.dragClick[1] + sch.dragTo((newX, newY)) + coords.append(center) + + self.dragClick = thisClick + self.Control.push({"type": "move", "chs": selected, "coords": coords}) + + elif self.isClicked: # no selection, mouse clicked + lastClick = self.click + thisClick = (event.x, event.y) + # enforce minimum distance b/w new crosshairs + dx = abs(thisClick[0] - lastClick[0]) / self.Dicom.zframe.imgscale + dy = abs(thisClick[1] - lastClick[1]) / self.Dicom.zframe.imgscale + if dx > util.CROSSHAIR_DRAG_BUFFER or dy > util.CROSSHAIR_DRAG_BUFFER: + self.click = thisClick + ch = self.Trace.add(*self.click) + self.Control.push({"type": "add", "chs": [ch]}) + + def onEscape(self, event): + """ + Handle key : empties the current selection + """ + self.isDragging = False + self.isClicked = False + self.Trace.unselectAll() + + def onBackspace(self, event): + """ + Handle key : removes current selection + """ + for sch in self.Trace.selected: + self.Trace.remove(sch) + self.Control.push({"type": "delete", "chs": self.Trace.selected}) + self.Trace.unselectAll() + + def filesUpdate(self): + """ + Changes to be executed every time we change files + """ + # update variables + self.currentFileSV.set(self.Data.files[self.currentFID]) + self.frame = 1 + self.frames = 1 + + # reset modules + self.Control.reset() + self.Trace.reset() + self.Dicom.reset() # need this after Trace.reset() #NOTE is this still true? + self.Audio.reset() + self.TextGrid.reset() + self.Spectrogram.reset() + + # check if we can pan left/right + self.filesPrevBtn["state"] = ( + "disabled" if self.Data.getFileLevel("_prev") == None else "normal" + ) + self.filesNextBtn["state"] = ( + "disabled" if self.Data.getFileLevel("_next") == None else "normal" + ) + # load first frame + self.framesUpdate() + + def filesPrev(self, event=None): + """ + controls self.filesPrevBtn for panning between available files + """ + if self.Data.getFileLevel("_prev") != None: + # change the index of the current file + self.currentFID -= 1 + # update + self.filesUpdate() + + def filesNext(self, event=None): + """ + controls self.filesNextBtn for panning between available files + """ + if self.Data.getFileLevel("_next") != None: + # change the index of the current file + self.currentFID += 1 + # update + self.filesUpdate() + + def filesJumpTo(self, choice): + """ + jump directly to an available file (from the OptionMenu widget) + """ + self.currentFID = self.Data.files.index(choice) + self.filesUpdate() + + def framesUpdate(self): + """ + Changes to be executed every time we change frames + """ + # frameTier = self.TextGrid.TextGrid.getFirst(self.TextGrid.frameTierName) + # if + + # update variables + self.frameSV.set(str(self.frame)) + + # update modules + self.Control.update() + self.Dicom.update() + self.Trace.update() + self.Audio.update() + self.TextGrid.update() + self.Spectrogram.update() + + # check if we can pan left/right + self.framesPrevBtn["state"] = ( + "disabled" if self.frame == self.TextGrid.startFrame else "normal" + ) + self.framesNextBtn["state"] = ( + "disabled" if self.frame == self.TextGrid.endFrame else "normal" + ) + + def framesPrev(self, event=None): + """ + controls self.framesPrevBtn for panning between frames + """ + # if self.Dicom.isLoaded and self.frame > self.TextGrid.startFrame: + if isinstance(self.focus_get(), (Entry, Spinbox)): + return + if self.frame > self.TextGrid.startFrame: + self.frame -= 1 + # if len(self.TextGrid.selectedIntvlFrames) != 0: + # while str(self.frame) not in self.TextGrid.selectedIntvlFrames or self.frame > self.TextGrid.last_frame: + # if self.frame <= int(self.TextGrid.selectedIntvlFrames[0]): + # self.frame = int(self.TextGrid.selectedIntvlFrames[0]) + # break + # self.frame -= 1 + self.framesUpdate() + + def framesNext(self, event=None): + """ + controls self.framesNextBtn for panning between frames + """ + # if self.Dicom.isLoaded and self.frame < self.TextGrid.endFrame: + if isinstance(self.focus_get(), (Entry, Spinbox)): + return + if self.frame < self.TextGrid.endFrame: + self.frame += 1 + # if len(self.TextGrid.selectedIntvlFrames) != 0: + # while str(self.frame) not in self.TextGrid.selectedIntvlFrames or self.frame < self.TextGrid.first_frame: + # if self.frame >= int(self.TextGrid.selectedIntvlFrames[-1]): + # self.frame = int(self.TextGrid.selectedIntvlFrames[-1]) + # break + # self.frame += 1 + self.framesUpdate() + + def unfocusAndJump(self, event): + self.framesJumpTo() + self.framesFrame.focus() + + def framesJumpTo(self): + """ + jump directly to a frame (from the Entry widget) + """ + try: + + choice = int(self.frameSV.get()) + + if choice < 1: + self.frame = 1 + elif choice > self.frames: + self.frame = self.frames + else: + self.frame = choice + + self.framesUpdate() + + except ValueError: + error("Please enter an integer!") + + +def main() -> None: + app = App() + # app.mainloop() + while True: + try: + app.mainloop() + break + except UnicodeDecodeError as e: + error(e) + + +if __name__ == "__main__": + main() diff --git a/ultratrace/modules/base.py b/ultratrace/modules/base.py index 4c19503..413fb06 100644 --- a/ultratrace/modules/base.py +++ b/ultratrace/modules/base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -class Module(ABC): +class Module(ABC): @abstractmethod def update(*args, **kwargs): pass @@ -17,4 +17,3 @@ def grid(*args, **kwargs): @abstractmethod def grid_remove(*args, **kwargs): pass - diff --git a/ultratrace/modules/control.py b/ultratrace/modules/control.py index 4757a30..dd05483 100644 --- a/ultratrace/modules/control.py +++ b/ultratrace/modules/control.py @@ -6,8 +6,9 @@ from tkinter.ttk import Button, Frame from tkinter import PhotoImage + class Control(Module): - ''' + """ This class provides a clean interface for managing Undo/Redo functionality. Implementation relies on storing two lists of actions as stacks, one for Undo and one for Redo. Calling Undo/Redo should pop the corresponding stack and @@ -18,9 +19,10 @@ class Control(Module): - change files - change frames - Dicom.resetZoom() - ''' + """ + def __init__(self, app): - info( ' - initializing module: Control' ) + info(" - initializing module: Control") # reference to our main object containing other functionality managers self.app = app # initialize our stacks @@ -29,78 +31,102 @@ def __init__(self, app): # some images for the buttons # Source for icons: https://material.io/tools/icons/?style=outline # License: Apache Version 2.0 www.apache.org/licenses/LICENSE-2.0.txt - data_copy = '''R0lGODlhGAAYAPAAAAAAAAAAACH5BAEAAAEALAAAAAAYABgAAAJHjI+pCe3/1oHUSdOunmDvHFTWBYrjUnbMuWIqAqEqCMdt+HI25yrVTZMEcT3NMPXJEZckJdKorCWbU2H0JqvKTBErl+XZFAAAOw''' - data_paste = '''R0lGODlhGAAYAPAAAAAAAAAAACH5BAEAAAEALAAAAAAYABgAAAJBjI+pq+DAonlPToqza7rv9FlBeJCSOUJpd3EXm7piDKoi+nkqvnttPaMhUAzeiwJMapJDm8U44+kynCkmiM1qZwUAOw''' + data_copy = """R0lGODlhGAAYAPAAAAAAAAAAACH5BAEAAAEALAAAAAAYABgAAAJHjI+pCe3/1oHUSdOunmDvHFTWBYrjUnbMuWIqAqEqCMdt+HI25yrVTZMEcT3NMPXJEZckJdKorCWbU2H0JqvKTBErl+XZFAAAOw""" + data_paste = """R0lGODlhGAAYAPAAAAAAAAAAACH5BAEAAAEALAAAAAAYABgAAAJBjI+pq+DAonlPToqza7rv9FlBeJCSOUJpd3EXm7piDKoi+nkqvnttPaMhUAzeiwJMapJDm8U44+kynCkmiM1qZwUAOw""" self.img_copy = PhotoImage(data=data_copy) self.img_paste = PhotoImage(data=data_paste) # bind Ctrl+z to UNDO and Ctrl+Shift+Z to REDO - if util.get_platform() == 'Linux': - self.app.bind('', self.undo ) - self.app.bind('', self.redo ) + if util.get_platform() == "Linux": + self.app.bind("", self.undo) + self.app.bind("", self.redo) else: - self.app.bind('', self.undo ) - self.app.bind('', self.redo ) + self.app.bind("", self.undo) + self.app.bind("", self.redo) # also make some buttons and bind them - self.frame = Frame(self.app.LEFT)#, pady=7) - self.frame.grid( row=5 ) + self.frame = Frame(self.app.LEFT) # , pady=7) + self.frame.grid(row=5) self.header = Header(self.frame, text="Points") - self.selectAllBtn = Button(self.frame, text='⬚', command=self.selectAll, takefocus=0, style="symbol.TButton", width=1.5) - self.copyBtn = Button(self.frame, image=self.img_copy, command=self.copy, takefocus=0) # FIXME: add tooltip for "Copy" - self.pasteBtn = Button(self.frame, image=self.img_paste, command=self.paste, takefocus=0) # FIXME: add tooltip for "Paste" - self.undoBtn = Button(self.frame, text='↶', command=self.undo, takefocus=0, width=1.5, style="symbol.TButton") - self.redoBtn = Button(self.frame, text='↷', command=self.redo, takefocus=0, width=1.5, style="symbol.TButton") + self.selectAllBtn = Button( + self.frame, + text="⬚", + command=self.selectAll, + takefocus=0, + style="symbol.TButton", + width=1.5, + ) + self.copyBtn = Button( + self.frame, image=self.img_copy, command=self.copy, takefocus=0 + ) # FIXME: add tooltip for "Copy" + self.pasteBtn = Button( + self.frame, image=self.img_paste, command=self.paste, takefocus=0 + ) # FIXME: add tooltip for "Paste" + self.undoBtn = Button( + self.frame, text="↶", command=self.undo, takefocus=0, width=1.5, style="symbol.TButton" + ) + self.redoBtn = Button( + self.frame, text="↷", command=self.redo, takefocus=0, width=1.5, style="symbol.TButton" + ) self.updateButtons() + def push(self, item): - ''' + """ add an item to the undo-stack and empty out the redo-stack - ''' - self.uStack.append( item ) + """ + self.uStack.append(item) self.rStack = [] self.updateButtons() + def reset(self): - ''' reset our stacks ''' - self.uStack = [] # undo - self.rStack = [] # redo + """reset our stacks""" + self.uStack = [] # undo + self.rStack = [] # redo + def update(self): - ''' changing files and changing frames should have the same effect ''' + """changing files and changing frames should have the same effect""" self.reset() + def selectAll(self): self.app.Trace.selectAll() + def copy(self): self.app.Trace.copy() + def paste(self): self.app.Trace.paste() + def undo(self, event=None): - ''' perform the undo-ing ''' + """perform the undo-ing""" if len(self.uStack): item = self.uStack.pop() - if item['type'] == 'add': - chs = item['chs'] + if item["type"] == "add": + chs = item["chs"] for ch in chs: - self.app.Trace.remove( ch ) - self.rStack.append({ 'type':'delete', 'chs':chs }) - elif item['type'] == 'delete': - chs = item['chs'] + self.app.Trace.remove(ch) + self.rStack.append({"type": "delete", "chs": chs}) + elif item["type"] == "delete": + chs = item["chs"] for ch in chs: ch.draw() - self.rStack.append({ 'type':'add', 'chs':chs }) - elif item['type'] == 'move': - chs = item['chs'] - coords = item['coords'] + self.rStack.append({"type": "add", "chs": chs}) + elif item["type"] == "move": + chs = item["chs"] + coords = item["coords"] for i in range(len(chs)): - chs[i].dragTo( coords[i] ) - self.rStack.append({ 'type':'move', 'chs':chs, 'coords':coords }) - elif item['type'] == 'recolor': - oldColor = self.app.Trace.recolor( item['trace'], item['color'] ) - self.rStack.append({ 'type':'recolor', 'trace':item['trace'], 'color':oldColor }) - elif item['type'] == 'rename': - self.app.Trace.renameTrace( newName=item['old'], oldName=item['new'] ) # this is backwards on purpose - self.rStack.append({ 'type':'rename', 'old':item['old'], 'new':item['new'] }) + chs[i].dragTo(coords[i]) + self.rStack.append({"type": "move", "chs": chs, "coords": coords}) + elif item["type"] == "recolor": + oldColor = self.app.Trace.recolor(item["trace"], item["color"]) + self.rStack.append({"type": "recolor", "trace": item["trace"], "color": oldColor}) + elif item["type"] == "rename": + self.app.Trace.renameTrace( + newName=item["old"], oldName=item["new"] + ) # this is backwards on purpose + self.rStack.append({"type": "rename", "old": item["old"], "new": item["new"]}) else: error(item) raise NotImplementedError @@ -109,35 +135,36 @@ def undo(self, event=None): self.app.Trace.write() self.updateButtons() else: - warn( 'Nothing to undo!' ) + warn("Nothing to undo!") + def redo(self, event=None): - ''' perform the redo-ing ''' + """perform the redo-ing""" if len(self.rStack): item = self.rStack.pop() - if item['type'] == 'add': - chs = item['chs'] + if item["type"] == "add": + chs = item["chs"] for ch in chs: - self.app.Trace.remove( ch ) - self.uStack.append({ 'type':'delete', 'chs':chs }) - elif item['type'] == 'delete': - chs = item['chs'] + self.app.Trace.remove(ch) + self.uStack.append({"type": "delete", "chs": chs}) + elif item["type"] == "delete": + chs = item["chs"] for ch in chs: ch.draw() - self.uStack.append({ 'type':'add', 'chs':chs }) - elif item['type'] == 'move': - chs = item['chs'] - coords = item['coords'] + self.uStack.append({"type": "add", "chs": chs}) + elif item["type"] == "move": + chs = item["chs"] + coords = item["coords"] for i in range(len(chs)): - chs[i].dragTo( coords[i] ) - self.uStack.append({ 'type':'move', 'chs':chs, 'coords':coords }) - elif item['type'] == 'recolor': - oldColor = self.app.Trace.recolor( item['trace'], item['color'] ) - self.uStack.append({ 'type':'recolor', 'trace':item['trace'], 'color':oldColor }) - elif item['type'] == 'rename': - self.app.Trace.renameTrace( newName=item['new'], oldName=item['old'] ) - self.uStack.append({ 'type':'rename', 'old':item['old'], 'new':item['new'] }) + chs[i].dragTo(coords[i]) + self.uStack.append({"type": "move", "chs": chs, "coords": coords}) + elif item["type"] == "recolor": + oldColor = self.app.Trace.recolor(item["trace"], item["color"]) + self.uStack.append({"type": "recolor", "trace": item["trace"], "color": oldColor}) + elif item["type"] == "rename": + self.app.Trace.renameTrace(newName=item["new"], oldName=item["old"]) + self.uStack.append({"type": "rename", "old": item["old"], "new": item["new"]}) else: error(item) raise NotImplementedError @@ -146,29 +173,32 @@ def redo(self, event=None): self.app.Trace.write() self.updateButtons() else: - warn( 'Nothing to redo!' ) + warn("Nothing to redo!") + def updateButtons(self): - ''' + """ Don't allow clicking buttons that wrap empty stacks. However, users will still be able to access that functionality thru key bindings. - ''' - self.undoBtn['state'] = 'normal' if len(self.uStack) else 'disabled' - self.redoBtn['state'] = 'normal' if len(self.rStack) else 'disabled' + """ + self.undoBtn["state"] = "normal" if len(self.uStack) else "disabled" + self.redoBtn["state"] = "normal" if len(self.rStack) else "disabled" self.grid() + def grid(self): - ''' + """ Grid button widgets - ''' - self.header.grid(row=0,column=0, columnspan=5) + """ + self.header.grid(row=0, column=0, columnspan=5) self.selectAllBtn.grid(row=1, column=0) self.copyBtn.grid(row=1, column=1, ipady=3) self.pasteBtn.grid(row=1, column=2, ipady=3) self.undoBtn.grid(row=1, column=3) self.redoBtn.grid(row=1, column=4) + def grid_remove(self): - ''' + """ Remove button widgets from grid - ''' + """ self.undoBtn.grid_remove() self.redoBtn.grid_remove() diff --git a/ultratrace/modules/dicom.py b/ultratrace/modules/dicom.py index 8d004f2..928c994 100644 --- a/ultratrace/modules/dicom.py +++ b/ultratrace/modules/dicom.py @@ -1,7 +1,13 @@ from .base import Module from .. import util from ..util.logging import * -from ..util.framereader import ULTScanLineReader, DicomReader, DicomPNGReader, LABEL_TO_READER, READERS +from ..util.framereader import ( + ULTScanLineReader, + DicomReader, + DicomPNGReader, + LABEL_TO_READER, + READERS, +) from ..widgets import Header import os @@ -15,21 +21,23 @@ try: from ..widgets import ZoomFrame import numpy as np - import pydicom # pydicom + import pydicom # pydicom + LIBS_INSTALLED = True except ImportError as e: warn(e) + class Dicom(Module): def __init__(self, app): - info( ' - initializing module: Dicom') + info(" - initializing module: Dicom") self.app = app if LIBS_INSTALLED: # grid load button - self.frame_holder = Frame(self.app.LEFT)#, pady=7) - self.frame_holder.grid( row=2 ) + self.frame_holder = Frame(self.app.LEFT) # , pady=7) + self.frame_holder.grid(row=2) self.frame = Frame(self.frame_holder) self.frame.pack(expand=True) @@ -39,28 +47,50 @@ def __init__(self, app): # zoom buttons self.zbframe = Frame(self.app.LEFT) - self.zbframe.grid( row=6, column=0) + self.zbframe.grid(row=6, column=0) # zoom frame (contains our tracing canvas) self.zframe = ZoomFrame(self.app.RIGHT, 1.3, app) # zoom in, zoom out, reset zoom buttons - #self.header = Header(self.zbframe, text="Zoom") - self.zoomResetBtn = Button(self.zbframe, text='⊜', command=self.zoomReset, width=1.5, style="symbol.TButton", takefocus=0)#, pady=7 ) - self.zoomInBtn = Button(self.zbframe, text='⊕', command=self.zoomIn, width=1.5, style="symbol.TButton", takefocus=0) - self.zoomOutBtn = Button(self.zbframe, text='⊝', command=self.zoomOut, width=1.5, style="symbol.TButton", takefocus=0) + # self.header = Header(self.zbframe, text="Zoom") + self.zoomResetBtn = Button( + self.zbframe, + text="⊜", + command=self.zoomReset, + width=1.5, + style="symbol.TButton", + takefocus=0, + ) # , pady=7 ) + self.zoomInBtn = Button( + self.zbframe, + text="⊕", + command=self.zoomIn, + width=1.5, + style="symbol.TButton", + takefocus=0, + ) + self.zoomOutBtn = Button( + self.zbframe, + text="⊝", + command=self.zoomOut, + width=1.5, + style="symbol.TButton", + takefocus=0, + ) # reset zoom keyboard shortcut - if util.get_platform() == 'Linux': - self.app.bind('', self.zoomReset ) - else: self.app.bind('', self.zoomReset ) + if util.get_platform() == "Linux": + self.app.bind("", self.zoomReset) + else: + self.app.bind("", self.zoomReset) self.reset() self.grid() def zoomReset(self, fromButton=False): - ''' + """ reset zoom frame canvas and rebind it - ''' + """ if self.isLoaded(): # creates a new canvas object and we redraw everything to it self.zframe.resetCanvas() @@ -69,7 +99,8 @@ def zoomReset(self, fromButton=False): # self.zframe.canvas.bind('', self.app.onMotion ) # we want to go here only after a button press - if fromButton: self.app.framesUpdate() + if fromButton: + self.app.framesUpdate() def zoomIn(self): self.zframe.zoomIn() @@ -78,50 +109,50 @@ def zoomOut(self): self.zframe.zoomOut() def update(self, _frame=None): - ''' + """ change the image on the zoom frame - ''' + """ if self.reader and self.reader.loaded: self.zframe.setImage(self.reader.getFrame(_frame or self.app.frame)) def load(self, event=None): - ''' + """ brings a dicom file into memory if it exists - ''' + """ if LIBS_INSTALLED: if self.reader and not self.reader.loaded: self.reader.load() self.app.frame = 1 self.update() - self.loadBtn['state'] = 'disabled' + self.loadBtn["state"] = "disabled" def chooseMethod(self, event=None): if self.mode: cls = LABEL_TO_READER[self.mode][self.method.get()] - if self.mode == 'dicom': - dcm = self.app.Data.checkFileLevel('.dicom') + if self.mode == "dicom": + dcm = self.app.Data.checkFileLevel(".dicom") if dcm: - if cls == DicomPNGReader and self.app.Data.getFileLevel('processed'): - self.reader = cls(dcm, self.app.Data.path) - else: - self.reader = cls(dcm) + if cls == DicomPNGReader and self.app.Data.getFileLevel("processed"): + self.reader = cls(dcm, self.app.Data.path) + else: + self.reader = cls(dcm) else: - self.reader = None - elif self.mode == 'ult': - ult = self.app.Data.checkFileLevel('.ult') - meta = self.app.Data.checkFileLevel('US.txt') + self.reader = None + elif self.mode == "ult": + ult = self.app.Data.checkFileLevel(".ult") + meta = self.app.Data.checkFileLevel("US.txt") if ult and meta: self.reader = cls(ult, meta) else: self.reader = None self.zframe.resetImageDimensions() if not self.reader: - self.loadBtn['state'] = 'disabled' + self.loadBtn["state"] = "disabled" elif self.reader.loaded: - self.loadBtn['state'] = 'disabled' + self.loadBtn["state"] = "disabled" self.update() else: - self.loadBtn['state'] = 'normal' + self.loadBtn["state"] = "normal" def isLoaded(self): return self.reader and self.reader.loaded @@ -132,21 +163,22 @@ def getFrames(self, framenums): def getFrameTimes(self): if self.reader: return self.reader.getFrameTimes() - elif self.mode == 'dicom': - rd = DicomReader(self.app.Data.unrelativize(self.app.Data.getFileLevel('.dicom'))) + elif self.mode == "dicom": + rd = DicomReader(self.app.Data.unrelativize(self.app.Data.getFileLevel(".dicom"))) return rd.getFrameTimes() - elif self.mode == 'ult': + elif self.mode == "ult": rd = ULTScanLineReader( - self.app.Data.unrelativize(self.app.Data.getFileLevel('.ult')), - self.app.Data.unrelativize(self.app.Data.getFileLevel('US.txt'))) + self.app.Data.unrelativize(self.app.Data.getFileLevel(".ult")), + self.app.Data.unrelativize(self.app.Data.getFileLevel("US.txt")), + ) return rd.getFrameTimes() else: return [0] def reset(self): - ''' + """ new files should default to not showing dicom unless it has already been processed - ''' + """ # hide frame navigation widgets # self.grid_remove() @@ -157,43 +189,47 @@ def reset(self): self.frame.pack(expand=True) self.reader = None - if self.app.Data.getFileLevel('.dicom'): - self.mode = 'dicom' - elif self.app.Data.getFileLevel('.ult'): - self.mode = 'ult' + if self.app.Data.getFileLevel(".dicom"): + self.mode = "dicom" + elif self.app.Data.getFileLevel(".ult"): + self.mode = "ult" else: self.mode = None - self.method.set('') - options = [x.label for x in READERS[self.mode]] or ['[no ultrasound]'] - self.methodMenu = OptionMenu(self.frame, self.method, '---', *options, command=self.chooseMethod) + self.method.set("") + options = [x.label for x in READERS[self.mode]] or ["[no ultrasound]"] + self.methodMenu = OptionMenu( + self.frame, self.method, "---", *options, command=self.chooseMethod + ) self.methodMenu.grid(row=0) - self.loadBtn = Button(self.frame, text='Load frames', command=self.load, takefocus=0, state='disabled') + self.loadBtn = Button( + self.frame, text="Load frames", command=self.load, takefocus=0, state="disabled" + ) self.loadBtn.grid(row=1) if len(READERS[self.mode]) == 1: self.method.set(options[0]) self.chooseMethod() def grid(self): - ''' + """ Grid frame navigation, zoom reset, and Control (Undo/Redo) widgets - ''' - self.app.framesHeader.grid( row=0 ) - self.app.framesPrevBtn.grid( row=0, column=0 ) - self.app.framesEntryText.grid( row=0, column=1 ) - self.app.framesEntryBtn.grid( row=0, column=2 ) - self.app.framesNextBtn.grid( row=0, column=3 ) - - #self.header.grid(row=0, column=0, columnspan=5) - self.zoomInBtn.grid( row=0, column=3) - self.zoomResetBtn.grid( row=0, column=2 ) - self.zoomOutBtn.grid( row=0, column=1) + """ + self.app.framesHeader.grid(row=0) + self.app.framesPrevBtn.grid(row=0, column=0) + self.app.framesEntryText.grid(row=0, column=1) + self.app.framesEntryBtn.grid(row=0, column=2) + self.app.framesNextBtn.grid(row=0, column=3) + + # self.header.grid(row=0, column=0, columnspan=5) + self.zoomInBtn.grid(row=0, column=3) + self.zoomResetBtn.grid(row=0, column=2) + self.zoomOutBtn.grid(row=0, column=1) self.app.Control.grid() def grid_remove(self): - ''' + """ Remove widgets from grid - ''' + """ self.app.framesHeader.grid_remove() self.app.framesPrevBtn.grid_remove() self.app.framesEntryText.grid_remove() diff --git a/ultratrace/modules/metadata.py b/ultratrace/modules/metadata.py index 2f9ac3a..7f1e326 100644 --- a/ultratrace/modules/metadata.py +++ b/ultratrace/modules/metadata.py @@ -7,19 +7,20 @@ import math from tkinter import filedialog -from magic import Magic # python-magic +from magic import Magic # python-magic + class Metadata(Module): def __init__(self, app, path): - ''' + """ opens a metadata file (or creates one if it doesn't exist), recursively searches a directory for acceptable files, writes metadata back into memory, and returns the metadata object acceptable files: the metadata file requires matching files w/in subdirectories based on filenames for example, it will try to locate files that have the same base filename and each of a set of required extensions - ''' - info( ' - initializing module: Data') + """ + info(" - initializing module: Data") if path == None: app.update() path = filedialog.askdirectory(initialdir=os.getcwd(), title="Choose a directory") @@ -27,22 +28,22 @@ def __init__(self, app, path): error("No directory chosen - exiting") exit(1) - debug( ' - parsing directory: `%s`' % path ) + debug(" - parsing directory: `%s`" % path) - if os.path.exists( path ) == False: - severe( " - ERROR: `%s` could not be located" % path ) + if os.path.exists(path) == False: + severe(" - ERROR: `%s` could not be located" % path) exit(1) self.app = app self.path = path - self.mdfile = os.path.join( self.path, 'metadata.json' ) + self.mdfile = os.path.join(self.path, "metadata.json") # either load up existing metadata - if os.path.exists( self.mdfile ): - debug( " - found metadata file: `%s`" % self.mdfile ) - with open( self.mdfile, 'r' ) as f: - self.data = json.load( f ) + if os.path.exists(self.mdfile): + debug(" - found metadata file: `%s`" % self.mdfile) + with open(self.mdfile, "r") as f: + self.data = json.load(f) # or create new stuff else: @@ -50,140 +51,150 @@ def __init__(self, app, path): "read the files" self.path = os.path.abspath(self.path) - debug( " - creating new metadata file: `%s`" % self.mdfile ) + debug(" - creating new metadata file: `%s`" % self.mdfile) self.data = { - 'firstrun_path': self.path, - 'defaultTraceName': 'tongue', - 'traces': { - 'tongue': { - 'color': 'red', - 'files': {} } }, - 'offset':0, - 'files': {} } + "firstrun_path": self.path, + "defaultTraceName": "tongue", + "traces": {"tongue": {"color": "red", "files": {}}}, + "offset": 0, + "files": {}, + } # we want each object to have entries for everything here - fileKeys = { '_prev', '_next', 'processed', 'offset' } # and `processed` + fileKeys = {"_prev", "_next", "processed", "offset"} # and `processed` MIMEs = { - 'audio/x-wav' : ['.wav'], - 'audio/x-flac' : ['.flac'], - 'audio/wav' : ['.wav'], - 'audio/flac' : ['.flac'], - 'application/dicom' : ['.dicom'], - 'text/plain' : ['.TextGrid', 'US.txt', '.txt', '.dat'], - 'application/octet-stream' : ['.ult'], - 'application/x-dosexec' : ['.ult'] + "audio/x-wav": [".wav"], + "audio/x-flac": [".flac"], + "audio/wav": [".wav"], + "audio/flac": [".flac"], + "application/dicom": [".dicom"], + "text/plain": [".TextGrid", "US.txt", ".txt", ".dat"], + "application/octet-stream": [".ult"], + "application/x-dosexec": [".ult"], } files = {} splines = None # now get the objects in subdirectories - for path, dirs, fs in os.walk( self.path ): + for path, dirs, fs in os.walk(self.path): for f in fs: # exclude some filetypes explicitly here by MIME type - filepath = os.path.join( path, f ) - filename, extension = os.path.splitext( f ) + filepath = os.path.join(path, f) + filename, extension = os.path.splitext(f) # allow us to follow symlinks real_filepath = os.path.realpath(filepath) - #make file path relative to metadata file - filepath = os.path.relpath(filepath,start=self.path) + # make file path relative to metadata file + filepath = os.path.relpath(filepath, start=self.path) mime_type = Magic(mime=True).from_file(real_filepath) # name mangling for ULT directories - if mime_type == 'text/plain' and extension == '.txt' and filename.endswith('US'): + if ( + mime_type == "text/plain" + and extension == ".txt" + and filename.endswith("US") + ): filename = filename[:-2] - extension = 'US.txt' - if extension == '.wav' and filename.endswith('_Track0'): + extension = "US.txt" + if extension == ".wav" and filename.endswith("_Track0"): filename = filename[:-7] - elif extension == '.wav' and (filename.endswith('_Track1') or filename.endswith('_Track2')): + elif extension == ".wav" and ( + filename.endswith("_Track1") or filename.endswith("_Track2") + ): continue - elif extension == '.dat' and filename == 'SPLINES': + elif extension == ".dat" and filename == "SPLINES": splines = filepath continue - if (mime_type == 'text/plain' or mime_type == 'application/json') and extension == '.measurement': - debug('Found old measurement file {}'.format(filename)) + if ( + mime_type == "text/plain" or mime_type == "application/json" + ) and extension == ".measurement": + debug("Found old measurement file {}".format(filename)) self.importOldMeasurement(real_filepath, filename) elif mime_type in MIMEs: # add `good` files - if extension in MIMEs[ mime_type ]: + if extension in MIMEs[mime_type]: if filename not in files: - files[filename] = { key:None for key in fileKeys } + files[filename] = {key: None for key in fileKeys} files[filename][extension] = filepath - elif mime_type == 'image/png' and '_dicom_to_png' in path: + elif mime_type == "image/png" and "_dicom_to_png" in path: # check for preprocessed dicom files - name, frame = filename.split( '_frame_' ) - #debug(files) + name, frame = filename.split("_frame_") + # debug(files) # if len(files) > 0: # might be able to combine the following; check if name not in files: - files[name] = {'processed': None} - if files[name]['processed'] == None: - files[name]['processed'] = {} - files[name]['processed'][str(int(frame))] = filepath + files[name] = {"processed": None} + if files[name]["processed"] == None: + files[name]["processed"] = {} + files[name]["processed"][str(int(frame))] = filepath # check that we find at least one file if len(files) == 0: - severe( ' - ERROR: `%s` contains no supported files' % path ) + severe(" - ERROR: `%s` contains no supported files" % path) exit() # sort the files so that we can guess about left/right ... extrema get None/null # also add in the "traces" bit here _prev = None - for key in sorted( files.keys() ): + for key in sorted(files.keys()): if _prev != None: - files[_prev]['_next'] = key - files[key]['_prev'] = _prev + files[_prev]["_next"] = key + files[key]["_prev"] = _prev _prev = key - files[key]['name'] = key + files[key]["name"] = key # sort files, set the geometry, and write - self.data[ 'files' ] = [ files[key] for key in sorted(files.keys()) ] - self.data[ 'geometry' ] = '1150x800+1400+480' + self.data["files"] = [files[key] for key in sorted(files.keys())] + self.data["geometry"] = "1150x800+1400+480" if splines != None: self.importULTMeasurement(splines) self.write() - self.app.geometry( self.getTopLevel('geometry') ) + self.app.geometry(self.getTopLevel("geometry")) self.files = self.getFilenames() def importOldMeasurement(self, filepath, filename): - ''' + """ Writes information from .measurement file into metadata file - ''' - #this is a hack -- should really go into Dicom (which is not yet loaded) and check + """ + # this is a hack -- should really go into Dicom (which is not yet loaded) and check defaultx = 800 defaulty = 600 - open_file = json.load(open(filepath, 'r')) + open_file = json.load(open(filepath, "r")) for key, value in open_file.items(): if isinstance(value, dict): - if 'type' in value.keys() and 'points' in value.keys(): - array = value['points'] + if "type" in value.keys() and "points" in value.keys(): + array = value["points"] - filenum, framenum = filename.split('_') + filenum, framenum = filename.split("_") # new_array = [{"x":point1/800,"y":point2/600} for point1, point2 in array] new_array = [] for point1, point2 in array: - #assuming traces were made at 1x zoom and no pan - el = {"x":point1/defaultx,"y":point2/defaulty} #converts coords to "true" (i.e. % through each axis), + # assuming traces were made at 1x zoom and no pan + el = { + "x": point1 / defaultx, + "y": point2 / defaulty, + } # converts coords to "true" (i.e. % through each axis), new_array.append(el) - list_of_files = self.data['traces']['tongue']['files'] + list_of_files = self.data["traces"]["tongue"]["files"] if not filenum in list_of_files: - list_of_files[filenum]={} + list_of_files[filenum] = {} list_of_files[filenum][framenum] = new_array def importULTMeasurement(self, filepath): from ..util.framereader import ULTScanLineReader - f = open(self.unrelativize(filepath), 'rb') + + f = open(self.unrelativize(filepath), "rb") contents = util.decode_bytes(f.read()) f.close() - data = [x.split('\t') for x in contents.splitlines()] + data = [x.split("\t") for x in contents.splitlines()] lines = [] coords_loc = {} confidence_loc = {} @@ -191,126 +202,139 @@ def importULTMeasurement(self, filepath): defaultTrace = None for col in data[0][3:]: name = col.split('"')[1] - if col.startswith('X,Y'): + if col.startswith("X,Y"): coords_loc[name] = offset offset += 84 if not defaultTrace: defaultTrace = name - elif col.startswith('Confidence'): + elif col.startswith("Confidence"): confidence_loc[name] = offset offset += 42 if defaultTrace: - self.data['defaultTraceName'] = defaultTrace + self.data["defaultTraceName"] = defaultTrace for linenum, line in enumerate(data[1:], start=2): dt = {} for k in coords_loc: - conf = [100]*42 + conf = [100] * 42 if k in confidence_loc: offset = confidence_loc[k] for i in range(42): - if line[i+offset]: conf[i] = int(line[i+offset]) + if line[i + offset]: + conf[i] = int(line[i + offset]) pts = [] offset = coords_loc[k] for i in range(42): - xs = line[(2*i)+offset] - ys = line[(2*i)+offset+1] + xs = line[(2 * i) + offset] + ys = line[(2 * i) + offset + 1] if xs and ys and conf[i] > 50: - pts.append((float(xs.replace(',', '.')), float(ys.replace(',', '.')))) + pts.append((float(xs.replace(",", ".")), float(ys.replace(",", ".")))) if pts: dt[k] = pts if dt: - lines.append((linenum, float(line[1].replace(',', '.')), line[2], dt)) - self.data['traces'] = {k: {'color': 'red', 'files': {}} for k in coords_loc} + lines.append((linenum, float(line[1].replace(",", ".")), line[2], dt)) + self.data["traces"] = {k: {"color": "red", "files": {}} for k in coords_loc} # TODO: all traces are imported as the same color for linenum, timestamp, date, data in lines: - for fblob in self.data['files']: - if '.txt' not in fblob or '.ult' not in fblob or 'US.txt' not in fblob: + for fblob in self.data["files"]: + if ".txt" not in fblob or ".ult" not in fblob or "US.txt" not in fblob: continue - f = open(self.unrelativize(fblob['.txt']), 'rb') + f = open(self.unrelativize(fblob[".txt"]), "rb") byt = f.read() f.close() - s = '' - for enc in ['utf-8', 'Windows-1251', 'Windows-1252', 'ISO-8859-1']: + s = "" + for enc in ["utf-8", "Windows-1251", "Windows-1252", "ISO-8859-1"]: try: s = byt.decode(enc) break except: pass if date in s: - reader = ULTScanLineReader(self.unrelativize(fblob['.ult']), self.unrelativize(fblob['US.txt'])) + reader = ULTScanLineReader( + self.unrelativize(fblob[".ult"]), self.unrelativize(fblob["US.txt"]) + ) ts = reader.getFrameTimes() framenum = 0 for i in range(len(ts)): if ts[i] >= timestamp: - framenum = i-1 + framenum = i - 1 break height = (reader.PixPerVector + reader.ZeroOffset) / reader.PixelsPerMm - width = 2*math.cos((math.pi/2) - (reader.Angle * reader.NumVectors/2))*height + width = ( + 2 + * math.cos((math.pi / 2) - (reader.Angle * reader.NumVectors / 2)) + * height + ) for k in data: - if fblob['name'] not in self.data['traces'][k]['files']: - self.data['traces'][k]['files'][fblob['name']] = {} + if fblob["name"] not in self.data["traces"][k]["files"]: + self.data["traces"][k]["files"][fblob["name"]] = {} conv = [] for pt in data[k]: - conv.append({'x': pt[0] / width, 'y': 1 - (pt[1] / height)}) - self.data['traces'][k]['files'][fblob['name']][str(framenum)] = conv - info('Line %s of %s imported as %s frame %s' % (linenum, filepath, fblob['name'], framenum)) + conv.append({"x": pt[0] / width, "y": 1 - (pt[1] / height)}) + self.data["traces"][k]["files"][fblob["name"]][str(framenum)] = conv + info( + "Line %s of %s imported as %s frame %s" + % (linenum, filepath, fblob["name"], framenum) + ) break else: - warn('Unable to import line %s of %s (could not match date %s)' % (linenum, filepath, date)) + warn( + "Unable to import line %s of %s (could not match date %s)" + % (linenum, filepath, date) + ) def write(self, _mdfile=None): - ''' + """ Write metadata out to file - ''' + """ # debug(self.data, 'write') - mdfile = self.mdfile if _mdfile==None else _mdfile - with open( mdfile, 'w' ) as f: - json.dump( self.data, f, indent=3 ) + mdfile = self.mdfile if _mdfile == None else _mdfile + with open(mdfile, "w") as f: + json.dump(self.data, f, indent=3) - def getFilenames( self ): - ''' + def getFilenames(self): + """ Returns a list of all the files discovered from the initial directory traversal - ''' - return [ f['name'] for f in self.data['files'] ] + """ + return [f["name"] for f in self.data["files"]] - def getPreprocessedDicom( self, _frame=None ): - ''' + def getPreprocessedDicom(self, _frame=None): + """ Gets preprocessed (.dicom->.png) picture data for a given frame - ''' - frame = self.app.frame if _frame==None else _frame #int(_frame)-1 - processed = self.getFileLevel( 'processed' ) + """ + frame = self.app.frame if _frame == None else _frame # int(_frame)-1 + processed = self.getFileLevel("processed") try: return self.unrelativize(processed[str(frame)]) - except Exception as e: # catches missing frames and missing preprocessed data + except Exception as e: # catches missing frames and missing preprocessed data error(e) return None - def getTopLevel( self, key ): - ''' + def getTopLevel(self, key): + """ Get directory-level metadata - ''' + """ if key in self.data.keys(): return self.data[key] else: return None - def setTopLevel( self, key, value ): - ''' + def setTopLevel(self, key, value): + """ Set directory-level metadata - ''' - self.data[ key ] = value + """ + self.data[key] = value self.write() - def getFileLevel( self, key, _fileid=None ): - ''' + def getFileLevel(self, key, _fileid=None): + """ Get file-level metadata - ''' - fileid = self.app.currentFID if _fileid==None else _fileid - mddict = self.data[ 'files' ][ fileid ] + """ + fileid = self.app.currentFID if _fileid == None else _fileid + mddict = self.data["files"][fileid] - if key == 'all': + if key == "all": return mddict.keys() - elif key in mddict and mddict[ key ] != None: + elif key in mddict and mddict[key] != None: # if type(mddict[key]) is dict: # for el in mddict[key].keys(): # mddict[key][el] = os.path.join(self.path, mddict[key][el]) @@ -322,11 +346,11 @@ def getFileLevel( self, key, _fileid=None ): return None def unrelativize(self, fil): - '''make from a relative path into non-relative path''' + """make from a relative path into non-relative path""" return os.path.join(self.path, fil) def checkFileLevel(self, key, _fileid=None, shoulderror=True): - '''getFileLevel, unrelativize, and make sure it exists''' + """getFileLevel, unrelativize, and make sure it exists""" val = self.getFileLevel(key, _fileid) if not val: return None @@ -335,107 +359,105 @@ def checkFileLevel(self, key, _fileid=None, shoulderror=True): return pth else: if shoulderror: - error('%s does not exist' % pth) + error("%s does not exist" % pth) return None - def setFileLevel( self, key, value, _fileid=None ): - ''' + def setFileLevel(self, key, value, _fileid=None): + """ Set file-level metadata - ''' - fileid = self.app.currentFID if _fileid==None else _fileid - self.data[ 'files' ][ fileid ][ key ] = value + """ + fileid = self.app.currentFID if _fileid == None else _fileid + self.data["files"][fileid][key] = value self.write() - def getCurrentFilename( self ): - ''' + def getCurrentFilename(self): + """ Helper function for interacting with traces - ''' - return self.data[ 'files' ][ self.app.currentFID ][ 'name' ] + """ + return self.data["files"][self.app.currentFID]["name"] - def getCurrentTraceColor( self ): - ''' + def getCurrentTraceColor(self): + """ Returns color of the currently selected trace - ''' + """ trace = self.app.Trace.getCurrentTraceName() - if trace==None: + if trace == None: return None - return self.data[ 'traces' ][ trace ][ 'color' ] + return self.data["traces"][trace]["color"] - def setTraceColor( self, trace, color ): - ''' + def setTraceColor(self, trace, color): + """ Set color for a particular trace name - ''' - self.data[ 'traces' ][ trace ][ 'color' ] = color + """ + self.data["traces"][trace]["color"] = color self.write() - def getCurrentTraceAllFrames( self ): - ''' + def getCurrentTraceAllFrames(self): + """ Returns a dictionary of with key->value give by frame->[crosshairs] for the current trace and file - ''' + """ trace = self.app.Trace.getCurrentTraceName() filename = self.getCurrentFilename() try: - return self.data[ 'traces' ][ trace ][ 'files' ][ filename ] + return self.data["traces"][trace]["files"][filename] except KeyError as e: return {} def getCurrentTraceTracedFrames(self): - ''' ''' + """ """ frames = self.getCurrentTraceAllFrames() tracedFrames = [] - for frame,traces in frames.items(): + for frame, traces in frames.items(): if traces != []: tracedFrames.append(frame) return tracedFrames - def getTraceCurrentFrame( self, trace ): - ''' + def getTraceCurrentFrame(self, trace): + """ Returns a list of the crosshairs for the given trace at the current file and current frame - ''' + """ filename = self.getCurrentFilename() - frame = str(self.app.frame)# if _frame==None else str(_frame) + frame = str(self.app.frame) # if _frame==None else str(_frame) try: - return self.data[ 'traces' ][ trace ][ 'files' ][ filename ][ frame ] + return self.data["traces"][trace]["files"][filename][frame] except KeyError as e: return [] - def setCurrentTraceCurrentFrame( self, crosshairs ): - ''' + def setCurrentTraceCurrentFrame(self, crosshairs): + """ Writes an array of the current crosshairs to the metadata dictionary at the current trace, current file, and current frame - ''' + """ trace = self.app.Trace.getCurrentTraceName() filename = self.getCurrentFilename() frame = self.app.frame - if trace not in self.data[ 'traces' ]: - self.data[ 'traces' ][ trace ] = { 'files':{}, 'color':None } - if filename not in self.data[ 'traces' ][ trace ][ 'files' ]: - self.data[ 'traces' ][ trace ][ 'files' ][ filename ] = {} - self.data[ 'traces' ][ trace ][ 'files' ][ filename ][ str(frame) ] = crosshairs + if trace not in self.data["traces"]: + self.data["traces"][trace] = {"files": {}, "color": None} + if filename not in self.data["traces"][trace]["files"]: + self.data["traces"][trace]["files"][filename] = {} + self.data["traces"][trace]["files"][filename][str(frame)] = crosshairs self.write() - def tracesExist( self, trace ): - ''' - - ''' + def tracesExist(self, trace): + """ """ filename = self.getCurrentFilename() try: - dict = self.data[ 'traces' ][ trace ][ 'files' ][ filename ] + dict = self.data["traces"][trace]["files"][filename] # debug(dict) return [x for x in dict if dict[x] != []] except KeyError as e: return [] def reset(self, *args, **kwargs): - raise NotImplementedError('cannot call MetadataModule::reset') + raise NotImplementedError("cannot call MetadataModule::reset") def update(self, *args, **kwargs): - raise NotImplementedError('cannot call MetadataModule::update') + raise NotImplementedError("cannot call MetadataModule::update") def grid(self, *args, **kwargs): - raise NotImplementedError('cannot call MetadataModule::grid') + raise NotImplementedError("cannot call MetadataModule::grid") def grid_remove(self, *args, **kwargs): - raise NotImplementedError('cannot call MetadataModule::grid_remove') + raise NotImplementedError("cannot call MetadataModule::grid_remove") diff --git a/ultratrace/modules/playback.py b/ultratrace/modules/playback.py index dffeb94..ac2307f 100644 --- a/ultratrace/modules/playback.py +++ b/ultratrace/modules/playback.py @@ -14,6 +14,7 @@ try: from pydub import AudioSegment import pyaudio + AUDIO_LIBS_INSTALLED = True except ImportError as e: warn(e) @@ -21,19 +22,22 @@ VIDEO_LIBS_INSTALLED = False try: from PIL import Image, ImageTk, ImageDraw, ImageEnhance + VIDEO_LIBS_INSTALLED = True except ImportError as e: warn(e) + class Playback(Module): - ''' + """ Module to handle playback of audio and video files. - ''' + """ + def __init__(self, app): self.app = app self.current = None if AUDIO_LIBS_INSTALLED: - info( ' - initializing module: Audio' ) + info(" - initializing module: Audio") self.sfile = None self.p = pyaudio.PyAudio() self.currentInterval = None @@ -43,61 +47,63 @@ def __init__(self, app): self.stoprequest = threading.Event() # widget management - #self.frame = Frame(self.app.BOTTOM) - self.frame = Frame(self.app.LEFT)#, pady=7) + # self.frame = Frame(self.app.BOTTOM) + self.frame = Frame(self.app.LEFT) # , pady=7) self.header = Header(self.frame, text="Playback") - self.playBtn = Button(self.frame, text="⏯", command=self.playpauseAV, state='disabled', takefocus=0) # NOTE: not currently appearing - self.app.bind('', self.playpauseAV ) - self.app.bind('', self.stopAV ) + self.playBtn = Button( + self.frame, text="⏯", command=self.playpauseAV, state="disabled", takefocus=0 + ) # NOTE: not currently appearing + self.app.bind("", self.playpauseAV) + self.app.bind("", self.stopAV) if VIDEO_LIBS_INSTALLED: - info( ' - initializing module: Video' ) - self.app.bind('', self.playpauseAV ) - self.app.bind('', self.stopAV ) + info(" - initializing module: Video") + self.app.bind("", self.playpauseAV) + self.app.bind("", self.stopAV) self.grid() self.reset() def update(self): - ''' + """ don't change the audio file when we change frames - ''' + """ pass def reset(self): - ''' + """ try to load an audio file - ''' + """ if AUDIO_LIBS_INSTALLED: self.current = None - audioFallbacks = [ '.wav', '.flac', '.ogg', '.mp3' ] + audioFallbacks = [".wav", ".flac", ".ogg", ".mp3"] for codec in audioFallbacks: - if self.loadAudio( codec ) == True: - self.playBtn.config( state='normal' ) + if self.loadAudio(codec) == True: + self.playBtn.config(state="normal") return def loadAudio(self, codec): - ''' + """ load an audio file with a specific codec - ''' - audiofile = self.app.Data.getFileLevel( codec ) + """ + audiofile = self.app.Data.getFileLevel(codec) if audiofile != None: try: audiofile = self.app.Data.unrelativize(audiofile) - self.sfile = AudioSegment.from_file( audiofile ) + self.sfile = AudioSegment.from_file(audiofile) self.current = audiofile - self.duration = len(self.sfile)/1000.0 + self.duration = len(self.sfile) / 1000.0 return True except Exception as e: - error('Unable to load audio file: `%s`' % audiofile, e) + error("Unable to load audio file: `%s`" % audiofile, e) return False def playpauseAV(self, event=None): - ''' - - ''' + """ """ # debug(self.started, self.paused, '1858') - if self.started == False or self.currentInterval != self.app.TextGrid.selectedItem: #if we haven't started playing or we're in a new interval - #reset monitoring variables + if ( + self.started == False or self.currentInterval != self.app.TextGrid.selectedItem + ): # if we haven't started playing or we're in a new interval + # reset monitoring variables self.currentInterval = self.app.TextGrid.selectedItem self.started = False self.paused = False @@ -142,106 +148,111 @@ def playpauseAV(self, event=None): self.playAudio() def readyAudio(self, start, end): - ''' - - ''' - #audio stuff - start_idx = round(float(start)*1000) - end_idx = round(float(end)*1000) + """ """ + # audio stuff + start_idx = round(float(start) * 1000) + end_idx = round(float(end) * 1000) self.flen = float(self.app.TextGrid.frame_len) fpb = 512 - extrafs = (end_idx-start_idx)%fpb - extrasecs = extrafs/self.sfile.frame_rate - pad = AudioSegment.silent(duration=round(extrasecs*1000)) + extrafs = (end_idx - start_idx) % fpb + extrasecs = extrafs / self.sfile.frame_rate + pad = AudioSegment.silent(duration=round(extrasecs * 1000)) seg_nopad = self.sfile[start_idx:end_idx] self.seg = seg_nopad + pad # seg.append() self.audioframe = 0 # open stream using callback (3) - self.stream = self.p.open(format=self.p.get_format_from_width(self.seg.sample_width), - channels=self.seg.channels, - rate=self.seg.frame_rate, - frames_per_buffer=fpb, - output=True, - start=False, - stream_callback=self.callback) + self.stream = self.p.open( + format=self.p.get_format_from_width(self.seg.sample_width), + channels=self.seg.channels, + rate=self.seg.frame_rate, + frames_per_buffer=fpb, + output=True, + start=False, + stream_callback=self.callback, + ) # debug(self.seg.frame_count()/fpb, 'number of chunks') # debug(self.seg.frame_count()%fpb, 'last chunk size') # self.chunkcount = 0 def readyVideo(self): - ''' - - ''' + """ """ self.app.Trace.reset() tags = self.app.TextGrid.selectedItem[0].gettags(self.app.TextGrid.selectedItem[1]) - framenums = [tag[5:] for tag in tags if tag[:5]=='frame'] + framenums = [tag[5:] for tag in tags if tag[:5] == "frame"] self.framestart = int(framenums[0]) imgs = self.app.Dicom.getFrames(framenums) canvas = self.app.Dicom.zframe.canvas bbox = canvas.bbox(canvas.find_all()[0]) dim = (bbox[2] - bbox[0], bbox[3] - bbox[1]) self.pngs = [] - traces = self.app.Data.getTopLevel('traces') + traces = self.app.Data.getTopLevel("traces") file = self.app.Data.getCurrentFilename() l = util.CROSSHAIR_SELECT_RADIUS for frame, img in zip(framenums, imgs): img = img.resize(dim) draw = ImageDraw.Draw(img) for name in traces: - color = traces[name]['color'] - if file in traces[name]['files'] and frame in traces[name]['files'][file]: - for pt in traces[name]['files'][file][frame]: - x = int(pt['x'] * img.width) - y = int(pt['y'] * img.height) - draw.line((x-l, y, x+l, y), fill=color) - draw.line((x, y-l, x, y+l), fill=color) + color = traces[name]["color"] + if file in traces[name]["files"] and frame in traces[name]["files"][file]: + for pt in traces[name]["files"][file][frame]: + x = int(pt["x"] * img.width) + y = int(pt["y"] * img.height) + draw.line((x - l, y, x + l, y), fill=color) + draw.line((x, y - l, x, y + l), fill=color) del draw self.pngs.append(ImageTk.PhotoImage(img)) - #video w/audio stuff + # video w/audio stuff self.dicomframe_timer = 0 self.dicomframe_num = 1 self.dicomframeQ = queue.Queue() - self.dicomframeQ.put(self.pngs[0]) #put now, because audio callback puts frames when audio segments end + self.dicomframeQ.put( + self.pngs[0] + ) # put now, because audio callback puts frames when audio segments end # for i in range(len(self.pngs)): # self.dicomframeQ.put(self.pngs[i]) def callback(self, in_data, frame_count, time_info, status): - ''' + """ Called by pyaudio stream. Gets chunks of audio ready for playing With video capabilities, also updates video frame information - ''' + """ # self.sync.clear() # self.chunkcount+=1 - data = b''.join([self.seg.get_frame(i) for i in range(self.audioframe, self.audioframe+frame_count)]) + data = b"".join( + [self.seg.get_frame(i) for i in range(self.audioframe, self.audioframe + frame_count)] + ) # debug(len(data), 'line 1960') - self.audioframe+=frame_count + self.audioframe += frame_count if self.app.Dicom.isLoaded(): - #check & update video frame + # check & update video frame canvas = self.app.Dicom.zframe.canvas - callbacklen = frame_count/self.seg.frame_rate + callbacklen = frame_count / self.seg.frame_rate self.dicomframe_timer += callbacklen - #go to next frame - if self.dicomframe_timer % self.flen != self.dicomframe_timer and self.dicomframe_num < len(self.pngs): - floor = math.floor(self.dicomframe_timer/self.flen) + # go to next frame + if ( + self.dicomframe_timer % self.flen != self.dicomframe_timer + and self.dicomframe_num < len(self.pngs) + ): + floor = math.floor(self.dicomframe_timer / self.flen) # debug(floor, 'line 1961') - self.dicomframe_timer = self.dicomframe_timer-self.flen*floor + self.dicomframe_timer = self.dicomframe_timer - self.flen * floor if floor > 1: - for i in range(floor-1): + for i in range(floor - 1): # debug(self.dicomframe_num+self.framestart+i, 'putting frame into Q') - if self.dicomframe_num+i < len(self.pngs): - self.dicomframeQ.put(self.pngs[self.dicomframe_num+i]) + if self.dicomframe_num + i < len(self.pngs): + self.dicomframeQ.put(self.pngs[self.dicomframe_num + i]) else: self.dicomframeQ.put(self.pngs[self.dicomframe_num]) # self.sync.set() - self.dicomframe_num+=floor + self.dicomframe_num += floor # debug(self.dicomframe_num, len(self.pngs), 'line 1968') - # else: #stop video loop + # else: #stop video loop if self.dicomframe_num >= len(self.pngs): self.stoprequest.set() @@ -253,7 +264,7 @@ def playAudio(self): if self.app.Dicom.isLoaded(): self.playVideoWithAudio() else: - pass #write a loop that keeps audio playing + pass # write a loop that keeps audio playing # stop stream (6) if self.stoprequest.is_set(): self.stopAV() @@ -266,17 +277,15 @@ def playAudio(self): # self.p.terminate() # NOTE: needs to be removed in order to play multiple audio files in a row def playVideoWithAudio(self): - ''' - - ''' + """ """ # self.sync.wait() if self.paused == True: return canvas = self.app.Dicom.zframe.canvas # debug(self.dicomframeQ.qsize(),'line 1991') try: - pic = self.dicomframeQ.get(timeout=.5) - canvas.itemconfig(canvas.find_all()[0] , image=pic ) + pic = self.dicomframeQ.get(timeout=0.5) + canvas.itemconfig(canvas.find_all()[0], image=pic) # canvas.lift(pic) # canvas.img = pic canvas.update() @@ -284,23 +293,25 @@ def playVideoWithAudio(self): error(e) # debug(pic, 'displayed') # debug(self.dicomframe_num+self.framestart, 'displayed') - if not self.stoprequest.is_set() or not self.dicomframeQ.empty(): #should this if be at the top? + if ( + not self.stoprequest.is_set() or not self.dicomframeQ.empty() + ): # should this if be at the top? self.playVideoWithAudio() # canvas.after(10, self.playVideoWithAudio) def playVideoNoAudio(self): - ''' - - ''' + """ """ canvas = self.app.Dicom.zframe.canvas # pic = self.dicomframeQ.get() pic = self.dicomframeQ.get(block=False) - canvas.itemconfig( canvas.find_all()[0], image=pic ) + canvas.itemconfig(canvas.find_all()[0], image=pic) canvas.update() - if not self.dicomframeQ.empty() and self.stoprequest.is_set() == False: #should this if be at the top? + if ( + not self.dicomframeQ.empty() and self.stoprequest.is_set() == False + ): # should this if be at the top? self.playVideoNoAudio() - def stopAV(self,event=None): + def stopAV(self, event=None): self.stoprequest.set() if AUDIO_LIBS_INSTALLED: self.stream.stop_stream() @@ -311,14 +322,13 @@ def stopAV(self,event=None): self.stoprequest.clear() def grid(self): - ''' grid widgets ''' - self.frame.grid( row=8 ) + """grid widgets""" + self.frame.grid(row=8) self.header.grid() self.playBtn.grid() def grid_remove(self): - ''' remove widgets from grid ''' + """remove widgets from grid""" self.frame.grid_remove() self.header.grid_remove() self.playBtn.grid_remove() - diff --git a/ultratrace/modules/search.py b/ultratrace/modules/search.py index 49563a3..3931ae8 100644 --- a/ultratrace/modules/search.py +++ b/ultratrace/modules/search.py @@ -7,6 +7,7 @@ from tkinter import Toplevel, StringVar, Grid from tkinter.ttk import Treeview, Button, Entry, Label, Scrollbar + class Search(Module): def __init__(self, app): self.app = app @@ -20,43 +21,53 @@ def __init__(self, app): # it should call SearchModule.loadIntervals() self.intervals = [] self.loadIntervals() + def handleClose(self, event=None): self.window.destroy() self.window = None + def createWindow(self): self.window = Toplevel(self.app) - self.window.title('Search') + self.window.title("Search") self.window.protocol("WM_DELETE_WINDOW", self.handleClose) self.input = Entry(self.window, textvariable=self.regex) self.input.grid(row=0, column=0) - self.input.bind('', self.search) - self.input.bind('', lambda ev: self.window.focus()) - self.searchButton = Button(self.window, text='Search', command=self.search, takefocus=0) + self.input.bind("", self.search) + self.input.bind("", lambda ev: self.window.focus()) + self.searchButton = Button(self.window, text="Search", command=self.search, takefocus=0) self.searchButton.grid(row=0, column=1) - self.resultCount = Label(self.window, text='0 results') + self.resultCount = Label(self.window, text="0 results") self.resultCount.grid(row=0, column=2) - cols = ('File', 'Tier', 'Time', 'Text') - self.scroll = Scrollbar(self.window, orient='vertical') - self.resultList = Treeview(self.window, columns=cols, show="headings", yscrollcommand=self.scroll.set, selectmode='browse') + cols = ("File", "Tier", "Time", "Text") + self.scroll = Scrollbar(self.window, orient="vertical") + self.resultList = Treeview( + self.window, + columns=cols, + show="headings", + yscrollcommand=self.scroll.set, + selectmode="browse", + ) self.scroll.config(command=self.resultList.yview) for col in cols: self.resultList.heading(col, text=col) - self.resultList.grid(row=2, column=0, columnspan=3, sticky='news') - self.resultList.bind('', self.onClick) + self.resultList.grid(row=2, column=0, columnspan=3, sticky="news") + self.resultList.bind("", self.onClick) Grid.rowconfigure(self.window, 2, weight=1) Grid.columnconfigure(self.window, 0, weight=1) - self.scroll.grid(row=2, column=3, sticky='ns') + self.scroll.grid(row=2, column=3, sticky="ns") + def openSearch(self): if self.window == None: self.createWindow() self.window.lift() self.input.focus() + def loadIntervals(self): - filecount = len(self.app.Data.getTopLevel('files')) + filecount = len(self.app.Data.getTopLevel("files")) self.intervals = [] for f in range(filecount): - filename = self.app.Data.getFileLevel('name', f) - tg = self.app.Data.checkFileLevel('.TextGrid', f, shoulderror=False) + filename = self.app.Data.getFileLevel("name", f) + tg = self.app.Data.checkFileLevel(".TextGrid", f, shoulderror=False) if tg: grid = self.app.TextGrid.fromFile(tg) for tier in grid: @@ -64,8 +75,9 @@ def loadIntervals(self): for el in tier: if el.mark: self.intervals.append((el, tier.name, filename)) + def search(self, event=None): - if self.regex.get() == '': + if self.regex.get() == "": self.results = [] else: pat = re.compile(self.regex.get(), re.IGNORECASE | re.MULTILINE | re.DOTALL) @@ -74,17 +86,22 @@ def search(self, event=None): s = pat.search(i[0].mark) if s: disp = i[0].mark - a = max(0, s.start()-self.context_size) - b = min(s.end()+self.context_size, len(disp)) - self.results.append(i + (('...' if a > 0 else '')+disp[a:b]+('...' if b < len(disp) else ''),)) - self.resultCount.configure(text='%s results' % len(self.results)) + a = max(0, s.start() - self.context_size) + b = min(s.end() + self.context_size, len(disp)) + self.results.append( + i + + (("..." if a > 0 else "") + disp[a:b] + ("..." if b < len(disp) else ""),) + ) + self.resultCount.configure(text="%s results" % len(self.results)) for kid in self.resultList.get_children(): self.resultList.delete(kid) for row, res in enumerate(self.results): - ls = (res[2], res[1], '%s-%s' % (res[0].minTime, res[0].maxTime), res[3]) - self.resultList.insert('', 'end', iid=str(row), values=ls) + ls = (res[2], res[1], "%s-%s" % (res[0].minTime, res[0].maxTime), res[3]) + self.resultList.insert("", "end", iid=str(row), values=ls) + def onClick(self, event=None): self.jumpTo(int(self.resultList.selection()[0])) + def jumpTo(self, index): self.app.filesJumpTo(self.results[index][2]) self.app.TextGrid.selectedTier.set(self.results[index][1]) @@ -98,13 +115,13 @@ def jumpTo(self, index): self.app.TextGrid.fillCanvases() def reset(self, *args, **kwargs): - raise NotImplementedError('cannot call SearchModule::reset') + raise NotImplementedError("cannot call SearchModule::reset") def update(self, *args, **kwargs): - raise NotImplementedError('cannot call SearchModule::update') + raise NotImplementedError("cannot call SearchModule::update") def grid(self, *args, **kwargs): - raise NotImplementedError('cannot call SearchModule::grid') + raise NotImplementedError("cannot call SearchModule::grid") def grid_remove(self, *args, **kwargs): - raise NotImplementedError('cannot call SearchModule::grid_remove') + raise NotImplementedError("cannot call SearchModule::grid_remove") diff --git a/ultratrace/modules/spectrogram.py b/ultratrace/modules/spectrogram.py index 4dae4df..86760cb 100644 --- a/ultratrace/modules/spectrogram.py +++ b/ultratrace/modules/spectrogram.py @@ -6,6 +6,7 @@ from tkinter.ttk import Button, Frame from tkinter import Canvas, DoubleVar + try: # ttk.Spinbox was added in Python 3.7 from tkinter.ttk import Spinbox @@ -18,23 +19,33 @@ import numpy as np import parselmouth from PIL import ImageTk + LIBS_INSTALLED = True except ImportError as e: warn(e) + class Spectrogram(Module): - def __init__(self,app): - info( ' - initializing module: Spectrogram' ) + def __init__(self, app): + info(" - initializing module: Spectrogram") self.app = app self.frame = Frame(self.app.BOTTOM) - self.frame.grid( row=0, column=1, pady=(self.app.pady*2,self.app.pady/2) ) + self.frame.grid(row=0, column=1, pady=(self.app.pady * 2, self.app.pady / 2)) self.axis_frame = Frame(self.app.BOTTOM) - self.axis_frame.grid( row=0, column=0, sticky='e', pady=(self.app.pady*2,self.app.pady/2) ) + self.axis_frame.grid( + row=0, column=0, sticky="e", pady=(self.app.pady * 2, self.app.pady / 2) + ) self.canvas_width = self.app.TextGrid.canvas_width self.canvas_height = 106 - self.canvas = Canvas(self.frame, width=self.canvas_width, height=self.canvas_height, background='gray', highlightthickness=0) + self.canvas = Canvas( + self.frame, + width=self.canvas_width, + height=self.canvas_height, + background="gray", + highlightthickness=0, + ) self.spectrogram = None self.spec_freq_max = DoubleVar() self.wl = DoubleVar() @@ -44,33 +55,61 @@ def __init__(self,app): self.oldSelected = None self.doDefaults() - #make spinboxes & buttons for spectrogram specs + # make spinboxes & buttons for spectrogram specs self.spinwin = Frame(self.axis_frame) - #spinboxes - axis_ceil_box = Spinbox(self.spinwin, textvariable=self.spec_freq_max, command=self.drawSpectrogram, width=7, increment=100, from_=0, to_=100000) - axis_ceil_box.bind('',self.drawSpectrogram) - axis_ceil_box.bind('',lambda ev: self.spinwin.focus()) - wl_box = Spinbox(self.spinwin, textvariable=self.wl, command=self.drawSpectrogram, width=7, increment=0.0005, from_=0, to_=1) - wl_box.bind('',self.drawSpectrogram) - wl_box.bind('',lambda ev: self.spinwin.focus()) - dyn_range_box = Spinbox(self.spinwin, textvariable=self.dyn_range, command=self.drawSpectrogram, width=7, increment=10, from_=0, to_=10000) - dyn_range_box.bind('',self.drawSpectrogram) - dyn_range_box.bind('',lambda ev: self.spinwin.focus()) - #buttons - default_btn = Button(self.spinwin, text='Standards', command=self.restoreDefaults, takefocus=0) - apply_btn = Button(self.spinwin, text='Apply', command=self.drawSpectrogram, takefocus=0, width=6) + # spinboxes + axis_ceil_box = Spinbox( + self.spinwin, + textvariable=self.spec_freq_max, + command=self.drawSpectrogram, + width=7, + increment=100, + from_=0, + to_=100000, + ) + axis_ceil_box.bind("", self.drawSpectrogram) + axis_ceil_box.bind("", lambda ev: self.spinwin.focus()) + wl_box = Spinbox( + self.spinwin, + textvariable=self.wl, + command=self.drawSpectrogram, + width=7, + increment=0.0005, + from_=0, + to_=1, + ) + wl_box.bind("", self.drawSpectrogram) + wl_box.bind("", lambda ev: self.spinwin.focus()) + dyn_range_box = Spinbox( + self.spinwin, + textvariable=self.dyn_range, + command=self.drawSpectrogram, + width=7, + increment=10, + from_=0, + to_=10000, + ) + dyn_range_box.bind("", self.drawSpectrogram) + dyn_range_box.bind("", lambda ev: self.spinwin.focus()) + # buttons + default_btn = Button( + self.spinwin, text="Standards", command=self.restoreDefaults, takefocus=0 + ) + apply_btn = Button( + self.spinwin, text="Apply", command=self.drawSpectrogram, takefocus=0, width=6 + ) # self.axis_frame.create_window(wwidth,self.canvas_height, window=self.spinwin, anchor='ne') - #grid spinboxes & buttons on subframe - axis_ceil_box.grid(row=0, columnspan=2, sticky='ne') - wl_box.grid(row=1, columnspan=2, sticky='ne') - dyn_range_box.grid(row=2, columnspan=2, sticky='ne') + # grid spinboxes & buttons on subframe + axis_ceil_box.grid(row=0, columnspan=2, sticky="ne") + wl_box.grid(row=1, columnspan=2, sticky="ne") + dyn_range_box.grid(row=2, columnspan=2, sticky="ne") default_btn.grid(row=3) apply_btn.grid(row=3, column=1) self.grid() - self.canvas.bind('', self.jumpToFrame) + self.canvas.bind("", self.jumpToFrame) # self.canvas.bind('', self.jumpToFrame) def doDefaults(self): @@ -83,10 +122,10 @@ def restoreDefaults(self): self.drawSpectrogram() def update(self): - ''' + """ Removes and redraws lines on top of Spectrogram corresponding to selected interval(s) - ''' - self.canvas.delete('line') + """ + self.canvas.delete("line") self.drawInterval() def reset(self): @@ -94,15 +133,15 @@ def reset(self): self.drawInterval() def drawSpectrogram(self, event=None): - ''' + """ Extracts spectrogram data from sound, and draws it to canvas - ''' + """ if not LIBS_INSTALLED: return if self.app.Audio.current: sound = parselmouth.Sound(self.app.Audio.current) - self.canvas.delete('all') + self.canvas.delete("all") ts_fac = 10000.0 wl = self.wl.get() @@ -124,12 +163,14 @@ def drawSpectrogram(self, event=None): # but we've defined ts as duration / 10000, so duration % ts = 0 # so the amount to increase the length by is ts * floor( wl / ts ) # at either end - D.S. - extra = self.ts * math.floor( wl / self.ts ) + extra = self.ts * math.floor(wl / self.ts) start_time = max(0, real_start - extra) end_time = min(real_end + extra, sound.get_total_duration()) sound_clip = sound.extract_part(from_time=start_time, to_time=end_time) - spec = sound_clip.to_spectrogram(window_length=wl, time_step=self.ts, maximum_frequency=self.spec_freq_max.get()) + spec = sound_clip.to_spectrogram( + window_length=wl, time_step=self.ts, maximum_frequency=self.spec_freq_max.get() + ) self.spectrogram = 10 * np.log10(np.flip(spec.values, 0)) # self.spectrogram += self.spectrogram.min() @@ -138,19 +179,21 @@ def drawSpectrogram(self, event=None): mx = self.spectrogram.max() dyn = self.dyn_range.get() # debug(self.spectrogram.min(), self.spectrogram.max()) - self.spectrogram = self.spectrogram.clip(mx-dyn, mx) - mx + self.spectrogram = self.spectrogram.clip(mx - dyn, mx) - mx # debug(self.spectrogram.min(), self.spectrogram.max()) - self.spectrogram *= (-255.0 / dyn) + self.spectrogram *= -255.0 / dyn # self.spectrogram += 60 # debug(self.spectrogram.min(), self.spectrogram.max()) img = PIL.Image.fromarray(self.spectrogram) - if img.mode != 'RGB': - img = img.convert('RGB') + if img.mode != "RGB": + img = img.convert("RGB") # contrast = ImageEnhance.Contrast(img) # img = contrast.enhance(5) # self.canvas_height = img.height - img = img.resize((int(self.canvas_width*(duration / screen_duration)), self.canvas_height)) + img = img.resize( + (int(self.canvas_width * (duration / screen_duration)), self.canvas_height) + ) photo_img = ImageTk.PhotoImage(img) self.canvas.config(height=self.canvas_height) @@ -161,94 +204,117 @@ def drawSpectrogram(self, event=None): tags = self.app.TextGrid.selectedItem[0].gettags(self.app.TextGrid.selectedItem[1]) coord = self.canvas_width coord *= 1 - ((screen_end - real_end) / screen_duration) - img = self.canvas.create_image(coord, self.canvas_height, anchor='se', image=photo_img) + img = self.canvas.create_image(coord, self.canvas_height, anchor="se", image=photo_img) self.img = photo_img - #pass on selected-ness + # pass on selected-ness if self.app.TextGrid.selectedItem: if self.app.TextGrid.selectedItem[0] == self.canvas: self.app.TextGrid.selectedItem = (self.canvas, img) - #pass on tags + # pass on tags for tag in tags: self.canvas.addtag_all(tag) def drawInterval(self): - ''' + """ Adapted with permission from https://courses.engr.illinois.edu/ece590sip/sp2018/spectrograms1_wideband_narrowband.html by Mark Hasegawa-Johnson - ''' + """ if self.app.TextGrid.selectedItem: widg = self.app.TextGrid.selectedItem[0] itm = self.app.TextGrid.selectedItem[1] - if widg in self.app.TextGrid.tier_pairs: #if widg is label + if widg in self.app.TextGrid.tier_pairs: # if widg is label itvl_canvas = self.app.TextGrid.tier_pairs[widg] - for i in itvl_canvas.find_withtag('line'): + for i in itvl_canvas.find_withtag("line"): loc = itvl_canvas.coords(i)[0] - self.canvas.create_line(loc, 0, loc, self.canvas_height, tags='line', fill='blue') - elif widg in self.app.TextGrid.tier_pairs.values(): #if widg is textgrid canvas - if itm-1 in widg.find_all(): - l_loc = widg.coords(itm-1)[0] - self.canvas.create_line(l_loc, 0, l_loc, self.canvas_height, tags='line', fill='blue') - if itm+1 in widg.find_all(): - r_loc = widg.coords(itm+1)[0] - self.canvas.create_line(r_loc, 0, r_loc, self.canvas_height, tags='line', fill='blue') + self.canvas.create_line( + loc, 0, loc, self.canvas_height, tags="line", fill="blue" + ) + elif widg in self.app.TextGrid.tier_pairs.values(): # if widg is textgrid canvas + if itm - 1 in widg.find_all(): + l_loc = widg.coords(itm - 1)[0] + self.canvas.create_line( + l_loc, 0, l_loc, self.canvas_height, tags="line", fill="blue" + ) + if itm + 1 in widg.find_all(): + r_loc = widg.coords(itm + 1)[0] + self.canvas.create_line( + r_loc, 0, r_loc, self.canvas_height, tags="line", fill="blue" + ) elif widg == self.canvas: l_time, r_time = self.app.TextGrid.getMinMaxTime() l_loc = self.timeToX(float(l_time)) r_loc = self.timeToX(float(r_time)) - self.canvas.create_line(l_loc, 0, l_loc, self.canvas_height, tags='line', fill='blue') - self.canvas.create_line(r_loc, 0, r_loc, self.canvas_height, tags='line', fill='blue') - - #draw selected frame - if self.app.TextGrid.firstFrame <= self.app.frame <= self.app.TextGrid.lastFrame : - xcoord = self.app.TextGrid.frames_canvas.coords(self.app.TextGrid.highlighted_frame)[0] - self.canvas.create_line(xcoord,0,xcoord,self.canvas_height, tags='line', fill='red') - #draw line where user last clicked on spectrogram + self.canvas.create_line( + l_loc, 0, l_loc, self.canvas_height, tags="line", fill="blue" + ) + self.canvas.create_line( + r_loc, 0, r_loc, self.canvas_height, tags="line", fill="blue" + ) + + # draw selected frame + if self.app.TextGrid.firstFrame <= self.app.frame <= self.app.TextGrid.lastFrame: + xcoord = self.app.TextGrid.frames_canvas.coords( + self.app.TextGrid.highlighted_frame + )[0] + self.canvas.create_line( + xcoord, 0, xcoord, self.canvas_height, tags="line", fill="red" + ) + # draw line where user last clicked on spectrogram if self.clicktime != -1 and self.specClick == False: x = self.timeToX(self.clicktime) - self.canvas.create_line(x,0,x,self.canvas_height, tags='line', fill='green') + self.canvas.create_line(x, 0, x, self.canvas_height, tags="line", fill="green") def jumpToFrame(self, event): - ''' ''' - #restore textgrid selected interval between clicks + """ """ + # restore textgrid selected interval between clicks if not self.app.TextGrid.selectedItem: key = next(iter(self.app.TextGrid.tier_pairs)) wdg = self.app.TextGrid.tier_pairs[key] - self.app.TextGrid.selectedItem = (wdg,wdg.find_all()[0]) + self.app.TextGrid.selectedItem = (wdg, wdg.find_all()[0]) self.app.TextGrid.setSelectedIntvlFrames(self.app.TextGrid.selectedItem) if self.app.TextGrid.selectedItem[0] == self.canvas: self.app.TextGrid.selectedItem = self.oldSelected self.app.TextGrid.setSelectedIntvlFrames(self.app.TextGrid.selectedItem) - #prevents wiping of canvases because of mouse click + # prevents wiping of canvases because of mouse click # self.app.resized = False # draw line at click location x = self.canvas.canvasx(event.x) self.clicktime = self.xToTime(x) - #jump to new frame - frame = self.app.TextGrid.my_find_closest(self.app.TextGrid.frames_canvas, self.canvas.canvasx(event.x)) + # jump to new frame + frame = self.app.TextGrid.my_find_closest( + self.app.TextGrid.frames_canvas, self.canvas.canvasx(event.x) + ) framenum = self.app.TextGrid.frames_canvas.gettags(frame)[0][5:] - self.app.frame=int(framenum) + self.app.frame = int(framenum) self.app.framesUpdate() - #remember which interval was selected before specgram click - if event.state==1: + # remember which interval was selected before specgram click + if event.state == 1: self.oldSelected = self.app.TextGrid.selectedItem - #for selecting & zooming interval (w/ shift) + # for selecting & zooming interval (w/ shift) self.specClick = True def xToTime(self, x): - ''' converts from a x coordinate (relative to the canvas) to the timestamp at that coordinate''' - return (x*float(self.app.TextGrid.end - self.app.TextGrid.start)/self.canvas_width) + float(self.app.TextGrid.start) - def timeToX(self,time): - ''' converts from a time to the x coordinate on a canvas representing that time''' - return self.canvas_width*(time - float(self.app.TextGrid.start))/float(self.app.TextGrid.end - self.app.TextGrid.start) + """converts from a x coordinate (relative to the canvas) to the timestamp at that coordinate""" + return ( + x * float(self.app.TextGrid.end - self.app.TextGrid.start) / self.canvas_width + ) + float(self.app.TextGrid.start) + + def timeToX(self, time): + """converts from a time to the x coordinate on a canvas representing that time""" + return ( + self.canvas_width + * (time - float(self.app.TextGrid.start)) + / float(self.app.TextGrid.end - self.app.TextGrid.start) + ) def grid(self): - ''' + """ Put tkinter items on app - ''' - self.canvas.grid(row=0, column=0, sticky='news') - self.spinwin.grid(row=0,column=0,sticky='ne') + """ + self.canvas.grid(row=0, column=0, sticky="news") + self.spinwin.grid(row=0, column=0, sticky="ne") # self.axis_canvas.grid(row=0,column=0,sticky='se') def grid_remove(self): diff --git a/ultratrace/modules/textgrid.py b/ultratrace/modules/textgrid.py index 2a2906e..608d778 100644 --- a/ultratrace/modules/textgrid.py +++ b/ultratrace/modules/textgrid.py @@ -6,6 +6,7 @@ from tkinter.ttk import Button, Frame, Label from tkinter import Canvas, StringVar, DoubleVar + try: # ttk.Spinbox was added in Python 3.7 from tkinter.ttk import Spinbox @@ -16,36 +17,39 @@ LIBS_INSTALLED = False try: - from textgrid import TextGrid as TextGridFile, IntervalTier, PointTier, Point # textgrid + from textgrid import TextGrid as TextGridFile, IntervalTier, PointTier, Point # textgrid from textgrid.exceptions import TextGridError + LIBS_INSTALLED = True except ImportError as e: warn(e) -ALIGNMENT_TIER_NAMES = [ 'frames', 'all frames', 'dicom frames', 'ultrasound frames' ] +ALIGNMENT_TIER_NAMES = ["frames", "all frames", "dicom frames", "ultrasound frames"] + class TextGrid(Module): - ''' + """ Manages all the widgets related to TextGrid files, including the tier name and the text content of that tier at a given frame - ''' + """ + def __init__(self, app): - ''' + """ Keep a reference to the master object for binding the widgets we create - ''' - info( ' - initializing module: TextGrid' ) + """ + info(" - initializing module: TextGrid") self.app = app self.frame = Frame(self.app.BOTTOM) self.label_padx = 0 - self.canvas_frame = Frame(self.app.BOTTOM)#, padx=self.label_padx) - self.frame.grid( row=1, column=0, sticky='ne') - self.canvas_frame.grid(row=1, column=1 ) + self.canvas_frame = Frame(self.app.BOTTOM) # , padx=self.label_padx) + self.frame.grid(row=1, column=0, sticky="ne") + self.canvas_frame.grid(row=1, column=1) self.TextGrid = None self.selectedTier = StringVar() self.tg_zoom_factor = 1.5 - self.canvas_width=800 - self.canvas_height=60 - self.collapse_height=15 + self.canvas_width = 800 + self.canvas_height = 60 + self.collapse_height = 15 self.selectedIntvlFrames = [] self.selectedItem = None self.start = 0 @@ -56,8 +60,8 @@ def __init__(self, app): self.startup() platform = util.get_platform() - #bindings - if platform == 'Linux': + # bindings + if platform == "Linux": self.app.bind("", self.getBounds) self.app.bind("", self.getBounds) self.app.bind("", self.getBounds) @@ -68,7 +72,7 @@ def __init__(self, app): self.app.bind("", self.changeTiers) self.app.bind("", self.changeIntervals) self.app.bind("", self.changeIntervals) - elif platform == 'Darwin': + elif platform == "Darwin": self.app.bind("", self.getBounds) self.app.bind("", self.getBounds) self.app.bind("", self.getBounds) @@ -78,7 +82,7 @@ def __init__(self, app): self.app.bind("", self.changeTiers) self.app.bind("", self.changeIntervals) self.app.bind("", self.changeIntervals) - #defaults (Command/Alt everything) + # defaults (Command/Alt everything) else: self.app.bind("", self.getBounds) self.app.bind("", self.getBounds) @@ -94,7 +98,6 @@ def __init__(self, app): self.app.bind("", self.getBounds) self.app.bind("", self.getBounds) - def setup(self): if LIBS_INSTALLED: self.loadOrGenerate() @@ -104,7 +107,7 @@ def setup(self): self.app.frames = len(self.TextGrid.getFirst(self.frameTierName)) tiers = [] for tier in self.TextGrid.getNames(): - if tier != self.frameTierName and tier != self.frameTierName + '.original': + if tier != self.frameTierName and tier != self.frameTierName + ".original": tiers.append(tier) if set(tiers) != self.tierNames: self.tierNames = set(tiers) @@ -114,7 +117,7 @@ def setup(self): for canvas in self.canvas_frame.winfo_children(): canvas.destroy() for tier in self.TextGrid.getNames(): - if tier != self.frameTierName and tier != self.frameTierName + '.original': + if tier != self.frameTierName and tier != self.frameTierName + ".original": self.TkWidgets.append(self.makeTierWidgets(tier)) self.makeFrameWidget() self.makeTimeWidget() @@ -127,20 +130,16 @@ def setup(self): except Exception as e: error(e) - - def startup(self): - ''' - - ''' + """ """ if LIBS_INSTALLED: self.tierNames = set() self.setup() def reset(self, event=None): - ''' + """ Try to load a TextGrid file based on information stored in the metadata - ''' + """ if LIBS_INSTALLED: self.selectedIntvlFrames = [] self.selectedItem = None @@ -151,12 +150,12 @@ def fromFile(self, filename): try: return TextGridFile.fromFile(filename) except (TextGridError, UnicodeDecodeError) as e: - f = open(filename, 'rb') + f = open(filename, "rb") contents = util.decode_bytes(f.read()) f.close() if contents: tmp = tempfile.NamedTemporaryFile() - tmp.write(contents.encode('utf-8')) + tmp.write(contents.encode("utf-8")) tmp.seek(0) try: return TextGridFile.fromFile(tmp.name) @@ -170,26 +169,28 @@ def fromFile(self, filename): return None def loadOrGenerate(self): - fname = self.app.Data.checkFileLevel('.TextGrid', shoulderror=False) + fname = self.app.Data.checkFileLevel(".TextGrid", shoulderror=False) if fname: self.TextGrid = self.fromFile(fname) else: - minTime = 0. - if not hasattr(self.app.Audio, 'duration'): + minTime = 0.0 + if not hasattr(self.app.Audio, "duration"): self.app.Audio.reset() try: maxTime = self.app.Audio.duration except: - warn('Audio has no duration attribute after calling reset(), defaulting to 1 second') - maxTime = 1. + warn( + "Audio has no duration attribute after calling reset(), defaulting to 1 second" + ) + maxTime = 1.0 self.TextGrid = TextGridFile(maxTime=maxTime) - keys = self.app.Data.getFileLevel('all') - if not ('.ult' in keys and '.txt' in keys): + keys = self.app.Data.getFileLevel("all") + if not (".ult" in keys and ".txt" in keys): sentenceTier = IntervalTier("text") sentenceTier.add(minTime, maxTime, "text") self.TextGrid.append(sentenceTier) - fname = self.app.Data.unrelativize(self.app.Data.getCurrentFilename() + '.TextGrid') - self.app.Data.setFileLevel('.TextGrid', fname) + fname = self.app.Data.unrelativize(self.app.Data.getCurrentFilename() + ".TextGrid") + self.app.Data.setFileLevel(".TextGrid", fname) names = self.TextGrid.getNames() for i, n in enumerate(names): if n in ALIGNMENT_TIER_NAMES: @@ -202,25 +203,25 @@ def loadOrGenerate(self): self.genFramesTier() def genFramesTier(self): - debug('generating frames tier for %s' % self.app.Data.getCurrentFilename()) - self.frameTierName = 'frames' + debug("generating frames tier for %s" % self.app.Data.getCurrentFilename()) + self.frameTierName = "frames" times = self.app.Dicom.getFrameTimes() self.app.Data.setFileLevel("NumberOfFrames", len(times)) try: maxTime = max(self.app.Audio.duration, times[-1]) except AttributeError: maxTime = times[-1] - tier = PointTier('frames', maxTime=maxTime) + tier = PointTier("frames", maxTime=maxTime) for f, t in enumerate(times): tier.addPoint(Point(t, str(f))) if not self.TextGrid.maxTime or maxTime > self.TextGrid.maxTime: self.TextGrid.maxTime = maxTime self.TextGrid.append(tier) - keys = self.app.Data.getFileLevel('all') - if '.ult' in keys and '.txt' in keys: - fname = self.app.Data.unrelativize(self.app.Data.getFileLevel('.txt')) - f = open(fname, 'rb') + keys = self.app.Data.getFileLevel("all") + if ".ult" in keys and ".txt" in keys: + fname = self.app.Data.unrelativize(self.app.Data.getFileLevel(".txt")) + f = open(fname, "rb") s = util.decode_bytes(f.read()) f.close() if s: @@ -229,9 +230,8 @@ def genFramesTier(self): sentenceTier.add(0, self.app.Audio.duration, line) self.TextGrid.append(sentenceTier) self.TextGrid.tiers = [self.TextGrid.tiers[-1]] + self.TextGrid.tiers[:-1] - - path = self.app.Data.unrelativize(self.app.Data.getFileLevel( '.TextGrid' )) + path = self.app.Data.unrelativize(self.app.Data.getFileLevel(".TextGrid")) self.TextGrid.write(path) self.TextGrid = TextGridFile.fromFile(path) # reload to account for length changes due to frames tier being different length than audio @@ -245,24 +245,25 @@ def isIntervalTier(tier): return False def shiftFrames(self): - ''' + """ Replicate original TextGrid point tier (renamed [tiername].original) Shift points on TextGrid tier in accordance with self.frame_shift Shift value is relative to 0, i.e. inputting the same shift amount a second time will not change the shift Redisplay shifted points - ''' + """ self.app.focus() shift = self.frame_shift.get() if type(shift) == float: - self.app.Data.setFileLevel( 'offset', shift ) + self.app.Data.setFileLevel("offset", shift) # diff = shift - self.app.Data.data['offset'] - originalTier = self.TextGrid.getFirst(self.frameTierName+'.original') - if originalTier: pass + originalTier = self.TextGrid.getFirst(self.frameTierName + ".original") + if originalTier: + pass else: orig = copy.deepcopy(self.TextGrid.getFirst(self.frameTierName)) - orig.name += '.original' + orig.name += ".original" self.TextGrid.append(orig) - originalTier = self.TextGrid.getFirst(self.frameTierName+'.original') + originalTier = self.TextGrid.getFirst(self.frameTierName + ".original") oldTier = self.TextGrid.getFirst(self.frameTierName) allPoints = oldTier[:] @@ -270,80 +271,110 @@ def shiftFrames(self): oldTier.removePoint(point) for point in originalTier: - new_time = point.time + shift/1000 ## NOTE: currently in ms + new_time = point.time + shift / 1000 ## NOTE: currently in ms if self.TextGrid.minTime <= new_time <= self.TextGrid.maxTime: self.TextGrid.getFirst(self.frameTierName).add(new_time, point.mark) # self.app.frames = len(self.TextGrid.getFirst(self.frameTierName)) #FIXME I feel like I shouldn't have to run the getFirst function every time, but I'm not sure when I have to go back to the original textgrid, and when I can just use a variable... self.firstFrame = int(self.TextGrid.getFirst(self.frameTierName)[0].mark) + 1 self.lastFrame = int(self.TextGrid.getFirst(self.frameTierName)[-1].mark) + 1 - self.app.Data.data['offset'] = shift + self.app.Data.data["offset"] = shift # self.frame_shift.set(shift) self.app.Data.write() # newTier.write(self.TextGrid.getFirst(self.frameTierName)) self.fillCanvases() - self.TextGrid.write(self.app.Data.unrelativize(self.app.Data.getFileLevel( '.TextGrid' ))) - + self.TextGrid.write(self.app.Data.unrelativize(self.app.Data.getFileLevel(".TextGrid"))) - #except ValueError: + # except ValueError: else: - error('Not a float!') + error("Not a float!") def makeTimeWidget(self): - self.time_canvas = Canvas(self.canvas_frame, width=self.canvas_width, height=self.canvas_height/3, highlightthickness=0) - s = self.time_canvas.create_text(3,0, anchor='nw', text=self.start) - e = self.time_canvas.create_text(self.canvas_width,0, anchor='ne', text=self.end) - c = self.time_canvas.create_text(self.canvas_width/2,0, anchor='n', text=self.current) - self.TkWidgets.append({'times':self.time_canvas}) + self.time_canvas = Canvas( + self.canvas_frame, + width=self.canvas_width, + height=self.canvas_height / 3, + highlightthickness=0, + ) + s = self.time_canvas.create_text(3, 0, anchor="nw", text=self.start) + e = self.time_canvas.create_text(self.canvas_width, 0, anchor="ne", text=self.end) + c = self.time_canvas.create_text(self.canvas_width / 2, 0, anchor="n", text=self.current) + self.TkWidgets.append({"times": self.time_canvas}) def makeFrameWidget(self): - ''' + """ makes frame widget - ''' - #make regular frame stuff -- label and tier - self.frames_canvas = Canvas(self.canvas_frame, width=self.canvas_width, height=self.canvas_height, background='gray', highlightthickness=0) - frames_label = Canvas(self.frame, width=self.label_width, height=self.canvas_height, highlightthickness=0, background='gray') - frames_label.create_text(self.label_width,0, anchor='ne', justify='center', - text='frames: ', width=self.label_width, activefill='blue') + """ + # make regular frame stuff -- label and tier + self.frames_canvas = Canvas( + self.canvas_frame, + width=self.canvas_width, + height=self.canvas_height, + background="gray", + highlightthickness=0, + ) + frames_label = Canvas( + self.frame, + width=self.label_width, + height=self.canvas_height, + highlightthickness=0, + background="gray", + ) + frames_label.create_text( + self.label_width, + 0, + anchor="ne", + justify="center", + text="frames: ", + width=self.label_width, + activefill="blue", + ) # make subframe to go on top of label canvas sbframe = Frame(frames_label) - #put new widgets onto subframe - offset = self.app.Data.getFileLevel( 'offset' ) + # put new widgets onto subframe + offset = self.app.Data.getFileLevel("offset") if offset != None: self.frame_shift.set(offset) # for audio alignment - go_btn = Button(sbframe, text='Offset', command=self.shiftFrames, takefocus=0) + go_btn = Button(sbframe, text="Offset", command=self.shiftFrames, takefocus=0) # minmax = len(self.app.Audio.sfile)*1000 - txtbox = Spinbox(sbframe, textvariable=self.frame_shift, width=7, from_=-10000000, to=10000000) - txtbox.bind('', lambda ev: sbframe.focus()) - txtbox.bind('', lambda ev: self.shiftFrames()) - go_btn.grid(row=0, column=0, sticky='e') - txtbox.grid(row=0, column=1, sticky='e') + txtbox = Spinbox( + sbframe, textvariable=self.frame_shift, width=7, from_=-10000000, to=10000000 + ) + txtbox.bind("", lambda ev: sbframe.focus()) + txtbox.bind("", lambda ev: self.shiftFrames()) + go_btn.grid(row=0, column=0, sticky="e") + txtbox.grid(row=0, column=1, sticky="e") # put subframe on canvas - window = frames_label.create_window(self.label_width*.3,self.canvas_height/3, anchor='nw', window=sbframe) + window = frames_label.create_window( + self.label_width * 0.3, self.canvas_height / 3, anchor="nw", window=sbframe + ) # ensure position of subframe gets updated - frames_label.bind('', lambda e: frames_label.itemconfig(window, width=e.width)) - sbframe.bind('', lambda e: frames_label.configure(scrollregion=frames_label.bbox("all"))) + frames_label.bind("", lambda e: frames_label.itemconfig(window, width=e.width)) + sbframe.bind( + "", lambda e: frames_label.configure(scrollregion=frames_label.bbox("all")) + ) - self.TkWidgets.append({'name':self.frameTierName,'frames':self.frames_canvas, - 'frames-label':frames_label}) + self.TkWidgets.append( + {"name": self.frameTierName, "frames": self.frames_canvas, "frames-label": frames_label} + ) self.frames_canvas.bind("", self.getClickedFrame) def getFrameTierName(self): - ''' + """ Handle some inconsistency in how we're naming our alignment tier - ''' + """ for name in ALIGNMENT_TIER_NAMES: if name in self.TextGrid.getNames(): return name - raise NameError( 'Unable to find alignment tier' ) + raise NameError("Unable to find alignment tier") def getClickedFrame(self, event): - ''' + """ Jumps to clicked frame - ''' + """ item = self.my_find_closest(event.widget, event.x) self.setSelectedIntvlFrames((event.widget, item)) frame = event.widget.gettags(item)[0][5:] @@ -354,30 +385,47 @@ def getClickedFrame(self, event): self.app.framesUpdate() def makeTierWidgets(self, tier): - ''' + """ Each tier should have two canvas widgets: `canvas-label` (the tier name), and `canvas` (the intervals on the tier with their marks) - ''' - self.tier_pairs = {} #ends up being format {label: canvas} + """ + self.tier_pairs = {} # ends up being format {label: canvas} # self.app.Trace.frame.update() - self.label_width=300#self.app.Trace.frame.winfo_width()+self.label_padx - self.end = self.TextGrid.maxTime#float(self.TextGrid.maxTime) + self.label_width = 300 # self.app.Trace.frame.winfo_width()+self.label_padx + self.end = self.TextGrid.maxTime # float(self.TextGrid.maxTime) # self.first_frame = 1 # self.last_frame = self.TextGrid.getFirst(self.frameTierName)[-1].mark tier_obj = self.TextGrid.getFirst(tier) - widgets = { 'name':tier, - #'label':Label(self.frame, text=('- '+tier+':'), wraplength=200, justify='left'), - 'canvas-label':Canvas(self.frame, width=self.label_width, height=self.canvas_height, highlightthickness=0), - # 'text' :Label(self.frame, text='', wraplength=550, justify='left'), - 'canvas':Canvas(self.canvas_frame, width=self.canvas_width, height=self.canvas_height, background='gray', highlightthickness=0)} - - canvas = widgets['canvas'] - label = widgets['canvas-label'] - - #builds tier label functionality - label_text = label.create_text(self.label_width, self.canvas_height/2, anchor='e', justify='center', - text='temp', width=self.label_width/2, activefill='blue') + widgets = { + "name": tier, + #'label':Label(self.frame, text=('- '+tier+':'), wraplength=200, justify='left'), + "canvas-label": Canvas( + self.frame, width=self.label_width, height=self.canvas_height, highlightthickness=0 + ), + # 'text' :Label(self.frame, text='', wraplength=550, justify='left'), + "canvas": Canvas( + self.canvas_frame, + width=self.canvas_width, + height=self.canvas_height, + background="gray", + highlightthickness=0, + ), + } + + canvas = widgets["canvas"] + label = widgets["canvas-label"] + + # builds tier label functionality + label_text = label.create_text( + self.label_width, + self.canvas_height / 2, + anchor="e", + justify="center", + text="temp", + width=self.label_width / 2, + activefill="blue", + ) canvas.bind("", self.genFrameList) label.bind("", self.genFrameList) @@ -391,10 +439,8 @@ def makeTierWidgets(self, tier): return widgets - def changeIntervals(self,event): - ''' - - ''' + def changeIntervals(self, event): + """ """ if self.selectedItem: duration = self.end - self.start @@ -403,38 +449,38 @@ def changeIntervals(self,event): itm = self.selectedItem[1] for el in self.TkWidgets: if widg in el.values(): - tier_name = el['name'] + tier_name = el["name"] break - #finding Interval mintime and maxtime + # finding Interval mintime and maxtime oldMinTime = None oldMaxTime = None - q=0 + q = 0 tags = widg.gettags(itm) while oldMinTime == None or oldMaxTime == None: - if tags[q][:7] == 'minTime': + if tags[q][:7] == "minTime": oldMinTime = float(tags[q][7:]) - elif tags[q][:7] == 'maxTime': + elif tags[q][:7] == "maxTime": oldMaxTime = float(tags[q][7:]) - q+=1 + q += 1 tier = self.TextGrid.getFirst(tier_name) - intvl_i = tier.indexContaining(oldMaxTime-((oldMaxTime-oldMinTime)/2)) + intvl_i = tier.indexContaining(oldMaxTime - ((oldMaxTime - oldMinTime) / 2)) - if event.keysym == 'Left': - new_intvl_i = intvl_i-1 - elif event.keysym == 'Right': - new_intvl_i = intvl_i+1 + if event.keysym == "Left": + new_intvl_i = intvl_i - 1 + elif event.keysym == "Right": + new_intvl_i = intvl_i + 1 if 0 <= new_intvl_i < len(tier): - #find characteristics of new adjacent interval + # find characteristics of new adjacent interval newMinTime = tier[new_intvl_i].minTime newMaxTime = tier[new_intvl_i].maxTime itvlDuration = newMaxTime - newMinTime - newCenter = newMinTime + itvlDuration/2 + newCenter = newMinTime + itvlDuration / 2 - #figure out new window parameters based on new interval - start = newCenter - duration/2 - end = newCenter + duration/2 + # figure out new window parameters based on new interval + start = newCenter - duration / 2 + end = newCenter + duration / 2 if start < 0: self.start = 0 self.end = duration @@ -442,13 +488,13 @@ def changeIntervals(self,event): self.start = self.TextGrid.maxTime - duration self.end = self.TextGrid.maxTime else: - self.start = newCenter - duration/2 - self.end = newCenter + duration/2 + self.start = newCenter - duration / 2 + self.end = newCenter + duration / 2 relDuration = self.end - self.start # select new item rel_time = newCenter - self.start - x_loc = float(rel_time/relDuration*self.canvas_width) + x_loc = float(rel_time / relDuration * self.canvas_width) item = self.my_find_closest(widg, x_loc) self.selectedItem = (widg, item) self.setSelectedIntvlFrames(self.selectedItem) @@ -457,9 +503,7 @@ def changeIntervals(self,event): self.genFrameList(widg=widg, x_loc=x_loc) def changeTiers(self, event): - ''' - - ''' + """ """ index = None if self.selectedItem: for i, el in enumerate(self.TkWidgets): @@ -467,14 +511,14 @@ def changeTiers(self, event): index = i if index != None: - if event.keysym == 'Up' and 'canvas' in self.TkWidgets[index-1]: - new_widg = self.TkWidgets[index-1]['canvas'] - elif event.keysym == 'Down' and 'canvas' in self.TkWidgets[index+1]: - new_widg = self.TkWidgets[index+1]['canvas'] + if event.keysym == "Up" and "canvas" in self.TkWidgets[index - 1]: + new_widg = self.TkWidgets[index - 1]["canvas"] + elif event.keysym == "Down" and "canvas" in self.TkWidgets[index + 1]: + new_widg = self.TkWidgets[index + 1]["canvas"] else: return - new_item = new_widg.find_withtag("frame"+str(self.app.frame))[0] + new_item = new_widg.find_withtag("frame" + str(self.app.frame))[0] self.selectedItem = (new_widg, new_item) self.fillCanvases() @@ -482,40 +526,38 @@ def changeTiers(self, event): self.app.Spectrogram.update() def getMinMaxTime(self): - ''' + """ Returns minTime and maxTime tags from selected interval If no minTime or maxTime, returns start or end time of viewed section of TextGrid - ''' - start=None - end=None + """ + start = None + end = None for tag in self.selectedItem[0].gettags(self.selectedItem[1]): - if tag[:7] == 'minTime': + if tag[:7] == "minTime": start = float(tag[7:]) - elif tag[:7] == 'maxTime': + elif tag[:7] == "maxTime": end = float(tag[7:]) - if start==None: - start=self.start - if end==None: - end=self.end + if start == None: + start = self.start + if end == None: + end = self.end - return (start,end) + return (start, end) def getBounds(self, event): - ''' - - ''' + """ """ # debug(event.char, event.keysym, event.keycode) # debug(self.app.frame) f = self.tg_zoom_factor a = self.end - self.start - z_out = (a-(a/f))/2 - z_in = ((f*a)-a)/2 + z_out = (a - (a / f)) / 2 + z_in = ((f * a) - a) / 2 old_start = self.start old_end = self.end - if event.keysym == 'n': + if event.keysym == "n": if self.selectedItem: self.start, self.end = self.getMinMaxTime() # for tag in self.selectedItem[0].gettags(self.selectedItem[1]): @@ -523,27 +565,27 @@ def getBounds(self, event): # self.start = float(tag[7:]) # elif tag[:7] == 'maxTime': # self.end = float(tag[7:]) - if event.keysym == 'a': + if event.keysym == "a": self.start = 0 self.end = self.TextGrid.maxTime - if event.keysym == 'o': + if event.keysym == "o": self.start = self.start - z_in self.end = self.end + z_in - if event.keysym == 'i': + if event.keysym == "i": self.start = self.start + z_out self.end = self.end - z_out - if event.keysym == 'Left': - start = self.start - a/(10*f) - end = self.end - a/(10*f) - if (start < 0): + if event.keysym == "Left": + start = self.start - a / (10 * f) + end = self.end - a / (10 * f) + if start < 0: self.start = 0 self.end = a else: self.start = start self.end = end - if event.keysym == 'Right': - start = self.start + a/(10*f) - end = self.end + a/(10*f) + if event.keysym == "Right": + start = self.start + a / (10 * f) + end = self.end + a / (10 * f) if end > self.TextGrid.maxTime: self.start = self.TextGrid.maxTime - a self.end = self.TextGrid.maxTime @@ -553,23 +595,19 @@ def getBounds(self, event): self.fillCanvases() - def getTracedFrames(self,frames): - ''' - - ''' - frames = [frame[5:] for frame in frames] #to get rid of word "frame in tag" + def getTracedFrames(self, frames): + """ """ + frames = [frame[5:] for frame in frames] # to get rid of word "frame in tag" tracedFrames = [] - for trace in self.app.Data.data['traces']: - tracedFrames = tracedFrames+self.app.Data.tracesExist(trace) + for trace in self.app.Data.data["traces"]: + tracedFrames = tracedFrames + self.app.Data.tracesExist(trace) return set(frames).intersection(tracedFrames) def fillCanvases(self): - ''' - - ''' + """ """ if self.start < 0: - self.start = 0. + self.start = 0.0 if self.end > self.TextGrid.maxTime: self.end = self.TextGrid.maxTime self.updateTimeLabels() @@ -579,18 +617,18 @@ def fillCanvases(self): duration = self.end - self.start self.frameTier = self.TextGrid.getFirst(self.frameTierName) for el in self.TkWidgets: - if 'name' in el: - tier = self.TextGrid.getFirst(el['name']) + if "name" in el: + tier = self.TextGrid.getFirst(el["name"]) # debug(tier) - if 'canvas' in el: - canvas = el['canvas'] - #remove previous intervals - canvas.delete('all') - #get starting interval + if "canvas" in el: + canvas = el["canvas"] + # remove previous intervals + canvas.delete("all") + # get starting interval i = tier.indexContaining(self.start) # not sure why, but this is sometimes None -JNW 2020-01-28 if i != None: - #debug(self.TextGrid, self.current, el, tier, self.start, i) + # debug(self.TextGrid, self.current, el, tier, self.start, i) time = tier[i].maxTime frame_i = 0 while i < len(tier) and tier[i].minTime <= self.end: @@ -601,125 +639,146 @@ def fillCanvases(self): if self.end <= tier[i].maxTime: time = self.end length = time - strtime - pixel_length = length/duration*self.canvas_width - - mod = length/2 - rel_time = time-self.start - loc=(rel_time-mod)/duration*self.canvas_width - - text = canvas.create_text(loc, self.canvas_height/2, justify='center', - text=tier[i].mark, width=pixel_length, activefill='blue') - minTimetag = "minTime"+str(tier[i].minTime) - maxTimetag = "maxTime"+str(tier[i].maxTime) + pixel_length = length / duration * self.canvas_width + + mod = length / 2 + rel_time = time - self.start + loc = (rel_time - mod) / duration * self.canvas_width + + text = canvas.create_text( + loc, + self.canvas_height / 2, + justify="center", + text=tier[i].mark, + width=pixel_length, + activefill="blue", + ) + minTimetag = "minTime" + str(tier[i].minTime) + maxTimetag = "maxTime" + str(tier[i].maxTime) canvas.addtag_withtag(minTimetag, text) canvas.addtag_withtag(maxTimetag, text) - #add containted frames to tags - while frame_i < len(self.frameTier) and self.frameTier[frame_i].time <= tier[i].maxTime: + # add containted frames to tags + while ( + frame_i < len(self.frameTier) + and self.frameTier[frame_i].time <= tier[i].maxTime + ): if self.frameTier[frame_i].time >= tier[i].minTime: - canvas.addtag_withtag("frame"+self.frameTier[frame_i].mark, text) - if tier[i].mark != '': - el['canvas-label'].addtag_all("frame"+self.frameTier[frame_i].mark) - frame_i+=1 - #pass on selected-ness + canvas.addtag_withtag("frame" + self.frameTier[frame_i].mark, text) + if tier[i].mark != "": + el["canvas-label"].addtag_all( + "frame" + self.frameTier[frame_i].mark + ) + frame_i += 1 + # pass on selected-ness if self.selectedItem: if self.selectedItem[0] != self.app.Spectrogram.canvas: # old_selected_tags = self.selectedItem[0].gettags(self.selectedItem[1]) - if minTimetag in old_selected_tags and maxTimetag in old_selected_tags and canvas == self.selectedItem[0]: + if ( + minTimetag in old_selected_tags + and maxTimetag in old_selected_tags + and canvas == self.selectedItem[0] + ): # I'm not sure why, but when collapsing canvases this sometimes # changes which tier is selected. Adding canvas == self.selectedItem[0] # seems to fix this though. # - D.S. 2020-01-30 self.selectedItem = (canvas, text) - #create line - loc=rel_time/duration*self.canvas_width - i+=1 + # create line + loc = rel_time / duration * self.canvas_width + i += 1 if i < len(tier) and loc < self.canvas_width: - canvas.create_line(loc,0,loc,self.canvas_height, tags='line') - time = tier[i].maxTime #here so that loop doesn't run an extra time + canvas.create_line(loc, 0, loc, self.canvas_height, tags="line") + time = tier[i].maxTime # here so that loop doesn't run an extra time - #fills labels with info about tiers w/traces + # fills labels with info about tiers w/traces self.updateTierLabels() - elif 'frames' in el: - frames = el['frames'] + elif "frames" in el: + frames = el["frames"] i = 0 - frames.delete('all') + frames.delete("all") first_frame_found = False - while i < len(tier) and tier[i].time <= self.end : + while i < len(tier) and tier[i].time <= self.end: # debug(tier[i].time, i,'frame time and frame number (line 1076)') if tier[i].time >= self.start: # x_coord = (tier[i].time-self.start)/duration*self.canvas_width - x_coord = ((tier[i].time-self.start)*self.canvas_width)/duration - #determine fill + x_coord = ((tier[i].time - self.start) * self.canvas_width) / duration + # determine fill if tier[i].mark in self.app.Data.getCurrentTraceTracedFrames(): - fill = 'black' + fill = "black" else: - fill = 'gray70' - frame = frames.create_line(x_coord, 0, x_coord, self.canvas_height, tags="frame"+tier[i].mark, fill=fill) + fill = "gray70" + frame = frames.create_line( + x_coord, + 0, + x_coord, + self.canvas_height, + tags="frame" + tier[i].mark, + fill=fill, + ) if first_frame_found == False and i + 1 < len(tier): self.firstFrame = int(tier[i].mark) + 1 first_frame_found = True - self.frame_len = tier[i+1].time - tier[i].time - CanvasTooltip(frames, frame,text=tier[i].mark) - i+=1 - self.lastFrame = int(tier[i-1].mark) + self.frame_len = tier[i + 1].time - tier[i].time + CanvasTooltip(frames, frame, text=tier[i].mark) + i += 1 + self.lastFrame = int(tier[i - 1].mark) self.paintCanvases() - if hasattr(self.app, 'Spectrogram'): + if hasattr(self.app, "Spectrogram"): # don't try to call this during startup # because TextGrid is loaded earlier self.app.Spectrogram.reset() def updateTimeLabels(self): - ''' - - ''' - self.current = self.TextGrid.getFirst(self.frameTierName)[self.app.frame-1].time - self.TkWidgets[-1]['times'].itemconfig(1,text='{:.6f}'.format(self.start)) - self.TkWidgets[-1]['times'].itemconfig(2,text='{:.6f}'.format(self.end)) - self.TkWidgets[-1]['times'].itemconfig(3,text='{:.6f}'.format(self.current)) + """ """ + self.current = self.TextGrid.getFirst(self.frameTierName)[self.app.frame - 1].time + self.TkWidgets[-1]["times"].itemconfig(1, text="{:.6f}".format(self.start)) + self.TkWidgets[-1]["times"].itemconfig(2, text="{:.6f}".format(self.end)) + self.TkWidgets[-1]["times"].itemconfig(3, text="{:.6f}".format(self.current)) def updateTierLabels(self): - ''' - - ''' + """ """ for el in self.TkWidgets: - if 'canvas' in el: - current_label = el['canvas-label'].find_all()[0] - nonempty_frames = el['canvas-label'].gettags(current_label) - el['canvas-label'].itemconfig(current_label, - text='{}:\n({}/{})'.format(el['name'],len(self.getTracedFrames(nonempty_frames)), len(nonempty_frames))) - + if "canvas" in el: + current_label = el["canvas-label"].find_all()[0] + nonempty_frames = el["canvas-label"].gettags(current_label) + el["canvas-label"].itemconfig( + current_label, + text="{}:\n({}/{})".format( + el["name"], len(self.getTracedFrames(nonempty_frames)), len(nonempty_frames) + ), + ) def my_find_closest(self, widg, x_loc): - ''' + """ replaces TkInter's find_closest function, which is buggy, determines whether found item is text, line, or label, and returns corresponding item - ''' - #could be more efficient FIXME + """ + # could be more efficient FIXME maybe_item = None dist = 999999999999 for el in widg.find_all(): obj_x = widg.coords(el)[0] - if abs(obj_x-x_loc) < dist: - dist = abs(obj_x-x_loc) + if abs(obj_x - x_loc) < dist: + dist = abs(obj_x - x_loc) maybe_item = el - if widg in self.tier_pairs.keys(): #on tier-label canvas - #fill selected tier frames + if widg in self.tier_pairs.keys(): # on tier-label canvas + # fill selected tier frames # self.setSelectedIntvlFrames(widg,item) item = maybe_item - elif widg in self.tier_pairs.values(): #on canvas with intervals/frames + elif widg in self.tier_pairs.values(): # on canvas with intervals/frames if isinstance(maybe_item, int): # #if item found is a boundary # if len(widg.gettags(maybe_item)) == 0 or widg.gettags(maybe_item) == ('current',): - if 'line' in widg.gettags(maybe_item): - #determine on which side of the line the event occurred + if "line" in widg.gettags(maybe_item): + # determine on which side of the line the event occurred if widg.coords(maybe_item)[0] > x_loc: - item = maybe_item-1 - else: #i.e. event was on line or to the right of it - item = maybe_item+1 + item = maybe_item - 1 + else: # i.e. event was on line or to the right of it + item = maybe_item + 1 else: item = maybe_item @@ -728,60 +787,61 @@ def my_find_closest(self, widg, x_loc): item = maybe_item return item - def setSelectedIntvlFrames(self,tupl): - ''' ''' - widg,item=tupl + + def setSelectedIntvlFrames(self, tupl): + """ """ + widg, item = tupl self.selectedIntvlFrames = [] for x in widg.gettags(item): - if x[:5] == 'frame': + if x[:5] == "frame": self.selectedIntvlFrames.append(x[5:]) def wipeFill(self): - ''' + """ Turns selected frame and interval back to black - ''' - for frame in range(1,self.app.frames+1): + """ + for frame in range(1, self.app.frames + 1): if str(frame) in self.app.Data.getCurrentTraceTracedFrames(): - fill = 'black' + fill = "black" else: - fill = 'gray70' - self.frames_canvas.itemconfig('frame'+str(frame), fill=fill) + fill = "gray70" + self.frames_canvas.itemconfig("frame" + str(frame), fill=fill) if self.selectedItem: - wdg,itm = self.selectedItem - if wdg.type(itm) != 'text' and wdg.type(itm) != None: - wdg,itm = self.app.Spectrogram.oldSelected - wdg.itemconfig(itm, fill='black') - if len(wdg.find_withtag(itm+1)) > 0: - wdg.itemconfig(itm+1, fill='black') - if len(wdg.find_withtag(itm-1)) > 0: - wdg.itemconfig(itm-1, fill='black') - #clicked tier label + wdg, itm = self.selectedItem + if wdg.type(itm) != "text" and wdg.type(itm) != None: + wdg, itm = self.app.Spectrogram.oldSelected + wdg.itemconfig(itm, fill="black") + if len(wdg.find_withtag(itm + 1)) > 0: + wdg.itemconfig(itm + 1, fill="black") + if len(wdg.find_withtag(itm - 1)) > 0: + wdg.itemconfig(itm - 1, fill="black") + # clicked tier label if wdg in self.tier_pairs.keys(): - wdg.itemconfig(1, fill='black') - self.tier_pairs[wdg].itemconfig('all', fill='black') - self.frames_canvas.itemconfig('all', fill='black') + wdg.itemconfig(1, fill="black") + self.tier_pairs[wdg].itemconfig("all", fill="black") + self.frames_canvas.itemconfig("all", fill="black") def genFrameList(self, event=None, widg=None, x_loc=None, SI=False): - ''' + """ Upon click, reads frames within interval from the tags to the text item of that interval, and highlights text of clicked interval - ''' + """ self.wipeFill() if event: - widg=event.widget - x_loc=event.x + widg = event.widget + x_loc = event.x - if SI==False: + if SI == False: item = self.my_find_closest(widg, x_loc) self.selectedItem = (widg, item) self.setSelectedIntvlFrames(self.selectedItem) - #automatically updates frame + # automatically updates frame if not str(self.app.frame) in self.selectedIntvlFrames: if self.selectedIntvlFrames: new_frame = int(self.selectedIntvlFrames[0]) else: - frame = self.my_find_closest(self.frames_canvas,x_loc) + frame = self.my_find_closest(self.frames_canvas, x_loc) framenum = self.frames_canvas.gettags(frame)[0][5:] new_frame = int(framenum) if self.firstFrame > new_frame: @@ -795,21 +855,21 @@ def genFrameList(self, event=None, widg=None, x_loc=None, SI=False): self.app.Spectrogram.update() def collapse(self, event): - ''' + """ collapse or uncollapse selected tier - ''' + """ widg = event.widget if event.num == 1: h = self.collapse_height - if int(widg['height']) == h: + if int(widg["height"]) == h: h = self.canvas_height elif event.num == 4 or event.delta > 0: h = self.canvas_height else: h = self.collapse_height - if int(widg['height']) == h: + if int(widg["height"]) == h: return elif h == self.canvas_height: mv = (self.canvas_height - self.collapse_height - 14) / 2 @@ -819,85 +879,86 @@ def collapse(self, event): # but it works - DS if widg in self.tier_pairs: - l, c = widg, self.tier_pairs[widg] + l, c = widg, self.tier_pairs[widg] else: - c = widg - l = None - for k in self.tier_pairs: - if self.tier_pairs[k] == widg: - l = k - break + c = widg + l = None + for k in self.tier_pairs: + if self.tier_pairs[k] == widg: + l = k + break l.configure(height=h) c.configure(height=h) - l.move('all', 0, mv) - self.app.event_generate('') + l.move("all", 0, mv) + self.app.event_generate("") def paintCanvases(self): - ''' - - ''' + """ """ if self.selectedItem: - wdg,itm = self.selectedItem - #paint selected - if wdg.type(itm) == 'text': - wdg.itemconfig(itm, fill='blue') - #paint boundaries of selected - if itm+1 in wdg.find_all(): - wdg.itemconfig(itm+1, fill='blue') - if itm-1 in wdg.find_all(): - wdg.itemconfig(itm-1, fill='blue') - if wdg in self.tier_pairs.keys(): #if on tier-label canvas + wdg, itm = self.selectedItem + # paint selected + if wdg.type(itm) == "text": + wdg.itemconfig(itm, fill="blue") + # paint boundaries of selected + if itm + 1 in wdg.find_all(): + wdg.itemconfig(itm + 1, fill="blue") + if itm - 1 in wdg.find_all(): + wdg.itemconfig(itm - 1, fill="blue") + if wdg in self.tier_pairs.keys(): # if on tier-label canvas canvas = self.tier_pairs[wdg] for el in canvas.find_all(): # #make all text intervals blue # if canvas.type(canvas.find_withtag(el)) == 'text': - canvas.itemconfig(el, fill='blue') + canvas.itemconfig(el, fill="blue") - #paint frames + # paint frames frames = wdg.gettags(itm) for frame in frames: - if frame[:5] == 'frame': + if frame[:5] == "frame": frame_obj = self.frames_canvas.find_withtag(frame) - #detect whether frame contains any traces + # detect whether frame contains any traces framenum = frame[5:] if framenum in self.app.Data.getCurrentTraceTracedFrames(): - fill = 'blue' + fill = "blue" else: - fill = 'dodger blue' + fill = "dodger blue" self.frames_canvas.itemconfig(frame_obj, fill=fill) - #current frame highlighted in red + # current frame highlighted in red if self.app.frame: - self.highlighted_frame = self.frames_canvas.find_withtag('frame'+str(self.app.frame)) - self.frames_canvas.itemconfig(self.highlighted_frame, fill='red') + self.highlighted_frame = self.frames_canvas.find_withtag("frame" + str(self.app.frame)) + self.frames_canvas.itemconfig(self.highlighted_frame, fill="red") def update(self): - ''' - - ''' + """ """ # debug(self.frames_canvas) - #create list of displayed frames' tags + # create list of displayed frames' tags itrobj = [] for itm in self.frames_canvas.find_all(): itrobj += list(self.frames_canvas.gettags(itm)) - #if selected frame is out of view - if "frame"+str(self.app.frame) not in itrobj: + # if selected frame is out of view + if "frame" + str(self.app.frame) not in itrobj: duration = self.end - self.start - #recenter view on selected frame - new_time = self.TextGrid.getFirst(self.frameTierName)[self.app.frame-1].time - self.start = new_time - (duration/2) - self.end = new_time + (duration/2) - #redraw + # recenter view on selected frame + new_time = self.TextGrid.getFirst(self.frameTierName)[self.app.frame - 1].time + self.start = new_time - (duration / 2) + self.end = new_time + (duration / 2) + # redraw self.fillCanvases() self.wipeFill() - #if selected frame outside selected interval, select interval on same tier containing frame + # if selected frame outside selected interval, select interval on same tier containing frame if self.selectedItem: - if self.selectedItem[0] in self.tier_pairs.keys() or self.selectedItem[0] in self.tier_pairs.values(): - if "frame"+str(self.app.frame) not in self.selectedItem[0].gettags(self.selectedItem[1]): #FIXME should also detect if on label canvas + if ( + self.selectedItem[0] in self.tier_pairs.keys() + or self.selectedItem[0] in self.tier_pairs.values() + ): + if "frame" + str(self.app.frame) not in self.selectedItem[0].gettags( + self.selectedItem[1] + ): # FIXME should also detect if on label canvas widg = self.selectedItem[0] if widg in self.tier_pairs: widg = self.tier_pairs[widg] - new_interval = widg.find_withtag("frame"+str(self.app.frame))[0] + new_interval = widg.find_withtag("frame" + str(self.app.frame))[0] self.selectedItem = (self.selectedItem[0], new_interval) # repaint all frames @@ -905,27 +966,29 @@ def update(self): self.updateTimeLabels() def grid(self, event=None): - ''' + """ Wrapper for gridding all of our Tk widgets. This funciton assumes that the tiers (as specified in the actual TextGrid files) are in some sort of reasonable order, with the default label being drawn on top. - ''' + """ for t in range(len(self.TkWidgets)): tierWidgets = self.TkWidgets[t] - if 'label' in tierWidgets: - tierWidgets['label'].grid(row=t, column=0, sticky='w') - if 'frames' in tierWidgets: - tierWidgets['frames'].grid(row=t, column=2, sticky='w', pady=self.app.pady) - tierWidgets['frames-label'].grid(row=t, column=0, sticky='w', pady=self.app.pady) - if 'canvas' in tierWidgets: - tierWidgets['canvas'].grid(row=t, column=2, sticky='w', pady=self.app.pady/2) - tierWidgets['canvas-label'].grid(row=t, column=0, sticky='w', pady=self.app.pady/2) - self.tier_pairs[tierWidgets['canvas-label']] = tierWidgets['canvas'] - if 'times' in tierWidgets: - tierWidgets['times'].grid(row=t, column=2, sticky='s') + if "label" in tierWidgets: + tierWidgets["label"].grid(row=t, column=0, sticky="w") + if "frames" in tierWidgets: + tierWidgets["frames"].grid(row=t, column=2, sticky="w", pady=self.app.pady) + tierWidgets["frames-label"].grid(row=t, column=0, sticky="w", pady=self.app.pady) + if "canvas" in tierWidgets: + tierWidgets["canvas"].grid(row=t, column=2, sticky="w", pady=self.app.pady / 2) + tierWidgets["canvas-label"].grid( + row=t, column=0, sticky="w", pady=self.app.pady / 2 + ) + self.tier_pairs[tierWidgets["canvas-label"]] = tierWidgets["canvas"] + if "times" in tierWidgets: + tierWidgets["times"].grid(row=t, column=2, sticky="s") def grid_remove(self): - raise NotImplementedError('cannot grid_remove the TextGridModule') + raise NotImplementedError("cannot grid_remove the TextGridModule") def openSearch(self, event=None): self.app.Search.openSearch() diff --git a/ultratrace/modules/trace.py b/ultratrace/modules/trace.py index 9e1d423..8e5ac51 100644 --- a/ultratrace/modules/trace.py +++ b/ultratrace/modules/trace.py @@ -9,23 +9,25 @@ from tkinter.ttk import Button, Entry, Frame, Scrollbar from tkinter import Listbox, StringVar + class Trace(Module): - ''' + """ Module to manage all of the different traces (with unique names/colors) and the Crosshairs objects associated to each one. In particular, handles creation/modfication of traces and crosshairs. - ''' + """ + def __init__(self, app): - info( ' - initializing module: Trace' ) + info(" - initializing module: Trace") self.app = app self.displayedColour = None - #self.app.Data.getCurrentTraceColor() + # self.app.Data.getCurrentTraceColor() # array of trace names for this directory - self.available = self.app.Data.getTopLevel( 'traces' ) - self.available = {} if self.available==None else self.available + self.available = self.app.Data.getTopLevel("traces") + self.available = {} if self.available == None else self.available # dictionary to hold trace -> [crosshairs] data self.crosshairs = {} @@ -38,24 +40,31 @@ def __init__(self, app): # declare & init trace string variable self.traceSV = StringVar() - self.traceSV.set( '' ) + self.traceSV.set("") # frame for (most of) our widgets - self.frame = Frame(self.app.LEFT)#, pady=7, padx=7) - self.frame.grid( row=4 ) + self.frame = Frame(self.app.LEFT) # , pady=7, padx=7) + self.frame.grid(row=4) # listbox to contain all of our traces lbframe = Frame(self.frame) self.scrollbar = Scrollbar(lbframe) - self.listbox = Listbox(lbframe, yscrollcommand=self.scrollbar.set, width=12, height=5, exportselection=False, takefocus=0) + self.listbox = Listbox( + lbframe, + yscrollcommand=self.scrollbar.set, + width=12, + height=5, + exportselection=False, + takefocus=0, + ) self.scrollbar.config(command=self.listbox.yview) for trace in self.available: - self.listbox.insert('end', trace) - for i, item in enumerate(self.listbox.get(0, 'end')): + self.listbox.insert("end", trace) + for i, item in enumerate(self.listbox.get(0, "end")): # select our "default trace" - if item==self.app.Data.getTopLevel( 'defaultTraceName' ): - self.listbox.selection_clear(0, 'end') - self.listbox.select_set( i ) + if item == self.app.Data.getTopLevel("defaultTraceName"): + self.listbox.selection_clear(0, "end") + self.listbox.select_set(i) break else: self.listbox.select_set(0) @@ -66,160 +75,213 @@ def __init__(self, app): # strategy for keeping track of everything that needs constistent grid() / # grid_remove() behavior self.TkWidgets = [ - self.getWidget( Header(self.frame, text="Landmarks"), row=5, column=0, columnspan=4 ), - self.getWidget( lbframe, row=10, column=0, rowspan=50 ), - self.getWidget( Button(self.frame, text='Set as default', command=self.setDefaultTraceName, takefocus=0), row=10, column=1, columnspan=3 ), - self.getWidget( Entry( self.frame, width=8, textvariable=self.displayedColour), row=13, column=1, columnspan=2, sticky='e'), - self.getWidget( Button(self.frame, text='⟳', command=self.recolor, takefocus=0, width="1.5", style="symbol.TButton"), row=13, column=3, sticky='w'), - self.getWidget( Button(self.frame, text='Clear', command=self.clear, takefocus=0), row=15, column=1, columnspan=3 ), - self.getWidget( Entry( self.frame, width=12, textvariable=self.traceSV), row=100, column=0, sticky='w,e' ), - self.getWidget( Button(self.frame, text='+', command=self.newTrace, takefocus=0, width=1.5), row=100, column=1, sticky='w' ), - self.getWidget( Button(self.frame, text='Rename', command=self.renameTrace, takefocus=0, width="7"), row=100, column=2, columnspan=2 ) ] + self.getWidget(Header(self.frame, text="Landmarks"), row=5, column=0, columnspan=4), + self.getWidget(lbframe, row=10, column=0, rowspan=50), + self.getWidget( + Button( + self.frame, text="Set as default", command=self.setDefaultTraceName, takefocus=0 + ), + row=10, + column=1, + columnspan=3, + ), + self.getWidget( + Entry(self.frame, width=8, textvariable=self.displayedColour), + row=13, + column=1, + columnspan=2, + sticky="e", + ), + self.getWidget( + Button( + self.frame, + text="⟳", + command=self.recolor, + takefocus=0, + width="1.5", + style="symbol.TButton", + ), + row=13, + column=3, + sticky="w", + ), + self.getWidget( + Button(self.frame, text="Clear", command=self.clear, takefocus=0), + row=15, + column=1, + columnspan=3, + ), + self.getWidget( + Entry(self.frame, width=12, textvariable=self.traceSV), + row=100, + column=0, + sticky="w,e", + ), + self.getWidget( + Button(self.frame, text="+", command=self.newTrace, takefocus=0, width=1.5), + row=100, + column=1, + sticky="w", + ), + self.getWidget( + Button(self.frame, text="Rename", command=self.renameTrace, takefocus=0, width="7"), + row=100, + column=2, + columnspan=2, + ), + ] # there's probably a better way to do this than indexing into self.TkWidgets - self.TkWidgets[3]['widget'].bind('', lambda ev: self.TkWidgets[0]['widget'].focus()) - self.TkWidgets[3]['widget'].bind('', lambda ev: self.TkWidgets[0]['widget'].focus()) - self.TkWidgets[6]['widget'].bind('', lambda ev: self.TkWidgets[0]['widget'].focus()) - self.TkWidgets[6]['widget'].bind('', lambda ev: self.TkWidgets[0]['widget'].focus()) - - if util.get_platform() == 'Linux': - self.app.bind('', self.recolor ) - self.app.bind('', self.copy ) - self.app.bind('', self.paste ) + self.TkWidgets[3]["widget"].bind("", lambda ev: self.TkWidgets[0]["widget"].focus()) + self.TkWidgets[3]["widget"].bind("", lambda ev: self.TkWidgets[0]["widget"].focus()) + self.TkWidgets[6]["widget"].bind("", lambda ev: self.TkWidgets[0]["widget"].focus()) + self.TkWidgets[6]["widget"].bind("", lambda ev: self.TkWidgets[0]["widget"].focus()) + + if util.get_platform() == "Linux": + self.app.bind("", self.recolor) + self.app.bind("", self.copy) + self.app.bind("", self.paste) else: - self.app.bind('', self.recolor ) - self.app.bind('', self.copy ) - self.app.bind('', self.paste ) + self.app.bind("", self.recolor) + self.app.bind("", self.copy) + self.app.bind("", self.paste) self.grid() def update(self): - ''' on change frames ''' + """on change frames""" # self.grid() - #NOTE this is called during zoom and pan - #this means the crosshairs are redrawn for every call, which is a lot - #we could probably just move them instead - self.reset() # clear our crosshairs + # NOTE this is called during zoom and pan + # this means the crosshairs are redrawn for every call, which is a lot + # we could probably just move them instead + self.reset() # clear our crosshairs self.read() # read from file - #self.frame.update() - #debug("TraceModule", self.frame.winfo_width()) + # self.frame.update() + # debug("TraceModule", self.frame.winfo_width()) + def reset(self): - ''' on change files ''' + """on change files""" # undraw all the crosshairs for trace in self.crosshairs: - for ch in self.crosshairs[ trace ]: + for ch in self.crosshairs[trace]: ch.undraw() # and empty out our trackers self.crosshairs = {} self.selected = set() def add(self, x, y, _trace=None, transform=True): - ''' + """ add a crosshair to the zoom frame canvas - ''' + """ - trace = self.getCurrentTraceName() if _trace==None else _trace - color = self.available[ trace ]['color'] - ch = Crosshairs( self.app.Dicom.zframe, x, y, color, transform ) + trace = self.getCurrentTraceName() if _trace == None else _trace + color = self.available[trace]["color"] + ch = Crosshairs(self.app.Dicom.zframe, x, y, color, transform) if trace not in self.crosshairs: - self.crosshairs[ trace ] = [] - self.crosshairs[ trace ].append( ch ) + self.crosshairs[trace] = [] + self.crosshairs[trace].append(ch) return ch + def remove(self, ch, write=True): - ''' + """ remove a crosshair from the zoom frame canvas ... doesn't actually remove it but instead just makes it "invisible" - ''' + """ ch.undraw() if write: self.write() return ch + def move(self): - ''' called when window resizes to move to correct relative locations''' + """called when window resizes to move to correct relative locations""" # trace = self.getCurrentTraceName() if self.crosshairs: for trace in self.crosshairs: - for ch in self.crosshairs[ trace ]: - truex,truey = ch.getTrueCoords() - ch.x,ch.y = ch.transformTrueToCoords(truex, truey) - ch.dragTo((ch.x,ch.y)) + for ch in self.crosshairs[trace]: + truex, truey = ch.getTrueCoords() + ch.x, ch.y = ch.transformTrueToCoords(truex, truey) + ch.dragTo((ch.x, ch.y)) def read(self): - ''' + """ read a list of crosshair coordinates from the metadata file - ''' + """ frame = self.app.frame for trace in self.available: try: newCrosshairs = [] for item in self.app.Data.getTraceCurrentFrame(trace): - ch = self.add( item['x'], item['y'], _trace=trace, transform=False ) + ch = self.add(item["x"], item["y"], _trace=trace, transform=False) if trace not in self.crosshairs: - self.crosshairs[ trace ] = [] - self.crosshairs[ trace ].append( ch ) - newCrosshairs.append( ch ) - self.app.Control.push({ 'type':'add', 'chs':newCrosshairs }) + self.crosshairs[trace] = [] + self.crosshairs[trace].append(ch) + newCrosshairs.append(ch) + self.app.Control.push({"type": "add", "chs": newCrosshairs}) except KeyError: pass + def write(self): - ''' + """ write out the coordinates of all of our crosshairs to the metadata file: - ''' + """ trace = self.getCurrentTraceName() traces = [] # prepare trace data in format for metadata array if trace in self.crosshairs: - for ch in self.crosshairs[ trace ]: + for ch in self.crosshairs[trace]: if ch.isVisible: - x,y = ch.getTrueCoords() - data = { 'x':x, 'y':y } + x, y = ch.getTrueCoords() + data = {"x": x, "y": y} if data not in traces: # add trace to temporary array for including in metadata array traces.append(data) # add to metadata array and update file - self.app.Data.setCurrentTraceCurrentFrame( traces ) + self.app.Data.setCurrentTraceCurrentFrame(traces) # update tier labels for number of annotated frames self.app.TextGrid.updateTierLabels() def getCurrentTraceName(self): - ''' + """ return string of current trace name - ''' + """ try: return self.listbox.get(self.listbox.curselection()) - except Exception as e: # tkinter.TclError? - error('Can\'t select from empty listbox!', e) + except Exception as e: # tkinter.TclError? + error("Can't select from empty listbox!", e) + def setDefaultTraceName(self): - ''' + """ wrapper for changing the default trace - ''' - self.app.Data.setTopLevel( 'defaultTraceName', self.getCurrentTraceName() ) + """ + self.app.Data.setTopLevel("defaultTraceName", self.getCurrentTraceName()) def select(self, ch): - ''' select a crosshairs ''' + """select a crosshairs""" ch.select() self.selected.add(ch) + def selectAll(self): - ''' select all crosshairs ''' + """select all crosshairs""" if self.getCurrentTraceName() in self.crosshairs: for ch in self.crosshairs[self.getCurrentTraceName()]: self.select(ch) def unselect(self, ch): - ''' unselect a crosshairs ''' + """unselect a crosshairs""" ch.unselect() self.selected.remove(ch) + def unselectAll(self): - ''' unselect all crosshairs ''' + """unselect all crosshairs""" for ch in self.selected: ch.unselect() self.selected = set() def getNearClickAllTraces(self, click): - ''' + """ takes a click object ( (x,y) tuple ) and returns a list of crosshairs within _CROSSHAIR_SELECT_RADIUS @@ -227,7 +289,7 @@ def getNearClickAllTraces(self, click): thru the other traces if it doesnt find anything if nothing is found for any trace, returns None - ''' + """ # get nearby crosshairs from this trace nearby = self.getNearClickOneTrace(click, self.getCurrentTraceName()) if nearby != None: @@ -241,97 +303,103 @@ def getNearClickAllTraces(self, click): # if we got something if nearby != None: # switch to that trace and exit the loop - for i, item in enumerate(self.listbox.get(0, 'end')): - if item==trace: - self.listbox.selection_clear(0, 'end') - self.listbox.select_set( i ) + for i, item in enumerate(self.listbox.get(0, "end")): + if item == trace: + self.listbox.selection_clear(0, "end") + self.listbox.select_set(i) return nearby return None + def getNearClickOneTrace(self, click, trace): - ''' + """ takes a click object and a trace and returns a list of crosshairs within util.CROSSHAIR_SELECT_RADIUS of that click - ''' + """ # see if we clicked near any existing crosshairs possibleSelections = {} if trace in self.crosshairs: - for ch in self.crosshairs[ trace ]: + for ch in self.crosshairs[trace]: d = ch.getDistance(click) if d < util.CROSSHAIR_SELECT_RADIUS: if d in possibleSelections: - possibleSelections[d].append( ch ) + possibleSelections[d].append(ch) else: - possibleSelections[d] = [ ch ] + possibleSelections[d] = [ch] # if we did ... if possibleSelections != {}: # ... get the closest one ... dMin = sorted(possibleSelections.keys())[0] # ... in case of a tie, select a random one - ch = random.choice( possibleSelections[dMin] ) + ch = random.choice(possibleSelections[dMin]) return ch return None def copy(self, event=None): - ''' copies relative positions of selected crosshairs for pasting''' + """copies relative positions of selected crosshairs for pasting""" # debug('copy') self.copied = [] if len(self.selected) > 0: for ch in self.selected: self.copied.append(ch.getTrueCoords()) + def paste(self, event=None): - ''' pastes copied crosshairs and add them to undo/redo buffer ''' + """pastes copied crosshairs and add them to undo/redo buffer""" if len(self.copied) > 0: newChs = [] for xy in self.copied: - ch = self.add(xy[0],xy[1], transform=False) + ch = self.add(xy[0], xy[1], transform=False) newChs.append(ch) self.write() - self.app.Control.push({ 'type':'add', 'chs':newChs }) + self.app.Control.push({"type": "add", "chs": newChs}) def recolor(self, event=None, trace=None, color=None): - ''' change the color of a particular trace ''' + """change the color of a particular trace""" - trace = self.getCurrentTraceName() if trace==None else trace + trace = self.getCurrentTraceName() if trace == None else trace # grab a new color and save our old color (for generating Control data) - newColor = self.getRandomHexColor() if color==None else color + newColor = self.getRandomHexColor() if color == None else color oldColor = self.app.Data.getCurrentTraceColor() - self.available[ trace ]['color'] = newColor - self.app.Data.setTraceColor( trace, newColor ) + self.available[trace]["color"] = newColor + self.app.Data.setTraceColor(trace, newColor) if trace in self.crosshairs: - for ch in self.crosshairs[ trace ]: - ch.recolor( newColor ) + for ch in self.crosshairs[trace]: + ch.recolor(newColor) - if trace==None or color == None: - self.app.Control.push({ 'type':'recolor', 'trace':self.getCurrentTraceName(), 'color':oldColor }) + if trace == None or color == None: + self.app.Control.push( + {"type": "recolor", "trace": self.getCurrentTraceName(), "color": oldColor} + ) self.redoQueue = [] # FIXME: get this to update the widget self.app.Trace.displayedColour = newColor # FIXME: also get the widget to update the colour! return oldColor + def clear(self): - ''' remove all crosshairs for the current trace ''' + """remove all crosshairs for the current trace""" # now we remove all the traces and save deleted = [] trace = self.getCurrentTraceName() if trace in self.crosshairs: - for ch in self.crosshairs[ trace ]: + for ch in self.crosshairs[trace]: if ch.isVisible: - deleted.append( ch ) - self.remove( ch, write=False ) + deleted.append(ch) + self.remove(ch, write=False) self.write() - self.app.Control.push({ 'type':'delete', 'chs':deleted }) + self.app.Control.push({"type": "delete", "chs": deleted}) + def newTrace(self): - ''' add a new trace to our listbox ''' + """add a new trace to our listbox""" # max length 12 chars (so it displays nicely) name = self.traceSV.get()[:12] @@ -343,67 +411,74 @@ def newTrace(self): color = self.getRandomHexColor() # save the new trace name and color to metadata & update vars - self.available[ name ] = { 'color':color, 'files':{} } - self.app.Data.setTopLevel( 'traces', self.available ) - self.traceSV.set('') + self.available[name] = {"color": color, "files": {}} + self.app.Data.setTopLevel("traces", self.available) + self.traceSV.set("") # update our listbox - self.listbox.insert('end', name) - self.listbox.selection_clear(0, 'end') - self.listbox.select_set( len(self.available)-1 ) + self.listbox.insert("end", name) + self.listbox.selection_clear(0, "end") + self.listbox.select_set(len(self.available) - 1) + def renameTrace(self, oldName=None, newName=None): - ''' change a trace name from oldName -> newName ''' + """change a trace name from oldName -> newName""" - fromUndo = (oldName!=None or newName!=None) - oldName = self.getCurrentTraceName() if oldName==None else oldName - newName = self.traceSV.get()[:12] if newName==None else newName + fromUndo = oldName != None or newName != None + oldName = self.getCurrentTraceName() if oldName == None else oldName + newName = self.traceSV.get()[:12] if newName == None else newName # don't overwrite anything if newName not in self.available and len(newName) > 0: # get data from the old name and change the dictionary key in the metadata - data = self.available.pop( oldName ) - self.available[ newName ] = data - self.app.Data.setTopLevel( 'traces', self.available ) - if oldName==self.app.Data.getTopLevel( 'defaultTraceName' ): - self.app.Data.setTopLevel( 'defaultTraceName', newName ) - self.traceSV.set('') + data = self.available.pop(oldName) + self.available[newName] = data + self.app.Data.setTopLevel("traces", self.available) + if oldName == self.app.Data.getTopLevel("defaultTraceName"): + self.app.Data.setTopLevel("defaultTraceName", newName) + self.traceSV.set("") # update our listbox index = self.listbox.curselection() self.listbox.delete(index) self.listbox.insert(index, newName) - self.listbox.selection_clear(0, 'end') + self.listbox.selection_clear(0, "end") self.listbox.select_set(index) - if not( fromUndo ): - self.app.Control.push({ 'type':'rename', 'old':oldName, 'new':newName }) + if not (fromUndo): + self.app.Control.push({"type": "rename", "old": oldName, "new": newName}) def getRandomHexColor(self): - ''' helper for getting a random color ''' - return '#%06x' % random.randint(0, 0xFFFFFF) - def getWidget(self, widget, row=0, column=0, rowspan=1, columnspan=1, sticky=() ): - ''' helper for managing all of our widgets ''' + """helper for getting a random color""" + return "#%06x" % random.randint(0, 0xFFFFFF) + + def getWidget(self, widget, row=0, column=0, rowspan=1, columnspan=1, sticky=()): + """helper for managing all of our widgets""" return { - 'widget' : widget, - 'row' : row, - 'rowspan': rowspan, - 'column' : column, - 'columnspan':columnspan, - 'sticky' : sticky } + "widget": widget, + "row": row, + "rowspan": rowspan, + "column": column, + "columnspan": columnspan, + "sticky": sticky, + } def grid(self): - ''' grid all of our widgets ''' + """grid all of our widgets""" for item in self.TkWidgets: - item['widget'].grid( - row=item['row'], column=item['column'], rowspan=item['rowspan'], - columnspan=item['columnspan'], sticky=item['sticky'] ) - self.listbox.pack(side='left', fill='y') - self.scrollbar.pack(side='right', fill='y') + item["widget"].grid( + row=item["row"], + column=item["column"], + rowspan=item["rowspan"], + columnspan=item["columnspan"], + sticky=item["sticky"], + ) + self.listbox.pack(side="left", fill="y") + self.scrollbar.pack(side="right", fill="y") + def grid_remove(self): - ''' remove all of our widgets from the grid ''' + """remove all of our widgets from the grid""" for item in self.TkWidgets: - item['widget'].grid_remove() + item["widget"].grid_remove() self.listbox.packforget() self.scrollbar.packforget() - diff --git a/ultratrace/util/__init__.py b/ultratrace/util/__init__.py index 8d1e055..667e004 100644 --- a/ultratrace/util/__init__.py +++ b/ultratrace/util/__init__.py @@ -1,19 +1,23 @@ from .logging import * __platform = None + + def get_platform(): # cache this result global __platform if __platform is None: try: import platform + __platform = platform.system() except ImportError: - __platform = 'generic' + __platform = "generic" return __platform return __platform -def printProgressBar(iteration, total, prefix = '', suffix = '', decimals = 1, length = 25, fill = '█'): + +def printProgressBar(iteration, total, prefix="", suffix="", decimals=1, length=25, fill="█"): """ Call in a loop to create terminal progress bar @params: @@ -27,19 +31,21 @@ def printProgressBar(iteration, total, prefix = '', suffix = '', decimals = 1, l """ percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) filledLength = int(length * iteration // total) - bar = fill * filledLength + '-' * (length - filledLength) - print('\r%s |%s| %s%% %s' % (prefix, bar, percent, suffix), end = '\r') + bar = fill * filledLength + "-" * (length - filledLength) + print("\r%s |%s| %s%% %s" % (prefix, bar, percent, suffix), end="\r") # Print New Line on Complete if iteration == total: print() + CROSSHAIR_SELECT_RADIUS = 9 CROSSHAIR_DRAG_BUFFER = 20 + def decode_bytes(byt): - for encoding in ['utf-8', 'Windows-1251', 'Windows-1252', 'ISO-8859-1']: + for encoding in ["utf-8", "Windows-1251", "Windows-1252", "ISO-8859-1"]: try: return byt.decode(encoding) except UnicodeDecodeError: pass - return '' + return "" diff --git a/ultratrace/util/framereader.py b/ultratrace/util/framereader.py index 85c763e..e94520d 100644 --- a/ultratrace/util/framereader.py +++ b/ultratrace/util/framereader.py @@ -8,6 +8,7 @@ from PIL import Image, ImageTk, ImageEnhance import tempfile import matplotlib + matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.backends.backend_agg import FigureCanvasAgg @@ -15,223 +16,249 @@ import math import os + class FrameReader(ABC): - def __init__(self, filename): - self.filename = filename - self.data = tempfile.TemporaryFile() - self.loaded = False + def __init__(self, filename): + self.filename = filename + self.data = tempfile.TemporaryFile() + self.loaded = False - @abstractmethod - def load(self): - pass + @abstractmethod + def load(self): + pass - @abstractmethod - def getFrame(self, framenum): - pass + @abstractmethod + def getFrame(self, framenum): + pass + + @abstractmethod + def getFrameTimes(self): + pass - @abstractmethod - def getFrameTimes(self): - pass class DicomReader(FrameReader): - def getFrameTimes(self): - dcm = dicom.read_file(self.filename, stop_before_pixels=True) - try: - blob = dcm[0x200d,0x3cf4][0][0x200d,0x3cf1][0] - headers = blob[0x200d,0x3cfb].value - offset = float(int.from_bytes(headers[:4], byteorder='little')) / 1000000 - return [float(int.from_bytes(headers[i:i+4], byteorder='little')) / 1000000 - offset for i in range(0, len(headers), 32)] - except: - frametime = dcm.get('FrameTime') - numframes = dcm.get('NumberOfFrames') - return [float(i * frametime) / 1000 for i in range(numframes)] - - def load(self): - pass - - def getFrame(self, framenum): - pass + def getFrameTimes(self): + dcm = dicom.read_file(self.filename, stop_before_pixels=True) + try: + blob = dcm[0x200D, 0x3CF4][0][0x200D, 0x3CF1][0] + headers = blob[0x200D, 0x3CFB].value + offset = float(int.from_bytes(headers[:4], byteorder="little")) / 1000000 + return [ + float(int.from_bytes(headers[i : i + 4], byteorder="little")) / 1000000 - offset + for i in range(0, len(headers), 32) + ] + except: + frametime = dcm.get("FrameTime") + numframes = dcm.get("NumberOfFrames") + return [float(i * frametime) / 1000 for i in range(numframes)] + + def load(self): + pass + + def getFrame(self, framenum): + pass + class DicomImgReader(DicomReader): - label = 'Read pixel data' - - def __init__(self, filename): - DicomReader.__init__(self, filename) - - def load(self): - debug('DicomImgReader: reading dicom') - dcm = dicom.read_file(self.filename) - debug('DicomImgReader: copying pixels') - pixels = dcm.pixel_array - del dcm - self.RGB = (len(pixels.shape) == 4) - if self.RGB and pixels.shape[0] == 3: - debug('DicomImgReader: reshaping pixels') - pixels = pixels.reshape(pixels.shape[1:] + [3]) - debug('DicomImgReader: dumping pixels') - pixels.tofile(self.data) - self.shape = pixels.shape - del pixels - self.frame_size = self.shape[1] * self.shape[2] - if self.RGB: - self.dtype = np.dtype(('uint8', (self.shape[1], self.shape[2], self.shape[3]))) - self.frame_size *= 3 - else: - self.dtype = np.dtype(('uint8', (self.shape[1], self.shape[2]))) - self.loaded = True - - def getFrame(self, framenum): - self.data.seek(self.frame_size * (framenum-1)) - buf = self.data.read(self.frame_size) - data = np.ndarray(shape=self.shape[1:], buffer=buf, dtype='uint8') - return Image.fromarray(data) + label = "Read pixel data" + + def __init__(self, filename): + DicomReader.__init__(self, filename) + + def load(self): + debug("DicomImgReader: reading dicom") + dcm = dicom.read_file(self.filename) + debug("DicomImgReader: copying pixels") + pixels = dcm.pixel_array + del dcm + self.RGB = len(pixels.shape) == 4 + if self.RGB and pixels.shape[0] == 3: + debug("DicomImgReader: reshaping pixels") + pixels = pixels.reshape(pixels.shape[1:] + [3]) + debug("DicomImgReader: dumping pixels") + pixels.tofile(self.data) + self.shape = pixels.shape + del pixels + self.frame_size = self.shape[1] * self.shape[2] + if self.RGB: + self.dtype = np.dtype(("uint8", (self.shape[1], self.shape[2], self.shape[3]))) + self.frame_size *= 3 + else: + self.dtype = np.dtype(("uint8", (self.shape[1], self.shape[2]))) + self.loaded = True + + def getFrame(self, framenum): + self.data.seek(self.frame_size * (framenum - 1)) + buf = self.data.read(self.frame_size) + data = np.ndarray(shape=self.shape[1:], buffer=buf, dtype="uint8") + return Image.fromarray(data) + class DicomScanLineReader(DicomReader): - label = 'Read unannotated' - - def __init__(self, filename): - DicomReader.__init__(self, filename) - dcm = dicom.read_file(self.filename, stop_before_pixels=True) - blob = dcm[0x200d,0x3cf4][0][0x200d,0x3cf1][0] - headers = blob[0x200d,0x3cfb].value - data = blob[0x200d,0x3cf3].value - framecount = int.from_bytes(data[4:8], byteorder='little') - offsets = [int.from_bytes(data[i*4+8:i*4+12], byteorder='little') for i in range(framecount)] - offsets.append(len(data)) - self.frames = [] - for frame in range(framecount): - framedata = data[offsets[frame]:offsets[frame+1]] - dt = zlib.decompress(framedata[32:]) - sh = (368, 712) - arr = np.ndarray(shape=sh, buffer=dt, dtype='uint8') - arr = np.flip(arr, (0,1)) - self.frames.append(arr) - self.loaded = True - - def load(self): - pass - - def getFrame(self, framenum): - return Image.fromarray(self.frames[framenum-1], 'L') + label = "Read unannotated" + + def __init__(self, filename): + DicomReader.__init__(self, filename) + dcm = dicom.read_file(self.filename, stop_before_pixels=True) + blob = dcm[0x200D, 0x3CF4][0][0x200D, 0x3CF1][0] + headers = blob[0x200D, 0x3CFB].value + data = blob[0x200D, 0x3CF3].value + framecount = int.from_bytes(data[4:8], byteorder="little") + offsets = [ + int.from_bytes(data[i * 4 + 8 : i * 4 + 12], byteorder="little") + for i in range(framecount) + ] + offsets.append(len(data)) + self.frames = [] + for frame in range(framecount): + framedata = data[offsets[frame] : offsets[frame + 1]] + dt = zlib.decompress(framedata[32:]) + sh = (368, 712) + arr = np.ndarray(shape=sh, buffer=dt, dtype="uint8") + arr = np.flip(arr, (0, 1)) + self.frames.append(arr) + self.loaded = True + + def load(self): + pass + + def getFrame(self, framenum): + return Image.fromarray(self.frames[framenum - 1], "L") + class DicomPNGReader(DicomReader): - label = 'Extract to PNGs' - - def __init__(self, filename, png_dir=None): - DicomReader.__init__(self, filename) - name = os.path.splitext(os.path.basename(filename))[0] - if png_dir: - dr = os.path.abspath(png_dir) - else: - dr = os.path.dirname(os.path.abspath(png_dir or filename)) - self.png_dir = os.path.join(dr, name + '_dicom_to_png') - info(self.png_dir) - self.png_name = os.path.join(self.png_dir, name + '_frame_%04d.png') - self.loaded = os.path.exists(self.png_dir) - - def load(self): - os.mkdir(self.png_dir) - info( 'Reading DICOM data ...', end='\r' ) - dcm = dicom.read_file(self.filename) - pixels = dcm.pixel_array - - # check encoding, manipulate array if we need to - if len(pixels.shape) == 3: # greyscale - RGB = False - frames, rows, columns = pixels.shape - elif len(pixels.shape) == 4: # RGB-encoded - RGB = True - if pixels.shape[0] == 3: # handle RGB-first - rgb, frames, rows, columns = pixels.shape - else: # and RGB-last - frames, rows, columns, rgb = pixels.shape - pixels = pixels.reshape([ frames, rows, columns, rgb ]) - - printProgressBar(0, frames, prefix = 'Processing:', suffix = 'complete') - for f in range(frames): - printProgressBar(f+1, frames, prefix = 'Processing:', suffix = ('complete (%d of %d)' % (f+1,frames))) - arr = pixels[ f,:,:,: ] if RGB else pixels[ f,:,: ] - img = Image.fromarray( arr ) - img.save(self.png_name % (f+1), format='PNG', compress_level=1) - - self.loaded = True - - def getFrame(self, framenum): - try: - return Image.open(self.png_name % framenum) - except FileNotFoundError: - error('file %s does not exist' % (self.png_name % framenum)) - return None + label = "Extract to PNGs" + + def __init__(self, filename, png_dir=None): + DicomReader.__init__(self, filename) + name = os.path.splitext(os.path.basename(filename))[0] + if png_dir: + dr = os.path.abspath(png_dir) + else: + dr = os.path.dirname(os.path.abspath(png_dir or filename)) + self.png_dir = os.path.join(dr, name + "_dicom_to_png") + info(self.png_dir) + self.png_name = os.path.join(self.png_dir, name + "_frame_%04d.png") + self.loaded = os.path.exists(self.png_dir) + + def load(self): + os.mkdir(self.png_dir) + info("Reading DICOM data ...", end="\r") + dcm = dicom.read_file(self.filename) + pixels = dcm.pixel_array + + # check encoding, manipulate array if we need to + if len(pixels.shape) == 3: # greyscale + RGB = False + frames, rows, columns = pixels.shape + elif len(pixels.shape) == 4: # RGB-encoded + RGB = True + if pixels.shape[0] == 3: # handle RGB-first + rgb, frames, rows, columns = pixels.shape + else: # and RGB-last + frames, rows, columns, rgb = pixels.shape + pixels = pixels.reshape([frames, rows, columns, rgb]) + + printProgressBar(0, frames, prefix="Processing:", suffix="complete") + for f in range(frames): + printProgressBar( + f + 1, + frames, + prefix="Processing:", + suffix=("complete (%d of %d)" % (f + 1, frames)), + ) + arr = pixels[f, :, :, :] if RGB else pixels[f, :, :] + img = Image.fromarray(arr) + img.save(self.png_name % (f + 1), format="PNG", compress_level=1) + + self.loaded = True + + def getFrame(self, framenum): + try: + return Image.open(self.png_name % framenum) + except FileNotFoundError: + error("file %s does not exist" % (self.png_name % framenum)) + return None + class ULTScanLineReader(FrameReader): - label = 'Read scan line data' - - def __init__(self, data, metadata): - FrameReader.__init__(self, data) - #f = open(data, 'rb') - #self.data.write(f.read()) - #f.close() - self.data = open(data, 'rb') - # sometimes copying to a temporary file misses things - # however, if we do this it might not get closed - f = open(metadata) - for l in f.readlines(): - k, v = l.strip().split('=') - if ',' in v or '.' in v: - self.__dict__[k] = float(v.replace(',', '.')) - else: - self.__dict__[k] = int(v) - f.close() - self.loaded = True - self.radspace = np.linspace(self.ZeroOffset, self.ZeroOffset+self.PixPerVector, num=self.PixPerVector) - total = self.Angle * self.NumVectors - self.thetaspace = np.linspace((math.pi + total) / 2, (math.pi - total) / 2, num=self.NumVectors) - self.FrameSize = self.NumVectors * self.PixPerVector - self.data.seek(0, os.SEEK_END) - self.FrameCount = self.data.tell() // self.FrameSize - - def load(self): - raise NotImplementedError() - - def getFrame(self, framenum): - self.data.seek(max(self.FrameSize * (framenum - 1), 0)) - byt = self.data.read(self.FrameSize) - data = np.ndarray(shape=(self.NumVectors, self.PixPerVector), buffer=byt, dtype='uint8').swapaxes(0,1) - fig = plt.figure() - fig.patch.set_facecolor('black') - ax = fig.add_subplot(111, polar='True') - ax.axis('off') - ax.set_thetamin(0) - ax.set_thetamax(180) - ax.pcolormesh(self.thetaspace, self.radspace, data, cmap='gray') - canvas = FigureCanvasAgg(fig) - canvas.draw() - r = self.ZeroOffset+self.PixPerVector - px = ax.transData.transform([[0,0], [math.pi/2, r], [self.thetaspace[0], r], [self.thetaspace[-1], r]]) - xmin = math.floor(px[2][0]) - xmax = math.ceil(px[3][0]) - ymin = math.floor(px[0][1]) - ymax = math.ceil(px[1][1]) - ret = Image.frombytes('RGB', canvas.get_width_height(), canvas.tostring_rgb()) - plt.close(fig) - return ret.crop((xmin, ymin, xmax, ymax)) - - def getFrameTimes(self): - inc = 1.0 / self.FramesPerSec - return [self.TimeInSecsOfFirstFrame + i * inc for i in range(self.FrameCount)] + label = "Read scan line data" + + def __init__(self, data, metadata): + FrameReader.__init__(self, data) + # f = open(data, 'rb') + # self.data.write(f.read()) + # f.close() + self.data = open(data, "rb") + # sometimes copying to a temporary file misses things + # however, if we do this it might not get closed + f = open(metadata) + for l in f.readlines(): + k, v = l.strip().split("=") + if "," in v or "." in v: + self.__dict__[k] = float(v.replace(",", ".")) + else: + self.__dict__[k] = int(v) + f.close() + self.loaded = True + self.radspace = np.linspace( + self.ZeroOffset, self.ZeroOffset + self.PixPerVector, num=self.PixPerVector + ) + total = self.Angle * self.NumVectors + self.thetaspace = np.linspace( + (math.pi + total) / 2, (math.pi - total) / 2, num=self.NumVectors + ) + self.FrameSize = self.NumVectors * self.PixPerVector + self.data.seek(0, os.SEEK_END) + self.FrameCount = self.data.tell() // self.FrameSize + + def load(self): + raise NotImplementedError() + + def getFrame(self, framenum): + self.data.seek(max(self.FrameSize * (framenum - 1), 0)) + byt = self.data.read(self.FrameSize) + data = np.ndarray( + shape=(self.NumVectors, self.PixPerVector), buffer=byt, dtype="uint8" + ).swapaxes(0, 1) + fig = plt.figure() + fig.patch.set_facecolor("black") + ax = fig.add_subplot(111, polar="True") + ax.axis("off") + ax.set_thetamin(0) + ax.set_thetamax(180) + ax.pcolormesh(self.thetaspace, self.radspace, data, cmap="gray") + canvas = FigureCanvasAgg(fig) + canvas.draw() + r = self.ZeroOffset + self.PixPerVector + px = ax.transData.transform( + [[0, 0], [math.pi / 2, r], [self.thetaspace[0], r], [self.thetaspace[-1], r]] + ) + xmin = math.floor(px[2][0]) + xmax = math.ceil(px[3][0]) + ymin = math.floor(px[0][1]) + ymax = math.ceil(px[1][1]) + ret = Image.frombytes("RGB", canvas.get_width_height(), canvas.tostring_rgb()) + plt.close(fig) + return ret.crop((xmin, ymin, xmax, ymax)) + + def getFrameTimes(self): + inc = 1.0 / self.FramesPerSec + return [self.TimeInSecsOfFirstFrame + i * inc for i in range(self.FrameCount)] + READERS = { - 'dicom': [DicomImgReader, DicomScanLineReader, DicomPNGReader], - 'ult': [ULTScanLineReader], - None: [] + "dicom": [DicomImgReader, DicomScanLineReader, DicomPNGReader], + "ult": [ULTScanLineReader], + None: [], } LABEL_TO_READER = {} for k in READERS: - LABEL_TO_READER[k] = {} - for cls in READERS[k]: - LABEL_TO_READER[k][cls.label] = cls + LABEL_TO_READER[k] = {} + for cls in READERS[k]: + LABEL_TO_READER[k][cls.label] = cls diff --git a/ultratrace/util/logging.py b/ultratrace/util/logging.py index b8b6cbc..9ab091b 100644 --- a/ultratrace/util/logging.py +++ b/ultratrace/util/logging.py @@ -1,21 +1,28 @@ import sys + def log(*msgs, **kwargs): print(*map(str, msgs), **kwargs, file=sys.stderr) + def debug(*msgs, **kwargs): - log('DEBUG: ', *msgs, **kwargs) + log("DEBUG: ", *msgs, **kwargs) + def info(*msgs, **kwargs): - log('INFO: ', *msgs, **kwargs) + log("INFO: ", *msgs, **kwargs) + def warn(*msgs, **kwargs): - log('WARN: ', *msgs, **kwargs) + log("WARN: ", *msgs, **kwargs) + def error(*msgs, **kwargs): - log('ERROR: ', *msgs, **kwargs) + log("ERROR: ", *msgs, **kwargs) + def severe(*msgs, **kwargs): - log('SEVERE:', *msgs, **kwargs) + log("SEVERE:", *msgs, **kwargs) + -__all__ = [ 'debug', 'info', 'warn', 'error', 'severe' ] +__all__ = ["debug", "info", "warn", "error", "severe"] diff --git a/ultratrace/widgets/canvas_tooltip.py b/ultratrace/widgets/canvas_tooltip.py index 29e945e..b9dd841 100644 --- a/ultratrace/widgets/canvas_tooltip.py +++ b/ultratrace/widgets/canvas_tooltip.py @@ -1,8 +1,9 @@ from tkinter.ttk import Frame, Label from tkinter import Toplevel + class CanvasTooltip: - ''' + """ It creates a tooltip for a given canvas tag or id as the mouse is above it. @@ -14,15 +15,19 @@ class CanvasTooltip: 41079350#41079350 Alberto Vassena on 2016.12.10. - ''' - - def __init__(self, canvas, tag_or_id, - *, - bg='#FFFFEA', - pad=(5, 3, 5, 3), - text='canvas info', - waittime=400, - wraplength=250): + """ + + def __init__( + self, + canvas, + tag_or_id, + *, + bg="#FFFFEA", + pad=(5, 3, 5, 3), + text="canvas info", + waittime=400, + wraplength=250 + ): self.waittime = waittime # in miliseconds, originally 500 self.wraplength = wraplength # in pixels, originally 180 self.canvas = canvas @@ -53,16 +58,16 @@ def unschedule(self): self.canvas.after_cancel(id_) def show(self, event=None): - def tip_pos_calculator(canvas, label, - *, - tip_delta=(10, 5), pad=(5, 3, 5, 3)): + def tip_pos_calculator(canvas, label, *, tip_delta=(10, 5), pad=(5, 3, 5, 3)): c = canvas s_width, s_height = c.winfo_screenwidth(), c.winfo_screenheight() - width, height = (pad[0] + label.winfo_reqwidth() + pad[2], - pad[1] + label.winfo_reqheight() + pad[3]) + width, height = ( + pad[0] + label.winfo_reqwidth() + pad[2], + pad[1] + label.winfo_reqheight() + pad[3], + ) mouse_x, mouse_y = c.winfo_pointerxy() @@ -109,20 +114,22 @@ def tip_pos_calculator(canvas, label, # Leaves only the label and removes the app window self.tw.wm_overrideredirect(True) - win = Frame(self.tw, - # background=bg, - borderwidth=0) - label = Label(win, - text=self.text, - justify='left', - background=bg, - relief="solid", - borderwidth=0, - wraplength=self.wraplength) - - label.grid(padx=(pad[0], pad[2]), - pady=(pad[1], pad[3]), - sticky='nsew') + win = Frame( + self.tw, + # background=bg, + borderwidth=0, + ) + label = Label( + win, + text=self.text, + justify="left", + background=bg, + relief="solid", + borderwidth=0, + wraplength=self.wraplength, + ) + + label.grid(padx=(pad[0], pad[2]), pady=(pad[1], pad[3]), sticky="nsew") win.grid() x, y = tip_pos_calculator(canvas, label) diff --git a/ultratrace/widgets/crosshairs.py b/ultratrace/widgets/crosshairs.py index b36fb61..33838e1 100644 --- a/ultratrace/widgets/crosshairs.py +++ b/ultratrace/widgets/crosshairs.py @@ -3,9 +3,10 @@ import math + class Crosshairs: def __init__(self, zframe, x, y, color, transform=True): - ''' + """ Crosshairs() serves two purposes: - handling (visual) placement of a `+` onto the zframe canvas - keeping track of point locations for saving/loading trace data @@ -16,16 +17,16 @@ def __init__(self, zframe, x, y, color, transform=True): y : y-canvas-coordinate of where we should place the center of the Crosshairs color : color for when unselected transform : Boolean RE whether the coordinates need to be adjusted - ''' + """ # keep a reference to the zframe self.zframe = zframe # set defaults here - self.selectedColor = 'blue' - self.unselectedColor= color - self.selectedWidth = 1.5#3 - self.unselectedWidth= 1#2 + self.selectedColor = "blue" + self.unselectedColor = color + self.selectedWidth = 1.5 # 3 + self.unselectedWidth = 1 # 2 # store position data self.x, self.y = x, y @@ -35,14 +36,28 @@ def __init__(self, zframe, x, y, color, transform=True): else: self.x, self.y = self.transformTrueToCoords(x, y) - self.len = self.transformLength( CROSSHAIR_SELECT_RADIUS ) + self.len = self.transformLength(CROSSHAIR_SELECT_RADIUS) # self.resetTrueCoords() self.isSelected = False self.isVisible = True # draw on the canvas - self.hline = self.zframe.canvas.create_line(self.x-self.len, self.y, self.x+self.len, self.y, fill=self.unselectedColor, width=self.unselectedWidth) - self.vline = self.zframe.canvas.create_line(self.x, self.y-self.len, self.x, self.y+self.len, fill=self.unselectedColor, width=self.unselectedWidth) + self.hline = self.zframe.canvas.create_line( + self.x - self.len, + self.y, + self.x + self.len, + self.y, + fill=self.unselectedColor, + width=self.unselectedWidth, + ) + self.vline = self.zframe.canvas.create_line( + self.x, + self.y - self.len, + self.x, + self.y + self.len, + fill=self.unselectedColor, + width=self.unselectedWidth, + ) # def resetTrueCoords(self): # ''' @@ -54,29 +69,29 @@ def __init__(self, zframe, x, y, color, transform=True): # pass def getTrueCoords(self): - ''' called when we're saving to file ''' + """called when we're saving to file""" return self.trueX, self.trueY def transformCoordsToTrue(self, x, y): - ''' + """ canvas coords -> absolute coords absolute coords are % along each axis (e.g. center of image = [.5,.5]) - ''' + """ # x = (self.trueX - self.zframe.panX) / self.zframe.imgscale # y = (self.trueY - self.zframe.panY) / self.zframe.imgscale # return x,y - truex = (x-self.zframe.panX)/(self.zframe.width*self.zframe.imgscale) - truey = (y-self.zframe.panY)/(self.zframe.height*self.zframe.imgscale) + truex = (x - self.zframe.panX) / (self.zframe.width * self.zframe.imgscale) + truey = (y - self.zframe.panY) / (self.zframe.height * self.zframe.imgscale) # truex = (x-self.zframe.panX)/self.zframe.imgscale # truey = (y-self.zframe.panY)/self.zframe.imgscale debug(truex, truey) return truex, truey def transformTrueToCoords(self, truex, truey): - ''' + """ absolute coords -> canvas coords absolute coords are % along each axis (e.g. center of image = [.5,.5]) - ''' + """ # x = (_x * self.zframe.imgscale) + self.zframe.panX # y = (_y * self.zframe.imgscale) + self.zframe.panY x = truex * self.zframe.width * self.zframe.imgscale + self.zframe.panX @@ -84,65 +99,79 @@ def transformTrueToCoords(self, truex, truey): return x, y def transformCoords(self, x, y): - ''' transforms coordinates by the canvas offsets ''' + """transforms coordinates by the canvas offsets""" x += self.zframe.canvas.canvasx(0) y += self.zframe.canvas.canvasy(0) - return x,y + return x, y def transformLength(self, l): - ''' transforms a length by our current zoom-amount ''' + """transforms a length by our current zoom-amount""" return l * self.zframe.imgscale def getDistance(self, click): - ''' calculates the distance from centerpoint to a click event ''' + """calculates the distance from centerpoint to a click event""" click = self.transformCoords(*click) - dx = abs( self.x - click[0] ) - dy = abs( self.y - click[1] ) - return math.sqrt( dx**2 + dy**2 ) if self.isVisible else float('inf') # invisible points infinitely far away + dx = abs(self.x - click[0]) + dy = abs(self.y - click[1]) + return ( + math.sqrt(dx**2 + dy**2) if self.isVisible else float("inf") + ) # invisible points infinitely far away def select(self): - ''' select this Crosshairs ''' + """select this Crosshairs""" if self.isVisible: - self.zframe.canvas.itemconfig( self.hline, fill=self.selectedColor, width=self.selectedWidth) - self.zframe.canvas.itemconfig( self.vline, fill=self.selectedColor, width=self.selectedWidth) + self.zframe.canvas.itemconfig( + self.hline, fill=self.selectedColor, width=self.selectedWidth + ) + self.zframe.canvas.itemconfig( + self.vline, fill=self.selectedColor, width=self.selectedWidth + ) self.isSelected = True def unselect(self): - ''' stop selecting this Crosshairs ''' + """stop selecting this Crosshairs""" if self.isVisible: - self.zframe.canvas.itemconfig( self.hline, fill=self.unselectedColor, width=self.unselectedWidth) - self.zframe.canvas.itemconfig( self.vline, fill=self.unselectedColor, width=self.unselectedWidth) + self.zframe.canvas.itemconfig( + self.hline, fill=self.unselectedColor, width=self.unselectedWidth + ) + self.zframe.canvas.itemconfig( + self.vline, fill=self.unselectedColor, width=self.unselectedWidth + ) self.isSelected = False def undraw(self): - ''' use this instead of deleting objects to make undos easier ''' - self.zframe.canvas.itemconfigure( self.hline, state='hidden' ) - self.zframe.canvas.itemconfigure( self.vline, state='hidden' ) + """use this instead of deleting objects to make undos easier""" + self.zframe.canvas.itemconfigure(self.hline, state="hidden") + self.zframe.canvas.itemconfigure(self.vline, state="hidden") self.unselect() self.isVisible = False def draw(self): - ''' called when we undo a delete ''' - self.zframe.canvas.itemconfigure( self.hline, state='normal' ) - self.zframe.canvas.itemconfigure( self.vline, state='normal' ) + """called when we undo a delete""" + self.zframe.canvas.itemconfigure(self.hline, state="normal") + self.zframe.canvas.itemconfigure(self.vline, state="normal") self.isVisible = True def dragTo(self, click): - ''' move the centerpoint to a given point (calculated in main class) ''' + """move the centerpoint to a given point (calculated in main class)""" if self.isVisible: - self.x += (click[0] - self.x) - self.y += (click[1] - self.y) + self.x += click[0] - self.x + self.y += click[1] - self.y # self.x, self.y = self.transformTrueToCoords(self.trueX, self.trueY) self.trueX, self.trueY = self.transformCoordsToTrue(self.x, self.y) - self.len = self.transformLength( CROSSHAIR_SELECT_RADIUS ) - self.zframe.canvas.coords( self.hline, self.x-self.len, self.y, self.x+self.len, self.y ) - self.zframe.canvas.coords( self.vline, self.x, self.y-self.len, self.x, self.y+self.len ) + self.len = self.transformLength(CROSSHAIR_SELECT_RADIUS) + self.zframe.canvas.coords( + self.hline, self.x - self.len, self.y, self.x + self.len, self.y + ) + self.zframe.canvas.coords( + self.vline, self.x, self.y - self.len, self.x, self.y + self.len + ) def recolor(self, color): - ''' change the fill color of the Crosshairs ''' + """change the fill color of the Crosshairs""" if self.isVisible: - self.unselectedColor = color # change this in the background (i.e. don't unselect) + self.unselectedColor = color # change this in the background (i.e. don't unselect) if self.isSelected == False: - self.zframe.canvas.itemconfig( self.hline, fill=color ) - self.zframe.canvas.itemconfig( self.vline, fill=color ) + self.zframe.canvas.itemconfig(self.hline, fill=color) + self.zframe.canvas.itemconfig(self.vline, fill=color) diff --git a/ultratrace/widgets/header.py b/ultratrace/widgets/header.py index bf466df..4b12516 100644 --- a/ultratrace/widgets/header.py +++ b/ultratrace/widgets/header.py @@ -1,8 +1,9 @@ from tkinter.ttk import Label + class Header(Label): def __init__(self, master, text): - ''' + """ Wrapper for Tk Label() object with a specified font - ''' - Label.__init__(self, master, text=text, font='TkDefaultFont 12 bold') + """ + Label.__init__(self, master, text=text, font="TkDefaultFont 12 bold") diff --git a/ultratrace/widgets/rect_tracker.py b/ultratrace/widgets/rect_tracker.py index 912d457..0ea4952 100644 --- a/ultratrace/widgets/rect_tracker.py +++ b/ultratrace/widgets/rect_tracker.py @@ -1,5 +1,5 @@ class RectTracker: - ''' Copied from http://code.activestate.com/recipes/577409-python-tkinter-canvas-rectangle-selection-box/''' + """Copied from http://code.activestate.com/recipes/577409-python-tkinter-canvas-rectangle-selection-box/""" def __init__(self, canvas): self.canvas = canvas @@ -7,16 +7,16 @@ def __init__(self, canvas): def draw(self, start, end, **opts): """Draw the rectangle""" - return self.canvas.create_rectangle(*(list(start)+list(end)), **opts) + return self.canvas.create_rectangle(*(list(start) + list(end)), **opts) def autodraw(self, **opts): """Setup automatic drawing; supports command option""" self.start = None # self.canvas.bind("", self.__update, '+') - self.canvas.bind("", self.__update, '+') - self.canvas.bind("", self.__stop, '+') + self.canvas.bind("", self.__update, "+") + self.canvas.bind("", self.__stop, "+") - self._command = opts.pop('command', lambda *args: None) + self._command = opts.pop("command", lambda *args: None) self.rectopts = opts def __update(self, event): diff --git a/ultratrace/widgets/zoom_frame.py b/ultratrace/widgets/zoom_frame.py index 338d37a..0c475fd 100644 --- a/ultratrace/widgets/zoom_frame.py +++ b/ultratrace/widgets/zoom_frame.py @@ -1,53 +1,61 @@ from tkinter import Canvas from tkinter.ttk import Frame -from PIL import ImageTk # pillow +from PIL import ImageTk # pillow from .rect_tracker import RectTracker + class ZoomFrame(Frame): - ''' + """ Wrapper for a Tk Frame() object that includes zooming and panning functionality. This code is inspired by the answer from https://stackoverflow.com/users/7550928/foo-bar at https://stackoverflow.com/questions/41656176/tkinter-canvas-zoom-move-pan ... Could probably be cleaned up and slimmed down - ''' + """ + def __init__(self, master, delta, app): Frame.__init__(self, master) self.app = app self.delta = delta self.maxZoom = 5 - #self.resetCanvas(master) + # self.resetCanvas(master) self.canvas_width = 800 self.width = 0 self.canvas_height = 600 self.height = 0 self.shown = False - self.aspect_ratio = 4.0/3.0 - - self.canvas = Canvas( master, bg='grey', width=self.canvas_width, height=self.canvas_height, highlightthickness=0 ) - self.canvas.grid(row=0, column=0, sticky='news') - self.canvas.update() # do i need + self.aspect_ratio = 4.0 / 3.0 + + self.canvas = Canvas( + master, + bg="grey", + width=self.canvas_width, + height=self.canvas_height, + highlightthickness=0, + ) + self.canvas.grid(row=0, column=0, sticky="news") + self.canvas.update() # do i need rect = RectTracker(self.canvas) - rect.autodraw(outline='blue') + rect.autodraw(outline="blue") # self.master.rowconfigure(0, weight=1) # do i need # self.master.columnconfigure(0, weight=1) # do i need # self.canvas.bind('', self.showImage ) # on canvas resize events - self.canvas.bind('', self.moveFrom ) - self.canvas.bind('', self.moveTo ) - self.canvas.bind('', self.wheel ) # Windows & Linux - self.canvas.bind('', self.wheel ) # Linux scroll up - self.canvas.bind('', self.wheel ) # Linux scroll down + self.canvas.bind("", self.moveFrom) + self.canvas.bind("", self.moveTo) + self.canvas.bind("", self.wheel) # Windows & Linux + self.canvas.bind("", self.wheel) # Linux scroll up + self.canvas.bind("", self.wheel) # Linux scroll down self.resetCanvas() - self.canvas.bind('', self.app.onClickZoom ) - self.canvas.bind('', self.app.onMotion ) + self.canvas.bind("", self.app.onClickZoom) + self.canvas.bind("", self.app.onMotion) - self.app.bind('', self.wheel ) - self.app.bind('', self.wheel ) + self.app.bind("", self.wheel) + self.app.bind("", self.wheel) def resetCanvas(self): self.canvas_width = 800 @@ -83,11 +91,11 @@ def resetImageDimensions(self): self.height = 0 self.aspect_ratio = 1 - def setImage(self, image): # expect an Image() instance + def setImage(self, image): # expect an Image() instance self.image = image if self.width == 0 and image: self.width, self.height = self.image.size - self.aspect_ratio = self.width/self.height + self.aspect_ratio = self.width / self.height win_width = self.app.RIGHT.winfo_width() asp_height = round(win_width / self.aspect_ratio) win_height = self.app.RIGHT.winfo_height() @@ -102,15 +110,19 @@ def setImage(self, image): # expect an Image() instance def showImage(self, event=None): # even if we're not showing a new frame, we want to remove the old one - self.canvas.delete('delendum') + self.canvas.delete("delendum") if self.image != None: - self.container = self.canvas.create_rectangle(0,0,self.width,self.height,width=0, tags='delendum') - self.canvas.scale('all', 0, 0, self.imgscale, self.imgscale) - self.canvas.move('all', self.panX, self.panY) + self.container = self.canvas.create_rectangle( + 0, 0, self.width, self.height, width=0, tags="delendum" + ) + self.canvas.scale("all", 0, 0, self.imgscale, self.imgscale) + self.canvas.move("all", self.panX, self.panY) bbox = self.canvas.bbox(self.container) image = self.image.resize((bbox[2] - bbox[0], bbox[3] - bbox[1])) imagetk = ImageTk.PhotoImage(image) - image = self.canvas.create_image(bbox[0], bbox[1], anchor='nw', image=imagetk, tags='delendum') + image = self.canvas.create_image( + bbox[0], bbox[1], anchor="nw", image=imagetk, tags="delendum" + ) self.canvas.lower(image) self.canvas.imagetk = imagetk self.shown = True @@ -118,18 +130,18 @@ def showImage(self, event=None): def wheel(self, event): if self.image != None: - if event.keysym == 'equal' or event.keysym == 'minus': #what is this for? - x = self.canvas_width/2 - y = self.canvas_height/2 - else: #do these vars get used? + if event.keysym == "equal" or event.keysym == "minus": # what is this for? + x = self.canvas_width / 2 + y = self.canvas_height / 2 + else: # do these vars get used? x = self.canvas.canvasx(event.x) y = self.canvas.canvasy(event.y) # Respond to Linux (event.num) or Windows (event.delta) wheel event - if event.num == 5 or event.delta < 0 or event.keysym == 'minus': # zoom out + if event.num == 5 or event.delta < 0 or event.keysym == "minus": # zoom out self.zoomOut() - elif event.num == 4 or event.delta > 0 or event.keysym == 'equal': # zoom in + elif event.num == 4 or event.delta > 0 or event.keysym == "equal": # zoom in self.zoomIn() def zoomOut(self): diff --git a/ultratrace2/__init__.py b/ultratrace2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ultratrace2/__main__.py b/ultratrace2/__main__.py deleted file mode 100644 index 2d73455..0000000 --- a/ultratrace2/__main__.py +++ /dev/null @@ -1,73 +0,0 @@ -import argparse -import logging - -from .app import initialize_app - -logging.basicConfig(level=logging.DEBUG) - - -def main(): - - parser = argparse.ArgumentParser(prog="ultratrace") - - # noqa: E128 - parser.add_argument( - "--headless", - action="store_true", - default=False, - help="run ultratrace without a GUI interface", - ) - parser.add_argument( - "path", - default=None, - help="path (unique to a participant) where subdirectories contain raw data", - ) - parser.add_argument( - "--no-audio", - dest="audio", - action="store_false", - help="don't try to load the audio widget", - ) - parser.add_argument( - "--no-dicom", - dest="dicom", - action="store_false", - help="don't try to load the dicom widget", - ) - parser.add_argument( - "--no-textgrid", - dest="textgrid", - action="store_false", - help="don't try to load the textgrid widget", - ) - parser.add_argument( - "--no-spectrogram", - dest="spectrogram", - action="store_false", - help="don't try to load the spectrogram widget", - ) - parser.add_argument( - "--no-video", - dest="video", - action="store_false", - help="don't try to load the video widget", - ) - parser.add_argument( - "--theme", help="name of Ttk theme to use for widgets", - ) - parser.add_argument( - "--max-undo-memory", - type=int, - default=1000, - help="number of operations to remember in the UndoManager", - ) - - args = parser.parse_args() - - app = initialize_app(headless=args.headless, path=args.path, theme=args.theme) - - app.main() - - -if __name__ == "__main__": - main() diff --git a/ultratrace2/app.py b/ultratrace2/app.py deleted file mode 100644 index 32fae47..0000000 --- a/ultratrace2/app.py +++ /dev/null @@ -1,40 +0,0 @@ -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, - ): - - 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 - - -# singleton -app: Optional[App] = None - - -def initialize_app( - headless: bool = False, path: Optional[str] = None, theme: Optional[str] = None -) -> App: - - global app - app = App(headless=headless, path=path, 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/__init__.py b/ultratrace2/model/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ultratrace2/model/color.py b/ultratrace2/model/color.py deleted file mode 100644 index f47e16f..0000000 --- a/ultratrace2/model/color.py +++ /dev/null @@ -1,65 +0,0 @@ -import random -from typing import Any - -PIXEL_MIN = 0 -PIXEL_MAX = 255 - - -def _pixel_value(raw_value: int) -> int: - if not (PIXEL_MIN <= raw_value <= PIXEL_MAX): - raise ValueError( - f"Color must be in [{PIXEL_MIN}, {PIXEL_MAX}], but got {raw_value}" - ) - return raw_value - - -class Color: - def __init__(self, r: int, g: int, b: int): - self._r = _pixel_value(r) - self._g = _pixel_value(g) - self._b = _pixel_value(b) - - @property - def r(self) -> int: - return self._r - - @r.setter - def r(self, raw_value: int) -> None: - self._r = _pixel_value(raw_value) - - @property - def g(self) -> int: - return self._g - - @g.setter - def g(self, raw_value: int) -> None: - self._g = _pixel_value(raw_value) - - @property - def b(self) -> int: - return self._b - - @b.setter - def b(self, raw_value: int) -> None: - self._b = _pixel_value(raw_value) - - def __repr__(self) -> str: - return f"Color(0x{self.r:02x},0x{self.g:02x},0x{self.b:02x})" - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, type(self)): - return False - return self.r == other.r and self.g == other.g and self.b == other.b - - -def get_random_color() -> Color: - return Color( - random.randint(PIXEL_MIN, PIXEL_MAX), - random.randint(PIXEL_MIN, PIXEL_MAX), - random.randint(PIXEL_MIN, PIXEL_MAX), - ) - - -RED = Color(PIXEL_MAX, PIXEL_MIN, PIXEL_MIN) -GREEN = Color(PIXEL_MIN, PIXEL_MAX, PIXEL_MIN) -BLUE = Color(PIXEL_MIN, PIXEL_MIN, PIXEL_MAX) diff --git a/ultratrace2/model/files/__init__.py b/ultratrace2/model/files/__init__.py deleted file mode 100644 index e5a9908..0000000 --- a/ultratrace2/model/files/__init__.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging - -from .registry import register_loader_for_extensions_and_mime_types as __register - - -logger = logging.getLogger(__name__) - - -try: - from .loaders import DICOMLoader - - __register( - [".dicom", ".dcm"], ["application/dicom"], DICOMLoader, - ) -except ImportError as e: - logger.warning(e) - -try: - from .loaders import FLACLoader - - __register( - [".flac"], ["audio/flac", "audio/x-flac"], FLACLoader, - ) -except ImportError as e: - logger.warning(e) - -try: - from .loaders import MeasurementLoader - - __register( - [], [], MeasurementLoader, - ) -except ImportError as e: - logger.warning(e) - -try: - from .loaders import MP3Loader - - __register( - [".mp3"], - ["audio/mp3", "audio/mpeg", "audio/MPA", "audio/mpa-robust"], - MP3Loader, - ) -except ImportError as e: - logger.warning(e) - -try: - from .loaders import OggLoader - - __register( - [".ogg", ".oga", ".spx"], ["audio/ogg"], OggLoader, - ) -except ImportError as e: - logger.warning(e) - -try: - from .loaders import TextGridLoader - - __register( - [".textgrid"], ["text/plain"], TextGridLoader, - ) -except ImportError as e: - logger.warning(e) - -try: - from .loaders import WAVLoader - - __register( - [".wav"], ["audio/x-wav", "audio/wav"], WAVLoader, - ) -except ImportError as e: - logger.warning(e) diff --git a/ultratrace2/model/files/bundle.py b/ultratrace2/model/files/bundle.py deleted file mode 100644 index 49870cc..0000000 --- a/ultratrace2/model/files/bundle.py +++ /dev/null @@ -1,149 +0,0 @@ -import logging -import os - -from typing import Dict, FrozenSet, Mapping, Optional, Sequence, Type - -from .loaders.base import ( - AlignmentFileLoader, - ImageSetFileLoader, - SoundFileLoader, - FileLoaderBase, - FileLoadError, -) -from .registry import get_loader_for - - -logger = logging.getLogger(__name__) - - -class FileBundle: - def __init__( - self, - name: str, - alignment_file: Optional[AlignmentFileLoader] = None, - image_set_file: Optional[ImageSetFileLoader] = None, - sound_file: Optional[SoundFileLoader] = None, - ): - self.name = name - self.alignment_file = alignment_file - self.image_set_file = image_set_file - self.sound_file = sound_file - - def has_impl(self) -> bool: - return any( - f is not None - for f in [self.alignment_file, self.image_set_file, self.sound_file] - ) - - def get_alignment_file(self) -> Optional[AlignmentFileLoader]: - return self.alignment_file - - def set_alignment_file(self, alignment_file: AlignmentFileLoader) -> None: - if self.alignment_file is not None: - logger.warning("Overwriting existing alignment file") - self.alignment_file = alignment_file - - def get_image_set_file(self) -> Optional[ImageSetFileLoader]: - return self.image_set_file - - def set_image_set_file(self, image_set_file: ImageSetFileLoader) -> None: - if self.image_set_file is not None: - logger.warning("Overwriting existing image-set file") - self.image_set_file = image_set_file - - def get_sound_file(self) -> Optional[SoundFileLoader]: - return self.sound_file - - def set_sound_file(self, sound_file: SoundFileLoader) -> None: - if self.sound_file is not None: - logger.warning("Overwriting existing sound file") - self.sound_file = sound_file - - def __repr__(self): - return f'Bundle("{self.name}",{self.alignment_file},{self.image_set_file},{self.sound_file})' - - def __eq__(self, other): - return ( - self.name == other.name - and self.alignment_file == other.alignment_file - and self.image_set_file == other.image_set_file - and self.sound_file == other.sound_file - ) - - -class FileBundleList: - - exclude_dirs: FrozenSet[str] = frozenset( - [ - ".git", - "node_modules", - "__pycache__", - ".ultratrace", - # FIXME: add more ignoreable dirs - ] - ) - - def __init__(self, bundles: Mapping[str, FileBundle]): - - self.current_bundle = None - self.bundles: Mapping[str, FileBundle] = bundles - - self.has_alignment_impl: bool = False - self.has_image_set_impl: bool = False - self.has_sound_impl: bool = False - - for bundle in bundles.values(): - self.has_alignment_impl |= bundle.alignment_file is not None - self.has_image_set_impl |= bundle.image_set_file is not None - self.has_sound_impl |= bundle.sound_file is not None - - @classmethod - def build_from_dir( - cls, root_path: str, extra_exclude_dirs: Sequence[str] = [] - ) -> "FileBundleList": - - assert os.path.exists(root_path) # should have been validated by Project - - # FIXME: implement `extra_exclude_dirs` as a command-line arg - exclude_dirs = cls.exclude_dirs.union(extra_exclude_dirs) - - bundles: Dict[str, FileBundle] = {} - - # NB: `topdown=True` increases runtime cost from O(n) -> O(n^2), but it allows us to - # modify `dirs` in-place so that we can skip certain directories. For more info, - # see https://stackoverflow.com/questions/19859840/excluding-directories-in-os-walk - for path, dirs, filenames in os.walk(root_path, topdown=True): - dirs[:] = [d for d in dirs if d not in exclude_dirs] - - for filename in filenames: - - name, _ = os.path.splitext(filename) - filepath_or_symlink = os.path.join(path, filename) - filepath = os.path.realpath(filepath_or_symlink) - if not os.path.exists(filepath): - logger.warning( - f'unable to open "{filepath_or_symlink}" (broken symlink?)' - ) - continue - - file_loader: Optional[Type[FileLoaderBase]] = get_loader_for(filepath) - if file_loader is None: - logger.warning(f"unrecognized filetype: {filepath}") - continue - - if name not in bundles: - bundles[name] = FileBundle(name) - - try: - loaded_file = file_loader.from_file(filepath) - if loaded_file is not None: - if isinstance(loaded_file, AlignmentFileLoader): - bundles[name].set_alignment_file(loaded_file) - elif isinstance(loaded_file, ImageSetFileLoader): - bundles[name].set_image_set_file(loaded_file) - elif isinstance(loaded_file, SoundFileLoader): - bundles[name].set_sound_file(loaded_file) - except FileLoadError as e: - logger.error(e) - - return cls(bundles) diff --git a/ultratrace2/model/files/loaders/__init__.py b/ultratrace2/model/files/loaders/__init__.py deleted file mode 100644 index c27a3dd..0000000 --- a/ultratrace2/model/files/loaders/__init__.py +++ /dev/null @@ -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 deleted file mode 100644 index 9822f8a..0000000 --- a/ultratrace2/model/files/loaders/base.py +++ /dev/null @@ -1,117 +0,0 @@ -import logging - -from abc import ABC, abstractmethod -from PIL import Image # type: ignore -from typing import Sequence, Tuple, Type, TypeVar -from typing_extensions import Protocol - - -logger = logging.getLogger(__name__) - - -class FileLoadError(Exception): - pass - - -Self = TypeVar("Self", bound="FileLoaderBase") - - -class FileLoaderBase(ABC): - @abstractmethod - def get_path(self) -> str: - ... - - @abstractmethod - def set_path(self, path) -> None: - ... - - def __repr__(self): - return f"{type(self).__name__}({self.get_path()})" - - @classmethod - @abstractmethod - def from_file(cls: Type[Self], path: str) -> Self: - """Construct an instance from a path. - - NB: If this concrete method fails to load the data at the given path, then - it should throw a `FileLoadError`.""" - - def __eq__(self, other): - return self.get_path() == other.get_path() and type(self) == type(other) - - @staticmethod - def get_priority() -> int: - return 0 - - -class IntervalBase(Protocol): - def get_start(self) -> float: - ... - - def get_end(self) -> float: - ... - - def get_contents(self) -> str: - ... - - def __bool__(self) -> bool: - ... - - -# NB: the Tuple is -Intervals = Sequence[Tuple[str, Sequence[IntervalBase]]] - - -class AlignmentFileLoader(FileLoaderBase): - @abstractmethod - def get_tier_names(self) -> Sequence[str]: - ... - - @abstractmethod - def get_intervals(self) -> Intervals: - ... - - @abstractmethod - def get_start(self) -> float: - ... - - @abstractmethod - def get_end(self) -> float: - ... - - @abstractmethod - def get_offset(self) -> float: - ... - - @abstractmethod - def set_offset(self, offset: float) -> None: - ... - - -class ImageSetFileLoader(FileLoaderBase): - @abstractmethod - def __len__(self) -> int: - """ImageSets should have some notion of their length. - - For example, for DICOM files, this is equal to the number of frames. This - number can then be used to "slice up" any accompanying Alignment or Sound - files. - """ - - @abstractmethod - def get_frame(self, i: int) -> Image.Image: - """ImageSets should support random access of frames.""" - - @abstractmethod - def get_height(self) -> int: - ... - - @abstractmethod - def get_width(self) -> int: - ... - - -class SoundFileLoader(FileLoaderBase): - @abstractmethod - def __len__(self) -> int: - """Length of file in ms""" diff --git a/ultratrace2/model/files/loaders/dicom.py b/ultratrace2/model/files/loaders/dicom.py deleted file mode 100644 index 8887a09..0000000 --- a/ultratrace2/model/files/loaders/dicom.py +++ /dev/null @@ -1,103 +0,0 @@ -from PIL import Image, ImageFile # type: ignore - -import numpy as np -import os -import pydicom # type: ignore - -from .base import FileLoadError, ImageSetFileLoader - - -# PIL (via pydicom) will fail to load "truncated" images sometimes, so we need to tell it to -# ignore these. For context, see https://github.com/python-pillow/Pillow/issues/1510 -ImageFile.LOAD_TRUNCATED_IMAGES = True - - -class DICOMLoader(ImageSetFileLoader): - def get_path(self) -> str: - return self._path - - def set_path(self, path) -> None: - self._path = path - - def __init__(self, path: str, pixels: np.ndarray): - """Construct the DICOMLoader from a pixel array. - - The `shape` of the pixel array should be `(n_frames, n_rows, n_columns)` for greyscale - images and `(n_frames, n_rows, n_columns, rgb_data)` for full-color images.""" - self.set_path(path) - self.pixels = pixels - # FIXME: these should be in the `.ultratrace/` dir - self.png_dir = f"{path}-frames" - if not os.path.exists(self.png_dir): - os.mkdir(self.png_dir, mode=0o755) - - def is_greyscale(self) -> bool: - return len(self.pixels.shape) == 3 - - def __len__(self) -> int: - return self.pixels.shape[0] - - def get_height(self) -> int: - return self.pixels.shape[1] - - def get_width(self) -> int: - return self.pixels.shape[2] - - def get_png_filepath_for_frame(self, i: int) -> str: - return os.path.join(self.png_dir, f"{i:06}.png") - - def get_frame(self, i: int) -> Image.Image: - png_filepath = self.get_png_filepath_for_frame(i) - if os.path.exists(png_filepath): - return Image.open(png_filepath) - else: - arr = ( - self.pixels[i, :, :] if self.is_greyscale() else self.pixels[i, :, :, :] - ) - img = Image.fromarray(arr) - img.save(png_filepath, format="PNG", compress_level=1) - return img - - @classmethod - def from_file(cls, path: str) -> "DICOMLoader": - try: - - if not os.path.exists(path): - raise FileNotFoundError(f"Cannot load from path: '{path}'") - - dicom = pydicom.read_file(path) - - pixels: np.ndarray = dicom.pixel_array - - if len(pixels.shape) == 2: - # For DICOM consisting of a single frame, we need to add a singleton axis. - pixels = np.expand_dims(pixels, axis=0) - return cls(path, pixels) - - elif len(pixels.shape) == 3: - n_frames, n_rows, n_columns = pixels.shape - return cls(path, pixels) - - elif len(pixels.shape) == 4: - # full-color RGB - if pixels.shape[0] == 3: - # RGB-first - rgb, n_frames, n_rows, n_columns = pixels.shape - pixels.reshape((n_frames, n_rows, n_columns, rgb)) - return cls(path, pixels) - - elif pixels.shape[3] == 3: - # RGB-last - n_frames, n_rows, n_columns, rgb = pixels.shape - return cls(path, pixels) - - raise ValueError(f"Invalid DICOM ({path}), unknown shape {pixels.shape}") - - except Exception as e: - raise FileLoadError( - f"Invalid DICOM ({path}), unable to read: {str(e)}" - ) from e - - def convert_to_png(self, *args, **kwargs): - # FIXME: implement this as a helper function - raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/flac.py b/ultratrace2/model/files/loaders/flac.py deleted file mode 100644 index d41ba5f..0000000 --- a/ultratrace2/model/files/loaders/flac.py +++ /dev/null @@ -1,7 +0,0 @@ -from .pydub import PydubLoader - - -class FLACLoader(PydubLoader): - @staticmethod - def get_priority() -> int: - return 4 diff --git a/ultratrace2/model/files/loaders/measurement.py b/ultratrace2/model/files/loaders/measurement.py deleted file mode 100644 index af63979..0000000 --- a/ultratrace2/model/files/loaders/measurement.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Sequence - -from .base import AlignmentFileLoader, Intervals - - -class MeasurementLoader(AlignmentFileLoader): - # FIXME: what is this? do we need to support it? - - def get_path(self) -> str: - return self._path - - def set_path(self, path) -> None: - self._path = path - - def __init__(self, path: str): - self.set_path(path) - - def get_tier_names(self) -> Sequence[str]: - raise NotImplementedError() - - def get_intervals(self) -> Intervals: - raise NotImplementedError() - - def get_start(self) -> float: - raise NotImplementedError() - - def get_end(self) -> float: - raise NotImplementedError() - - def get_offset(self) -> float: - raise NotImplementedError() - - def set_offset(self, offset: float) -> None: - raise NotImplementedError() - - @classmethod - def from_file(cls, path: str) -> "MeasurementLoader": - raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/mp3.py b/ultratrace2/model/files/loaders/mp3.py deleted file mode 100644 index 8e6a537..0000000 --- a/ultratrace2/model/files/loaders/mp3.py +++ /dev/null @@ -1,7 +0,0 @@ -from .pydub import PydubLoader - - -class MP3Loader(PydubLoader): - @staticmethod - def get_priority() -> int: - return 1 diff --git a/ultratrace2/model/files/loaders/ogg.py b/ultratrace2/model/files/loaders/ogg.py deleted file mode 100644 index 80bcdb6..0000000 --- a/ultratrace2/model/files/loaders/ogg.py +++ /dev/null @@ -1,7 +0,0 @@ -from .pydub import PydubLoader - - -class OggLoader(PydubLoader): - @staticmethod - def get_priority() -> int: - return 2 diff --git a/ultratrace2/model/files/loaders/pydub.py b/ultratrace2/model/files/loaders/pydub.py deleted file mode 100644 index 470878c..0000000 --- a/ultratrace2/model/files/loaders/pydub.py +++ /dev/null @@ -1,26 +0,0 @@ -import pydub # type: ignore - -from .base import FileLoadError, SoundFileLoader - - -class PydubLoader(SoundFileLoader): - def get_path(self) -> str: - return self._path - - def set_path(self, path) -> None: - self._path = path - - def __init__(self, path: str, audio_segment: pydub.AudioSegment): - self.set_path(path) - self.audio_segment = audio_segment - - def __len__(self) -> int: - return len(self.audio_segment) - - @classmethod - def from_file(cls, path: str) -> "PydubLoader": - try: - audio_segment = pydub.AudioSegment.from_file(path) - return PydubLoader(path, audio_segment) - except Exception as e: - raise FileLoadError(f"Invalid AudioSegment ({path}), unable to read") from e diff --git a/ultratrace2/model/files/loaders/tests/__init__.py b/ultratrace2/model/files/loaders/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ultratrace2/model/files/loaders/tests/test_dicom.py b/ultratrace2/model/files/loaders/tests/test_dicom.py deleted file mode 100644 index 00081e7..0000000 --- a/ultratrace2/model/files/loaders/tests/test_dicom.py +++ /dev/null @@ -1,43 +0,0 @@ -from PIL import Image # type: ignore - -import pytest - -from ..dicom import DICOMLoader -from ..base import FileLoadError - - -@pytest.mark.parametrize( - "path", ["", "/path/to/nowhere", "/dev/null", "/etc/sudoers"], -) -def test_loading_from_invalid_file(path) -> None: - with pytest.raises(FileLoadError): - DICOMLoader.from_file(path) - - -@pytest.mark.parametrize( - "path,n_frames,height,width,is_greyscale", - [ - ("./test-data/example-dicom/0002.DCM", 96, 512, 512, True), - ("./test-data/example-dicom/0003.DCM", 17, 512, 512, True), - ("./test-data/example-dicom/0004.DCM", 17, 512, 512, True), - ("./test-data/example-dicom/0009.DCM", 137, 512, 512, True), - ("./test-data/example-dicom/0012.DCM", 70, 512, 512, True), - ("./test-data/example-dicom/0015.DCM", 1, 1024, 1024, True), - # FIXME: The `0020.DCM` file is not getting parsed correctly by pydicom -- it is a - # full-color image, using PALETTE COLOR and not RGB channel of pixel_array. - # This is probably something we should fix eventually! Looks like there's - # some preliminary work [here](https://github.com/pydicom/pydicom/issues/205), - # although I haven't dug too deeply. - ("./test-data/example-dicom/0020.DCM", 11, 430, 600, True), - ("./test-data/example-dicom/MRBRAIN.DCM", 1, 512, 512, True), - ("./test-data/example-dicom/bmode.dcm", 37, 768, 1024, False), - ], -) -def test_loading_from_valid_file(path, n_frames, height, width, is_greyscale) -> None: - loader = DICOMLoader.from_file(path) - assert loader.get_path() == path - assert len(loader) == n_frames - assert loader.get_height() == height - assert loader.get_width() == width - assert loader.is_greyscale() == is_greyscale - assert isinstance(loader.get_frame(0), Image.Image) diff --git a/ultratrace2/model/files/loaders/tests/test_flac.py b/ultratrace2/model/files/loaders/tests/test_flac.py deleted file mode 100644 index 54f79c1..0000000 --- a/ultratrace2/model/files/loaders/tests/test_flac.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from ..base import FileLoadError -from ..flac import FLACLoader - - -@pytest.mark.parametrize( - "path", ["", "/path/to/nowhere", "/dev/null", "/etc/sudoers"], -) -def test_loading_from_invalid_file(path) -> None: - with pytest.raises(FileLoadError): - FLACLoader.from_file(path) - - -@pytest.mark.parametrize( - "path,duration_ms", - [ - ("./test-data/example-audio/2L-125_04_stereo.mqa.flac", 97760), - ("./test-data/example-audio/2L-125_stereo-176k-24b_04.flac", 97760), - ("./test-data/example-audio/2L-125_stereo-44k-16b_04.flac", 97760), - ("./test-data/example-audio/2L-125_stereo-88k-24b_04.flac", 97760), - ("./test-data/ftyers/20150629171639.flac", 28200), - ], -) -def test_loading_from_valid_file(path: str, duration_ms: int) -> None: - flac_file = FLACLoader.from_file(path) - assert len(flac_file) == duration_ms diff --git a/ultratrace2/model/files/loaders/tests/test_mp3.py b/ultratrace2/model/files/loaders/tests/test_mp3.py deleted file mode 100644 index 9fd85f9..0000000 --- a/ultratrace2/model/files/loaders/tests/test_mp3.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from ..base import FileLoadError -from ..mp3 import MP3Loader - - -@pytest.mark.parametrize( - "path", ["", "/path/to/nowhere", "/dev/null", "/etc/sudoers"], -) -def test_loading_from_invalid_file(path) -> None: - with pytest.raises(FileLoadError): - MP3Loader.from_file(path) - - -@pytest.mark.parametrize( - "path,duration_ms", - [ - ("./test-data/example-audio/bachfugue.mp3", 39552), - ("./test-data/example-audio/cello82.mp3", 14160), - ("./test-data/example-audio/gtr-nylon22.mp3", 5068), - ("./test-data/example-audio/harpsi-cs.mp3", 18912), - ("./test-data/example-audio/pno-cs.mp3", 20064), - ], -) -def test_loading_from_valid_file(path: str, duration_ms: int) -> None: - mp3_file = MP3Loader.from_file(path) - assert len(mp3_file) == duration_ms diff --git a/ultratrace2/model/files/loaders/tests/test_ogg.py b/ultratrace2/model/files/loaders/tests/test_ogg.py deleted file mode 100644 index 7d354d2..0000000 --- a/ultratrace2/model/files/loaders/tests/test_ogg.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from ..base import FileLoadError -from ..ogg import OggLoader - - -@pytest.mark.parametrize( - "path", ["", "/path/to/nowhere", "/dev/null", "/etc/sudoers"], -) -def test_loading_from_invalid_file(path) -> None: - with pytest.raises(FileLoadError): - OggLoader.from_file(path) - - -@pytest.mark.parametrize( - "path,duration_ms", - [ - ("./test-data/example-audio/20110815035350_example.ogg", 6519), - ("./test-data/example-audio/20110925121709_example.ogg", 10392), - ("./test-data/example-audio/example.ogg", 6120), - ("./test-data/example-audio/massenet_le_cid.ogg", 261078), - ], -) -def test_loading_from_valid_file(path: str, duration_ms: int) -> None: - ogg_file = OggLoader.from_file(path) - assert len(ogg_file) == duration_ms diff --git a/ultratrace2/model/files/loaders/tests/test_textgrid.py b/ultratrace2/model/files/loaders/tests/test_textgrid.py deleted file mode 100644 index 2099766..0000000 --- a/ultratrace2/model/files/loaders/tests/test_textgrid.py +++ /dev/null @@ -1,936 +0,0 @@ -from typing import Sequence, Tuple - -import pytest - -from ..base import FileLoadError -from ..textgrid import TextGridLoader - - -@pytest.mark.parametrize( - "path", ["", "/path/to/nowhere", "/dev/null", "/etc/sudoers"], -) -def test_loading_from_invalid_file(path) -> None: - with pytest.raises(FileLoadError): - TextGridLoader.from_file(path) - - -@pytest.mark.parametrize( - "path,tier_names,intervals,start_time,end_time", - [ - # FIXME: The textgrid library is unable to parse `manual-00-short` and `manual-00-mixed` - # even though those are taken directly from the Praat TextGrid file formats page. - # We should probably (eventually) commit upstream to the textgrid lib directly - # and then add those tests here. - ( - "./test-data/example-textgrid/manual-00-pretty.TextGrid", - ["Mary", "John", "bell"], - [("Mary", []), ("John", [])], - 0.0, - 2.3, - ), - ( - "./test-data/example-textgrid/manual-01-ascii.TextGrid", - ["sentence", "phonemes", "bell"], - [ - ("sentence", [(0.0, 2.3, 'kej "ahoj" dvakrt')]), - ( - "phonemes", - [(0.0, 0.7, "rikj"), (0.7, 1.6, "aj"), (1.6, 2.3, "dakrat")], - ), - ], - 0.0, - 2.3, - ), - ( - "./test-data/example-textgrid/manual-01-latin1.TextGrid", - ["sentence", "phonemes", "bell"], - [ - ("sentence", [(0.0, 2.3, 'нkej "ahoj" dvakrбt')]), - ( - "phonemes", - [(0.0, 0.7, "rikj"), (0.7, 1.6, "aj"), (1.6, 2.3, "dakrat")], - ), - ], - 0.0, - 2.3, - ), - ( - "./test-data/example-textgrid/manual-01-macroman.TextGrid", - ["sentence", "phonemes", "bell"], - [ - ("sentence", [(0.0, 2.3, '’kej "ahoj" dvakr‡t')]), - ( - "phonemes", - [(0.0, 0.7, "rikj"), (0.7, 1.6, "aj"), (1.6, 2.3, "dakrat")], - ), - ], - 0.0, - 2.3, - ), - ( - "./test-data/example-textgrid/manual-01-utf16.TextGrid", - ["sentence", "phonemes", "bell"], - [ - ("sentence", [(0.0, 2.3, 'říkej "ahoj" dvakrát')]), - ( - "phonemes", - [ - (0.0, 0.7, "r̝iːkɛj"), - (0.7, 1.6, "ʔaɦɔj"), - (1.6, 2.3, "dʋakraːt"), - ], - ), - ], - 0.0, - 2.3, - ), - ( - "./test-data/example-textgrid/manual-01-utf8.TextGrid", - ["sentence", "phonemes", "bell"], - [ - ("sentence", [(0.0, 2.3, 'říkej "ahoj" dvakrát')]), - ( - "phonemes", - [ - (0.0, 0.7, "r̝iːkɛj"), - (0.7, 1.6, "ʔaɦɔj"), - (1.6, 2.3, "dʋakraːt"), - ], - ), - ], - 0.0, - 2.3, - ), - ( - "./test-data/example-textgrid/manual-02.TextGrid", - ["sentence", "phonemes", "bell"], - [ - ("sentence", [(0.0, 2.3, "\\r<\\i'kej \"ahoj\" dvakr\\a't")]), - ( - "phonemes", - [ - (0.0, 0.7, "r\\T^i\\:fk\\efj"), - (0.7, 1.6, "\\?ga\\h^\\ctj"), - (1.6, 2.3, "d\\vsakra\\:ft"), - ], - ), - ], - 0.0, - 2.3, - ), - ( - "./test-data/ftyers/20150629171639.TextGrid", - [ - "sentence", - "word", - "orthographic vowel", - "all frames", - "all frames", - "all frames.original", - ], - [ - ( - "sentence", - [ - ( - 1.13305, - 6.30017, - "The North Wind and the Sun were disputing which of them was stronger when a traveler came along wrapped in a warm cloak", - ), - ( - 6.61652, - 12.3503, - "They agreed that the one who first succeeded in making the traveler take his cloak off should be considered the stronger - of the two", - ), - ( - 12.82174, - 20.15046, - "Then the North Wind blew as hard as he could but the more he blew the more closely did the traveler fold his cloak around him and at last the North Wind gave up the attempt", - ), - ( - 20.51291, - 27.9785, - "Then the Sun shone out warmly and immediately the traveler took off his cloak and so the North Wind was obliged to confess that the Sun was the stronger of the two", - ), - ], - ), - ( - "word", - [ - (1.1741, 1.26303, "The"), - (1.26303, 1.53488, "North"), - (1.53488, 1.72617, "Wind"), - (1.72617, 1.85098, "and"), - (1.85098, 1.90515, "the"), - (1.90515, 2.20277, "Sun"), - (2.20277, 2.27132, "were"), - (2.27132, 2.83808, "disputing"), - (2.83808, 3.04897, "which"), - (3.04897, 3.13477, "of"), - (3.13477, 3.2975, "them"), - (3.2975, 3.4244, "was"), - (3.4244, 3.86055, "stronger"), - (4.15649, 4.2791, "when"), - (4.2791, 4.35674, "a"), - (4.35674, 4.71045, "traveler"), - (4.71045, 4.94699, "came"), - (4.94699, 5.21364, "along"), - (5.21364, 5.49714, "wrapped"), - (5.49714, 5.566, "in"), - (5.566, 5.60618, "a"), - (5.60618, 5.89004, "warm"), - (5.89004, 6.26946, "cloak"), - (6.63021, 6.75009, "They"), - (6.75009, 7.04638, "agreed"), - (7.04638, 7.18678, "that"), - (7.18678, 7.25354, "the"), - (7.25354, 7.48102, "one"), - (7.48102, 7.59326, "who"), - (7.59326, 7.88381, "first"), - (7.88381, 8.37525, "succeeded"), - (8.37525, 8.47671, "in"), - (8.47671, 8.79612, "making"), - (8.79612, 8.84137, "the"), - (8.84137, 9.21158, "traveler"), - (9.21158, 9.46704, "take"), - (9.46704, 9.59498, "his"), - (9.59498, 9.92845, "cloak"), - (9.92845, 10.11975, "off"), - (10.14108, 10.34043, "should"), - (10.34043, 10.45967, "be"), - (10.45967, 10.91663, "considered"), - (10.91663, 11.02239, "the"), - (11.02239, 11.39659, "stronger"), - (11.39659, 11.43174, "of"), - (11.43174, 11.61046, "the"), - (11.77425, 11.8946, "of"), - (11.8946, 12.02365, "the"), - (12.02365, 12.28886, "two"), - (12.88976, 13.04955, "Then"), - (13.04955, 13.10145, "the"), - (13.10145, 13.3804, "North"), - (13.3804, 13.60097, "Wind"), - (13.60097, 13.7353, "blew"), - (13.7353, 13.90427, "as"), - (13.90427, 14.1768, "hard as"), - (14.1768, 14.26991, "he"), - (14.26991, 14.52342, "could"), - (14.77763, 14.84847, "but"), - (14.84847, 14.91004, "the"), - (14.91004, 15.1006, "more"), - (15.1006, 15.24618, "he"), - (15.24618, 15.50006, "blew"), - (15.50006, 15.55004, "the"), - (15.55004, 15.73678, "more"), - (15.73678, 16.14386, "closely"), - (16.14386, 16.27216, "did"), - (16.27216, 16.35921, "the"), - (16.35921, 16.6905, "traveler"), - (16.6905, 16.94599, "fold"), - (16.94599, 17.08592, "his"), - (17.08592, 17.3618, "cloak"), - (17.3618, 17.65188, "around"), - (17.65188, 17.87502, "him"), - (18.1661, 18.23755, "and"), - (18.23755, 18.3371, "at"), - (18.3371, 18.61213, "last"), - (18.61213, 18.67544, "the"), - (18.67544, 18.97427, "North"), - (18.97427, 19.21412, "Wind"), - (19.21412, 19.41082, "gave"), - (19.41082, 19.5304, "up"), - (19.5304, 19.63142, "the "), - (19.63142, 20.1171, "attempt"), - (20.52291, 20.73339, "Then"), - (20.73339, 20.79474, "the"), - (20.79474, 21.06328, "sun"), - (21.06328, 21.27853, "shone"), - (21.27853, 21.45237, "out"), - (21.45237, 21.88008, "warmly"), - (22.26467, 22.38812, "and"), - (22.38812, 22.89013, "immediately"), - (22.89013, 22.98207, "the"), - (22.98207, 23.34444, "traveler"), - (23.34444, 23.54744, "took"), - (23.54744, 23.69483, "off"), - (23.69483, 23.82288, "his"), - (23.82288, 24.17364, "cloak"), - (24.43011, 24.51298, "and"), - (24.51298, 24.68153, "so"), - (24.68153, 24.76507, "the"), - (24.76507, 25.0629, "North"), - (25.0629, 25.28546, "Wind"), - (25.28546, 25.43242, "was"), - (25.43242, 25.81091, "obliged"), - (25.81091, 25.8854, "to"), - (25.8854, 26.34972, "confess"), - (26.34972, 26.50665, "that"), - (26.50665, 26.57972, "the"), - (26.57972, 26.81976, "sun"), - (26.81976, 27.03761, "was"), - (27.03761, 27.08438, "the"), - (27.08438, 27.50286, "stronger"), - (27.50286, 27.5566, "of"), - (27.5566, 27.68696, "the"), - (27.68696, 27.96531, "two"), - ], - ), - ( - "orthographic vowel", - [ - (1.21054, 1.25753, "e"), - (1.30925, 1.44417, "o"), - (1.59274, 1.62757, "i"), - (1.72674, 1.78484, "a"), - (1.85778, 1.9017, "e"), - (2.01965, 2.10287, "u"), - (2.2301, 2.26511, "e"), - (2.32825, 2.34117, "i"), - (2.51195, 2.58627, "u"), - (2.70273, 2.74322, "i"), - (2.89803, 2.93894, "i"), - (3.05032, 3.10246, "o"), - (3.15315, 3.21387, "e"), - (3.31534, 3.34849, "a"), - (3.55822, 3.60366, "o"), - (3.70936, 3.81198, "e"), - (4.22392, 4.2472, "e"), - (4.28098, 4.33702, "a"), - (4.46113, 4.5316, "a"), - (4.56105, 4.5983, "e"), - (4.63398, 4.6752, "e"), - (4.78859, 4.87856, "a"), - (4.94777, 4.99212, "a"), - (5.03839, 5.11906, "o"), - (5.29286, 5.35461, "a"), - (5.50137, 5.52856, "i"), - (5.56653, 5.60475, "a"), - (5.7216, 5.79217, "a"), - (6.00126, 6.10843, "o"), - (6.67259, 6.74766, "e"), - (6.75249, 6.77923, "a"), - (6.93534, 7.00285, "e"), - (7.08278, 7.14906, "a"), - (7.22381, 7.2493, "e"), - (7.36447, 7.41169, "o"), - (7.52924, 7.59317, "o"), - (7.73381, 7.82358, "i"), - (7.95135, 7.98338, "u"), - (8.13638, 8.22631, "e"), - (8.2618, 8.33114, "e"), - (8.3779, 8.41589, "i"), - (8.51753, 8.60435, "a"), - (8.79711, 8.84106, "e"), - (8.96588, 9.03969, "a"), - (9.08128, 9.10996, "e"), - (9.13811, 9.18936, "e"), - (9.29406, 9.38309, "a"), - (9.48481, 9.512, "i"), - (9.72107, 9.81258, "o"), - (9.93806, 10.02836, "o"), - (10.25625, 10.28977, "o"), - (10.37135, 10.42742, "e"), - (10.69188, 10.74955, "i"), - (10.79516, 10.86778, "e"), - (10.97614, 11.01711, "e"), - (11.19716, 11.23255, "o"), - (11.40279, 11.42924, "o"), - (11.48295, 11.59318, "e"), - (11.79265, 11.83251, "o"), - (11.95444, 11.99504, "e"), - (12.11569, 12.23942, "o"), - (12.91914, 12.97725, "e"), - (13.05551, 13.10018, "e"), - (13.16461, 13.28182, "o"), - (13.43099, 13.48574, "i"), - (13.6752, 13.72511, "e"), - (13.75193, 13.81339, "a"), - (13.9624, 14.05128, "a"), - (14.05416, 14.08059, "a"), - (14.1849, 14.24126, "e"), - (14.36409, 14.4412, "o"), - (14.79647, 14.84266, "u"), - (14.87761, 14.90833, "e"), - (14.98334, 15.09953, "o"), - (15.1612, 15.19395, "e"), - (15.348, 15.45657, "e"), - (15.52658, 15.54893, "e"), - (15.62545, 15.70442, "o"), - (15.84118, 15.92946, "o"), - (16.0479, 16.10377, "y"), - (16.17543, 16.21789, "i"), - (16.29724, 16.32735, "e"), - (16.44795, 16.51754, "a"), - (16.57203, 16.6006, "e"), - (16.63971, 16.66466, "e"), - (16.78637, 16.86468, "o"), - (16.95277, 17.01146, "i"), - (17.18009, 17.2598, "o"), - (17.36391, 17.40483, "a"), - (17.47948, 17.58872, "o"), - (17.7161, 17.76816, "i"), - (18.16614, 18.20563, "a"), - (18.2401, 18.31982, "a"), - (18.38802, 18.47113, "a"), - (18.64363, 18.67524, "e"), - (18.73857, 18.84152, "o"), - (19.01743, 19.05791, "i"), - (19.26459, 19.36106, "a"), - (19.41437, 19.48562, "u"), - (19.59077, 19.62499, "e"), - (19.64392, 19.68985, "a"), - (19.80055, 19.87133, "e"), - (20.57713, 20.66376, "e"), - (20.74309, 20.79301, "e"), - (20.90548, 20.9799, "u"), - (21.18777, 21.24849, "o"), - (21.28076, 21.42402, "o"), - (21.53542, 21.62601, "a"), - (21.72915, 21.81974, "y"), - (22.29646, 22.35145, "a"), - (22.389, 22.42886, "i"), - (22.4999, 22.6171, "e"), - (22.66547, 22.74704, "a"), - (22.78985, 22.87798, "y"), - (22.91826, 22.95402, "e"), - (23.0875, 23.14896, "a"), - (23.25815, 23.31052, "e"), - (23.40988, 23.45607, "o"), - (23.54803, 23.6216, "o"), - (23.70222, 23.7499, "i"), - (23.92623, 24.04133, "o"), - (24.43929, 24.48175, "a"), - (24.59156, 24.68058, "o"), - (24.71881, 24.76425, "e"), - (24.82966, 24.92956, "o"), - (25.12872, 25.16773, "i"), - (25.33998, 25.38579, "a"), - (25.43405, 25.47167, "o"), - (25.58946, 25.70857, "i"), - (26.13167, 26.22926, "e"), - (26.39529, 26.46011, "a"), - (26.52107, 26.57903, "e"), - (26.69589, 26.76256, "u"), - (26.89584, 26.94762, "a"), - (27.04356, 27.08416, "e"), - (27.23584, 27.28884, "o"), - (27.38195, 27.4168, "e"), - (27.50524, 27.52014, "o"), - (27.615, 27.65206, "e"), - (27.76229, 27.86511, "o"), - ], - ), - ], - 0.0, - 28.2, - ), - ( - "./test-data/qumuq/File001.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.93943, 1.99264, "qa")])], - 0.0, - 6.17651, - ), - ( - "./test-data/qumuq/File002.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.16248, 2.22355, "qa")])], - 0.0, - 3.65714, - ), - ( - "./test-data/qumuq/File003.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.83472, 2.87745, "hu")])], - 0.0, - 4.52789, - ), - ( - "./test-data/qumuq/File004.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.63815, 2.72688, "qu")])], - 0.0, - 4.58594, - ), - ( - "./test-data/qumuq/File005.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.21314, 2.26784, "qu")])], - 0.0, - 3.52943, - ), - ( - "./test-data/qumuq/File006.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.2178, 2.28321, "qo")])], - 0.0, - 3.86612, - ), - ( - "./test-data/qumuq/File007.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.34781, 2.38584, "qo")])], - 0.0, - 3.99383, - ), - ( - "./test-data/qumuq/File008.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.10069, 2.15859, "qy")])], - 0.0, - 3.87773, - ), - ( - "./test-data/qumuq/File009.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.20942, 2.25576, "qy")])], - 0.0, - 3.58748, - ), - ( - "./test-data/qumuq/File010.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.58067, 2.62575, "ke")])], - 0.0, - 4.48145, - ), - ( - "./test-data/qumuq/File011.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.27343, 2.32168, "ke")])], - 0.0, - 3.87773, - ), - ( - "./test-data/qumuq/File012.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.07421, 2.12072, "kyu")])], - 0.0, - 3.78485, - ), - ( - "./test-data/qumuq/File013.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.40478, 2.49015, "kyu")])], - 0.0, - 4.16798, - ), - ( - "./test-data/qumuq/File014.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.58642, 2.63381, "kyo")])], - 0.0, - 4.00544, - ), - ( - "./test-data/qumuq/File015.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.17211, 2.22538, "gyo")])], - 0.0, - 3.91256, - ), - ( - "./test-data/qumuq/File016.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.30034, 2.34592, "kyo"), (2.44509, 2.48543, "kyo")])], - 0.0, - 4.28408, - ), - ( - "./test-data/qumuq/File017.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.2721, 2.3223, "ki")])], - 0.0, - 4.31891, - ), - ( - "./test-data/qumuq/File018.TextGrid", - ["annotation_0", "annotation_1"], - [ - ("annotation_0", [(2.22421, 2.2718, "ki")]), - ("annotation_1", [(2.22388, 2.27278, "ki")]), - ], - 0.0, - 4.04027, - ), - ( - "./test-data/qumuq/File019.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.09779, 2.13957, "gi")])], - 0.0, - 4.06349, - ), - ( - "./test-data/qumuq/File020.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.04628, 2.10793, "ge")])], - 0.0, - 4.04027, - ), - ( - "./test-data/qumuq/File021.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.07854, 2.13522, "ge")])], - 0.0, - 3.98222, - ), - ( - "./test-data/qumuq/File022.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.07058, 2.12062, "gyu")])], - 0.0, - 4.09832, - ), - ( - "./test-data/qumuq/File023.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.06821, 2.14229, "gyu")])], - 0.0, - 4.24925, - ), - ( - "./test-data/qumuq/File024.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.09652, 2.14453, "gyo")])], - 0.0, - 4.04027, - ), - ( - "./test-data/qumuq/File025.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.11358, 2.16484, "gi")])], - 0.0, - 4.05188, - ), - ( - "./test-data/qumuq/File026.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.67932, 2.71989, "qa")])], - 0.0, - 4.82975, - ), - ( - "./test-data/qumuq/File027.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.46998, 2.56039, "qa")])], - 0.0, - 4.69043, - ), - ( - "./test-data/qumuq/File028.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.45969, 2.46637, "qu")])], - 0.0, - 4.51628, - ), - ( - "./test-data/qumuq/File029.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.21021, 2.2274, "qo")])], - 0.0, - 4.45823, - ), - ( - "./test-data/qumuq/File030.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.18686, 2.25073, "qo")])], - 0.0, - 3.92417, - ), - ( - "./test-data/qumuq/File031.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.48159, 1.51862, "qy")])], - 0.0, - 4.17959, - ), - ( - "./test-data/qumuq/File032.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.11058, 2.15365, "kyu")])], - 0.0, - 4.52789, - ), - ( - "./test-data/qumuq/File033.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.18819, 2.21932, "kyo")])], - 0.0, - 4.15637, - ), - ( - "./test-data/qumuq/File034.TextGrid", - ["annotation_0"], - [("annotation_0", [(2.13509, 2.17395, "ke")])], - 0.0, - 4.79492, - ), - ( - "./test-data/qumuq/File035.TextGrid", - ["annotation_0", "PointTier_0"], - [("annotation_0", [(1.59137, 1.66228, "qa")])], - 0.0, - 2.70512, - ), - ( - "./test-data/qumuq/File036.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.42596, 1.51891, "qa")])], - 0.0, - 2.73995, - ), - ( - "./test-data/qumuq/File037.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.37714, 1.43278, "hu")])], - 0.0, - 3.00698, - ), - ( - "./test-data/qumuq/File038.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.31126, 1.39279, "qu")])], - 0.0, - 2.93732, - ), - ( - "./test-data/qumuq/File039.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.37945, 1.45364, "qu")])], - 0.0, - 3.00698, - ), - ( - "./test-data/qumuq/File040.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.1963, 1.26353, "qo")])], - 0.0, - 3.00698, - ), - ( - "./test-data/qumuq/File041.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.42368, 1.49461, "qo")])], - 0.0, - 3.28562, - ), - ( - "./test-data/qumuq/File042.TextGrid", - ["annotation_0", "annotation_1"], - [ - ("annotation_0", [(1.21016, 1.27817, "qy")]), - ("annotation_1", [(1.21016, 1.27817, "qy")]), - ], - 0.0, - 3.04181, - ), - ( - "./test-data/qumuq/File043.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.19227, 1.2347, "qy")])], - 0.0, - 2.75156, - ), - ( - "./test-data/qumuq/File044.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.50212, 1.54659, "ge")])], - 0.0, - 3.20435, - ), - ( - "./test-data/qumuq/File045.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.16304, 1.25671, "ke")])], - 0.0, - 2.53098, - ), - ( - "./test-data/qumuq/File046.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.60763, 1.64487, "kyu")])], - 0.0, - 3.01859, - ), - ( - "./test-data/qumuq/File047.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.05913, 1.13002, "kyu")])], - 0.0, - 2.78639, - ), - ( - "./test-data/qumuq/File048.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.20758, 1.28556, "kyo")])], - 0.0, - 3.06503, - ), - ( - "./test-data/qumuq/File049.TextGrid", - ["annotation_0"], - [("annotation_0", [(0.94966, 1.00581, "gyo")])], - 0.0, - 3.16635, - ), - ( - "./test-data/qumuq/File050.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.12329, 1.16406, "kyo")])], - 0.0, - 2.93732, - ), - ( - "./test-data/qumuq/File051.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.33913, 1.43652, "ki")])], - 0.0, - 3.15791, - ), - ( - "./test-data/qumuq/File052.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.32778, 1.4243, "ki")])], - 0.0, - 3.05342, - ), - ( - "./test-data/qumuq/File053.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.57661, 1.61533, "gi")])], - 0.0, - 3.58748, - ), - ( - "./test-data/qumuq/File054.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.94317, 1.95767, "ge")])], - 0.0, - 3.76163, - ), - ( - "./test-data/qumuq/File055.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.13762, 1.17025, "ge")])], - 0.0, - 2.82122, - ), - ( - "./test-data/qumuq/File056.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.16062, 1.19915, "gyu")])], - 0.0, - 3.12308, - ), - ( - "./test-data/qumuq/File057.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.23125, 1.26045, "gyu")])], - 0.0, - 2.9141, - ), - ( - "./test-data/qumuq/File058.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.00529, 1.04492, "gyo")])], - 0.0, - 2.70512, - ), - ( - "./test-data/qumuq/File059.TextGrid", - ["annotation_0"], - [("annotation_0", [(0.51556, 0.55604, "gi")])], - 0.0, - 2.76317, - ), - ( - "./test-data/qumuq/File060.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.49674, 1.53555, "qa")])], - 0.0, - 3.1463, - ), - ( - "./test-data/qumuq/File061.TextGrid", - ["annotation_0"], - [("annotation_0", [(0.81916, 0.85662, "qa")])], - 0.0, - 3.23918, - ), - ( - "./test-data/qumuq/File062.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.29079, 1.31585, "qu")])], - 0.0, - 3.25079, - ), - ( - "./test-data/qumuq/File063.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.04925, 1.08233, "qo")])], - 0.0, - 3.06503, - ), - ( - "./test-data/qumuq/File064.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.09138, 1.20376, "qo")])], - 0.0, - 3.23918, - ), - ( - "./test-data/qumuq/File065.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.92892, 2.0147, "qy")])], - 0.0, - 3.00698, - ), - ( - "./test-data/qumuq/File066.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.71294, 1.77112, "kyu")])], - 0.0, - 3.01859, - ), - ( - "./test-data/qumuq/File067.TextGrid", - ["annotation_0"], - [("annotation_0", [(1.68494, 1.75111, "kyo")])], - 0.0, - 3.06503, - ), - ], -) -def test_loading_from_valid_file( - path: str, - tier_names: Sequence[str], - intervals: Sequence[Tuple[str, Sequence[Tuple[float, float, str]]]], - start_time: float, - end_time: float, -) -> None: - loaded_file = TextGridLoader.from_file(path) - assert isinstance(loaded_file, TextGridLoader) - assert len(loaded_file.get_tier_names()) == len(tier_names) - for actual_tier_name, expected_tier_name in zip( - loaded_file.get_tier_names(), tier_names - ): - assert actual_tier_name == expected_tier_name - assert loaded_file.get_start() == start_time - assert loaded_file.get_end() == end_time - offset = 3.1415 - loaded_file.set_offset(offset) - assert loaded_file.get_start() == start_time + offset - assert loaded_file.get_end() == end_time + offset - assert len(loaded_file.get_intervals()) == len(intervals) - for actual_tier, expected_tier in zip(loaded_file.get_intervals(), intervals): - actual_tier_name, actual_tier_intervals = actual_tier - expected_tier_name, expected_tier_intervals = expected_tier - assert actual_tier_name == expected_tier_name - assert len(actual_tier_intervals) == len(expected_tier_intervals) - for actual_interval, expected_interval in zip( - actual_tier_intervals, expected_tier_intervals - ): - expected_start, expected_end, expected_contents = expected_interval - assert actual_interval.get_start() == expected_start - assert actual_interval.get_end() == expected_end - assert actual_interval.get_contents() == expected_contents diff --git a/ultratrace2/model/files/loaders/tests/test_wav.py b/ultratrace2/model/files/loaders/tests/test_wav.py deleted file mode 100644 index fb48c4c..0000000 --- a/ultratrace2/model/files/loaders/tests/test_wav.py +++ /dev/null @@ -1,172 +0,0 @@ -import pytest - -from ..base import FileLoadError -from ..wav import WAVLoader - - -@pytest.mark.parametrize( - "path", ["", "/path/to/nowhere", "/dev/null", "/etc/sudoers"], -) -def test_loading_from_invalid_file(path) -> None: - with pytest.raises(FileLoadError): - WAVLoader.from_file(path) - - -@pytest.mark.parametrize( - "path,duration_ms", - [ - ("./test-data/example-audio/BabyElephantWalk60.wav", 60000), - ("./test-data/example-audio/CantinaBand3.wav", 3000), - ("./test-data/example-audio/CantinaBand60.wav", 60000), - ("./test-data/example-audio/Fanfare60.wav", 60000), - ("./test-data/example-audio/gettysburg10.wav", 10003), - ("./test-data/example-audio/gettysburg.wav", 17577), - ("./test-data/example-audio/ImperialMarch60.wav", 60000), - ("./test-data/example-audio/PinkPanther30.wav", 30000), - ("./test-data/example-audio/PinkPanther60.wav", 60000), - ("./test-data/example-audio/preamble10.wav", 9599), - ("./test-data/example-audio/preamble.wav", 19098), - ("./test-data/example-audio/StarWars3.wav", 3000), - ("./test-data/example-audio/StarWars60.wav", 60000), - ("./test-data/example-audio/taunt.wav", 4099), - ("./test-data/qumuq/File001_Track0.wav", 6177), - ("./test-data/qumuq/File001_Track1.wav", 6177), - ("./test-data/qumuq/File002_Track0.wav", 3657), - ("./test-data/qumuq/File002_Track1.wav", 3657), - ("./test-data/qumuq/File003_Track0.wav", 4528), - ("./test-data/qumuq/File003_Track1.wav", 4528), - ("./test-data/qumuq/File004_Track0.wav", 4586), - ("./test-data/qumuq/File004_Track1.wav", 4586), - ("./test-data/qumuq/File005_Track0.wav", 3529), - ("./test-data/qumuq/File005_Track1.wav", 3529), - ("./test-data/qumuq/File006_Track0.wav", 3866), - ("./test-data/qumuq/File006_Track1.wav", 3866), - ("./test-data/qumuq/File007_Track0.wav", 3994), - ("./test-data/qumuq/File007_Track1.wav", 3994), - ("./test-data/qumuq/File008_Track0.wav", 3878), - ("./test-data/qumuq/File008_Track1.wav", 3878), - ("./test-data/qumuq/File009_Track0.wav", 3587), - ("./test-data/qumuq/File009_Track1.wav", 3587), - ("./test-data/qumuq/File010_Track0.wav", 4481), - ("./test-data/qumuq/File010_Track1.wav", 4481), - ("./test-data/qumuq/File011_Track0.wav", 3878), - ("./test-data/qumuq/File011_Track1.wav", 3878), - ("./test-data/qumuq/File012_Track0.wav", 3785), - ("./test-data/qumuq/File012_Track1.wav", 3785), - ("./test-data/qumuq/File013_Track0.wav", 4168), - ("./test-data/qumuq/File013_Track1.wav", 4168), - ("./test-data/qumuq/File014_Track0.wav", 4005), - ("./test-data/qumuq/File014_Track1.wav", 4005), - ("./test-data/qumuq/File015_Track0.wav", 3913), - ("./test-data/qumuq/File015_Track1.wav", 3913), - ("./test-data/qumuq/File016_Track0.wav", 4284), - ("./test-data/qumuq/File016_Track1.wav", 4284), - ("./test-data/qumuq/File017_Track0.wav", 4319), - ("./test-data/qumuq/File017_Track1.wav", 4319), - ("./test-data/qumuq/File018_Track0.wav", 4040), - ("./test-data/qumuq/File018_Track1.wav", 4040), - ("./test-data/qumuq/File019_Track0.wav", 4063), - ("./test-data/qumuq/File019_Track1.wav", 4063), - ("./test-data/qumuq/File020_Track0.wav", 4040), - ("./test-data/qumuq/File020_Track1.wav", 4040), - ("./test-data/qumuq/File021_Track0.wav", 3982), - ("./test-data/qumuq/File021_Track1.wav", 3982), - ("./test-data/qumuq/File022_Track0.wav", 4098), - ("./test-data/qumuq/File022_Track1.wav", 4098), - ("./test-data/qumuq/File023_Track0.wav", 4249), - ("./test-data/qumuq/File023_Track1.wav", 4249), - ("./test-data/qumuq/File024_Track0.wav", 4040), - ("./test-data/qumuq/File024_Track1.wav", 4040), - ("./test-data/qumuq/File025_Track0.wav", 4052), - ("./test-data/qumuq/File025_Track1.wav", 4052), - ("./test-data/qumuq/File026_Track0.wav", 4830), - ("./test-data/qumuq/File026_Track1.wav", 4830), - ("./test-data/qumuq/File027_Track0.wav", 4690), - ("./test-data/qumuq/File027_Track1.wav", 4690), - ("./test-data/qumuq/File028_Track0.wav", 4516), - ("./test-data/qumuq/File028_Track1.wav", 4516), - ("./test-data/qumuq/File029_Track0.wav", 4458), - ("./test-data/qumuq/File029_Track1.wav", 4458), - ("./test-data/qumuq/File030_Track0.wav", 3924), - ("./test-data/qumuq/File030_Track1.wav", 3924), - ("./test-data/qumuq/File031_Track0.wav", 4180), - ("./test-data/qumuq/File031_Track1.wav", 4180), - ("./test-data/qumuq/File032_Track0.wav", 4528), - ("./test-data/qumuq/File032_Track1.wav", 4528), - ("./test-data/qumuq/File033_Track0.wav", 4156), - ("./test-data/qumuq/File033_Track1.wav", 4156), - ("./test-data/qumuq/File034_Track0.wav", 4795), - ("./test-data/qumuq/File034_Track1.wav", 4795), - ("./test-data/qumuq/File035_Track0.wav", 2705), - ("./test-data/qumuq/File035_Track1.wav", 2705), - ("./test-data/qumuq/File036_Track0.wav", 2740), - ("./test-data/qumuq/File036_Track1.wav", 2740), - ("./test-data/qumuq/File037_Track0.wav", 3007), - ("./test-data/qumuq/File037_Track1.wav", 3007), - ("./test-data/qumuq/File038_Track0.wav", 2937), - ("./test-data/qumuq/File038_Track1.wav", 2937), - ("./test-data/qumuq/File039_Track0.wav", 3007), - ("./test-data/qumuq/File039_Track1.wav", 3007), - ("./test-data/qumuq/File040_Track0.wav", 3007), - ("./test-data/qumuq/File040_Track1.wav", 3007), - ("./test-data/qumuq/File041_Track0.wav", 3286), - ("./test-data/qumuq/File041_Track1.wav", 3286), - ("./test-data/qumuq/File042_Track0.wav", 3042), - ("./test-data/qumuq/File042_Track1.wav", 3042), - ("./test-data/qumuq/File043_Track0.wav", 2752), - ("./test-data/qumuq/File043_Track1.wav", 2752), - ("./test-data/qumuq/File044_Track0.wav", 3204), - ("./test-data/qumuq/File044_Track1.wav", 3204), - ("./test-data/qumuq/File045_Track0.wav", 2531), - ("./test-data/qumuq/File045_Track1.wav", 2531), - ("./test-data/qumuq/File046_Track0.wav", 3019), - ("./test-data/qumuq/File046_Track1.wav", 3019), - ("./test-data/qumuq/File047_Track0.wav", 2786), - ("./test-data/qumuq/File047_Track1.wav", 2786), - ("./test-data/qumuq/File048_Track0.wav", 3065), - ("./test-data/qumuq/File048_Track1.wav", 3065), - ("./test-data/qumuq/File049_Track0.wav", 3166), - ("./test-data/qumuq/File049_Track1.wav", 3166), - ("./test-data/qumuq/File050_Track0.wav", 2937), - ("./test-data/qumuq/File050_Track1.wav", 2937), - ("./test-data/qumuq/File051_Track0.wav", 3158), - ("./test-data/qumuq/File051_Track1.wav", 3158), - ("./test-data/qumuq/File052_Track0.wav", 3053), - ("./test-data/qumuq/File052_Track1.wav", 3053), - ("./test-data/qumuq/File053_Track0.wav", 3587), - ("./test-data/qumuq/File053_Track1.wav", 3587), - ("./test-data/qumuq/File054_Track0.wav", 3762), - ("./test-data/qumuq/File054_Track1.wav", 3762), - ("./test-data/qumuq/File055_Track0.wav", 2821), - ("./test-data/qumuq/File055_Track1.wav", 2821), - ("./test-data/qumuq/File056_Track0.wav", 3123), - ("./test-data/qumuq/File056_Track1.wav", 3123), - ("./test-data/qumuq/File057_Track0.wav", 2914), - ("./test-data/qumuq/File057_Track1.wav", 2914), - ("./test-data/qumuq/File058_Track0.wav", 2705), - ("./test-data/qumuq/File058_Track1.wav", 2705), - ("./test-data/qumuq/File059_Track0.wav", 2763), - ("./test-data/qumuq/File059_Track1.wav", 2763), - ("./test-data/qumuq/File060_Track0.wav", 3146), - ("./test-data/qumuq/File060_Track1.wav", 3146), - ("./test-data/qumuq/File061_Track0.wav", 3239), - ("./test-data/qumuq/File061_Track1.wav", 3239), - ("./test-data/qumuq/File062_Track0.wav", 3251), - ("./test-data/qumuq/File062_Track1.wav", 3251), - ("./test-data/qumuq/File063_Track0.wav", 3065), - ("./test-data/qumuq/File063_Track1.wav", 3065), - ("./test-data/qumuq/File064_Track0.wav", 3239), - ("./test-data/qumuq/File064_Track1.wav", 3239), - ("./test-data/qumuq/File065_Track0.wav", 3007), - ("./test-data/qumuq/File065_Track1.wav", 3007), - ("./test-data/qumuq/File066_Track0.wav", 3019), - ("./test-data/qumuq/File066_Track1.wav", 3019), - ("./test-data/qumuq/File067_Track0.wav", 3065), - ("./test-data/qumuq/File067_Track1.wav", 3065), - ("./test-data/qumuq/File068_Track0.wav", 3390), - ("./test-data/qumuq/File068_Track1.wav", 3390), - ], -) -def test_loading_from_valid_file(path: str, duration_ms: int) -> None: - wav_file = WAVLoader.from_file(path) - assert len(wav_file) == duration_ms diff --git a/ultratrace2/model/files/loaders/textgrid.py b/ultratrace2/model/files/loaders/textgrid.py deleted file mode 100644 index ca10d15..0000000 --- a/ultratrace2/model/files/loaders/textgrid.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import IO, Sequence - -import logging -import os -import tempfile -import textgrid # type: ignore - -from .base import AlignmentFileLoader, FileLoadError, Intervals - - -logger = logging.getLogger(__name__) - - -class TextGridInterval: - def __init__(self, tg_interval: textgrid.Interval): - self.tg_interval = tg_interval - - def get_start(self) -> float: - return self.tg_interval.minTime - - def get_end(self) -> float: - return self.tg_interval.maxTime - - def get_contents(self) -> str: - return self.tg_interval.mark - - def __bool__(self) -> bool: - return bool(self.get_contents()) - - -class TextGridLoader(AlignmentFileLoader): - def get_path(self) -> str: - return self._path - - def set_path(self, path) -> None: - self._path = path - - def __init__(self, path: str, tg_data: textgrid.TextGrid): - self.set_path(path) - self.tg_data = tg_data - self.offset = 0.0 - - def get_tier_names(self) -> Sequence[str]: - return self.tg_data.getNames() - - def get_intervals(self) -> Intervals: - all_intervals = [] - for tier in self.tg_data.tiers: - if isinstance(tier, textgrid.PointTier): - continue - tier_intervals = [] - for tier_interval in tier: - tg_interval = TextGridInterval(tier_interval) - if not tg_interval: - continue - tier_intervals.append(tg_interval) - all_intervals.append((tier.name, tier_intervals)) - return all_intervals - - def get_start(self) -> float: - return self.tg_data.minTime + self.offset - - def get_end(self) -> float: - return self.tg_data.maxTime + self.offset - - def get_offset(self) -> float: - return self.offset - - def set_offset(self, offset: float) -> None: - self.offset = offset - - @classmethod - def from_file(cls, path: str) -> "TextGridLoader": - try: - - if not os.path.exists(path): - raise FileNotFoundError(f"Cannot load from path: '{path}'") - - for encoding in ( - "utf-8", - "utf-16", - "Windows-1251", - "Windows-1252", - "ISO-8859-1", # aka Latin-1 - "macroman", - ): - try: - tg_data = TextGridLoader.load_with_encoding(path, encoding) - return cls(path, tg_data) - except UnicodeDecodeError: - pass - except textgrid.exceptions.TextGridError as e: - logger.error(e) - - raise ValueError("Unable to parse file") - - except Exception as e: - raise FileLoadError( - f"Invalid TextGrid ({path}), unable to read: {str(e)}" - ) from e - - @staticmethod - def load_with_encoding(path: str, encoding: str) -> textgrid.TextGrid: - if encoding == "utf-8" or encoding == "utf-16": - return textgrid.TextGrid.fromFile(path) - else: - with tempfile.NamedTemporaryFile() as temp_file: - TextGridLoader.copy_to_temp_file_with_encoding( - path, temp_file, encoding - ) - return textgrid.TextGrid.fromFile(temp_file.name) - - @staticmethod - def copy_to_temp_file_with_encoding( - original_path: str, temp_file: IO[bytes], encoding: str - ) -> None: - with open(original_path, "rb") as orig_file: - transcoded_contents = orig_file.read().decode(encoding).encode("utf-8") - temp_file.write(transcoded_contents) - temp_file.seek(0) diff --git a/ultratrace2/model/files/loaders/wav.py b/ultratrace2/model/files/loaders/wav.py deleted file mode 100644 index 1dcdce6..0000000 --- a/ultratrace2/model/files/loaders/wav.py +++ /dev/null @@ -1,7 +0,0 @@ -from .pydub import PydubLoader - - -class WAVLoader(PydubLoader): - @staticmethod - def get_priority() -> int: - return 3 diff --git a/ultratrace2/model/files/registry.py b/ultratrace2/model/files/registry.py deleted file mode 100644 index 34fc9df..0000000 --- a/ultratrace2/model/files/registry.py +++ /dev/null @@ -1,83 +0,0 @@ -import magic # type: ignore -import os - -from typing import Dict, Mapping, Optional, Sequence, Set, Type, Union - -from .loaders.base import ( - FileLoaderBase, - AlignmentFileLoader, - ImageSetFileLoader, - SoundFileLoader, -) - - -AbstractLoader = Union[ - Type[AlignmentFileLoader], Type[ImageSetFileLoader], Type[SoundFileLoader] -] - -# global maps -__extension_to_loaders_map: Dict[str, Type[FileLoaderBase]] = {} -__mime_type_to_loaders_map: Dict[str, Type[FileLoaderBase]] = {} -__loader_priorities_map: Mapping[AbstractLoader, Set[int]] = { - AlignmentFileLoader: set(), - ImageSetFileLoader: set(), - SoundFileLoader: set(), -} - - -def register_loader_for_extensions_and_mime_types( - extensions: Sequence[str], - mime_types: Sequence[str], - loader_cls: Type[FileLoaderBase], -) -> None: - """Register a loader which recognizes a file in the format indicated by the given - extensions and MIME types. - - Parameters: - extensions: A set of file extension, e.g. [".wav"], indicating the file format - mime_types: A set of MIME types, e.g. ["audio/x-wav", "audio/wav"], also indicating the file format - loader_cls: A file loader which knows how to load files with the given file extensions and MIME types - """ - - loader_type: AbstractLoader - if issubclass(loader_cls, AlignmentFileLoader): - loader_type = AlignmentFileLoader - elif issubclass(loader_cls, ImageSetFileLoader): - loader_type = ImageSetFileLoader - elif issubclass(loader_cls, SoundFileLoader): - loader_type = SoundFileLoader - else: - raise ValueError(f"Invalid loader class: {loader_cls.__name__}") - priority = loader_cls.get_priority() - if priority in __loader_priorities_map[loader_type]: - raise ValueError( - f"Cannot have duplicate priorities for loader type {loader_type.__name__}" - ) - - for extension in extensions: - __extension_to_loaders_map[extension] = loader_cls - - for mime_type in mime_types: - __mime_type_to_loaders_map[mime_type] = loader_cls - - -def get_loader_for(path: str) -> Optional[Type[FileLoaderBase]]: - - _, extension = os.path.splitext(path.lower()) - mime_type = magic.Magic(mime=True).from_file(path) - if mime_type is None: - # Early return since we can't possibly match anymore - return None - - loader_cls_by_extension = __extension_to_loaders_map.get(extension, None) - loader_cls_by_mime_type = __mime_type_to_loaders_map.get(mime_type, None) - - # NB: Use set-intersection (could potentially use set-union instead). - if loader_cls_by_extension is None or loader_cls_by_mime_type is None: - return None - if loader_cls_by_extension == loader_cls_by_mime_type: - return loader_cls_by_extension - else: - raise ValueError( - f"Warning: got {loader_cls_by_extension.__name__} for {extension} and {loader_cls_by_mime_type} for {mime_type}" - ) diff --git a/ultratrace2/model/files/tests/__init__.py b/ultratrace2/model/files/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ultratrace2/model/files/tests/test_bundle.py b/ultratrace2/model/files/tests/test_bundle.py deleted file mode 100644 index 6881191..0000000 --- a/ultratrace2/model/files/tests/test_bundle.py +++ /dev/null @@ -1,294 +0,0 @@ -from typing import Dict, Mapping, Sequence, Tuple, Type - -import os -import pytest - -from ..bundle import FileBundle, FileBundleList -from ..loaders import DICOMLoader, FLACLoader, MP3Loader, TextGridLoader, WAVLoader -from ..loaders.base import ( - FileLoaderBase, - AlignmentFileLoader, - ImageSetFileLoader, - SoundFileLoader, -) - - -@pytest.mark.parametrize( - "kwargs", - [ - dict(), - dict(alignment_file=None), - dict(image_set_file=None), - dict(sound_file=None), - dict(alignment_file=None, image_set_file=None), - dict(alignment_file=None, sound_file=None), - dict(image_set_file=None, sound_file=None), - dict(alignment_file=None, image_set_file=None, sound_file=None), - ], -) -def test_empty_file_bundle_constructor(kwargs: Mapping[str, None]) -> None: - fb = FileBundle("test", **kwargs) - assert not fb.has_impl() - assert str(fb) == 'Bundle("test",None,None,None)' - - -@pytest.mark.parametrize( - "kwargs", - [ - dict( - alignment_file=TextGridLoader.from_file( - "./test-data/example-bundles/ex000/file00.TextGrid" - ) - ), - dict( - image_set_file=DICOMLoader.from_file( - "./test-data/example-bundles/ex004/file00.dicom" - ) - ), - dict( - sound_file=MP3Loader.from_file( - "./test-data/example-bundles/ex002/file00.mp3" - ) - ), - dict( - alignment_file=TextGridLoader.from_file( - "./test-data/example-bundles/ex000/file00.TextGrid" - ), - image_set_file=DICOMLoader.from_file( - "./test-data/example-bundles/ex004/file00.dicom" - ), - ), - dict( - alignment_file=TextGridLoader.from_file( - "./test-data/example-bundles/ex000/file00.TextGrid" - ), - sound_file=MP3Loader.from_file( - "./test-data/example-bundles/ex002/file00.mp3" - ), - ), - dict( - image_set_file=DICOMLoader.from_file( - "./test-data/example-bundles/ex004/file00.dicom" - ), - sound_file=MP3Loader.from_file( - "./test-data/example-bundles/ex002/file00.mp3" - ), - ), - dict( - alignment_file=TextGridLoader.from_file( - "./test-data/example-bundles/ex000/file00.TextGrid" - ), - image_set_file=DICOMLoader.from_file( - "./test-data/example-bundles/ex004/file00.dicom" - ), - sound_file=MP3Loader.from_file( - "./test-data/example-bundles/ex002/file00.mp3" - ), - ), - ], -) -def test_file_bundle_constructor(kwargs: Mapping[str, FileLoaderBase]) -> None: - fb = FileBundle("test", **kwargs) - assert fb.has_impl() - if "alignment_file" in kwargs: - alignment_file = fb.get_alignment_file() - assert isinstance(alignment_file, AlignmentFileLoader) - assert alignment_file.get_path() == kwargs["alignment_file"].get_path() - if "image_set_file" in kwargs: - image_set_file = fb.get_image_set_file() - assert isinstance(image_set_file, ImageSetFileLoader) - assert image_set_file.get_path() == kwargs["image_set_file"].get_path() - if "sound_file" in kwargs: - sound_file = fb.get_sound_file() - assert isinstance(sound_file, SoundFileLoader) - assert sound_file.get_path() == kwargs["sound_file"].get_path() - - -def test_build_from_nonexistent_dir(mocker) -> None: - mock_file_bundle_list_constructor = mocker.patch( - "ultratrace2.model.files.bundle.FileBundleList.__init__", return_value=None, - ) - with pytest.raises(AssertionError): - FileBundleList.build_from_dir("/tmp/this-does-not-exist-123123") - mock_file_bundle_list_constructor.assert_not_called() - - -@pytest.mark.parametrize( - "source_dir,expected_file_map,should_emit_warning", - [ - ( - "./test-data/example-bundles/ex000", - {"file00": [(TextGridLoader, "file00.TextGrid")]}, - False, - ), - ( - "./test-data/example-bundles/ex001", - { - "file00": [(TextGridLoader, "file00.TextGrid")], - "file01": [(TextGridLoader, "file01.TextGrid")], - }, - False, - ), - ( - "./test-data/example-bundles/ex002", - { - "file00": [ - (MP3Loader, "file00.mp3"), - (TextGridLoader, "file00.TextGrid"), - ] - }, - False, - ), - ( - "./test-data/example-bundles/ex003", - { - "file00": [(TextGridLoader, "file00.TextGrid")], - "file01": [(MP3Loader, "file01.mp3")], - }, - False, - ), - ( - "./test-data/example-bundles/ex004", - { - "file00": [ - (DICOMLoader, "file00.dicom"), - (MP3Loader, "file00.mp3"), - (TextGridLoader, "file00.TextGrid"), - ] - }, - False, - ), - ("./test-data/example-bundles/ex005", {}, False), - ("./test-data/example-bundles/ex006", {}, True), - ( - "./test-data/example-bundles/ex007", - {"file00": [(TextGridLoader, "file00.TextGrid")]}, - True, - ), - ( - "./test-data/example-bundles/ex008", - { - "file00": [ - (DICOMLoader, "file00.dicom"), - (MP3Loader, "file00.mp3"), - (TextGridLoader, "file00.TextGrid"), - ], - "file01": [ - (DICOMLoader, "file01.dicom"), - (MP3Loader, "file01.mp3"), - (TextGridLoader, "file01.TextGrid"), - ], - "file02": [ - (DICOMLoader, "file02.dicom"), - (MP3Loader, "file02.mp3"), - (TextGridLoader, "file02.TextGrid"), - ], - }, - False, - ), - ( - "./test-data/example-bundles/ex009", - {"file00": [(WAVLoader, "file00.wav")]}, - True, - ), - ( - "./test-data/example-bundles/ex010", - {"file00": [(TextGridLoader, "sub00/file00.TextGrid")]}, - False, - ), - ( - "./test-data/example-bundles/ex011", - { - "file00": [(TextGridLoader, "sub00/file00.TextGrid")], - "file01": [(TextGridLoader, "sub01/sub00/sub00/sub00/file01.TextGrid")], - }, - False, - ), - ( - "./test-data/example-bundles/ex012", - { - "file00": [ - (MP3Loader, "sub01/sub00/sub00/sub00/file00.mp3"), - (TextGridLoader, "sub00/file00.TextGrid"), - ] - }, - False, - ), - ( - "./test-data/example-bundles/ex013", - {"file00": [(TextGridLoader, "sub01/file00.TextGrid")]}, - True, - ), - ( - "./test-data/example-bundles/ex014", - {"link00": [(TextGridLoader, "../ex004/file00.TextGrid")]}, - False, - ), - ( - "./test-data/example-bundles/ex015", - { - "file00": [(MP3Loader, "file00.mp3")], - "link00": [(TextGridLoader, "../ex004/file00.TextGrid")], - }, - False, - ), - ( - "./test-data/example-bundles/ex016", - { - "link00": [ - (MP3Loader, "link00.mp3"), - (TextGridLoader, "../ex004/file00.TextGrid"), - ] - }, - False, - ), - ( - "./test-data/ftyers", - { - "20150629171639": [ - (DICOMLoader, "20150629171639.dicom"), - (FLACLoader, "20150629171639.flac"), - (TextGridLoader, "20150629171639.TextGrid"), - ], - }, - True, - ), - ], -) -def test_build_from_dir( - mocker, - source_dir: str, - expected_file_map: Dict[str, Sequence[Tuple[Type[FileLoaderBase], str]]], - should_emit_warning: bool, -) -> None: - mock_file_bundle_list_constructor = mocker.patch( - "ultratrace2.model.files.bundle.FileBundleList.__init__", return_value=None, - ) - mock_warning = mocker.patch("ultratrace2.model.files.bundle.logger.warning") - FileBundleList.build_from_dir(source_dir) - expected_bundles = {} - for expected_name, expected_files in expected_file_map.items(): - alignment_file = None - image_set_file = None - sound_file = None - for loader, source_subpath in expected_files: - source_path = os.path.abspath(os.path.join(source_dir, source_subpath)) - if issubclass(loader, AlignmentFileLoader): - alignment_file = loader.from_file(source_path) - elif issubclass(loader, ImageSetFileLoader): - image_set_file = loader.from_file(source_path) - elif issubclass(loader, SoundFileLoader): - sound_file = loader.from_file(source_path) - else: - raise RuntimeError("malformed input") - expected_bundles[expected_name] = FileBundle( - name=expected_name, - alignment_file=alignment_file, - image_set_file=image_set_file, - sound_file=sound_file, - ) - mock_file_bundle_list_constructor.assert_called_with(expected_bundles) - if should_emit_warning: - mock_warning.assert_called_once() - else: - mock_warning.assert_not_called() diff --git a/ultratrace2/model/project.py b/ultratrace2/model/project.py deleted file mode 100644 index 9345ab9..0000000 --- a/ultratrace2/model/project.py +++ /dev/null @@ -1,85 +0,0 @@ -import logging -import os -import pickle - -from .trace import TraceList -from .files.bundle import FileBundleList - -logger = logging.getLogger(__name__) - - -class Project: - def __init__(self, traces: TraceList, files: FileBundleList): - """ - Internal function: to construct a Project, either call ::get_by_path() - """ - self.traces = traces - self.files = files - - def save(self): - raise NotImplementedError() - - @classmethod - def load(cls, save_file: str) -> "Project": - with open(save_file, "rb") as fp: - project = pickle.load(fp) - assert isinstance(project, Project) - return project - - @classmethod - def get_by_path(cls, root_path: str) -> "Project": - - root_path = os.path.realpath(os.path.abspath(root_path)) # absolute path - if not os.path.exists(root_path): - raise ValueError( - f"cannot initialize project at {root_path}: directory does not exist" - ) - - if not os.path.isdir(root_path): - raise ValueError( - f"cannot initialize project at {root_path}: not a directory" - ) - - save_dir = cls.get_save_dir(root_path) - if not os.path.exists(save_dir): - os.mkdir(save_dir, mode=0o755) - - save_file = cls.get_save_file(root_path) - try: - return cls.load(save_file) - except Exception as e: - logger.warning(e) - logger.info( - f"Unable to find existing project at {root_path}, creating new one..." - ) - - traces = TraceList() - file_bundles = FileBundleList.build_from_dir(root_path) - return cls(traces, file_bundles) - - @staticmethod - def get_save_dir(path: str) -> str: - return os.path.join(path, ".ultratrace") - - @staticmethod - 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): - raise NotImplementedError() - - def current_trace(self): - raise NotImplementedError() - - def current_frame(self): - raise NotImplementedError() - - def has_alignment_impl(self) -> bool: - return self.files.has_alignment_impl - - def has_image_impl(self) -> bool: - return self.files.has_image_set_impl - - def has_sound_impl(self) -> bool: - return self.files.has_sound_impl diff --git a/ultratrace2/model/tests/__init__.py b/ultratrace2/model/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ultratrace2/model/tests/test_color.py b/ultratrace2/model/tests/test_color.py deleted file mode 100644 index e25a106..0000000 --- a/ultratrace2/model/tests/test_color.py +++ /dev/null @@ -1,55 +0,0 @@ -from contextlib import nullcontext -from typing import Any, Optional, Type, Union - -import pytest - -from ..color import Color - - -@pytest.mark.parametrize( - "r,g,b,error", - [ - (0, 0, 0, None), - (255, 255, 255, None), - (-1, 0, 0, ValueError), - (0, -1, 0, ValueError), - (0, 0, -1, ValueError), - (256, 0, 0, ValueError), - (0, 256, 0, ValueError), - (0, 0, 256, ValueError), - ], -) -def test_color(r: int, g: int, b: int, error: Optional[Type[Exception]]): - ctx = pytest.raises(error) if error is not None else nullcontext() - with ctx: - Color(r, g, b) - - -@pytest.mark.parametrize( - "raw_value,expected", [(0, 0), (255, 255), (-1, ValueError), (256, ValueError)] -) -def test_set_r(raw_value: int, expected: Union[int, Type[Exception]]): - c = Color(100, 100, 100) - if isinstance(expected, int): - c.r = raw_value - assert c.r == expected - elif issubclass(expected, Exception): - with pytest.raises(expected): - c.r = raw_value - else: - raise AssertionError() - - -@pytest.mark.parametrize( - "color,other,should_be_equal", - [ - (Color(0, 0, 0), Color(0, 0, 0), True), - (Color(0, 0, 1), Color(0, 0, 0), False), - (Color(0, 0, 0), "not a color", False), - ], -) -def test_color_eq(color: Color, other: Any, should_be_equal: bool): - if should_be_equal: - assert color == other - else: - assert color != other diff --git a/ultratrace2/model/tests/test_project.py b/ultratrace2/model/tests/test_project.py deleted file mode 100644 index 38c7f7e..0000000 --- a/ultratrace2/model/tests/test_project.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest - -from ..project import Project -from ..trace import TraceList -from ..files.bundle import FileBundle, FileBundleList - - -@pytest.mark.parametrize( - "save_file,error", - [ - ("", FileNotFoundError), - ("/path/to/nowhere", FileNotFoundError), - ("/dev/null", EOFError), # pickle cannot open - ("/etc/sudoers", PermissionError), # not readable - ], -) -def test_load_project_invalid(save_file: str, error: Exception) -> None: - with pytest.raises(error): - Project.load(save_file) - - -# FIXME: not implemented, so can't test :/ -""" -def test_load_project_valid(save_file) -> None: - pass -""" - - -@pytest.mark.parametrize( - "traces,files", - [ - (TraceList(), FileBundleList({})), - (TraceList(), FileBundleList({"x": FileBundle("x")})), - ], -) -def test_init_project(traces: TraceList, files: FileBundleList) -> None: - p = Project(traces, files) - assert isinstance(p, Project) diff --git a/ultratrace2/model/tests/test_trace.py b/ultratrace2/model/tests/test_trace.py deleted file mode 100644 index 4d4bfe4..0000000 --- a/ultratrace2/model/tests/test_trace.py +++ /dev/null @@ -1,66 +0,0 @@ -from ..color import Color -from ..trace import Trace, TraceList - - -def test_change_color(mocker): - mock_get_random_color = mocker.patch("ultratrace2.model.trace.get_random_color") - initial_color = Color(0, 0, 0) - final_color = Color(100, 0, 0) - tr = Trace("test", initial_color) - - color_pre_change = tr.get_color() - change_color_ret = tr.change_color(final_color) - color_post_change = tr.get_color() - - assert color_pre_change == initial_color == change_color_ret - assert color_post_change == final_color - mock_get_random_color.assert_not_called() - - -def test_change_random_color(mocker): - mock_get_random_color = mocker.patch("ultratrace2.model.trace.get_random_color") - initial_color = Color(0, 0, 0) - final_color = Color(100, 0, 0) - mock_get_random_color.return_value = final_color - tr = Trace("test", initial_color) - - color_pre_change = tr.get_color() - change_color_ret = tr.change_color(None) - color_post_change = tr.get_color() - - assert color_pre_change == initial_color == change_color_ret - assert color_post_change == final_color - mock_get_random_color.assert_called_once() - - -def test_change_name(): - initial_name = "test" - final_name = "testtest" - tr = Trace(initial_name, Color(0, 0, 0)) - - name_pre_change = tr.get_name() - change_name_ret = tr.change_name(final_name) - name_post_change = tr.get_name() - - assert name_pre_change == initial_name == change_name_ret - assert name_post_change == final_name - - -def test_add_xhair(mocker): - MockFileBundle = mocker.patch("ultratrace2.model.files.bundle.FileBundle") - file_bundle = MockFileBundle.return_value - tr = Trace("test", Color(0, 0, 0)) - - len_xhairs_pre = len(tr.xhairs) - tr.add_xhair(file_bundle, 0, 0.0, 0.0) - len_xhairs_post = len(tr.xhairs) - - assert len_xhairs_pre == 0 - assert len_xhairs_post == 1 - assert len(tr.xhairs[file_bundle][0]) == 1 - - -def test_add_trace(): - tl = TraceList() - tr = tl.add_trace("test", Color(0, 0, 0)) - assert tr.id in tl.traces diff --git a/ultratrace2/model/tests/test_xhair.py b/ultratrace2/model/tests/test_xhair.py deleted file mode 100644 index 41d90a2..0000000 --- a/ultratrace2/model/tests/test_xhair.py +++ /dev/null @@ -1,53 +0,0 @@ -import math - -import pytest - -from ..xhair import XHair -from ..color import Color - - -def _get_black() -> Color: - return Color(0, 0, 0) - - -@pytest.mark.parametrize( - "p,q,expected", - [ - (XHair(_get_black, 0, 0), XHair(_get_black, 1, 0), 1), - (XHair(_get_black, 0, 0), XHair(_get_black, 1, 1), 2), - (XHair(_get_black, 2, 0), XHair(_get_black, 0, 2), 8), - (XHair(_get_black, math.inf, 0), XHair(_get_black, 0, 0), math.inf), - ], -) -def test_sq_dist_from(p: XHair, q: XHair, expected: float): - assert p.sq_dist_from(q) == q.sq_dist_from(p) == expected - - -def test_get_color(): - x = XHair(_get_black, 0, 0) - assert x.get_color() == Color(0, 0, 0) - - -def test_get_color_with_mutation(): - class ColorRef: - def __init__(self, initial_color: Color): - self._color = initial_color - - def get_color(self) -> Color: - return self._color - - def set_color(self, color: Color) -> None: - self._color = color - - initial_color = Color(0, 0, 0) - final_color = Color(0, 100, 0) - ref = ColorRef(initial_color) - - x = XHair(ref.get_color, 0, 0) - color_pre_set = x.get_color() - ref.set_color(final_color) - color_post_set = x.get_color() - - assert color_pre_set == initial_color - assert color_post_set != color_pre_set - assert color_post_set == final_color diff --git a/ultratrace2/model/trace.py b/ultratrace2/model/trace.py deleted file mode 100644 index f2f4d29..0000000 --- a/ultratrace2/model/trace.py +++ /dev/null @@ -1,81 +0,0 @@ -from collections import OrderedDict -from typing import ClassVar, Dict, Optional, Set, TYPE_CHECKING -from uuid import uuid4, UUID - -from .color import Color, get_random_color, RED -from .xhair import XHair - -if TYPE_CHECKING: - from .files.bundle import FileBundle - - -class Trace: - - DEFAULT_TRACE_NAME: ClassVar[str] = "tongue" - DEFAULT_TRACE_COLOR: ClassVar[Color] = RED - - def __init__(self, name: str, color: Color): - self.id: UUID = uuid4() - self.is_visible: bool = True - self.xhairs: Dict["FileBundle", Dict[int, Set[XHair]]] = {} - self.name = name - self.color = color - - def get_color(self) -> Color: - return self.color - - def change_color(self, new_color: Optional[Color]) -> Color: - if new_color is None: - new_color = get_random_color() - old_color = self.color - self.color = new_color - return old_color - - def get_name(self) -> str: - return self.name - - def change_name(self, new_name: str) -> str: - old_name = self.name - self.name = new_name - return old_name - - def show(self) -> None: - self.is_visible = True - - def hide(self) -> None: - self.is_visible = False - - def add_xhair(self, bundle: "FileBundle", frame: int, x: float, y: float) -> None: - if bundle not in self.xhairs: - self.xhairs[bundle] = {} - if frame not in self.xhairs[bundle]: - self.xhairs[bundle][frame] = set() - xhair = XHair(self.get_color, x, y) - self.xhairs[bundle][frame].add(xhair) - - -class TraceList: - def __init__(self): - self.traces: OrderedDict[UUID, Trace] = OrderedDict() - default_trace = self.add_trace( - Trace.DEFAULT_TRACE_NAME, Trace.DEFAULT_TRACE_COLOR, - ) - self.default_trace: Trace = default_trace - self.selected_trace: Trace = default_trace - - def add_trace(self, name: str, color: Color) -> Trace: - trace = Trace(name, color) - self.traces[trace.id] = trace - return trace - - def get_default_trace(self) -> Trace: - return self.default_trace - - def set_default_trace(self, trace: Trace) -> None: - self.default_trace = trace - - def get_selected_trace(self) -> Trace: - return self.selected_trace - - def set_selected_trace(self, trace: Trace) -> None: - self.selected_trace = trace diff --git a/ultratrace2/model/xhair.py b/ultratrace2/model/xhair.py deleted file mode 100644 index 87c3064..0000000 --- a/ultratrace2/model/xhair.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Callable, Tuple, Union -from uuid import uuid4 - -from .color import Color - - -class XHair: - def __init__(self, color_factory: Callable[[], Color], x: float, y: float): - - self.id = uuid4() - self._color_factory = color_factory - self.x = x - self.y = y - - self.is_selected = False - self.is_hidden = False - - def sq_dist_from(self, other: Union["XHair", Tuple[float, float]]) -> float: - # 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 toggle_select(self) -> None: - self.is_selected = not self.is_selected - - def select(self) -> None: - self.is_selected = True - - def unselect(self) -> None: - self.is_selected = False - - def show(self): - self.is_hidden = False - - def hide(self): - self.is_hidden = True - - def move(self, x: float, y: float) -> None: - self.x = x - self.y = y - - def get_color(self) -> Color: - return self._color_factory() diff --git a/ultratrace2/tests/__init__.py b/ultratrace2/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ultratrace2/tests/test_app.py b/ultratrace2/tests/test_app.py deleted file mode 100644 index 6f4d02b..0000000 --- a/ultratrace2/tests/test_app.py +++ /dev/null @@ -1,37 +0,0 @@ -from pathlib import Path -from typing import Any, Dict - -import pytest - -from .. import app as app_py -from ..app import App, initialize_app - - -@pytest.mark.parametrize( - "kwargs,error", - [ - (dict(headless=True), ValueError), - (dict(headless=True, path="/path/to/nowhere"), ValueError), - (dict(headless=True, path="/dev/null"), ValueError), # not a directory - (dict(headless=True, path="/"), PermissionError), # not writeable - ], -) -def test_initialize_app_invalid( - kwargs: Dict[str, Any], error: Exception, tmpdir -) -> None: - app_py.app = None # overwrite global object - with pytest.raises(error): - initialize_app(**kwargs) - - -@pytest.mark.parametrize("kwargs", [(dict(headless=True)),]) # noqa: E231 -def test_initialize_app_valid(kwargs: Dict[str, Any], tmp_path: Path) -> None: - # overwrite global object - app_py.app = None - # initialize an empty dir - path = tmp_path / "ultratrace-test-app" - path.mkdir() - # initialize - kwargs["path"] = str(path) - app = initialize_app(**kwargs) - assert isinstance(app, App) diff --git a/ultratrace2/utils/__init__.py b/ultratrace2/utils/__init__.py deleted file mode 100644 index e69de29..0000000