Skip to content

Commit

Permalink
Python rewrite
Browse files Browse the repository at this point in the history
  • Loading branch information
Jennings Zhang authored and Jennings Zhang committed Apr 12, 2023
1 parent 1bd8d68 commit a7eeed5
Show file tree
Hide file tree
Showing 30 changed files with 1,066 additions and 804 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ To print its available options, run:
apptainer exec docker://fnndsc/pl-surfigures surfigures --help
```

### Colors

Valid values for the options `--font-color` and `--background-color` are described here: https://imagemagick.org/script/color.php

Valid values for `--color-map` are described in `colour_object -help`.

> gray, hot, hot_inv, cold_metal, cold_metal_inv,
> green_metal, green_metal_inv, lime_metal, lime_metal_inv,
> red_metal, red_metal_inv, purple_metal, purple_metal_inv,
> spectral, red, green, blue, label, rgba
## Examples

`surfigures` requires two positional arguments: a directory containing
Expand All @@ -60,3 +71,22 @@ input data, and a directory where to create output data.
```shell
apptainer exec docker://fnndsc/pl-surfigures:latest surfigures incoming/ outgoing/
```

For a dark theme:

```shell
apptainer exec docker://fnndsc/pl-surfigures:latest surfigures \
--font-color green1 --background-color black \
incoming/ outgoing/
```

Let's say your vertex-wise data files use the file extensions `.area.s5`
and `.depth.s5`, where the range for values of `.area.s5` data are between
0 and 1, and the range for values of `.depth.s5` is `-0.5` to `0.5`.
Use the `--range` option to specify this. The format is`file_extension:min:max`, multiple values are comma-delimited.

```shell
apptainer exec docker://fnndsc/pl-surfigures:latest surfigures \
--range .area.s5:0.0:1.0,.depth.s5:0.0:5.0 \
incoming/ outgoing/
```
146 changes: 88 additions & 58 deletions examples/outgoing/same_folder.log

Large diffs are not rendered by default.

Binary file modified examples/outgoing/same_folder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
146 changes: 88 additions & 58 deletions examples/outgoing/separate_folders/mni_icbm.log

Large diffs are not rendered by default.

Binary file modified examples/outgoing/separate_folders/mni_icbm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 2 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from setuptools import setup
from setuptools import setup, find_packages
import re

_version_re = re.compile(r"(?<=^__version__ = (\"|'))(.+)(?=\"|')")
Expand All @@ -21,14 +21,13 @@ def get_version(rel_path: str) -> str:
setup(
name='surfigures',
version=get_version('surfigures/__init__.py'),
packages=['surfigures'],
packages=find_packages('.' ,exclude='tests'),
description='Create PNG figures of surfaces and vertex-wise data',
author='Jennings Zhang',
author_email='Jennings.Zhang@childrens.harvard.edu',
url='https://github.com/FNNDSC/pl-surfigures',
install_requires=['chris_plugin==0.2.0a1', 'loguru~=0.6.0'],
license='MIT',
scripts=['verify_surface_all.pl'],
entry_points={
'console_scripts': [
'surfigures = surfigures.__main__:main'
Expand Down
2 changes: 1 addition & 1 deletion surfigures/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

__version__ = '1.0.0'
__version__ = '1.1.0'

DISPLAY_TITLE = r"""
_ __ _
Expand Down
6 changes: 3 additions & 3 deletions surfigures/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from surfigures import DISPLAY_TITLE, __version__
from surfigures.args import parser
from surfigures.options import Options
from surfigures.inputs import InputFinder
from surfigures.inputs.find import SubjectMapper
from concurrent.futures import ThreadPoolExecutor

from surfigures.run import run_surfigures
Expand All @@ -30,8 +30,8 @@ def main(given_args, inputdir: Path, outputdir: Path):

options = Options.from_args(given_args)

finder = InputFinder(input_dir=inputdir, output_dir=outputdir)
usable_mapper, skipped_inputs = zip(*finder.map(given_args.suffix, given_args.output))
mapper = SubjectMapper(input_dir=inputdir, output_dir=outputdir)
usable_mapper, skipped_inputs = zip(*mapper.map(given_args.suffix, given_args.output))

skipped_inputs = list(filter(is_some, skipped_inputs))
if skipped_inputs:
Expand Down
12 changes: 6 additions & 6 deletions surfigures/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
parser.add_argument('-o', '--output', default='{}.png', type=str,
help='output file template and file type. "{}" is replaced by the subject name.')

# TODO: feature bloat.
# This should be its own ChRIS plugin, https://github.com/FNNDSC/pl-abs
# but I'm doing it here in Python because I am out of time!
parser.add_argument('-a', '--abs', default='.disterr.txt', type=str,
help='file extension of input files which should have their absolute values be taken.')

parser.add_argument('-r', '--range', default='.disterr.txt:-2.0:2.0,.smtherr.txt:0.0:2.0',
type=str, help='Ranges for specific file extensions.')
parser.add_argument('--min', type=str, default='0.0', help='Default range minimum value')
parser.add_argument('--max', type=str, default='10.0', help='Default range maximum value')
parser.add_argument('-b', '--background-color', type=str, default='white',
help='Figure background color')
parser.add_argument('-f', '--font-color', type=str, default='green',
help='Figure labels font color')
parser.add_argument('-c', '--color-map', type=str, default='spectral',
help='color map to use for data value visualization')
5 changes: 5 additions & 0 deletions surfigures/draw/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Use MNI ``ray_trace`` and ImageMagick to create figures of surfaces.
Inspired by the CIVET QC program ``verify_clasp``.
"""
9 changes: 9 additions & 0 deletions surfigures/draw/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
TILE_SIZE = 400
TILE_SIZE = 400
FONT_SIZE = 28
ROW_GAP = 50
COL_CAP = 1

HEMI_LABEL_RATIO_L = 0.13
HEMI_LABEL_RATIO_R = 1 - HEMI_LABEL_RATIO_L
HEMI_LABEL_RATIO_Y = 0.15
123 changes: 123 additions & 0 deletions surfigures/draw/fig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Here lies the code which puts everything together.
Note to developer: options such as colors and sizes should not be
hard-coded anywhere. Here is the only place where the values for
those options should be passed to the functions which accept them.
"""

from pathlib import Path
from typing import Sequence
from dataclasses import dataclass

from surfigures.draw import constants
from surfigures.draw.prep import SectionBuilder, BaseHemiPreparer, ColoredHemiPreparer
from surfigures.draw.section import RowPair
from surfigures.draw.tile import LazyTile
from surfigures.inputs.subject import SubjectSet
from surfigures.options import Options
from surfigures.util.runnable import Runnable, Runner


@dataclass(frozen=True)
class FigureCreator(Runnable[Path]):

inputs: SubjectSet
output_path: Path
options: Options

def run(self, sp: Runner) -> Path:
mid_surface_left = self.inputs.mid_surface_left(sp)
mid_surface_right = self.inputs.mid_surface_right(sp)

figure_data: Sequence[SectionBuilder] = (
*(
SectionBuilder(
BaseHemiPreparer(layer.left),
BaseHemiPreparer(layer.right),
)
for layer in self.inputs.surfaces
),
*(
SectionBuilder(
ColoredHemiPreparer(
mid_surface_left,
files.left,
*self.options.range_for(files.left),
self.options.color_map
),
ColoredHemiPreparer(
mid_surface_right,
files.right,
*self.options.range_for(files.right),
self.options.color_map
)
)
for files in self.inputs.data_files
)
)

section_captions = [
*(s.caption for s in self.inputs.surfaces),
*(s.caption for s in self.inputs.data_files)
]

figure_template = (f.run(sp) for f in figure_data)
row_pairs = [f.to_row_pair() for f in figure_template]
tile_grid = [row for rows in map(_rowpair2rows, row_pairs) for row in rows]
"""2D matrix of LazyTile"""
lazy_tiles = [tile for row in tile_grid for tile in row]
"""1D flattened structure of data and order as tile_grid"""
n_row = len(tile_grid)
n_col = len(tile_grid[0])

tile_files = [sp.tmp_dir / f'{i}_{section_captions[i // (n_col * 2)]}.rgb' for i in range(len(lazy_tiles))]

for tile, name in zip(lazy_tiles, tile_files):
cmd = tile.ray_trace.to_cmd(self.options.bg, constants.TILE_SIZE, constants.TILE_SIZE, name)
sp.run(cmd)

montage_file = sp.tmp_dir / 'montage_output.png'
montage_cmd = (
'montage',
'-tile', f'{n_col}x{n_row}',
'-background', self.options.bg,
'-geometry', f'{constants.TILE_SIZE}x{constants.TILE_SIZE}+{constants.COL_CAP}+{constants.ROW_GAP}',
*tile_files,
montage_file
)
sp.run(montage_cmd)

annotation_flags = []
for row, row_tiles in enumerate(tile_grid):
for col, tile in enumerate(row_tiles):
annotation_flags.extend(tile.labels2args(
row, col,
constants.TILE_SIZE, constants.TILE_SIZE,
constants.COL_CAP, constants.ROW_GAP
))

caption_x = round(constants.COL_CAP * 5 + constants.TILE_SIZE * 2 + 100)
for i, caption in enumerate(section_captions):
row = i * 2
caption_y = round(constants.ROW_GAP * (0.5 + 2 * row) + constants.TILE_SIZE * row)
annot = ['-annotate', f'0x0+{caption_x}+{caption_y}', caption]
annotation_flags.extend(annot)

convert_cmd = (
'convert',
'-box', self.options.bg,
'-fill', self.options.font_color,
'-pointsize', str(constants.FONT_SIZE),
*annotation_flags,
montage_file,
self.output_path
)
sp.run(convert_cmd)

return self.output_path


def _rowpair2rows(row_pair: RowPair) -> tuple[Sequence[LazyTile], Sequence[LazyTile]]:
half = len(row_pair) // 2
return row_pair[:half], row_pair[half:]
96 changes: 96 additions & 0 deletions surfigures/draw/prep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
Helper functions for preparing surfaces for figure generation.
"""

import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Sequence

from surfigures.draw.section import Section
from surfigures.util.runnable import Runnable, Runner


@dataclass(frozen=True)
class BaseHemiPreparer(Runnable[tuple[Path, str]]):
"""
No-op surface file wrapper. Subclasses of ``DrawableHemiBuilder`` apply preprocessing
to the surface to prepare the file for use with ``ray_trace``.
"""
surface: Path

def preprocess_surface_cmd(self, output: Path) -> Optional[Sequence[str | os.PathLike]]:
return None

def generate_textblock_cmd(self) -> Optional[Sequence[str | os.PathLike]]:
return None

def get_uniqueish_name(self) -> str:
return self.surface.name

def run(self, sp: Runner) -> tuple[Path, str]:
tmp_colored = sp.tmp_dir / (self.get_uniqueish_name() + self.surface.suffix)
color_cmd = self.preprocess_surface_cmd(tmp_colored)
if color_cmd:
sp.run(color_cmd)
colored_surface = tmp_colored
else:
colored_surface = self.surface

stats_cmd = self.generate_textblock_cmd()
if stats_cmd:
textblock = sp.run(stats_cmd, stdout=sp.PIPE).stdout
else:
textblock = ''

return colored_surface, textblock


@dataclass(frozen=True)
class ColoredHemiPreparer(BaseHemiPreparer):
"""
Wraps a surface file with a corresponding vertex-wise data file.
It runs the commands ``colour_object`` and ``vertstats_stats``.
"""
data_file: Path
data_min: str
data_max: str
color_map: Optional[str]
"""
See ``colour_object -help`
``color_map`` should be a ``typing.Literal``, but I am tired...
color_map is one of:
gray, hot, hot_inv, cold_metal, cold_metal_inv,
green_metal, green_metal_inv, lime_metal, lime_metal_inv,
red_metal, red_metal_inv, purple_metal, purple_metal_inv,
spectral, red, green, blue, label, rgba
"""

def preprocess_surface_cmd(self, output: Path) -> Optional[Sequence[str | os.PathLike]]:
return 'colour_object', self.surface, self.data_file, output, self.color_map, self.data_min, self.data_max

def generate_textblock_cmd(self) -> Optional[Sequence[str | os.PathLike]]:
return 'vertstats_stats', self.data_file

def get_uniqueish_name(self) -> str:
parts = [self.surface.name, self.data_file.name, self.color_map, self.data_min, self.data_max]
return '_'.join(map(str, parts))


@dataclass(frozen=True)
class SectionBuilder(Runnable[Section]):
"""
Builder for ``DrawableBrain``.
"""

left: BaseHemiPreparer
right: BaseHemiPreparer

def run(self, sp: Runner) -> Section:
surface_left, textblock_left = self.left.run(sp)
surface_right, textblock_right = self.right.run(sp)
return Section(surface_left, surface_right, textblock_left, textblock_right)
Loading

0 comments on commit a7eeed5

Please sign in to comment.