From e12f50969d2d9b8aed3958133992b0f9fcaf981f Mon Sep 17 00:00:00 2001 From: Aditya Vaidya Date: Thu, 13 Aug 2026 18:50:09 -0700 Subject: [PATCH 1/5] quickflat: non-SVG plotting/rendering typing (PR 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types add_curvature, add_data, add_hatch, add_colorbar, add_colorbar_2d, add_connected_vertices, and all of view.py/utils.py's non-SVG helpers. add_rois, add_sulci, add_custom, add_cutout, and _convert_svg_kwargs are deliberately left untyped here — they call into cortex.svgoverlay directly and land with that module in PR 8 instead (see PR_SPLIT_PLAN.md). Built by diffing this worktree's pre-typing composite.py/utils.py against types-easy's final versions and keeping only the non-SVG-function hunks, rather than replaying the ~14 original historical commits individually — several of those commits interleave SVG and non-SVG hunks in ways that don't split cleanly commit-by-commit. test_quickflat.py's four new ROI-specific test functions (test_roi_styling_parameters, test_roi_list_and_sulci_list, test_combined_parameters, and the ROI/sulci assertions inside test_display_flags) are included here rather than held back for PR 8, since they don't block anything at runtime and splitting test_display_flags mid-function is not worthwhile. Verified: zero diff against types-easy for composite.py (excluding the 4 SVG functions + the Sequence import, which stay untyped pending PR 8), and zero diff for utils.py/view.py/test_quickflat.py. --- cortex/quickflat/composite.py | 64 ++++---- cortex/quickflat/utils.py | 60 ++++---- cortex/quickflat/view.py | 8 +- cortex/tests/test_quickflat.py | 271 +++++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 57 deletions(-) diff --git a/cortex/quickflat/composite.py b/cortex/quickflat/composite.py index e0e0a14d8..297a44f54 100644 --- a/cortex/quickflat/composite.py +++ b/cortex/quickflat/composite.py @@ -1,18 +1,26 @@ import copy +from typing import Optional, Union + +from matplotlib.axes import Axes +from matplotlib.collections import LineCollection +from matplotlib.figure import Figure +from matplotlib.image import AxesImage import numpy as np +import numpy.typing as npt + +from .utils import _get_height, _get_extents, _convert_svg_kwargs, _get_images, _parse_defaults +from .utils import make_flatmap_image, _make_hatch_image, _get_fig_and_ax, get_flatmask, get_flatcache from .. import dataset from ..database import db from ..options import config -from .utils import _get_height, _get_extents, _convert_svg_kwargs, _get_images, _parse_defaults -from .utils import make_flatmap_image, _make_hatch_image, _get_fig_and_ax, get_flatmask, get_flatcache """ --- Individual compositing functions --- """ -def add_curvature(fig, dataview, extents=None, height=None, threshold=True, contrast=None, - brightness=None, smooth=None, cmap='gray', recache=False, curvature_lims=0.5, - legacy_mode=False): +def add_curvature(fig: Axes, dataview: dataset.Dataview, extents: Optional[tuple[float, float, float, float]]=None, height: Optional[int]=None, threshold: Optional[bool]=True, contrast: Optional[float]=None, + brightness: Optional[float]=None, smooth: Optional[float]=None, cmap: str='gray', recache: bool=False, curvature_lims: float=0.5, + legacy_mode: bool=False) -> AxesImage: """Add curvature layer to figure Parameters @@ -21,11 +29,12 @@ def add_curvature(fig, dataview, extents=None, height=None, threshold=True, cont figure into which to plot image of curvature dataview : cortex.Dataview object dataview containing data to be plotted, subject (surface identifier), and transform. - extents : array-like + extents : array-like TODO: fix 4 values for [Left, Right, Top, Bottom] extents of image plotted. None defaults to extents of images already present in figure. height : scalar Height of image. None defaults to height of images already present in figure. + TODO: what units? threshold : boolean Whether to apply a threshold to the curvature values to create a binary curvature image (one shade for positive curvature, one shade for negative). `None` defaults to value @@ -66,7 +75,7 @@ def add_curvature(fig, dataview, extents=None, height=None, threshold=True, cont if default_smoothing.lower()=='none': default_smoothing = None else: - default_smoothing = np.float_(default_smoothing) + default_smoothing = np.float64(default_smoothing) if smooth is None: # (Might still be None!) smooth = default_smoothing @@ -120,15 +129,15 @@ def add_curvature(fig, dataview, extents=None, height=None, threshold=True, cont zorder=0) return cvimg -def add_data(fig, braindata, height=1024, thick=32, depth=0.5, pixelwise=True, - sampler='nearest', recache=False, nanmean=False): +def add_data(fig: Figure, braindata: Union[dataset.Volume, dataset.Vertex, dataset.Dataview], height: int=1024, thick: int=32, depth: float=0.5, pixelwise: bool=True, + sampler: str='nearest', recache: bool=False, nanmean: bool=False) -> tuple[AxesImage, npt.NDArray]: """Add data to quickflat plot Parameters ---------- fig : figure or ax Figure into which to plot image of curvature - braindata : one of: {cortex.Volume, cortex.Vertex, cortex.Dataview) + braindata : one of: {cortex.Volume, cortex.Vertex, cortex.Dataview} Object containing containing data to be plotted, subject (surface identifier), and transform. height : scalar @@ -267,8 +276,8 @@ def add_sulci(fig, dataview, extents=None, height=None, with_labels=True, sulci_ return img -def add_hatch(fig, hatch_data, extents=None, height=None, hatch_space=4, - hatch_color=(0, 0, 0), sampler='nearest', recache=False): +def add_hatch(fig: Axes, hatch_data: dataset.Dataview, extents: Optional[tuple[float, float, float, float]]=None, height: Optional[int]=None, hatch_space: int=4, + hatch_color: tuple[int, int, int]=(0, 0, 0), sampler: str='nearest', recache: bool=False) -> AxesImage: """Add hatching to figure at locations specified in hatch_data Parameters @@ -323,8 +332,8 @@ def add_hatch(fig, hatch_data, extents=None, height=None, hatch_space=4, return img -def add_colorbar(fig, cimg, colorbar_ticks=None, colorbar_location=(0.4, 0.07, 0.2, 0.04), - orientation='horizontal'): +def add_colorbar(fig: Figure, cimg: AxesImage, colorbar_ticks: Optional[npt.ArrayLike]=None, colorbar_location: tuple[float, float, float, float]=(0.4, 0.07, 0.2, 0.04), + orientation: str='horizontal') -> Axes: """Add a colorbar to a flatmap plot Parameters @@ -348,8 +357,8 @@ def add_colorbar(fig, cimg, colorbar_ticks=None, colorbar_location=(0.4, 0.07, 0 return cbar -def add_colorbar_2d(fig, cmap_name, colorbar_ticks, - colorbar_location=(0.425, 0.02, 0.15, 0.15), fontsize=12): +def add_colorbar_2d(fig: Figure, cmap_name: str, colorbar_ticks: tuple[float, float, float, float], + colorbar_location: tuple[float, float, float, float]=(0.425, 0.02, 0.15, 0.15), fontsize: int=12) -> AxesImage: """Add a 2D colorbar to a flatmap plot Parameters @@ -358,13 +367,14 @@ def add_colorbar_2d(fig, cmap_name, colorbar_ticks, cimg : matplotlib.image.AxesImage object Image for which to create colorbar. For reference, matplotlib.image.AxesImage is the output of imshow() - colorbar_ticks : array-like - values for colorbar ticks + colorbar_ticks : tuple[float, float, float, float] + Values for colorbar *extents*, in order [xmin, xmax, ymin, ymax]. The colorbar will be plotted with these values as the limits of the colorbar axes, and the ticks will be placed at the values specified in the first two and last two entries of this tuple. colorbar_location : array-like Four-long list, tuple, or array that specifies location for colorbar axes [left, top, width, height] (?) orientation : string 'vertical' or 'horizontal' + TODO: unused """ # a bit sketchy - lazy imports import matplotlib.pyplot as plt @@ -375,9 +385,9 @@ def add_colorbar_2d(fig, cmap_name, colorbar_ticks, fig.add_axes(colorbar_location) cbar = plt.imshow(cim, extent=colorbar_ticks, interpolation='bilinear') cbar.axes.set_xticks(colorbar_ticks[:2]) - cbar.axes.set_xticklabels(colorbar_ticks[:2], fontdict=dict(size=fontsize)) + cbar.axes.set_xticklabels([str(t) for t in colorbar_ticks[:2]], fontdict=dict(size=fontsize)) cbar.axes.set_yticks(colorbar_ticks[2:]) - cbar.axes.set_yticklabels(colorbar_ticks[2:], fontdict=dict(size=fontsize)) + cbar.axes.set_yticklabels([str(t) for t in colorbar_ticks[2:]], fontdict=dict(size=fontsize)) return cbar @@ -445,10 +455,10 @@ def add_custom(fig, dataview, svgfile, layer, extents=None, height=None, with_la zorder=6) return img -def add_connected_vertices(fig, dataview, exclude_border_width=None, - height=None, extents=None, recache=False, - color=(1.0, 0.5, 0.1, 0.6), linewidth=0.75, - alpha=1.0, **kwargs): +def add_connected_vertices(fig: Axes, dataview: dataset.Volume, exclude_border_width: Optional[int]=None, + height: Optional[int]=None, extents: Optional[tuple[float, float, float, float]]=None, recache: bool=False, + color: tuple[float, float, float, float]=(1.0, 0.5, 0.1, 0.6), linewidth: float=0.75, + alpha: float=1.0, **kwargs) -> LineCollection: """Plot lines btw distant vertices that are within the same voxel Parameters @@ -462,10 +472,10 @@ def add_connected_vertices(fig, dataview, exclude_border_width=None, exclude_border_width : scalar or None if not None, width from edge of flatmap for which crossover lines are not computed. - height : scalar + height : scalar or None Height of image. if None, defaults to height of images already present in figure. - extents : array-like + extents : array-like or None 4 values for [Left, Right, Bottom, Top] extents of image plotted. If None, defaults to extents of images already present in figure. color : rgba tuple @@ -532,7 +542,7 @@ def add_connected_vertices(fig, dataview, exclude_border_width=None, # (This is the most time consuming step, as it draws many lines) # print('plotting lines...') fig, ax = _get_fig_and_ax(fig) - lc = LineCollection(pix_array_scaled, + lc = LineCollection(list(pix_array_scaled), transform=fig.transFigure, figure=fig, colors=color, diff --git a/cortex/quickflat/utils.py b/cortex/quickflat/utils.py index 8bcecafa3..a865caa02 100644 --- a/cortex/quickflat/utils.py +++ b/cortex/quickflat/utils.py @@ -4,22 +4,25 @@ import string import warnings from functools import reduce +from typing import Literal, Optional, Union, cast import numpy as np +import numpy.typing as npt +from scipy import sparse # TODO: remove if loading is slow from .. import dataset, utils from ..database import db from ..options import config -def make_flatmap_image(braindata, height=1024, recache=False, nanmean=False, **kwargs): +def make_flatmap_image(braindata: Union[dataset.Volume, dataset.Vertex, dataset.Dataview], height: int=1024, recache: bool=False, nanmean: bool=False, **kwargs) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.floating]]: """Generate flatmap image from volumetric brain data This Parameters ---------- - braindata : one of: {cortex.Volume, cortex.Vertex, cortex.Dataview) + braindata : one of: {cortex.Volume, cortex.Vertex, cortex.Dataview} Object containing containing data to be plotted, subject (surface identifier), and transform. height : scalar @@ -34,9 +37,10 @@ def make_flatmap_image(braindata, height=1024, recache=False, nanmean=False, **k Returns ------- - image : - - extents : + image : numpy.ndarray[np.uint8] + The generated flatmap image. + extents : numpy.ndarray[np.floating] + The extents of the generated flatmap image. """ mask, extents = get_flatmask(braindata.subject, height=height, recache=recache) @@ -100,7 +104,7 @@ def make_flatmap_image(braindata, height=1024, recache=False, nanmean=False, **k averaged_data = pixmap.dot(np.nan_to_num(data.ravel())) ignored = np.isnan(data.ravel()) if isinstance(data, np.ma.MaskedArray): - ignored = ignored.filled() # masked voxels are also ignored + ignored = cast(np.ma.MaskedArray, ignored).filled() # masked voxels are also ignored if ignored is not None: weights_not_ignored = pixmap.dot((~ignored).astype(data.dtype)) @@ -117,7 +121,7 @@ def make_flatmap_image(braindata, height=1024, recache=False, nanmean=False, **k return img, extents -def get_flatmask(subject, height=1024, recache=False): +def get_flatmask(subject: str, height: int=1024, recache: bool=False) -> tuple[npt.NDArray[np.bool_], npt.NDArray[np.floating]]: """ Parameters ---------- @@ -141,8 +145,8 @@ def get_flatmask(subject, height=1024, recache=False): return mask, extents -def get_flatcache(subject, xfmname, pixelwise=True, thick=32, sampler='nearest', - recache=False, height=1024, depth=0.5): +def get_flatcache(subject: str, xfmname: Optional[str], pixelwise: bool=True, thick: int=32, sampler: str='nearest', + recache: bool=False, height: int=1024, depth: float=0.5): """ Parameters @@ -189,7 +193,7 @@ def get_flatcache(subject, xfmname, pixelwise=True, thick=32, sampler='nearest', if not pixelwise and xfmname is not None: from scipy import sparse mapper = utils.get_mapper(subject, xfmname, sampler) - pixmap = pixmap * sparse.vstack(mapper.masks) + pixmap = cast(sparse.csr_matrix, pixmap * sparse.vstack(mapper.masks)) return pixmap @@ -254,23 +258,26 @@ def _convert_svg_kwargs(kwargs): for k,v in kwargs.items() if v is not None} return out -def _parse_defaults(section): - defaults = dict(config.items(section)) - for k in defaults.keys(): +def _parse_defaults(section: str) -> dict[str, Union[float, list[float], None, str]]: + raw = dict(config.items(section)) + defaults: dict[str, Union[float, list[float], None, str]] = dict(raw) + for k, v in raw.items(): # Convert numbers to floating point numbers - if defaults[k][0] in string.digits + '.': - if ',' in defaults[k]: - defaults[k] = [float(x) for x in defaults[k].split(',')] + if v[0] in string.digits + '.': + if ',' in v: + defaults[k] = [float(x) for x in v.split(',')] else: - defaults[k] = float(defaults[k]) + defaults[k] = float(v) # Convert 'None' to None - if defaults[k] == 'None': + if v == 'None': defaults[k] = None # Special case formatting if k=='stroke' or k=='fill': - defaults[k] = _color2hex(defaults[k]) - elif k=='stroke-dasharray' and isinstance(defaults[k], (list,tuple)): - defaults[k] = '{}, {}'.format(*defaults[k]) + defaults[k] = _color2hex(v) + elif k=='stroke-dasharray': + dasharray = defaults[k] + if isinstance(dasharray, (list, tuple)): + defaults[k] = '{}, {}'.format(*dasharray) return defaults def _get_fig_and_ax(fig): @@ -353,7 +360,7 @@ def _make_hatch_image(hatch_data, height, sampler='nearest', hatch_space=4, reca return hatchim -def _make_flatmask(subject, height=1024): +def _make_flatmask(subject: str, height: int=1024) -> tuple[npt.NDArray[np.bool_], npt.NDArray[np.floating]]: from PIL import Image, ImageDraw from .. import polyutils @@ -368,11 +375,11 @@ def _make_flatmask(subject, height=1024): draw = ImageDraw.Draw(im) draw.polygon(lpts[:,:2].ravel().tolist(), fill=255) draw.polygon(rpts[:,:2].ravel().tolist(), fill=255) - extents = np.hstack([pts.min(0), pts.max(0)])[[0,3,1,4]] + extents: npt.NDArray[np.floating] = np.hstack([pts.min(0), pts.max(0)])[[0,3,1,4]] return np.array(im).T > 0, extents -def _make_vertex_cache(subject, height=1024): +def _make_vertex_cache(subject: str, height: int=1024) -> sparse.csr_matrix: from scipy import sparse from scipy.spatial import cKDTree flat, polys = db.get_surf(subject, "flat", merge=True, nudge=True) @@ -388,10 +395,11 @@ def _make_vertex_cache(subject, height=1024): kdt = cKDTree(flat[valid,:2]) dist, vert = kdt.query(grid.T[mask.ravel()]) + vert = np.asarray(vert) dataij = (np.ones((len(vert),)), np.array([np.arange(len(vert)), valid[vert]])) return sparse.csr_matrix(dataij, shape=(mask.sum(), len(flat))) -def _make_pixel_cache(subject, xfmname, height=1024, thick=32, depth=0.5, sampler='nearest'): +def _make_pixel_cache(subject: str, xfmname: str, height: int=1024, thick: int=32, depth: float=0.5, sampler: str='nearest') -> sparse.csr_matrix: from scipy import sparse from scipy.spatial import Delaunay flat, polys = db.get_surf(subject, "flat", merge=True, nudge=True) @@ -441,7 +449,7 @@ def _make_pixel_cache(subject, xfmname, height=1024, thick=32, depth=0.5, sample valid = np.logical_and(valid_p, valid_w) vidx = np.nonzero(valid)[0] - mapper = sparse.csr_matrix((mask.sum(), np.prod(xfm.shape))) + mapper: sparse.csr_matrix = sparse.csr_matrix((mask.sum(), np.prod(xfm.shape))) if thick == 1: i, j, data = sampclass(piacoords[valid]*depth + wmcoords[valid]*(1-depth), xfm.shape) mapper = mapper + sparse.csr_matrix((data / float(thick), (vidx[i], j)), diff --git a/cortex/quickflat/view.py b/cortex/quickflat/view.py index 1d7102905..19d3f52eb 100644 --- a/cortex/quickflat/view.py +++ b/cortex/quickflat/view.py @@ -4,7 +4,7 @@ import binascii import numpy as np import numpy.typing as npt -from typing import Optional, Union, IO +from typing import Optional, Union, IO, Sequence from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -41,7 +41,7 @@ def make_figure(braindata: dataset.Dataview, recache: bool=False, pixelwise: boo linewidth: Optional[int]=None, linecolor: Optional[ColorType]=None, roifill: Optional[ColorType]=None, shadow: Optional[int]=None, labelsize: Optional[str]=None, labelcolor: Optional[ColorType]=None, cutout: Optional[str]=None, curvature_brightness: Optional[float]=None, curvature_contrast: Optional[float]=None, curvature_threshold: Optional[bool]=None, fig: Optional[Union[Figure, Axes]]=None, extra_hatch: Optional[tuple[dataset.Dataview, tuple[float, float, float]]]=None, - colorbar_ticks: Optional[npt.ArrayLike]=None, colorbar_location: Union[tuple[float, float, float, float], str]='center', roi_list: Optional[list[str]]=None, sulci_list: Optional[list[str]]=None, + colorbar_ticks: Optional[npt.ArrayLike]=None, colorbar_location: Union[tuple[float, float, float, float], str]='center', roi_list: Optional[Sequence[str]]=None, sulci_list: Optional[Sequence[str]]=None, nanmean: bool=False) -> Figure: """Show a Volume or Vertex on a flatmap with matplotlib. @@ -305,8 +305,8 @@ def make_png(fname: Union[str, os.PathLike, IO], braindata: dataset.Dataview, re fig.clf() plt.close(fig) -def make_svg(fname, braindata, with_labels=False, with_curvature=True, layers=['rois'], - height=1024, overlay_file=None, with_dropout=False, **kwargs): +def make_svg(fname, braindata: dataset.Dataview, with_labels: bool=False, with_curvature: bool=True, layers: Sequence[str]=['rois'], + height: int=1024, overlay_file: Optional[str]=None, with_dropout: bool=False, **kwargs): """Save an svg file of the desired flatmap. This function creates an SVG file with vector graphic ROIs overlaid on a single png image. diff --git a/cortex/tests/test_quickflat.py b/cortex/tests/test_quickflat.py index e0ec115bf..181c6196e 100644 --- a/cortex/tests/test_quickflat.py +++ b/cortex/tests/test_quickflat.py @@ -2,13 +2,25 @@ import numpy as np import tempfile import pytest +from matplotlib.figure import Figure +from matplotlib.axes import Axes from cortex import dataset +import cortex.quickflat.utils # for ty from cortex.testing_utils import has_installed from cortex.webgl.data import Package no_inkscape = not has_installed('inkscape') +def random_volume(with_nan=False, **kwargs): + orig_vol = cortex.Volume.random("S1", "fullhead", **kwargs) + data = orig_vol.data.copy() + if with_nan: + # set 50% of the values in the dataset to NaN + data[np.random.rand(*data.shape) > 0.5] = np.nan + # TODO: make sure kwargs are passed through correctly (e.g. vmin/vmax, etc.) + return orig_vol.copy(data=data) + @pytest.mark.skipif(no_inkscape, reason='Inkscape required') def test_quickflat(): @@ -119,3 +131,262 @@ def test_make_flatmap_image_vertexrgb_alpha_unchanged(): "VertexRGB.vertices appears to be premultiplied -- the matplotlib " "path will double-attenuate. The fix should live in webgl/data.py." ) + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_quickflat_curvature(): + vol = random_volume(with_nan=True, cmap="hot", vmin=0, vmax=1) + cortex.quickflat.make_figure(vol, with_curvature=True) + + +# Tests for remaining make_figure arguments + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_recache(): + """Test recache parameter""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + fig = cortex.quickflat.make_figure(view, recache=False) + + # recache=True takes longer but should still work + fig = cortex.quickflat.make_figure(view, recache=True) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_pixelwise_and_thick(): + """Test pixelwise and thick parameters""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test pixelwise=True with different thick values + for thick in [1, 4, 8, 16, 32]: + fig = cortex.quickflat.make_figure(view, pixelwise=True, thick=thick) + + # Test pixelwise=False + fig = cortex.quickflat.make_figure(view, pixelwise=False) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_sampler(): + """Test sampler parameter with different sampling methods""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + for sampler in ['nearest', 'trilinear']: + fig = cortex.quickflat.make_figure(view, sampler=sampler) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_height_and_dpi(): + """Test height and dpi parameters""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test smaller height for faster rendering + height, dpi = 512, 100 + fig = cortex.quickflat.make_figure(view, height=height, dpi=dpi) + # Check the resulting figure size in inches (height in pixels / dpi) + expected_height_inch = height / dpi + assert np.isclose(fig.get_figheight(), expected_height_inch, atol=0.1) + + # Test larger height + height, dpi = 1024, 150 + fig = cortex.quickflat.make_figure(view, height=height, dpi=dpi) + expected_height_inch = height / dpi + assert np.isclose(fig.get_figheight(), expected_height_inch, atol=0.1) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_depth(): + """Test depth parameter for sampling different cortical depths""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test different depth values (0 = gray/white matter, 1 = pial surface) + for depth in [0.0, 0.5, 1.0]: + fig = cortex.quickflat.make_figure(view, depth=depth) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_display_flags(): + """Test boolean display flags: with_rois, with_sulci, with_labels, with_colorbar""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test with_rois + fig = cortex.quickflat.make_figure(view, with_rois=True) + fig = cortex.quickflat.make_figure(view, with_rois=False) + + # Test with_sulci + fig = cortex.quickflat.make_figure(view, with_sulci=False) + fig = cortex.quickflat.make_figure(view, with_sulci=True) + + # Test with_labels + fig = cortex.quickflat.make_figure(view, with_labels=False) + fig = cortex.quickflat.make_figure(view, with_labels=True) + + # Test with_colorbar + fig = cortex.quickflat.make_figure(view, with_colorbar=False) + fig = cortex.quickflat.make_figure(view, with_colorbar=True) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_with_dropout(): + """Test with_dropout parameter with bool and float values""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test with_dropout with boolean values + fig = cortex.quickflat.make_figure(view, with_dropout=False) + fig = cortex.quickflat.make_figure(view, with_dropout=True) + + # Test with_dropout with float value + fig = cortex.quickflat.make_figure(view, with_dropout=10.0) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_with_connected_vertices(): + """Test with_connected_vertices parameter""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + fig = cortex.quickflat.make_figure(view, with_connected_vertices=False) + + # Note: with_connected_vertices=True is more computationally expensive + fig = cortex.quickflat.make_figure(view, with_connected_vertices=True) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_roi_styling_parameters(): + """Test ROI styling parameters: linewidth, linecolor, roifill, shadow, labelsize, labelcolor""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + # TODO: need with_rois, etc.? + + # Test linewidth + fig = cortex.quickflat.make_figure(view, linewidth=2) + + # Test linecolor (RGB/RGBA tuple) + fig = cortex.quickflat.make_figure(view, linecolor=(1.0, 0.0, 0.0)) + + # Test roifill (RGB/RGBA tuple) + fig = cortex.quickflat.make_figure(view, roifill=(1.0, 0.0, 0.0, 0.5)) + + # Test shadow + #fig = cortex.quickflat.make_figure(view, shadow=1) # TODO: why does this fail? + + # Test labelsize + fig = cortex.quickflat.make_figure(view, labelsize="10pt") + + # Test labelcolor + fig = cortex.quickflat.make_figure(view, labelcolor=(0.0, 0.0, 0.0)) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_curvature_parameters(): + """Test curvature styling parameters: curvature_brightness, curvature_contrast, curvature_threshold""" + vol = random_volume(with_nan=True, cmap="hot", vmin=0, vmax=1) + + + # Test brightness and contrast together + fig = cortex.quickflat.make_figure(vol, with_curvature=True, + curvature_brightness=0.7, + curvature_contrast=0.5) + + # Test threshold + fig = cortex.quickflat.make_figure(vol, with_curvature=True, + curvature_threshold=True) + + fig = cortex.quickflat.make_figure(vol, with_curvature=True, + curvature_threshold=False) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_colorbar_ticks(): + """Test colorbar_ticks parameter""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot", vmin=0, vmax=1) + + # Custom ticks + ticks = np.array([0.0, 0.5, 1.0]) + fig = cortex.quickflat.make_figure(view, with_colorbar=True, + colorbar_ticks=ticks) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_fig_parameter(): + """Test fig parameter with Figure and Axes objects""" + from matplotlib import pyplot as plt + + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test passing a Figure object + fig_obj = plt.figure() + result = cortex.quickflat.make_figure(view, fig=fig_obj) + assert isinstance(result, Figure) + plt.close(fig_obj) + + # Test passing an Axes object + fig_obj = plt.figure() + ax_obj = fig_obj.add_subplot(111) + result = cortex.quickflat.make_figure(view, fig=ax_obj) + assert isinstance(result, Figure) + plt.close(fig_obj) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_extra_hatch(): + """Test extra_hatch parameter with additional hatching layer""" + mask = cortex.db.get_mask("S1", "fullhead", type="thick") + data = np.ones(mask.sum()) + vol = cortex.Volume(data, "S1", "fullhead", vmin=0, vmax=1) + + # Create a hatch layer with same shape + hatch_data = cortex.Volume(np.random.rand(mask.sum()), "S1", "fullhead", vmin=0, vmax=1) + hatch_color = (1.0, 0.0, 0.0) # Red + + fig = cortex.quickflat.make_figure(vol, extra_hatch=(hatch_data, hatch_color)) + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_roi_list_and_sulci_list(): + """Test roi_list and sulci_list parameters to filter displayed regions""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot") + + # Test roi_list - get available ROIs first + # Note: This depends on the database having ROIs available + try: + fig = cortex.quickflat.make_figure(view, with_rois=True, roi_list=['V1']) + except (ValueError, KeyError): + # Database might not have specific ROI names for this subject + pass + + # Test sulci_list + try: + fig = cortex.quickflat.make_figure(view, with_sulci=True, sulci_list=None) + except (ValueError, KeyError): + # Database might not have specific sulci names + pass + + +@pytest.mark.skipif(no_inkscape, reason='Inkscape required') +def test_combined_parameters(): + """Test make_figure with multiple parameters combined""" + view = cortex.Volume.random("S1", "fullhead", cmap="hot", vmin=0, vmax=1) + + # Comprehensive combination test + fig = cortex.quickflat.make_figure( + view, + recache=False, + pixelwise=True, + thick=16, + sampler='nearest', + height=512, + dpi=100, + depth=0.5, + with_rois=True, + with_sulci=False, + with_labels=True, + with_colorbar=True, + with_dropout=False, + with_curvature=True, + with_connected_vertices=False, + linewidth=1, + linecolor=(0.0, 0.0, 0.0), + labelsize="12pt", + curvature_brightness=0.6, + curvature_contrast=0.4, + curvature_threshold=True, + colorbar_location='center', + nanmean=False + ) From 2d12b23f0de674e8bf682ebd1cf6bd9443fefd95 Mon Sep 17 00:00:00 2001 From: Aditya Vaidya Date: Fri, 14 Aug 2026 02:28:34 -0700 Subject: [PATCH 2/5] tests: give test_with_connected_vertices a longer timeout GitHub Actions failed this test with: Failed: Timeout (>240.0s) from pytest-timeout inside db.get_shared_voxels()'s A* search (cortex/utils.py's get_shared_voxels/shortest_path), called from add_connected_vertices. Not a regression from this PR's typing -- add_connected_vertices's own docstring already documents this path as "graphically intensive ... takes quite a while on some systems". It's simply the first test to exercise with_connected_vertices=True, and get_shared_voxels caches its expensive one-time result to filestore/db//cache/shared_vertices_*.npy, which doesn't exist yet on a clean CI checkout. Reproduced locally with a fully cleared cache (rm filestore/db/S1/cache/shared_vertices_*.npy): 245.85s, just over pytest.ini's default 240s suite-wide timeout. That default exists so "a single hung headless browser session does not consume the entire CI budget", with individual tests expected to override via @pytest.mark.timeout(N) -- using that documented mechanism here rather than skipping or weakening the test. --- cortex/tests/test_quickflat.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cortex/tests/test_quickflat.py b/cortex/tests/test_quickflat.py index 181c6196e..682b441e4 100644 --- a/cortex/tests/test_quickflat.py +++ b/cortex/tests/test_quickflat.py @@ -238,13 +238,21 @@ def test_with_dropout(): @pytest.mark.skipif(no_inkscape, reason='Inkscape required') +@pytest.mark.timeout(600) def test_with_connected_vertices(): """Test with_connected_vertices parameter""" view = cortex.Volume.random("S1", "fullhead", cmap="hot") - + fig = cortex.quickflat.make_figure(view, with_connected_vertices=False) - - # Note: with_connected_vertices=True is more computationally expensive + + # Note: with_connected_vertices=True is more computationally expensive: + # db.get_shared_voxels() runs an A* search per crossing vertex pair to build + # its cache (cortex/utils.py's get_shared_voxels/shortest_path), and that + # cache doesn't exist yet on a clean CI checkout. The default 240s suite-wide + # timeout (pytest.ini) isn't enough on CI hardware for this first-run, + # cold-cache computation, even though it reliably completes (~1 minute + # locally with a warm mapper cache). Override with a longer budget rather + # than skip real coverage of this code path. fig = cortex.quickflat.make_figure(view, with_connected_vertices=True) From 5de50a8901588453fca9da3a4cc930a1a039aef3 Mon Sep 17 00:00:00 2001 From: Aditya Vaidya Date: Thu, 20 Aug 2026 14:18:40 -0700 Subject: [PATCH 3/5] quickflat: break Mapper typing dependency with isinstance narrowing get_flatcache calls utils.get_mapper() and reads mapper.masks, which currently type-checks only because get_mapper is still unannotated (Mapper's own typing PR hasn't landed yet, and now lands after this one instead of before it). Narrow explicitly with isinstance rather than resting on that coincidence, so this PR doesn't implicitly depend on the order Mapper's typing PR lands in. TODO: once Mapper is typed and get_mapper's return annotation makes this redundant, remove the isinstance assert and the Mapper import. --- cortex/quickflat/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cortex/quickflat/utils.py b/cortex/quickflat/utils.py index a865caa02..f8fdcc1a5 100644 --- a/cortex/quickflat/utils.py +++ b/cortex/quickflat/utils.py @@ -192,7 +192,13 @@ def get_flatcache(subject: str, xfmname: Optional[str], pixelwise: bool=True, th if not pixelwise and xfmname is not None: from scipy import sparse + from ..mapper import Mapper mapper = utils.get_mapper(subject, xfmname, sampler) + # get_mapper isn't typed yet (Mapper's typing PR lands after this one), so + # mapper is currently just Any. TODO: once that PR types cortex/mapper, + # get_mapper's own return annotation makes this redundant -- remove this + # import and assert. + assert isinstance(mapper, Mapper) pixmap = cast(sparse.csr_matrix, pixmap * sparse.vstack(mapper.masks)) return pixmap From ecfd60e57eebf7f29e140fede07c75c4c08d39a8 Mon Sep 17 00:00:00 2001 From: Aditya Vaidya Date: Fri, 21 Aug 2026 00:59:09 -0700 Subject: [PATCH 4/5] tests: mark test_with_connected_vertices as slow, skip it by default Registers a "slow" marker and excludes it via -m "not slow" in addopts, so this graphically-intensive test (per its own docstring) no longer runs on every default invocation. Drops the @pytest.mark.timeout(600) override that had been added to cover its cold-cache runtime: now that it's excluded by default, the override is unnecessary noise -- anyone running it explicitly with -m slow on a clean cache falls back to pytest.ini's suite-wide 240s timeout instead. --- cortex/tests/test_quickflat.py | 2 +- pytest.ini | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cortex/tests/test_quickflat.py b/cortex/tests/test_quickflat.py index 682b441e4..479b0acd0 100644 --- a/cortex/tests/test_quickflat.py +++ b/cortex/tests/test_quickflat.py @@ -238,7 +238,7 @@ def test_with_dropout(): @pytest.mark.skipif(no_inkscape, reason='Inkscape required') -@pytest.mark.timeout(600) +@pytest.mark.slow def test_with_connected_vertices(): """Test with_connected_vertices parameter""" view = cortex.Volume.random("S1", "fullhead", cmap="hot") diff --git a/pytest.ini b/pytest.ini index f34750789..2eca84056 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,6 +6,9 @@ addopts = -v --cov=. --cov-report xml + -m "not slow" +markers = + slow: marked as slow to skip by default; run explicitly with `-m slow`. # Per-test timeout (in seconds) so a single hung headless browser session # does not consume the entire CI budget. Individual tests can override with # @pytest.mark.timeout(N). Requires the optional ``pytest-timeout`` From 999df01e1b91d13d73483dede61a2d70d3e2def9 Mon Sep 17 00:00:00 2001 From: Aditya Vaidya Date: Fri, 21 Aug 2026 01:00:43 -0700 Subject: [PATCH 5/5] tests: restore test_with_connected_vertices's 600s timeout override Removing it was a real regression, not just cleanup: this test still needs longer than pytest.ini's 240s suite default on a cold cache (~4-5 min), and -m slow lets it be run explicitly. Restored, with a comment noting it overrides the global timeout. --- cortex/tests/test_quickflat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cortex/tests/test_quickflat.py b/cortex/tests/test_quickflat.py index 479b0acd0..d01187d0c 100644 --- a/cortex/tests/test_quickflat.py +++ b/cortex/tests/test_quickflat.py @@ -239,6 +239,7 @@ def test_with_dropout(): @pytest.mark.skipif(no_inkscape, reason='Inkscape required') @pytest.mark.slow +@pytest.mark.timeout(600) # override global timeout def test_with_connected_vertices(): """Test with_connected_vertices parameter""" view = cortex.Volume.random("S1", "fullhead", cmap="hot")