diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4b15d5..0e776f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,7 @@ jobs: build: name: Build - if: false # delete this line and uncomment the line below to enable automatic builds - # if: github.event_name == 'push' || github.event_name == 'release' + if: github.event_name == 'push' || github.event_name == 'release' # needs: [ test ] # uncomment to require passing tests runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index ffd9fdb..21866b4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ __pycache__/ .vscode/ venv/ + +.~lock.*# diff --git a/Dockerfile b/Dockerfile index a5d8fe6..908382b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,11 @@ # FROM docker.io/fnndsc/conda:python3.10.2-cuda11.6.0 FROM docker.io/python:3.11.3-slim-bullseye -LABEL org.opencontainers.image.authors="FNNDSC " \ - org.opencontainers.image.title="ChRIS Plugin Title" \ - org.opencontainers.image.description="A ChRIS plugin that..." +LABEL org.opencontainers.image.authors="FNNDSC " \ + org.opencontainers.image.title="Inner Subplate Surface Fit" \ + org.opencontainers.image.description="Outer to inner surface mesh deformation using a radial distance map for human fetal MRI" -ARG SRCDIR=/usr/local/src/app +ARG SRCDIR=/usr/local/src/pl-inner-subplate-surface-fit WORKDIR ${SRCDIR} COPY requirements.txt . @@ -19,4 +19,4 @@ RUN pip install ".[${extras_require}]" \ && cd / && rm -rf ${SRCDIR} WORKDIR / -CMD ["commandname"] +CMD ["innerspfit"] diff --git a/README.md b/README.md index 96cc70a..1dcaab5 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,10 @@ -# _ChRIS_ Plugin Template +# Inner Subplate Surface Fit -[![test status](https://github.com/FNNDSC/python-chrisapp-template/actions/workflows/src.yml/badge.svg)](https://github.com/FNNDSC/python-chrisapp-template/actions/workflows/src.yml) -[![MIT License](https://img.shields.io/github/license/FNNDSC/python-chrisapp-template)](LICENSE) +[![Version](https://img.shields.io/docker/v/fnndsc/pl-inner-subplate-surface-fit?sort=semver)](https://hub.docker.com/r/fnndsc/pl-inner-subplate-surface-fit) +[![MIT License](https://img.shields.io/github/license/fnndsc/pl-inner-subplate-surface-fit)](https://github.com/FNNDSC/pl-inner-subplate-surface-fit/blob/main/LICENSE) +[![ci](https://github.com/FNNDSC/pl-inner-subplate-surface-fit/actions/workflows/ci.yml/badge.svg)](https://github.com/FNNDSC/pl-inner-subplate-surface-fit/actions/workflows/ci.yml) -This is a minimal template repository for _ChRIS_ plugin applications in Python. - -## About _ChRIS_ Plugins - -A _ChRIS_ plugin is a scientific data-processing software which can run anywhere all-the-same: -in the cloud via a [web app](https://github.com/FNNDSC/ChRIS_ui/), or on your own laptop -from the terminal. They are easy to build and easy to understand: most simply, a -_ChRIS_ plugin is a command-line program which processes data from an input directory -and creates data to an output directory with the usage -`commandname [options...] inputdir/ outputdir/`. - -For more information, visit our website https://chrisproject.org - -## How to Use This Template - -Go to https://github.com/FNNDSC/python-chrisapp-template and click "Use this template". -The newly created repository is ready to use right away. - -A script `bootstrap.sh` is provided to help fill in and rename values for your new project. -It is optional to use. - -1. Edit the variables in `bootstrap.sh` -2. Run `./bootstrap.sh` -3. Follow the instructions it will print out - -## Example Plugins - -Here are some good, complete examples of _ChRIS_ plugins created from this template. - -- https://github.com/FNNDSC/pl-dcm2niix (basic command wrapper example) -- (parallelizes a command) -- https://github.com/FNNDSC/pl-mri-preview (uses [NiBabel](https://nipy.org/nibabel/)) -- https://github.com/FNNDSC/pl-pyvista-volume (example using Python package project structure and pytest) -- https://github.com/FNNDSC/pl-fetal-cp-surface-extract (has a good README.md) - -## What's Inside - -| Path | Purpose | -|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `app.py` | Main script: start editing here! | -| `tests/` | Unit tests | -| `setup.py` | [Python project metadata and installation script](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#setup-py) | -| `requirements.txt` | List of Python dependencies | -| `Dockerfile` | [Container image build recipe](https://docs.docker.com/engine/reference/builder/) | -| `.github/workflows/ci.yml` | "continuous integration" using [Github Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions): automatic testing, building, and uploads to https://chrisstore.co | - -## Contributing - -The source code for the `main` branch of this repository is on the -[src](https://github.com/fnndsc/python-chrisapp-template/tree/src) -branch, which has an additional file -[`.github/workflows/src.yml`](https://github.com/FNNDSC/python-chrisapp-template/blob/src/.github/workflows/src.yml) -When tests pass, changes are automatically merged into `main`. -Developers should commit to or make pull requests targeting `src`. -Do not push directly to `main`. - -This is a workaround in order to do automatic testing of this template -without including the `.github/workflows/src.yml` file in the template itself. - - diff --git a/app.py b/app.py deleted file mode 100755 index 68e8e68..0000000 --- a/app.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python - -from pathlib import Path -from argparse import ArgumentParser, Namespace, ArgumentDefaultsHelpFormatter - -from chris_plugin import chris_plugin, PathMapper - -__version__ = '1.0.0' - -DISPLAY_TITLE = r""" -ChRIS Plugin Template Title -""" - - -parser = ArgumentParser(description='!!!CHANGE ME!!! An example ChRIS plugin which ' - 'counts the number of occurrences of a given ' - 'word in text files.', - formatter_class=ArgumentDefaultsHelpFormatter) -parser.add_argument('-w', '--word', required=True, type=str, - help='word to count') -parser.add_argument('-p', '--pattern', default='**/*.txt', type=str, - help='input file filter glob') -parser.add_argument('-V', '--version', action='version', - version=f'%(prog)s {__version__}') - - -# The main function of this *ChRIS* plugin is denoted by this ``@chris_plugin`` "decorator." -# Some metadata about the plugin is specified here. There is more metadata specified in setup.py. -# -# documentation: https://fnndsc.github.io/chris_plugin/chris_plugin.html#chris_plugin -@chris_plugin( - parser=parser, - title='My ChRIS plugin', - category='', # ref. https://chrisstore.co/plugins - min_memory_limit='100Mi', # supported units: Mi, Gi - min_cpu_limit='1000m', # millicores, e.g. "1000m" = 1 CPU core - min_gpu_limit=0 # set min_gpu_limit=1 to enable GPU -) -def main(options: Namespace, inputdir: Path, outputdir: Path): - """ - *ChRIS* plugins usually have two positional arguments: an **input directory** containing - input files and an **output directory** where to write output files. Command-line arguments - are passed to this main method implicitly when ``main()`` is called below without parameters. - - :param options: non-positional arguments parsed by the parser given to @chris_plugin - :param inputdir: directory containing (read-only) input files - :param outputdir: directory where to write output files - """ - - print(DISPLAY_TITLE) - - # Typically it's easier to think of programs as operating on individual files - # rather than directories. The helper functions provided by a ``PathMapper`` - # object make it easy to discover input files and write to output files inside - # the given paths. - # - # Refer to the documentation for more options, examples, and advanced uses e.g. - # adding a progress bar and parallelism. - mapper = PathMapper.file_mapper(inputdir, outputdir, glob=options.pattern, suffix='.count.txt') - for input_file, output_file in mapper: - # The code block below is a small and easy example of how to use a ``PathMapper``. - # It is recommended that you put your functionality in a helper function, so that - # it is more legible and can be unit tested. - data = input_file.read_text() - frequency = data.count(options.word) - output_file.write_text(str(frequency)) - - -if __name__ == '__main__': - main() diff --git a/bootstrap.sh b/bootstrap.sh deleted file mode 100755 index eb413da..0000000 --- a/bootstrap.sh +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env bash -# bootstrap.sh: customize python-chrisapp-template with project details -# -# WARNING: This script is for advanced users only! Do not proceed -# unless you understand what this does. New developers would find -# it easier to use python-chrisapp-template as is. Simply ignore -# and optionally delete this file. - -# ======================================== -# CONFIGURATION -# ======================================== - -# ---------------------------------------- -# STEP 1. Change these values to your liking. -# ---------------------------------------- - -PLUGIN_NAME="$(basename $(dirname $(realpath $0)))" # name of current directory -PLUGIN_TITLE='Inner Subplate Surface Fit' -SCRIPT_NAME='innerspfit' -DESCRIPTION='Outer to inner surface mesh deformation using a radial distance map for human fetal MRI' -ORGANIZATION='FNNDSC' -EMAIL='Jennings.Zhang@childrens.harvard.edu' - -# Github Actions: automatically test and build your code. -# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration -# -# These options will fail unless your Github settings are preconfigured. -# Repositories under github.com/FNNDSC are preconfigured, so these defaults might work. -# Please review the file .github/workflows/ci.yml before you push it. - -# Automatically build images on Github Actions each time you run `git push`, -# and also publish to https://chrisstore.co each time you run `git push --tags` -# If the value is "no" then builds will not be automated. -ENABLE_ACTIONS_BUILD=yes - -# WARNING: the default configuration in .github/workflows/ci.yml is to allow for -# the build to proceed regardless of whether tests pass. To modify this behavior -# and other advanced features (such as multi-architecture builds such as arm64, ppc64le) -# you must edit .github/workflows/ci.yml by hand. - - -# ---------------------------------------- -# STEP 2. Uncomment the line where it says READY=yes -# ---------------------------------------- - -READY=yes - -# ---------------------------------------- -# STEP 3. Run: ./bootstrap.sh -# ---------------------------------------- - - -if [ "$(uname -o 2> /dev/null)" != 'GNU/Linux' ]; then - >&2 echo "error: this script only works on GNU/Linux." -fi - -if ! [ "$READY" = 'yes' ]; then - >&2 echo "error: you are not READY." - exit 1 -fi - -cd $(dirname "$0") - - -# ======================================== -# VALIDATE INPUT -# ======================================== - -function contains_invalid_characters () { - [[ "$1" = *"/"* ]] -} - -# given a variable name, exit if the variable's value contains invalid characters. -function check_variable_value_valid () { - local varname="$1" - local varvalue="${!varname}" - if contains_invalid_characters "$varvalue"; then - >&2 echo "error: invalid characters in $varname=$varvalue" - exit 1 - fi -} - -# may not contain '/' -check_variable_value_valid PLUGIN_NAME -check_variable_value_valid SCRIPT_NAME -check_variable_value_valid ORGANIZATION -check_variable_value_valid EMAIL - - -# ======================================== -# COMMIT THE USER-SET CONFIG -# ======================================== - -# print command to run before running it -function verb () { - set -x - "$@" - { set +x; } 2> /dev/null -} - -# fail on error -set -e -set -o pipefail - -verb git commit -m 'Configure python-chrisapp-template/bootstrap.sh' -- "$0" - - -# ======================================== -# REPLACE VALUES -# ======================================== - -# execute sed on all files in project, excluding hidden paths and venv/ -function replace_in_all () { - if [ -z "$2" ]; then - return - fi - find . -type f \ - -not -path '*/\.*/*' -not -path '*/\venv/*' -not -name 'bootstrap.sh' \ - -exec sed -i -e "s/$1/$2/g" '{}' \; -} - -replace_in_all commandname "$SCRIPT_NAME" -replace_in_all pl-appname "$PLUGIN_NAME" -replace_in_all 'dev@babyMRI.org' "$EMAIL" -replace_in_all FNNDSC "$ORGANIZATION" - -# .github/ -if [ "${ENABLE_ACTIONS_TEST,,}" = 'yes' ]; then - sed -i -e '/delete this line to enable automatic testing/d' .github/workflows/ci.yml -fi - -if [ "${ENABLE_ACTIONS_BUILD,,}" = 'yes' ]; then - sed -i -e '/delete this line and uncomment the line below to enable automatic builds/d' .github/workflows/ci.yml - sed -i -e 's/# *if: github\.event_name/if: github\.event_name/' .github/workflows/ci.yml -fi - -# replace "/" with "\/" in string -function escape_slashes () { - sed 's/\//\\&/g' <<< "$@" -} - -escaped_description="$(escape_slashes "$DESCRIPTION")" -escaped_title="$(escape_slashes "$PLUGIN_TITLE")" - -# README.md -temp_file=$(mktemp) -sed -e'/^# ChRIS Plugin Title$/'\{ -e:1 -en\;b1 -e\} -ed README.md \ - | sed "s/^# ChRIS Plugin Title\$/# $escaped_title/" \ - | sed '/^END README TEMPLATE -->$/d' \ - | sed "s/fnndsc/${ORGANIZATION,,}/g" \ - | sed "s/app\\.py/$SCRIPT_NAME.py/g" \ - > $temp_file -mv $temp_file README.md - -# Dockerfile -sed "s#ARG SRCDIR=/usr/local/src/app#ARG SRCDIR=/usr/local/src/$PLUGIN_NAME#" Dockerfile \ - | sed "s/org\.opencontainers\.image\.title=\"ChRIS Plugin Title\"/org.opencontainers.image.title=\"$escaped_title\"/" \ - | sed "s/org\.opencontainers\.image\.description=\"A ChRIS plugin that\.\.\.\"/org.opencontainers.image.description=\"$escaped_description\"/" \ - > $temp_file -mv $temp_file Dockerfile - -# setup.py - -function guess_https_url () { - local origin="$(git remote get-url origin)" - local https_url="$origin" - if [[ "$https_url" = "git@"* ]]; then - # convert SSH url to HTTPS url by - # 1. change last ':' to '/' - # 2. replace leading 'git@' with 'https://' - https_url="$( - echo "$https_url" \ - | sed 's#\(.*\):#\1/#' \ - | sed 's#^git@#https://#' - )" - fi - echo "${https_url:0:-4}" # remove trailing ".git" -} - -appname_without_prefix="$(sed -E 's/(pl|dbg|ep)-//' <<< "$PLUGIN_NAME")" -sed "s/name='.*'/name='$appname_without_prefix'/" setup.py \ - | sed "s/description='.*'/description='$escaped_description'/" \ - | sed "s/py_modules=\['app'\]/py_modules=['$SCRIPT_NAME']/" \ - | sed "s/app:main/$SCRIPT_NAME:main/" \ - | sed "s#url='.*'#url='$(guess_https_url)'#" \ - | sed "s/app\.py/$SCRIPT_NAME.py/" \ - > $temp_file -mv $temp_file setup.py - -# app.py - -# FIGlet over HTTPS, since it's probably not installed locally -function figlet_wrapper () { - curl -fsSG 'https://figlet-hosting-of-medical-image-analysis-platform-dcb83b.apps.shift.nerc.mghpcc.org/' --data-urlencode "message=$*" \ - | grep -v '^[[:space:]]*$' -} - -function inject_figleted_title () { - python << EOF -for line in open('app.py'): - if line == 'ChRIS Plugin Template Title\n': - print(r"""$1""") - else: - print(line, end='') -EOF -} - -figleted_title="$(figlet_wrapper "$PLUGIN_NAME")" -echo "$figleted_title" -inject_figleted_title "$figleted_title" \ - | sed "s/title='My ChRIS plugin'/title='$escaped_title'/" \ - | sed "s/description='cli description'/description='$escaped_description'/" \ - > "$SCRIPT_NAME.py" -rm app.py - -# tests/ -for test_file in tests/*.py; do - sed "s/from app import/from $SCRIPT_NAME import/" $test_file > $temp_file - mv $temp_file $test_file -done - -# ======================================== -# SETUP -# ======================================== - -if ! [ -e venv ]; then - verb python -m venv venv -fi - ->&2 echo + source venv/bin/activate -source venv/bin/activate -verb pip install -r requirements.txt -verb pip install -e '.[dev]' - - -if [ -z "$TERM" ]; then - tput=tput -else - tput=true -fi - -$tput bold ->&2 printf '\n%s\n\n' '✨Done!✨' -$tput sgr0 - -$tput setaf 3 ->&2 echo 'To undo these actions and start over, run:' ->&2 printf '\n\t%s\n\t%s\n\t%s\n\t%s\n\n' \ - 'git reset --hard' \ - 'git clean -df' \ - 'rm -rf venv *.egg-info' \ - "git reset 'HEAD^'" -$tput setaf 6 ->&2 echo 'Activate the Python virtual environment by running:' ->&2 printf '\n\t%s\n\n' 'source venv/bin/activate' ->&2 echo 'Save these changes by running:' ->&2 printf '\n\t%s\n\n' 'git add -A && git commit -m "Run bootstrap.sh"' -$tput setaf 2 -echo 'For more information on how to get started, see README.md' -$tput sgr0 - -verb rm -v "$0" - -# Note to self: consider rewriting this in Python? diff --git a/innerspfit/__init__.py b/innerspfit/__init__.py new file mode 100644 index 0000000..4600653 --- /dev/null +++ b/innerspfit/__init__.py @@ -0,0 +1,12 @@ +__version__ = '1.0.0' + +DISPLAY_TITLE = r""" + _ _ _ _ _ __ __ _ _ + | | (_) | | | | | | / _| / _(_) | + _ __ | |______ _ _ __ _ __ ___ _ __ ______ ___ _ _| |__ _ __ | | __ _| |_ ___ ______ ___ _ _ _ __| |_ __ _ ___ ___ ______| |_ _| |_ +| '_ \| |______| | '_ \| '_ \ / _ \ '__|______/ __| | | | '_ \| '_ \| |/ _` | __/ _ \______/ __| | | | '__| _/ _` |/ __/ _ \______| _| | __| +| |_) | | | | | | | | | | __/ | \__ \ |_| | |_) | |_) | | (_| | || __/ \__ \ |_| | | | || (_| | (_| __/ | | | | |_ +| .__/|_| |_|_| |_|_| |_|\___|_| |___/\__,_|_.__/| .__/|_|\__,_|\__\___| |___/\__,_|_| |_| \__,_|\___\___| |_| |_|\__| +| | | | +|_| |_| +""" diff --git a/innerspfit/__main__.py b/innerspfit/__main__.py new file mode 100644 index 0000000..3f5d52d --- /dev/null +++ b/innerspfit/__main__.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +import itertools +import os +import subprocess as sp +import sys +from argparse import ArgumentParser, Namespace, ArgumentDefaultsHelpFormatter +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Optional + +from chris_plugin import chris_plugin, PathMapper +from loguru import logger + +from innerspfit import __version__, DISPLAY_TITLE +from innerspfit.model import Model + + +parser = ArgumentParser(description='surface_fit wrapper', + formatter_class=ArgumentDefaultsHelpFormatter) +parser.add_argument('--no-fail', dest='no_fail', action='store_true', + help='Produce exit code 0 even if any subprocesses do not.') +parser.add_argument('-V', '--version', action='version', + version=f'%(prog)s {__version__}') +parser.add_argument('-t', '--threads', type=int, default=0, + help='Number of threads to use for parallel jobs. ' + 'Pass 0 to use number of visible CPUs.') + + +@chris_plugin( + parser=parser, + title='Inner subplate surface mesh deformation', + category='Surfaces', + min_memory_limit='1Gi', + min_cpu_limit='1000m', +) +def main(options: Namespace, inputdir: Path, outputdir: Path): + print(DISPLAY_TITLE, file=sys.stderr, flush=True) + + if options.threads > 0: + nproc = options.threads + else: + nproc = len(os.sched_getaffinity(0)) + logger.info('Using {} threads.', nproc) + + model = Model() + mapper = PathMapper.file_mapper(inputdir, outputdir, glob='**/*.mnc', suffix='.obj') + with ThreadPoolExecutor(max_workers=nproc) as pool: + results = pool.map(lambda t, p: run_surface_fit(*t, p), mapper, itertools.repeat(model)) + + if not options.no_fail and not all(results): + sys.exit(1) + + +def run_surface_fit(grid: Path, output_surf: Path, model: Model) -> bool: + """ + :return: True if successful + """ + starting_surface = locate_surface_for(grid) + if starting_surface is None: + logger.error('No starting surface found for {}', grid) + return False + + # TODO measure GI here + gi = ... + + params = (p.to_cliargs() for p in model.get_params_for(gi)) + cli_args = [arg for row in params for arg in row] + + extra_args = [ + '-disterr', output_surf.with_suffix('.disterr.txt'), + '-disterr-abs', output_surf.with_suffix('.disterr.abs.txt') + ] + + cmd = ['surface_fit_script.pl', *cli_args, *extra_args, grid, starting_surface, output_surf] + log_file = output_surf.with_name(output_surf.name + '.log') + logger.info('Starting: {}', ' '.join(map(str, cmd))) + # with log_file.open('wb') as log_handle: + # job = sp.run(cmd, stdout=log_handle, stderr=log_handle) + # rc_file = log_file.with_suffix('.rc') + # rc_file.write_text(str(job.returncode)) + # + # if job.returncode == 0: + # logger.info('Finished: {} -> {}', starting_surface, output_surf) + # return True + # + # logger.error('FAILED -- check log file for details: {}', log_file) + # return False + + +def locate_surface_for(mask: Path) -> Optional[Path]: + glob = mask.parent.glob('*.obj') + first = next(glob, None) + second = next(glob, None) + if second is not None: + return None + return first + + +if __name__ == '__main__': + main() diff --git a/innerspfit/model.csv b/innerspfit/model.csv new file mode 100644 index 0000000..0e92027 --- /dev/null +++ b/innerspfit/model.csv @@ -0,0 +1,14 @@ +approximate_age,surface_area,gyrification_index,stage,iso_value,size,iter_outer,iter_inner,step_size,laplacian_weight,stretch_weight,self_weight,self_dist,oversample,taubin,comments +25,9500,1,0,10,20480,600,100,0.2,0.000002,300,0.5,0.005,0,10,Brains 23-25 clumped together +26,10000,1.02,0,10,20480,300,100,0.2,0.0002,200,0.5,0.005,0,10, +26,10000,1.02,1,10,20480,300,100,0.2,0.000002,200,0.5,0.005,0,10, +27,10000,1.05,0,10,20480,300,100,0.2,0.00001,200,0.5,0.005,0,10, +27,10000,1.05,1,10,20480,300,100,0.2,0.000002,200,0.5,0.005,0,10, +28,10000,1.07,0,10,20480,300,100,0.2,0.0002,200,0.5,0.005,0,10, +28,10000,1.07,1,10,20480,300,100,0.2,0.000002,200,0.5,0.005,0,10, +29,10000,1.12,0,10,20480,300,100,0.2,0.00001,100,0.5,0.005,0,10, +29,10000,1.12,1,10,20480,300,100,0.2,0.00005,100,0.5,0.005,0,10, +30,10000,1.17,0,10,20480,300,100,0.2,0.000002,100,0.5,0.005,0,10, +30,10000,1.17,1,10,20480,300,100,0.2,0.000002,50,0.5,0.005,0,10, +31,10000,1.2,0,10,20480,300,100,0.2,0.000002,100,0.5,0.005,0,10, +31,10000,1.2,1,10,20480,300,100,0.2,0.000002,50,0.5,0.005,0,10, diff --git a/innerspfit/model.py b/innerspfit/model.py new file mode 100644 index 0000000..996cb4b --- /dev/null +++ b/innerspfit/model.py @@ -0,0 +1,106 @@ +import dataclasses +import importlib.resources +import pandas as pd +from pathlib import Path +import os +from collections import defaultdict +from typing import NewType, Self, Iterator + +_MODEL_PATH = Path(str(importlib.resources.files(__package__).joinpath('model.csv'))) +_MODEL_PARAM_TYPES: defaultdict[str, type] = defaultdict(lambda: str) +_MODEL_PARAM_TYPES['approximate_age'] = int +_MODEL_PARAM_TYPES['stage'] = int +_MODEL_PARAM_TYPES['gyrification_index'] = float + +KnownGi = NewType('KnownGi', float) +"""A specific value for gyrification index which is found in the column in the CSV.""" + + +@dataclasses.dataclass(frozen=True) +class SurfaceFitParams: + size: str + stretch_weight: str + laplacian_weight: str + iter_outer: str + iter_inner: str + iso_value: str + step_size: str + oversample: str + self_dist: str + self_weight: str + taubin: str + + @classmethod + def from_namedtuple(cls, t) -> Self: + """Deserialize row""" + return cls( + size=t.size, + stretch_weight=t.stretch_weight, + laplacian_weight=t.laplacian_weight, + iter_outer=t.iter_outer, + iter_inner=t.iter_inner, + iso_value=t.iso_value, + step_size=t.step_size, + oversample=t.oversample, + self_dist=t.self_dist, + self_weight=t.self_weight, + taubin=t.taubin + ) + + @classmethod + def field_names(cls): + return frozenset(f.name for f in dataclasses.fields(cls)) + + def to_cliargs(self) -> list[str]: + return [ + '-size', + self.size, + '-sw', + self.stretch_weight, + '-lw', + self.laplacian_weight, + '-iter-outer', + self.iter_outer, + '-iter-inner', + self.iter_inner, + '-iso-value', + self.iso_value, + '-step-size', + self.step_size, + '-oversample', + self.oversample, + '-self-dist', + self.self_dist, + '-self-weight', + self.self_weight, + '-taubin', + self.taubin + ] + + +class Model: + def __init__(self, path: str | os.PathLike = _MODEL_PATH): + self._df = pd.read_csv(path, dtype=_MODEL_PARAM_TYPES) + missing_fields = SurfaceFitParams.field_names() - set(self._df.columns) + if missing_fields: + raise ValueError(f'{path} is missing the following columns: {missing_fields}') + + def get_params_for(self, gi: float) -> Iterator[SurfaceFitParams]: + matched_gi = self._match_gi(gi) + sliced_df = self._select_schedule_for(matched_gi) + return map(SurfaceFitParams.from_namedtuple, sliced_df) + + def _match_gi(self, gi: float) -> KnownGi: + """ + Round the given value up to a known value from ``self._df['gyrification_index']``. + """ + max_gi = self._df['gyrification_index'].max() + if gi > max_gi: + return max_gi + known_gis = iter(self._df['gyrification_index']) + while gi > (matched := next(known_gis)): + pass + return KnownGi(matched) + + def _select_schedule_for(self, gi: KnownGi) -> pd.DataFrame: + return self._df[self._df['gyrification_index'] == gi] diff --git a/innerspfit/script.py b/innerspfit/script.py new file mode 100644 index 0000000..e69de29 diff --git a/innerspfit/trying.ipynb b/innerspfit/trying.ipynb new file mode 100644 index 0000000..4d6b6da --- /dev/null +++ b/innerspfit/trying.ipynb @@ -0,0 +1,207 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 8, + "id": "initial_id", + "metadata": { + "collapsed": true, + "ExecuteTime": { + "end_time": "2023-10-06T17:37:35.767269976Z", + "start_time": "2023-10-06T17:37:35.726781073Z" + } + }, + "outputs": [], + "source": [ + "from collections import defaultdict\n", + "import importlib\n", + "from pathlib import Path\n", + "import pandas as pd\n", + "\n", + "# _MODEL_PATH = Path(str(importlib.resources.files(__package__).joinpath('model.csv')))\n", + "_MODEL_PARAM_TYPES: defaultdict[str, type] = defaultdict(lambda: str)\n", + "_MODEL_PARAM_TYPES['approximate_age'] = int\n", + "_MODEL_PARAM_TYPES['stage'] = int\n", + "_MODEL_PARAM_TYPES['gyrification_index'] = float\n", + "\n", + "df = pd.read_csv('model.csv', dtype=_MODEL_PARAM_TYPES)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "outputs": [ + { + "data": { + "text/plain": " approximate_age surface_area gyrification_index stage iso_value size \\\n0 25 9500 1.00 0 10 20480 \n1 26 10000 1.02 0 10 20480 \n2 26 10000 1.02 1 10 20480 \n3 27 10000 1.05 0 10 20480 \n4 27 10000 1.05 1 10 20480 \n5 28 10000 1.07 0 10 20480 \n6 28 10000 1.07 1 10 20480 \n7 29 10000 1.12 0 10 20480 \n8 29 10000 1.12 1 10 20480 \n9 30 10000 1.17 0 10 20480 \n10 30 10000 1.17 1 10 20480 \n11 31 10000 1.20 0 10 20480 \n12 31 10000 1.20 1 10 20480 \n\n iter_outer iter_inner step_size laplacian_weight stretch_weight \\\n0 600 100 0.2 0.000002 300 \n1 300 100 0.2 0.0002 200 \n2 300 100 0.2 0.000002 200 \n3 300 100 0.2 0.00001 200 \n4 300 100 0.2 0.000002 200 \n5 300 100 0.2 0.0002 200 \n6 300 100 0.2 0.000002 200 \n7 300 100 0.2 0.00001 100 \n8 300 100 0.2 0.00005 100 \n9 300 100 0.2 0.000002 100 \n10 300 100 0.2 0.000002 50 \n11 300 100 0.2 0.000002 100 \n12 300 100 0.2 0.000002 50 \n\n self_weight self_dist oversample taubin comments \n0 0.5 0.005 0 10 Brains 23-25 clumped together \n1 0.5 0.005 0 10 NaN \n2 0.5 0.005 0 10 NaN \n3 0.5 0.005 0 10 NaN \n4 0.5 0.005 0 10 NaN \n5 0.5 0.005 0 10 NaN \n6 0.5 0.005 0 10 NaN \n7 0.5 0.005 0 10 NaN \n8 0.5 0.005 0 10 NaN \n9 0.5 0.005 0 10 NaN \n10 0.5 0.005 0 10 NaN \n11 0.5 0.005 0 10 NaN \n12 0.5 0.005 0 10 NaN ", + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
approximate_agesurface_areagyrification_indexstageiso_valuesizeiter_outeriter_innerstep_sizelaplacian_weightstretch_weightself_weightself_distoversampletaubincomments
02595001.00010204806001000.20.0000023000.50.005010Brains 23-25 clumped together
126100001.02010204803001000.20.00022000.50.005010NaN
226100001.02110204803001000.20.0000022000.50.005010NaN
327100001.05010204803001000.20.000012000.50.005010NaN
427100001.05110204803001000.20.0000022000.50.005010NaN
528100001.07010204803001000.20.00022000.50.005010NaN
628100001.07110204803001000.20.0000022000.50.005010NaN
729100001.12010204803001000.20.000011000.50.005010NaN
829100001.12110204803001000.20.000051000.50.005010NaN
930100001.17010204803001000.20.0000021000.50.005010NaN
1030100001.17110204803001000.20.000002500.50.005010NaN
1131100001.20010204803001000.20.0000021000.50.005010NaN
1231100001.20110204803001000.20.000002500.50.005010NaN
\n
" + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-06T17:37:36.089816499Z", + "start_time": "2023-10-06T17:37:36.082719861Z" + } + }, + "id": "cff3fca69cef3ab7" + }, + { + "cell_type": "code", + "execution_count": 10, + "outputs": [ + { + "data": { + "text/plain": "Index(['approximate_age', 'surface_area', 'gyrification_index', 'stage',\n 'iso_value', 'size', 'iter_outer', 'iter_inner', 'step_size',\n 'laplacian_weight', 'stretch_weight', 'self_weight', 'self_dist',\n 'oversample', 'taubin', 'comments'],\n dtype='object')" + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.columns" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-06T17:37:44.468236878Z", + "start_time": "2023-10-06T17:37:44.460063777Z" + } + }, + "id": "373d9859dc146d9b" + }, + { + "cell_type": "code", + "execution_count": 12, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Pandas(Index=0, approximate_age=25, surface_area='9500', gyrification_index=1.0, stage=0, iso_value='10', size='20480', iter_outer='600', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='300', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments='Brains 23-25 clumped together')\n", + "Pandas(Index=1, approximate_age=26, surface_area='10000', gyrification_index=1.02, stage=0, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.0002', stretch_weight='200', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=2, approximate_age=26, surface_area='10000', gyrification_index=1.02, stage=1, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='200', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=3, approximate_age=27, surface_area='10000', gyrification_index=1.05, stage=0, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.00001', stretch_weight='200', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=4, approximate_age=27, surface_area='10000', gyrification_index=1.05, stage=1, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='200', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=5, approximate_age=28, surface_area='10000', gyrification_index=1.07, stage=0, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.0002', stretch_weight='200', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=6, approximate_age=28, surface_area='10000', gyrification_index=1.07, stage=1, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='200', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=7, approximate_age=29, surface_area='10000', gyrification_index=1.12, stage=0, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.00001', stretch_weight='100', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=8, approximate_age=29, surface_area='10000', gyrification_index=1.12, stage=1, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.00005', stretch_weight='100', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=9, approximate_age=30, surface_area='10000', gyrification_index=1.17, stage=0, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='100', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=10, approximate_age=30, surface_area='10000', gyrification_index=1.17, stage=1, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='50', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=11, approximate_age=31, surface_area='10000', gyrification_index=1.2, stage=0, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='100', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n", + "Pandas(Index=12, approximate_age=31, surface_area='10000', gyrification_index=1.2, stage=1, iso_value='10', size='20480', iter_outer='300', iter_inner='100', step_size='0.2', laplacian_weight='0.000002', stretch_weight='50', self_weight='0.5', self_dist='0.005', oversample='0', taubin='10', comments=nan)\n" + ] + } + ], + "source": [ + "for a in df.itertuples():\n", + " print(a.)" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-06T17:38:47.792823300Z", + "start_time": "2023-10-06T17:38:47.784053922Z" + } + }, + "id": "37d184a473ff26f2" + }, + { + "cell_type": "code", + "execution_count": 13, + "outputs": [], + "source": [ + "from innerspfit.model import SurfaceFitParams\n" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-06T17:39:59.747165077Z", + "start_time": "2023-10-06T17:39:59.705368979Z" + } + }, + "id": "b00fc336d95e6b6f" + }, + { + "cell_type": "code", + "execution_count": 14, + "outputs": [], + "source": [ + "import dataclasses" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-06T17:40:14.208898120Z", + "start_time": "2023-10-06T17:40:14.193589536Z" + } + }, + "id": "a5b5d515bd0d48cc" + }, + { + "cell_type": "code", + "execution_count": 16, + "outputs": [ + { + "data": { + "text/plain": "['size',\n 'stretch_weight',\n 'laplacian_weight',\n 'iter_outer',\n 'iter_inner',\n 'iso_value',\n 'step_size',\n 'oversample',\n 'self_dist',\n 'self_weight',\n 'taubin']" + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[f.name for f in dataclasses.fields(SurfaceFitParams)]" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-06T17:40:33.807255877Z", + "start_time": "2023-10-06T17:40:33.766391429Z" + } + }, + "id": "ae3722ca7db80dd3" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [], + "metadata": { + "collapsed": false + }, + "id": "5c29a94ec36e11aa" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/requirements.txt b/requirements.txt index b224a74..71b6695 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -chris_plugin==0.2.0 - +chris_plugin==0.2.1 +pandas~=2.1.1 +loguru~=0.7.2 diff --git a/setup.py b/setup.py index 240b0dd..3a1cc02 100644 --- a/setup.py +++ b/setup.py @@ -19,18 +19,18 @@ def get_version(rel_path: str) -> str: setup( - name='chris-plugin-template', - version=get_version('app.py'), - description='A ChRIS DS plugin template', + name='innerspfit', + version=get_version('innerspfit/__init__.py'), + description='Outer to inner surface mesh deformation using a radial distance map for human fetal MRI', author='FNNDSC', - author_email='dev@babyMRI.org', - url='https://github.com/FNNDSC/python-chrisapp-template', - py_modules=['app'], - install_requires=['chris_plugin'], + author_email='Jennings.Zhang@childrens.harvard.edu', + url='https://github.com/FNNDSC/pl-inner-subplate-surface-fit', + packages=['innerspfit'], + install_requires=['chris_plugin', 'pandas', 'loguru'], license='MIT', entry_points={ 'console_scripts': [ - 'commandname = app:main' + 'innerspfit = innerspfit.__main__:main' ] }, classifiers=[ @@ -44,5 +44,8 @@ def get_version(rel_path: str) -> str: 'dev': [ 'pytest~=7.1' ] + }, + package_data={ + 'innerspfit': ['model.csv'] } ) diff --git a/tests/test_example.py b/tests/test_example.py deleted file mode 100644 index 83d3846..0000000 --- a/tests/test_example.py +++ /dev/null @@ -1,21 +0,0 @@ -from pathlib import Path - -from app import parser, main - - -def test_main(tmp_path: Path): - # setup example data - inputdir = tmp_path / 'incoming' - outputdir = tmp_path / 'outgoing' - inputdir.mkdir() - outputdir.mkdir() - (inputdir / 'plaintext.txt').write_text('hello ChRIS, I am a ChRIS plugin') - - # simulate run of main function - options = parser.parse_args(['--word', 'ChRIS', '--pattern', '*.txt']) - main(options, inputdir, outputdir) - - # assert behavior is expected - expected_output_file = outputdir / 'plaintext.count.txt' - assert expected_output_file.exists() - assert expected_output_file.read_text() == '2' diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..4f696c4 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,32 @@ +import pytest + +from innerspfit.model import Model + + +@pytest.fixture(scope='session') +def model() -> Model: + return Model() + + +@pytest.mark.parametrize( + "gi, matched", + [ + (0.8, 1.00), + (0.9, 1.00), + (1.0, 1.00), + (1.01, 1.02), + (1.015, 1.02), + (1.02, 1.02), + (1.03, 1.05), + (1.04, 1.05), + (1.05, 1.05), + (1.06, 1.07), + (1.07, 1.07), + (1.19, 1.20), + (1.20, 1.20), + (1.21, 1.20), + (1.22, 1.20), + ] +) +def test_get_params_for(gi: float, matched: float, model: Model): + assert model._match_gi(gi) == matched