From 741a0f95acc238bc1a16e51ce6e1d992c4174ca0 Mon Sep 17 00:00:00 2001 From: ershook Date: Fri, 1 May 2026 17:17:31 -0400 Subject: [PATCH 1/7] Add column / row wise normalization to imshow and pyrshow --- TESTS/unitTests.py | 146 ++++++++++++++++++++++++- src/pyrtools/tools/display.py | 197 ++++++++++++++++++++++++++++------ 2 files changed, 312 insertions(+), 31 deletions(-) diff --git a/TESTS/unitTests.py b/TESTS/unitTests.py index abc48cb..e029ee1 100755 --- a/TESTS/unitTests.py +++ b/TESTS/unitTests.py @@ -9,6 +9,7 @@ import pyrtools as pt from pyrtools.pyramids.pyramid import Pyramid +from pyrtools.tools.display import colormap_range import scipy.io import os @@ -1485,7 +1486,150 @@ def test_pyrshow_2d_shape_err(self): with self.assertRaises(ValueError): pt.pyrshow(pyr.pyr_coeffs) - +def _get_clims(fig): + """get vmin vmax for each image in fig as list of tuples (vmin, vmax)""" + return [ax.images[0].get_clim() for ax in fig.axes if ax.images] + +# define test images such that each image has a different range of values, +# so we can test that the correct vrange is applied to each one +IMAGES = [np.arange(4 * i, 4 * i + 4, dtype=float).reshape(2, 2) for i in range(4)] + +class TestVrange(unittest.TestCase): + + def tearDown(self): + plt.close("all") + + def _imshow(self, vrange): + return pt.imshow(IMAGES, vrange=vrange, zoom=1, col_wrap=2) + + def _expected_clims(self, vrange): + clims, _ = colormap_range(image=IMAGES, vrange=vrange, cmap=None, n_rows=2, n_cols=2) + return clims + + def test_global_vrange_all_images_share_clim(self): + for mode in range(4): + with self.subTest(mode=mode): + clims = _get_clims(self._imshow(f"auto{mode}")) + self.assertTrue(all(c == clims[0] for c in clims)) + + def test_global_vrange_vmin(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + vmin, _ = _get_clims(self._imshow(f"auto{mode}"))[img_idx] + exp_vmin, _ = self._expected_clims(f"auto{mode}")[img_idx] + self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) + + def test_global_vrange_vmax(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + _, vmax = _get_clims(self._imshow(f"auto{mode}"))[img_idx] + _, exp_vmax = self._expected_clims(f"auto{mode}")[img_idx] + self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) + + def test_global_vrange_title_matches_clim(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + fig = self._imshow(f"auto{mode}") + clim_vmin, clim_vmax = _get_clims(fig)[img_idx] + title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) + self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) + + def test_row_vrange_same_row_shares_clim(self): + for mode in range(4): + with self.subTest(mode=mode): + clims = _get_clims(self._imshow(f"auto{mode}row")) + self.assertEqual(clims[0], clims[1], "row 0 images differ") + self.assertEqual(clims[2], clims[3], "row 1 images differ") + + def test_row_vrange_vmin(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + vmin, _ = _get_clims(self._imshow(f"auto{mode}row"))[img_idx] + exp_vmin, _ = self._expected_clims(f"auto{mode}row")[img_idx] + self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) + + def test_row_vrange_vmax(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + _, vmax = _get_clims(self._imshow(f"auto{mode}row"))[img_idx] + _, exp_vmax = self._expected_clims(f"auto{mode}row")[img_idx] + self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) + + def test_row_vrange_title_matches_clim(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + fig = self._imshow(f"auto{mode}row") + clim_vmin, clim_vmax = _get_clims(fig)[img_idx] + title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) + self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) + + def test_col_vrange_same_col_shares_clim(self): + for mode in range(4): + with self.subTest(mode=mode): + clims = _get_clims(self._imshow(f"auto{mode}col")) + self.assertEqual(clims[0], clims[2], "col 0 images differ") + self.assertEqual(clims[1], clims[3], "col 1 images differ") + + def test_col_vrange_vmin(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + vmin, _ = _get_clims(self._imshow(f"auto{mode}col"))[img_idx] + exp_vmin, _ = self._expected_clims(f"auto{mode}col")[img_idx] + self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) + + def test_col_vrange_vmax(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + _, vmax = _get_clims(self._imshow(f"auto{mode}col"))[img_idx] + _, exp_vmax = self._expected_clims(f"auto{mode}col")[img_idx] + self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) + + def test_col_vrange_title_matches_clim(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + fig = self._imshow(f"auto{mode}col") + clim_vmin, clim_vmax = _get_clims(fig)[img_idx] + title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) + self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) + + def test_indep_vrange_vmin(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + vmin, _ = _get_clims(self._imshow(f"indep{mode}"))[img_idx] + exp_vmin, _ = self._expected_clims(f"indep{mode}")[img_idx] + self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) + + def test_indep_vrange_vmax(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + _, vmax = _get_clims(self._imshow(f"indep{mode}"))[img_idx] + _, exp_vmax = self._expected_clims(f"indep{mode}")[img_idx] + self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) + + def test_indep_vrange_title_matches_clim(self): + for mode in range(4): + for img_idx in range(4): + with self.subTest(mode=mode, img_idx=img_idx): + fig = self._imshow(f"indep{mode}") + clim_vmin, clim_vmax = _get_clims(fig)[img_idx] + title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) + self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) + def main(): unittest.main() diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index 39b8daa..16b2330 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -1,3 +1,4 @@ +import math import warnings import numpy as np import matplotlib.pyplot as plt @@ -225,7 +226,7 @@ def reshape_axis(ax, axis_size_pix): return ax -def colormap_range(image, vrange='indep1', cmap=None): +def colormap_range(image, vrange= 'indep1', cmap=None, n_rows = None, n_cols = None): """Find the appropriate ranges for colormaps of provided images Arguments @@ -236,10 +237,13 @@ def colormap_range(image, vrange='indep1', cmap=None): dimension), or list of 2d arrays. all images will be automatically rescaled so they're displayed at the same size. thus, their sizes must be scalar multiples of each other. - vrange : `tuple` or `str` + vrange : `tuple` or `list` or `str` If a 2-tuple, specifies the image values vmin/vmax that are mapped to (ie. clipped to) the minimum and maximum value of the colormap, respectively. + If a list of 2-tuples, the length of number of images, each image has an + independent vmin/vmax that are mapped to the minimum/maximum value of + the colormap, respectively. If a string: * `'auto0'`: all images have same vmin/vmax, which have the same absolute value, and come from the minimum or maximum across @@ -253,6 +257,17 @@ def colormap_range(image, vrange='indep1', cmap=None): the display intensity range. For example: vmin is the 10th percentile image value minus 1/8 times the difference between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. * `'indep0'`: each image has an independent vmin/vmax, which have the same absolute value, which comes from either their minimum or maximum value, whichever has the larger absolute value. @@ -263,7 +278,6 @@ def colormap_range(image, vrange='indep1', cmap=None): * `'indep3'`: each image has an independent vmin/vmax, chosen so that the 10th/90th percentile values map to the 10th/90th percentile intensities. - Returns ------- vrange_list : `list` @@ -274,10 +288,27 @@ def colormap_range(image, vrange='indep1', cmap=None): # flatimg is one long 1d array, which enables the min, max, mean, std, and percentile calls to # operate on the values from each of the images simultaneously. flatimg = np.concatenate([i.flatten() for i in image]).flatten() - + if isinstance(vrange, str): if vrange[:4] == 'auto': - if vrange == 'auto0': + if 'row' in vrange: + assert n_cols is not None, "n_cols must be provided when using row-wise vrange (e.g. 'auto1row')" + vrange_list = [] + for i in range(math.ceil(len(image) / n_cols)): + vr, _ = colormap_range( + image[n_cols * i : n_cols * (i + 1)], vrange.split('row')[0] + ) + vrange_list.extend(vr) + elif 'col' in vrange: + assert n_cols is not None, "n_cols must be provided when using col-wise vrange" + vrange_list = [None] * len(image) + for j in range(n_cols): + col_images = [image[i] for i in range(j, len(image), n_cols)] + vr, _ = colormap_range(col_images, vrange.split('col')[0]) + for k, i in enumerate(range(j, len(image), n_cols)): + vrange_list[i] = vr[k] + + elif vrange == 'auto0': M = np.nanmax([np.abs(np.nanmin(flatimg)), np.abs(np.nanmax(flatimg))]) vrange_list = [-M, M] elif vrange == 'auto1' or vrange == 'auto': @@ -290,8 +321,9 @@ def colormap_range(image, vrange='indep1', cmap=None): p2 = np.nanpercentile(flatimg, 90) vrange_list = [p1-(p2-p1)/8.0, p2+(p2-p1)/8.0] - # make sure to return as many ranges as there are images - vrange_list = [vrange_list] * len(image) + if 'row' not in vrange and 'col' not in vrange: + # make sure to return as many ranges as there are images + vrange_list = [vrange_list] * len(image) elif vrange[:5] == 'indep': # independent vrange from recursive calls of this function per image @@ -301,17 +333,22 @@ def colormap_range(image, vrange='indep1', cmap=None): vrange_list, _ = colormap_range(image, vrange='auto1') warnings.warn('Unknown vrange argument, using auto1 instead') else: - # two numbers were passed, either as a list or tuple - if len(vrange) != 2: - raise Exception("If you're passing numbers to vrange," - "there must be 2 of them!") - vrange_list = [tuple(vrange)] * len(image) + # either a single 2-tuple was passed or a list of 2-tuples one for each image was passed. + if len(vrange) == 2: + vrange_list = [tuple(vrange)] * len(image) + elif len(vrange) == len(image): + # explicitly cast as tuple in case of list of lists + vrange_list = [tuple(v) for v in vrange] + else: + raise Exception("If you're passing numbers to vrange," + "there must be a single 2-tuple or as many as there are images!") + # double check that we're returning the right number of vranges assert len(image) == len(vrange_list) if cmap is None: - if '0' in vrange: + if isinstance(vrange, str) and '0' in vrange: cmap = cm.RdBu_r else: cmap = cm.gray @@ -630,7 +667,7 @@ def _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct): else: fig = ax.figure axes = [reshape_axis(ax, zoom * max_shape)] - return fig, axes + return fig, axes, n_cols, n_rows def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, @@ -648,10 +685,12 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, `(n,h,w)` for multiple grayscale images). all images will be automatically rescaled so they're displayed at the same size. thus, their sizes must be scalar multiples of each other. - vrange : `tuple` or `str` + vrange : `tuple` or `list` or `str` If a 2-tuple, specifies the image values vmin/vmax that are mapped to - the minimum and maximum value of the colormap, respectively. If a - string: + the minimum and maximum value of the colormap, respectively. If a list + of 2-tuples, each image has an independent vmin/vmax, where each images + minimum/maximum values are specified by the 2-tuples in the list ordered + from first image to last. If a string: * `'auto0'`: all images have same vmin/vmax, which have the same absolute value, and come from the minimum or maximum across all @@ -665,6 +704,17 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, the display intensity range. For example: vmin is the 10th percentile image value minus 1/8 times the difference between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. * `'indep0'`: each image has an independent vmin/vmax, which have the same absolute value, which comes from either their minimum or maximum value, whichever has the larger @@ -735,14 +785,13 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, # Process complex images for plotting, double-check image size to see if we # have RGB(A) images image, title, contains_rgb = _process_signal(image, title, plot_complex) - # make sure we can properly zoom all images zooms, max_shape = _check_zooms(image, zoom, contains_rgb) # get the figure and axes created - fig, axes = _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct) + fig, axes, n_cols, n_rows = _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct) - vrange_list, cmap = colormap_range(image=image, vrange=vrange, cmap=cmap) + vrange_list, cmap = colormap_range(image=image, vrange=vrange, cmap=cmap, n_rows = n_rows, n_cols = n_cols) for im, a, r, t, z in zip(image, axes, vrange_list, title, zooms): _showIm(im, a, r, z, t, cmap, **kwargs) @@ -776,10 +825,12 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, Requires ipython to be installed. repeat : `bool` whether to loop the animation or just play it once - vrange : `tuple` or `str` + vrange : `tuple` or `list` or `str` If a 2-tuple, specifies the image values vmin/vmax that are mapped to the minimum and - maximum value of the colormap, respectively. If a string: - + maximum value of the colormap, respectively. If a list + of 2-tuples, each image has an independent vmin/vmax, where each images + minimum/maximum values are specified by the 2-tuples in the list ordered + from first image to last. If a string: * `'auto/auto1'`: all images have same vmin/vmax, which are the minimum/maximum values across all images * `'auto2'`: all images have same vmin/vmax, which are the mean (across all images) minus/ @@ -788,6 +839,17 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, values to the 10th/90th percentile of the display intensity range. For example: vmin is the 10th percentile image value minus 1/8 times the difference between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum values * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 @@ -844,8 +906,9 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, title, vert_pct = _convert_title_to_list(title, video) video, title, contains_rgb = _process_signal(video, title, plot_complex, video=True) zooms, max_shape = _check_zooms(video, zoom, contains_rgb, video=True) - fig, axes = _setup_figure(ax, col_wrap, video, zoom, max_shape, vert_pct) - vrange_list, cmap = colormap_range(image=video, vrange=vrange, cmap=cmap) + fig, axes, n_cols, n_rows = _setup_figure(ax, col_wrap, video, zoom, max_shape, vert_pct) + vrange_list, cmap = colormap_range(image=video, vrange=vrange, + cmap=cmap, n_rows = n_rows, n_cols = n_cols) first_image = [v[0] for v in video] for im, a, r, t, z in zip(first_image, axes, vrange_list, title, zooms): @@ -889,9 +952,11 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 is_complex : `bool` default False, indicates whether the pyramids is real or complex indicating whether the pyramid is complex or real - vrange : `tuple` or `str` - If a 2-tuple, specifies the image values vmin/vmax that are mapped to the minimum and - maximum value of the colormap, respectively. If a string: + vrange : `tuple` or `list` or `str` + If a single 2-tuple, specifies the image values vmin/vmax that are mapped to the minimum and + maximum value of the colormap, respectively. + If a list of 2-tuples, each image has an independent vmin/vmax, where each images minimum/maximum + values are specified by the 2-tuples in the list ordered from first image to last. If a string: * `'auto/auto1'`: all images have same vmin/vmax, which are the minimum/maximum values across all images @@ -901,6 +966,17 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 values to the 10th/90th percentile of the display intensity range. For example: vmin is the 10th percentile image value minus 1/8 times the difference between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum values * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 @@ -943,22 +1019,80 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 # not sure about scope here, so we make sure to copy the # pyr_coeffs dictionary. imgs, highpass, lowpass = convert_pyr_coeffs_to_pyr(pyr_coeffs.copy()) + imgs = [i.squeeze() for i in imgs] + + if is_complex: + # Make sure image is a list, do some preliminary checks + image_converted = _convert_signal_to_list(imgs) + + # want to do this check before converting title to a list (at which + # point `title is None` will always be False). we do it here instad + # of checking whether the first item of title is None because it's + # conceivable that the user passed `title=[None, 'important + # title']`, and in that case we do want the space for the title + titles, vert_pct = _convert_title_to_list('', imgs) + + plot_complex = kwargs.get("plot_complex", "rectangular") + imgs, titles, _ = _process_signal(image_converted, titles, plot_complex) + + if 'row' in vrange: + vrange_list = [] + for i in range(math.ceil(len(imgs) / num_orientations)): + vr, _ = colormap_range( + imgs[num_orientations * i : num_orientations * (i + 1)], vrange.split('row')[0] + ) + vrange_list.extend(vr) + + + ## If complex need to collect both imaginary and real parts of the coefficients for each "column" + # (i.e. each orientation) to compute the colormap range across both real and imaginary parts, + # so we loop through orientations and collect the corresponding real and imaginary parts + # of the coefficients for each orientation together to compute the colormap range for that column. + # If not complex, then we just loop through orientations and collect the coefficients for each orientation + # together to compute the colormap range for that column. + + + elif 'col' in vrange: + vrange_list = [None] * len(imgs) + if not is_complex: + for j in range(num_orientations): + col_images = [imgs[i] for i in range(j, len(imgs), num_orientations)] + vr, _ = colormap_range(col_images, vrange.split('col')[0]) + for k, i in enumerate(range(j, len(imgs), num_orientations)): + vrange_list[i] = vr[k] + else: + for j in range(0, num_orientations * 2, 2): + col_images = [] + for i in range(j, len(imgs), num_orientations * 2): + col_images.extend([imgs[i], imgs[i + 1]]) + vr, _ = colormap_range(col_images, vrange.split('col')[0]) + for k, i in enumerate(range(j, len(imgs), num_orientations * 2)): + vrange_list[i] = vr[k] + vrange_list[i + 1] = vr[k] + # we can similarly grab the labels for height and band # from the keys in this pyramid coefficients dictionary pyr_coeffs_keys = [k for k in pyr_coeffs.keys() if isinstance(k, tuple)] - titles = ["height %02d, band %02d" % (h, b) for h, b in sorted(pyr_coeffs_keys)] + if not is_complex: + titles = ["height %02d, band %02d" % (h, b) for h, b in sorted(pyr_coeffs_keys)] if show_residuals: if highpass is not None: titles += ["residual highpass"] imgs.append(highpass) + if 'row' in vrange or 'col' in vrange: + vrange_list.append([highpass.min(), highpass.max()]) if lowpass is not None: titles += ["residual lowpass"] imgs.append(lowpass) + if 'row' in vrange or 'col' in vrange: + vrange_list.append([lowpass.min(), lowpass.max()]) if col_wrap_new is not None and col_wrap_new != 1: if col_wrap is None: col_wrap = col_wrap_new # if these are really 1d (i.e., have shape (1, x) or (x, 1)), then we want them to be 1d + imgs = [i.squeeze() for i in imgs] + if imgs[0].ndim == 1: # then we just want to plot each of the bands in a different subplot, no need to be fancy. if col_wrap is not None: @@ -995,4 +1129,7 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 "times, where this number is the height of the " f"pyramid{residual_err_msg}. " f"Instead, found:\n{err_msg}") - return imshow(imgs, vrange=vrange, col_wrap=col_wrap, zoom=zoom, title=titles, **kwargs) + + if 'col' in vrange or 'row' in vrange: + vrange = vrange_list + return imshow(imgs, vrange=vrange, col_wrap=col_wrap, zoom=zoom, title=titles, **kwargs) \ No newline at end of file From e4f84e284e4b4b24198a1ca0482761d522ebc5ee Mon Sep 17 00:00:00 2001 From: erica Date: Thu, 10 Sep 2026 11:14:04 -0400 Subject: [PATCH 2/7] rewrote pyrshow -- updates to complex column plotting --- src/pyrtools/tools/display.py | 114 ++++++++++++++-------------------- 1 file changed, 46 insertions(+), 68 deletions(-) diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index 16b2330..6974cdd 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -226,7 +226,7 @@ def reshape_axis(ax, axis_size_pix): return ax -def colormap_range(image, vrange= 'indep1', cmap=None, n_rows = None, n_cols = None): +def colormap_range(image, vrange= 'indep1', cmap=None, n_cols = None): """Find the appropriate ranges for colormaps of provided images Arguments @@ -257,7 +257,7 @@ def colormap_range(image, vrange= 'indep1', cmap=None, n_rows = None, n_cols = N the display intensity range. For example: vmin is the 10th percentile image value minus 1/8 times the difference between the 90th and 10th percentile - * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are computed using the auto[X] methods described above Eg. `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, mean minus/plus 2 std dev, or percentile statistics are @@ -278,6 +278,11 @@ def colormap_range(image, vrange= 'indep1', cmap=None, n_rows = None, n_cols = N * `'indep3'`: each image has an independent vmin/vmax, chosen so that the 10th/90th percentile values map to the 10th/90th percentile intensities. + cmap : matplotlib colormap, optional + colormap to use when showing these images. If None, will pick RdBu_r if vrange is some variant of auto0 or indep0, else will pick gray. + n_cols : `int` + number of columns in the figure. + Returns ------- vrange_list : `list` @@ -791,7 +796,7 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, # get the figure and axes created fig, axes, n_cols, n_rows = _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct) - vrange_list, cmap = colormap_range(image=image, vrange=vrange, cmap=cmap, n_rows = n_rows, n_cols = n_cols) + vrange_list, cmap = colormap_range(image=image, vrange=vrange, cmap=cmap, n_cols = n_cols) for im, a, r, t, z in zip(image, axes, vrange_list, title, zooms): _showIm(im, a, r, z, t, cmap, **kwargs) @@ -908,7 +913,7 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, zooms, max_shape = _check_zooms(video, zoom, contains_rgb, video=True) fig, axes, n_cols, n_rows = _setup_figure(ax, col_wrap, video, zoom, max_shape, vert_pct) vrange_list, cmap = colormap_range(image=video, vrange=vrange, - cmap=cmap, n_rows = n_rows, n_cols = n_cols) + cmap=cmap, n_cols = n_cols) first_image = [v[0] for v in video] for im, a, r, t, z in zip(first_image, axes, vrange_list, title, zooms): @@ -959,30 +964,32 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 values are specified by the 2-tuples in the list ordered from first image to last. If a string: * `'auto/auto1'`: all images have same vmin/vmax, which are the minimum/maximum values - across all images + across all images * `'auto2'`: all images have same vmin/vmax, which are the mean (across all images) minus/ - plus 2 std dev (across all images) + plus 2 std dev (across all images) * `'auto3'`: all images have same vmin/vmax, chosen so as to map the 10th/90th percentile - values to the 10th/90th percentile of the display intensity range. For - example: vmin is the 10th percentile image value minus 1/8 times the - difference between the 90th and 10th percentile + values to the 10th/90th percentile of the display intensity range. For + example: vmin is the 10th percentile image value minus 1/8 times the + difference between the 90th and 10th percentile * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, - mean minus/plus 2 std dev, or percentile statistics are - computed across all images in a given row, and those - values are used as the vmin/vmax for all images in that row. - High pass and low pass residuals have independent vmin/vmax - based on min/max of the residual image itself. + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1col'`, `'auto2col'`, or `'auto3col'`. + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. Note for complex pyramids, + the vmin/vmax for each column is computed across both the real and + imaginary parts * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum - values + values * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 - std dev + std dev * `'indep3'`: each image has an independent vmin/vmax, chosen so that the 10th/90th - percentile values map to the 10th/90th percentile intensities. + percentile values map to the 10th/90th percentile intensities. col_wrap : `int` or None Only usable when the pyramid is one-dimensional (e.g., Gaussian or Laplacian Pyramid), otherwise the column wrap is determined by the number of bands. If not None, how many axes @@ -1019,8 +1026,19 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 # not sure about scope here, so we make sure to copy the # pyr_coeffs dictionary. imgs, highpass, lowpass = convert_pyr_coeffs_to_pyr(pyr_coeffs.copy()) - imgs = [i.squeeze() for i in imgs] + + imgs_formatted = imgs + + # for purposes of determining vrange we want the same vmin and vmax for the real and imaginary parts of the complex images, + # so we concatenate them together and compute the min and max across both parts. + if is_complex: + imgs_formatted = [np.concatenate([im.real.ravel(), im.imag.ravel()]) for im in imgs] + + vrange_list, cmap = colormap_range(image=imgs_formatted, vrange=vrange, n_cols=num_orientations) + if is_complex: + vrange_list = [v for v in vrange_list for _ in range(2)] + if is_complex: # Make sure image is a list, do some preliminary checks image_converted = _convert_signal_to_list(imgs) @@ -1035,41 +1053,6 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 plot_complex = kwargs.get("plot_complex", "rectangular") imgs, titles, _ = _process_signal(image_converted, titles, plot_complex) - if 'row' in vrange: - vrange_list = [] - for i in range(math.ceil(len(imgs) / num_orientations)): - vr, _ = colormap_range( - imgs[num_orientations * i : num_orientations * (i + 1)], vrange.split('row')[0] - ) - vrange_list.extend(vr) - - - ## If complex need to collect both imaginary and real parts of the coefficients for each "column" - # (i.e. each orientation) to compute the colormap range across both real and imaginary parts, - # so we loop through orientations and collect the corresponding real and imaginary parts - # of the coefficients for each orientation together to compute the colormap range for that column. - # If not complex, then we just loop through orientations and collect the coefficients for each orientation - # together to compute the colormap range for that column. - - - elif 'col' in vrange: - vrange_list = [None] * len(imgs) - if not is_complex: - for j in range(num_orientations): - col_images = [imgs[i] for i in range(j, len(imgs), num_orientations)] - vr, _ = colormap_range(col_images, vrange.split('col')[0]) - for k, i in enumerate(range(j, len(imgs), num_orientations)): - vrange_list[i] = vr[k] - else: - for j in range(0, num_orientations * 2, 2): - col_images = [] - for i in range(j, len(imgs), num_orientations * 2): - col_images.extend([imgs[i], imgs[i + 1]]) - vr, _ = colormap_range(col_images, vrange.split('col')[0]) - for k, i in enumerate(range(j, len(imgs), num_orientations * 2)): - vrange_list[i] = vr[k] - vrange_list[i + 1] = vr[k] - # we can similarly grab the labels for height and band # from the keys in this pyramid coefficients dictionary pyr_coeffs_keys = [k for k in pyr_coeffs.keys() if isinstance(k, tuple)] @@ -1079,20 +1062,16 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 if highpass is not None: titles += ["residual highpass"] imgs.append(highpass) - if 'row' in vrange or 'col' in vrange: - vrange_list.append([highpass.min(), highpass.max()]) + vrange_list.append([highpass.min(), highpass.max()]) if lowpass is not None: titles += ["residual lowpass"] imgs.append(lowpass) - if 'row' in vrange or 'col' in vrange: - vrange_list.append([lowpass.min(), lowpass.max()]) + vrange_list.append([lowpass.min(), lowpass.max()]) if col_wrap_new is not None and col_wrap_new != 1: if col_wrap is None: col_wrap = col_wrap_new - # if these are really 1d (i.e., have shape (1, x) or (x, 1)), then we want them to be 1d - - imgs = [i.squeeze() for i in imgs] + imgs = [i.squeeze() for i in imgs] if imgs[0].ndim == 1: # then we just want to plot each of the bands in a different subplot, no need to be fancy. if col_wrap is not None: @@ -1129,7 +1108,6 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 "times, where this number is the height of the " f"pyramid{residual_err_msg}. " f"Instead, found:\n{err_msg}") - - if 'col' in vrange or 'row' in vrange: - vrange = vrange_list - return imshow(imgs, vrange=vrange, col_wrap=col_wrap, zoom=zoom, title=titles, **kwargs) \ No newline at end of file + + vrange=vrange_list + return imshow(imgs, vrange=vrange, col_wrap=col_wrap, zoom=zoom, title=titles, **kwargs) \ No newline at end of file From e0ece9115d3ea22eed05cbdd426a40a77e0b7ce7 Mon Sep 17 00:00:00 2001 From: erica Date: Sat, 12 Sep 2026 21:25:01 -0400 Subject: [PATCH 3/7] Updated colormaprange to be responsible for complex column handling --- src/pyrtools/tools/display.py | 78 ++++++++++++++--------------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index c7a6e05..430098a 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -271,6 +271,8 @@ def colormap_range(image, contains_rgb, vrange='indep1', cmap=None, n_cols = Non * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are computed using the auto[X] methods described above Eg. `'auto1col'`, `'auto2col'`, or `'auto3col'`. + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both + the realand imaginary parts of all images in that column. * `'indep0'`: each image has an independent vmin/vmax, which have the same absolute value, which comes from either their minimum or maximum value, whichever has the larger absolute value. @@ -307,17 +309,21 @@ def colormap_range(image, contains_rgb, vrange='indep1', cmap=None, n_cols = Non vrange_tmp = [] for i in range(math.ceil(len(image) / n_cols)): vr, _ = colormap_range( - image[n_cols * i : n_cols * (i + 1)], vrange.split('row')[0] - ) + image[n_cols * i : n_cols * (i + 1)], contains_rgb, vrange.split('row')[0]) vrange_tmp.extend(vr) elif 'col' in vrange: - assert n_cols is not None, "n_cols must be provided when using col-wise vrange" - vrange_tmp = [None] * len(image) - for j in range(n_cols): - col_images = [image[i] for i in range(j, len(image), n_cols)] - vr, _ = colormap_range(col_images, vrange.split('col')[0]) - for k, i in enumerate(range(j, len(image), n_cols)): - vrange_tmp[i] = vr[k] + if 'complex' in vrange: + imgs_formatted = [np.concatenate([image[ii+1], image[ii+1]]) for ii in range(0, len(image), 2)] + # Divide by 2 because we are grouping complex images into pairs + vrange_tmp, cmap = colormap_range(imgs_formatted, contains_rgb, vrange=vrange.split('complex')[0], n_cols=n_cols//2) + vrange_tmp = [v for v in vrange_tmp for _ in range(2)] + else: + vrange_tmp = [None] * len(image) + for j in range(n_cols): + col_images = [image[i] for i in range(j, len(image), n_cols)] + vr, _ = colormap_range(col_images, contains_rgb, vrange.split('col')[0]) + for k, i in enumerate(range(j, len(image), n_cols)): + vrange_tmp[i] = vr[k] elif vrange == 'auto0': M = np.nanmax([np.abs(np.nanmin(flatimg)), np.abs(np.nanmax(flatimg))]) vrange_tmp = [-M, M] @@ -685,7 +691,7 @@ def _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct): else: fig = ax.figure axes = [reshape_axis(ax, zoom * max_shape)] - return fig, axes, n_cols, n_rows + return fig, axes, n_cols def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, @@ -733,6 +739,8 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are computed using the auto[X] methods described above Eg. `'auto1col'`, `'auto2col'`, or `'auto3col'`. + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both the real + and imaginary parts of all images in that column. * `'indep0'`: each image has an independent vmin/vmax, which have the same absolute value, which comes from either their minimum or maximum value, whichever has the larger @@ -816,7 +824,7 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, zooms, max_shape = _check_zooms(image, zoom, any(contains_rgb)) # get the figure and axes created - fig, axes, n_cols, n_rows = _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct) + fig, axes, n_cols = _setup_figure(ax, col_wrap, image, zoom, max_shape, vert_pct) if any(contains_rgb) and vrange != "indep1": warnings.warn("RGB images cannot have their vrange set: matplotlib " @@ -824,6 +832,8 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, "or [0, 255] (for ints).") vrange_list, cmap = colormap_range(image, contains_rgb, vrange, cmap, n_cols) + print(len(image)) + print(len(vrange_list)) assert len(image) == len(vrange_list) for im, a, r, t, z in zip(image, axes, vrange_list, title, zooms): @@ -883,6 +893,8 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are computed using the auto[X] methods described above Eg. `'auto1col'`, `'auto2col'`, or `'auto3col'`. + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both the real + and imaginary parts of all images in that column. * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum values * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 @@ -948,7 +960,7 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, title, vert_pct = _convert_title_to_list(title, video) video, title, contains_rgb = _process_signal(video, title, plot_complex, video=True) zooms, max_shape = _check_zooms(video, zoom, any(contains_rgb), video=True) - fig, axes, n_cols, n_rows = _setup_figure(ax, col_wrap, video, zoom, max_shape, vert_pct) + fig, axes, n_cols = _setup_figure(ax, col_wrap, video, zoom, max_shape, vert_pct) if any(contains_rgb) and vrange != "indep1": warnings.warn("RGB images cannot have their vrange set: matplotlib " "will always show them with vrange [0, 1] (for floats) " @@ -989,7 +1001,7 @@ def animate_video(t): return anim -def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1, show_residuals=True, **kwargs): +def pyrshow(pyr_coeffs, is_complex=False, vrange='auto1row', col_wrap=None, zoom=1, show_residuals=True, **kwargs): """Display the coefficients of the pyramid in an orderly fashion Arguments @@ -1026,6 +1038,8 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 `'auto1col'`, `'auto2col'`, or `'auto3col'`. Note for complex pyramids, the vmin/vmax for each column is computed across both the real and imaginary parts + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both the real + and imaginary parts of all images in that column. * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum values * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 @@ -1049,7 +1063,7 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 fig: `PyrFigure` the figure displaying the coefficients. """ - # right now, we do *not* do this the same as the old code. Instead of taking the coefficients + # right now, we do *not* do this the same as the old code. Instead of taking the coefficients # and arranging them in a spiral, we use imshow and arrange them neatly, displaying all at the # same size (and zoom / original image size clear), with different options for vrange. It # doesn't seem worth it to me to implement a version that looks like the old one, since that @@ -1068,51 +1082,21 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 # not sure about scope here, so we make sure to copy the # pyr_coeffs dictionary. imgs, highpass, lowpass = convert_pyr_coeffs_to_pyr(pyr_coeffs.copy()) - - imgs_formatted = imgs - - # for purposes of determining vrange we want the same vmin and vmax for the real and imaginary parts of the complex images, - # so we concatenate them together and compute the min and max across both parts. - if is_complex: - imgs_formatted = [np.concatenate([im.real.ravel(), im.imag.ravel()]) for im in imgs] - - vrange_list, cmap = colormap_range(image=imgs_formatted, vrange=vrange, n_cols=num_orientations) - if is_complex: - vrange_list = [v for v in vrange_list for _ in range(2)] - - - if is_complex: - # Make sure image is a list, do some preliminary checks - image_converted = _convert_signal_to_list(imgs) - - # want to do this check before converting title to a list (at which - # point `title is None` will always be False). we do it here instad - # of checking whether the first item of title is None because it's - # conceivable that the user passed `title=[None, 'important - # title']`, and in that case we do want the space for the title - titles, vert_pct = _convert_title_to_list('', imgs) - - plot_complex = kwargs.get("plot_complex", "rectangular") - imgs, titles, _ = _process_signal(image_converted, titles, plot_complex) - # we can similarly grab the labels for height and band # from the keys in this pyramid coefficients dictionary pyr_coeffs_keys = [k for k in pyr_coeffs.keys() if isinstance(k, tuple)] - if not is_complex: - titles = ["height %02d, band %02d" % (h, b) for h, b in sorted(pyr_coeffs_keys)] + titles = ["height %02d, band %02d" % (h, b) for h, b in sorted(pyr_coeffs_keys)] if show_residuals: if highpass is not None: titles += ["residual highpass"] imgs.append(highpass) - vrange_list.append([highpass.min(), highpass.max()]) if lowpass is not None: titles += ["residual lowpass"] imgs.append(lowpass) - vrange_list.append([lowpass.min(), lowpass.max()]) if col_wrap_new is not None and col_wrap_new != 1: if col_wrap is None: col_wrap = col_wrap_new - + # if these are really 1d (i.e., have shape (1, x) or (x, 1)), then we want them to be 1d imgs = [i.squeeze() for i in imgs] if imgs[0].ndim == 1: # then we just want to plot each of the bands in a different subplot, no need to be fancy. @@ -1150,6 +1134,4 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='indep1', col_wrap=None, zoom=1 "times, where this number is the height of the " f"pyramid{residual_err_msg}. " f"Instead, found:\n{err_msg}") - - vrange=vrange_list return imshow(imgs, vrange=vrange, col_wrap=col_wrap, zoom=zoom, title=titles, **kwargs) \ No newline at end of file From 53a3aead42206155629c62a13f2a7ac4eeb8f546 Mon Sep 17 00:00:00 2001 From: erica Date: Fri, 18 Sep 2026 13:40:32 -0400 Subject: [PATCH 4/7] Update docs and unit tests --- TESTS/unitTests.py | 70 +++++++++++++++++++---------------- src/pyrtools/tools/display.py | 2 - 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/TESTS/unitTests.py b/TESTS/unitTests.py index 68f5657..7c34eef 100755 --- a/TESTS/unitTests.py +++ b/TESTS/unitTests.py @@ -1601,37 +1601,45 @@ def test_pyrshow_2d_shape_err(self): with self.assertRaises(ValueError): pt.pyrshow(pyr.pyr_coeffs) -def _get_clims(fig): - """get vmin vmax for each image in fig as list of tuples (vmin, vmax)""" - return [ax.images[0].get_clim() for ax in fig.axes if ax.images] - -# define test images such that each image has a different range of values, -# so we can test that the correct vrange is applied to each one -IMAGES = [np.arange(4 * i, 4 * i + 4, dtype=float).reshape(2, 2) for i in range(4)] - class TestVrange(unittest.TestCase): - def tearDown(self): - plt.close("all") + def _get_clims(self, fig): + """get vmin vmax for each image in fig as list of tuples (vmin, vmax)""" + return [ax.images[0].get_clim() for ax in fig.axes if ax.images] + + def _get_title_clims(self, fig): + """get vmin vmax for each image in fig as list of tuples (vmin, vmax)""" + clims = [] + for ax in fig.axes: + title = ax.get_title() + vmin, vmax = title.split('[')[1].split(']')[0].split(',') + clims.append((float(vmin.strip()), float(vmax.strip()))) + return clims + + def _get_images(self): + # define test images such that each image has a different range of values, + # so we can test that the correct vrange is applied to each one + images = [np.arange(4 * i, 4 * i + 4, dtype=float).reshape(2, 2) for i in range(4)] + return images def _imshow(self, vrange): - return pt.imshow(IMAGES, vrange=vrange, zoom=1, col_wrap=2) + return pt.imshow(self._get_images(), vrange=vrange, zoom=1, col_wrap=2) def _expected_clims(self, vrange): - clims, _ = colormap_range(image=IMAGES, vrange=vrange, cmap=None, n_rows=2, n_cols=2) + clims, _ = colormap_range(image=self._get_images(), contains_rgb= [False]*len(self._get_images()), vrange=vrange, cmap=None, n_cols=2) return clims def test_global_vrange_all_images_share_clim(self): for mode in range(4): with self.subTest(mode=mode): - clims = _get_clims(self._imshow(f"auto{mode}")) + clims = self._get_clims(self._imshow(f"auto{mode}")) self.assertTrue(all(c == clims[0] for c in clims)) def test_global_vrange_vmin(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - vmin, _ = _get_clims(self._imshow(f"auto{mode}"))[img_idx] + vmin, _ = self._get_clims(self._imshow(f"auto{mode}"))[img_idx] exp_vmin, _ = self._expected_clims(f"auto{mode}")[img_idx] self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) @@ -1639,7 +1647,7 @@ def test_global_vrange_vmax(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - _, vmax = _get_clims(self._imshow(f"auto{mode}"))[img_idx] + _, vmax = self._get_clims(self._imshow(f"auto{mode}"))[img_idx] _, exp_vmax = self._expected_clims(f"auto{mode}")[img_idx] self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) @@ -1648,15 +1656,15 @@ def test_global_vrange_title_matches_clim(self): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): fig = self._imshow(f"auto{mode}") - clim_vmin, clim_vmax = _get_clims(fig)[img_idx] - title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + clim_vmin, clim_vmax = self._get_clims(fig)[img_idx] + title_vmin, title_vmax = self._get_title_clims(fig)[img_idx] self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) def test_row_vrange_same_row_shares_clim(self): for mode in range(4): with self.subTest(mode=mode): - clims = _get_clims(self._imshow(f"auto{mode}row")) + clims = self._get_clims(self._imshow(f"auto{mode}row")) self.assertEqual(clims[0], clims[1], "row 0 images differ") self.assertEqual(clims[2], clims[3], "row 1 images differ") @@ -1664,7 +1672,7 @@ def test_row_vrange_vmin(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - vmin, _ = _get_clims(self._imshow(f"auto{mode}row"))[img_idx] + vmin, _ = self._get_clims(self._imshow(f"auto{mode}row"))[img_idx] exp_vmin, _ = self._expected_clims(f"auto{mode}row")[img_idx] self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) @@ -1672,7 +1680,7 @@ def test_row_vrange_vmax(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - _, vmax = _get_clims(self._imshow(f"auto{mode}row"))[img_idx] + _, vmax = self._get_clims(self._imshow(f"auto{mode}row"))[img_idx] _, exp_vmax = self._expected_clims(f"auto{mode}row")[img_idx] self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) @@ -1681,15 +1689,15 @@ def test_row_vrange_title_matches_clim(self): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): fig = self._imshow(f"auto{mode}row") - clim_vmin, clim_vmax = _get_clims(fig)[img_idx] - title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + clim_vmin, clim_vmax = self._get_clims(fig)[img_idx] + title_vmin, title_vmax = self._get_title_clims(fig)[img_idx] self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) def test_col_vrange_same_col_shares_clim(self): for mode in range(4): with self.subTest(mode=mode): - clims = _get_clims(self._imshow(f"auto{mode}col")) + clims = self._get_clims(self._imshow(f"auto{mode}col")) self.assertEqual(clims[0], clims[2], "col 0 images differ") self.assertEqual(clims[1], clims[3], "col 1 images differ") @@ -1697,7 +1705,7 @@ def test_col_vrange_vmin(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - vmin, _ = _get_clims(self._imshow(f"auto{mode}col"))[img_idx] + vmin, _ = self._get_clims(self._imshow(f"auto{mode}col"))[img_idx] exp_vmin, _ = self._expected_clims(f"auto{mode}col")[img_idx] self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) @@ -1705,7 +1713,7 @@ def test_col_vrange_vmax(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - _, vmax = _get_clims(self._imshow(f"auto{mode}col"))[img_idx] + _, vmax = self._get_clims(self._imshow(f"auto{mode}col"))[img_idx] _, exp_vmax = self._expected_clims(f"auto{mode}col")[img_idx] self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) @@ -1714,8 +1722,8 @@ def test_col_vrange_title_matches_clim(self): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): fig = self._imshow(f"auto{mode}col") - clim_vmin, clim_vmax = _get_clims(fig)[img_idx] - title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + clim_vmin, clim_vmax = self._get_clims(fig)[img_idx] + title_vmin, title_vmax = self._get_title_clims(fig)[img_idx] self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) @@ -1723,7 +1731,7 @@ def test_indep_vrange_vmin(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - vmin, _ = _get_clims(self._imshow(f"indep{mode}"))[img_idx] + vmin, _ = self._get_clims(self._imshow(f"indep{mode}"))[img_idx] exp_vmin, _ = self._expected_clims(f"indep{mode}")[img_idx] self.assertTrue(np.isclose(vmin, exp_vmin, atol=1e-6)) @@ -1731,7 +1739,7 @@ def test_indep_vrange_vmax(self): for mode in range(4): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): - _, vmax = _get_clims(self._imshow(f"indep{mode}"))[img_idx] + _, vmax = self._get_clims(self._imshow(f"indep{mode}"))[img_idx] _, exp_vmax = self._expected_clims(f"indep{mode}")[img_idx] self.assertTrue(np.isclose(vmax, exp_vmax, atol=1e-6)) @@ -1740,8 +1748,8 @@ def test_indep_vrange_title_matches_clim(self): for img_idx in range(4): with self.subTest(mode=mode, img_idx=img_idx): fig = self._imshow(f"indep{mode}") - clim_vmin, clim_vmax = _get_clims(fig)[img_idx] - title_vmin, title_vmax = _get_title_clims(fig)[img_idx] + clim_vmin, clim_vmax = self._get_clims(fig)[img_idx] + title_vmin, title_vmax = self._get_title_clims(fig)[img_idx] self.assertEqual("{:.1e}".format(clim_vmin), "{:.1e}".format(title_vmin)) self.assertEqual("{:.1e}".format(clim_vmax), "{:.1e}".format(title_vmax)) diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index 430098a..96208eb 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -832,8 +832,6 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, "or [0, 255] (for ints).") vrange_list, cmap = colormap_range(image, contains_rgb, vrange, cmap, n_cols) - print(len(image)) - print(len(vrange_list)) assert len(image) == len(vrange_list) for im, a, r, t, z in zip(image, axes, vrange_list, title, zooms): From 17f1fe40228ed4452376a879eb1a1be79b99f0ce Mon Sep 17 00:00:00 2001 From: erica Date: Fri, 18 Sep 2026 13:43:35 -0400 Subject: [PATCH 5/7] add error for using indeprow/indepcol --- src/pyrtools/tools/display.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index 96208eb..cd007cd 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -295,6 +295,13 @@ def colormap_range(image, contains_rgb, vrange='indep1', cmap=None, n_cols = Non for each image. """ if isinstance(vrange, str): + + if 'indep' in vrange and 'row' in vrange: + raise ValueError("indep and row cannot be used together in vrange. Use either indep or auto[x]row") + if 'indep' in vrange and 'col' in vrange: + raise ValueError("indep and col cannot be used together in vrange. Use either indep or " \ + "auto[x]col or auto[x]colcomplex") + if vrange[:4] == 'auto': # flatimg is one long 1d array, which enables the min, max, mean, std, and # percentile calls to operate on the values from each of the images simultaneously. From 2cb498645f9029d03b6b29b62a7b97ea6aa959c0 Mon Sep 17 00:00:00 2001 From: erica Date: Fri, 18 Sep 2026 13:46:53 -0400 Subject: [PATCH 6/7] fix spacing --- src/pyrtools/tools/display.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index cd007cd..874e35a 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -348,7 +348,6 @@ def colormap_range(image, contains_rgb, vrange='indep1', cmap=None, n_cols = Non # because the vrange doesn't depend on the computed values above, it # must be either [0, 1] for floats or [0, 255] for ints pass - # make sure to return as many ranges as there are images vrange_list = [] if 'row' not in vrange and 'col' not in vrange: From e7fb17ac56483fe9a71782071b00c875f704e269 Mon Sep 17 00:00:00 2001 From: erica Date: Fri, 18 Sep 2026 14:24:35 -0400 Subject: [PATCH 7/7] update vrange docs to format correclty --- src/pyrtools/tools/display.py | 241 +++++++++++++++++++++------------- 1 file changed, 152 insertions(+), 89 deletions(-) diff --git a/src/pyrtools/tools/display.py b/src/pyrtools/tools/display.py index 874e35a..35e1b6e 100644 --- a/src/pyrtools/tools/display.py +++ b/src/pyrtools/tools/display.py @@ -716,6 +716,12 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, automatically rescaled so they're displayed at the same size. thus, their sizes must be scalar multiples of each other. vrange : `tuple` or `list` or `str` + + .. attention:: + this only affects the behavior for grayscale images. RGB images + will always be displayed with vrange [0, 1] (for floats) or [0, 255] + (for ints), because of how matplotlib handles them. + If a 2-tuple, specifies the image values vmin/vmax that are mapped to the minimum and maximum value of the colormap, respectively. If a list of 2-tuples, each image has an independent vmin/vmax, where each images @@ -723,44 +729,52 @@ def imshow(image, vrange='indep1', zoom=1, title='', col_wrap=None, ax=None, from first image to last. If a string: * `'auto0'`: all images have same vmin/vmax, which have the same absolute - value, and come from the minimum or maximum across all - images, whichever has the larger absolute value + value, and come from the minimum or maximum across all + images, whichever has the larger absolute value + * `'auto/auto1'`: all images have same vmin/vmax, which are the - minimum/maximum values across all images + minimum/maximum values across all images + * `'auto2'`: all images have same vmin/vmax, which are the mean (across - all images) minus/ plus 2 std dev (across all images) + all images) minus/ plus 2 std dev (across all images) + * `'auto3'`: all images have same vmin/vmax, chosen so as to map the - 10th/90th percentile values to the 10th/90th percentile of - the display intensity range. For example: vmin is the 10th - percentile image value minus 1/8 times the difference - between the 90th and 10th percentile + 10th/90th percentile values to the 10th/90th percentile of + the display intensity range. For example: vmin is the 10th + percentile image value minus 1/8 times the difference + between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, - mean minus/plus 2 std dev, or percentile statistics are - computed across all images in a given row, and those - values are used as the vmin/vmax for all images in that row. - High pass and low pass residuals have independent vmin/vmax - based on min/max of the residual image itself. + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1col'`, `'auto2col'`, or `'auto3col'`. + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both the real - and imaginary parts of all images in that column. + and imaginary parts of all images in that column. + * `'indep0'`: each image has an independent vmin/vmax, which have the - same absolute value, which comes from either their - minimum or maximum value, whichever has the larger - absolute value. + same absolute value, which comes from either their + minimum or maximum value, whichever has the larger + absolute value. + * `'indep1'`: each image has an independent vmin/vmax, which are their - minimum/maximum values + minimum/maximum values. + * `'indep2'`: each image has an independent vmin/vmax, which is their - mean minus/plus 2 std dev + mean minus/plus 2 std dev. + * `'indep3'`: each image has an independent vmin/vmax, chosen so that - the 10th/90th percentile values map to the 10th/90th - percentile intensities. - NOTE: this only affects the behavior for grayscale images. RGB images - will always be displayed with vrange [0, 1] (for floats) or [0, 255] - (for ints), because of how matplotlib handles them. + the 10th/90th percentile values map to the 10th/90th + percentile intensities. + zoom : `float` ratio of display pixels to image pixels. if >1, must be an integer. If <1, must be 1/d where d is a a divisor of the size of the largest @@ -873,41 +887,65 @@ def animshow(video, framerate=2., as_html5=True, repeat=False, repeat : `bool` whether to loop the animation or just play it once vrange : `tuple` or `list` or `str` - If a 2-tuple, specifies the image values vmin/vmax that are mapped to the minimum and - maximum value of the colormap, respectively. If a list + + .. attention:: + this only affects the behavior for grayscale images. RGB images + will always be displayed with vrange [0, 1] (for floats) or [0, 255] + (for ints), because of how matplotlib handles them. + + If a 2-tuple, specifies the image values vmin/vmax that are mapped to + the minimum and maximum value of the colormap, respectively. If a list of 2-tuples, each image has an independent vmin/vmax, where each images minimum/maximum values are specified by the 2-tuples in the list ordered - from first image to last. If a string: - * `'auto/auto1'`: all images have same vmin/vmax, which are the minimum/maximum values - across all images - * `'auto2'`: all images have same vmin/vmax, which are the mean (across all images) minus/ - plus 2 std dev (across all images) - * `'auto3'`: all images have same vmin/vmax, chosen so as to map the 10th/90th percentile - values to the 10th/90th percentile of the display intensity range. For - example: vmin is the 10th percentile image value minus 1/8 times the - difference between the 90th and 10th percentile + from first image to last. If a string: + + * `'auto0'`: all images have same vmin/vmax, which have the same absolute + value, and come from the minimum or maximum across all + images, whichever has the larger absolute value + + * `'auto/auto1'`: all images have same vmin/vmax, which are the + minimum/maximum values across all images + + * `'auto2'`: all images have same vmin/vmax, which are the mean (across + all images) minus/ plus 2 std dev (across all images) + + * `'auto3'`: all images have same vmin/vmax, chosen so as to map the + 10th/90th percentile values to the 10th/90th percentile of + the display intensity range. For example: vmin is the 10th + percentile image value minus 1/8 times the difference + between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, - mean minus/plus 2 std dev, or percentile statistics are - computed across all images in a given row, and those - values are used as the vmin/vmax for all images in that row. - High pass and low pass residuals have independent vmin/vmax - based on min/max of the residual image itself. + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1col'`, `'auto2col'`, or `'auto3col'`. + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both the real - and imaginary parts of all images in that column. - * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum - values - * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 - std dev - * `'indep3'`: each image has an independent vmin/vmax, chosen so that the 10th/90th - percentile values map to the 10th/90th percentile intensities. - NOTE: this only affects the behavior for grayscale images. RGB images - will always be displayed with vrange [0, 1] (for floats) or [0, 255] - (for ints), because of how matplotlib handles them. + and imaginary parts of all images in that column. + + * `'indep0'`: each image has an independent vmin/vmax, which have the + same absolute value, which comes from either their + minimum or maximum value, whichever has the larger + absolute value. + + * `'indep1'`: each image has an independent vmin/vmax, which are their + minimum/maximum values. + + * `'indep2'`: each image has an independent vmin/vmax, which is their + mean minus/plus 2 std dev. + + * `'indep3'`: each image has an independent vmin/vmax, chosen so that + the 10th/90th percentile values map to the 10th/90th + percentile intensities. + zoom : `float` amount we zoom the video frames (must result in an integer when multiplied by video.shape[1:]) @@ -1016,40 +1054,65 @@ def pyrshow(pyr_coeffs, is_complex=False, vrange='auto1row', col_wrap=None, zoom default False, indicates whether the pyramids is real or complex indicating whether the pyramid is complex or real vrange : `tuple` or `list` or `str` - If a single 2-tuple, specifies the image values vmin/vmax that are mapped to the minimum and - maximum value of the colormap, respectively. - If a list of 2-tuples, each image has an independent vmin/vmax, where each images minimum/maximum - values are specified by the 2-tuples in the list ordered from first image to last. If a string: - - * `'auto/auto1'`: all images have same vmin/vmax, which are the minimum/maximum values - across all images - * `'auto2'`: all images have same vmin/vmax, which are the mean (across all images) minus/ - plus 2 std dev (across all images) - * `'auto3'`: all images have same vmin/vmax, chosen so as to map the 10th/90th percentile - values to the 10th/90th percentile of the display intensity range. For - example: vmin is the 10th percentile image value minus 1/8 times the - difference between the 90th and 10th percentile + + .. attention:: + this only affects the behavior for grayscale images. RGB images + will always be displayed with vrange [0, 1] (for floats) or [0, 255] + (for ints), because of how matplotlib handles them. + + If a 2-tuple, specifies the image values vmin/vmax that are mapped to + the minimum and maximum value of the colormap, respectively. If a list + of 2-tuples, each image has an independent vmin/vmax, where each images + minimum/maximum values are specified by the 2-tuples in the list ordered + from first image to last. If a string: + + * `'auto0'`: all images have same vmin/vmax, which have the same absolute + value, and come from the minimum or maximum across all + images, whichever has the larger absolute value + + * `'auto/auto1'`: all images have same vmin/vmax, which are the + minimum/maximum values across all images + + * `'auto2'`: all images have same vmin/vmax, which are the mean (across + all images) minus/ plus 2 std dev (across all images) + + * `'auto3'`: all images have same vmin/vmax, chosen so as to map the + 10th/90th percentile values to the 10th/90th percentile of + the display intensity range. For example: vmin is the 10th + percentile image value minus 1/8 times the difference + between the 90th and 10th percentile + * `'auto[X]row'`: each row of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, - mean minus/plus 2 std dev, or percentile statistics are - computed across all images in a given row, and those - values are used as the vmin/vmax for all images in that row. - High pass and low pass residuals have independent vmin/vmax - based on min/max of the residual image itself + computed using the auto[X] methods described above Eg. + `'auto1row'`, `'auto2row'`, or `'auto3row'`Ie. min/max, + mean minus/plus 2 std dev, or percentile statistics are + computed across all images in a given row, and those + values are used as the vmin/vmax for all images in that row. + High pass and low pass residuals have independent vmin/vmax + based on min/max of the residual image itself. + * `'auto[X]col'`: each column of the figure has the same vmin/vmax, which are - computed using the auto[X] methods described above Eg. - `'auto1col'`, `'auto2col'`, or `'auto3col'`. Note for complex pyramids, - the vmin/vmax for each column is computed across both the real and - imaginary parts + computed using the auto[X] methods described above Eg. + `'auto1col'`, `'auto2col'`, or `'auto3col'`. + * `'auto[X]colcomplex'`: vmin and vmax for each column is computed across both the real - and imaginary parts of all images in that column. - * `'indep1'`: each image has an independent vmin/vmax, which are their minimum/maximum - values - * `'indep2'`: each image has an independent vmin/vmax, which is their mean minus/plus 2 - std dev - * `'indep3'`: each image has an independent vmin/vmax, chosen so that the 10th/90th - percentile values map to the 10th/90th percentile intensities. + and imaginary parts of all images in that column. + + * `'indep0'`: each image has an independent vmin/vmax, which have the + same absolute value, which comes from either their + minimum or maximum value, whichever has the larger + absolute value. + + * `'indep1'`: each image has an independent vmin/vmax, which are their + minimum/maximum values. + + * `'indep2'`: each image has an independent vmin/vmax, which is their + mean minus/plus 2 std dev. + + * `'indep3'`: each image has an independent vmin/vmax, chosen so that + the 10th/90th percentile values map to the 10th/90th + percentile intensities. + col_wrap : `int` or None Only usable when the pyramid is one-dimensional (e.g., Gaussian or Laplacian Pyramid), otherwise the column wrap is determined by the number of bands. If not None, how many axes