diff --git a/stimupy/components/__init__.py b/stimupy/components/__init__.py index 4bee84de..20ae5192 100644 --- a/stimupy/components/__init__.py +++ b/stimupy/components/__init__.py @@ -1,7 +1,6 @@ import itertools import warnings from copy import deepcopy - import numpy as np from stimupy.utils import int_factorize, resolution @@ -435,6 +434,15 @@ def round_n_phases(n_phases, length, period="either"): def create_overview(): + """ + Create dictionary with examples from all stimulus-components + + Returns + ------- + stims : dict + dict with all stimuli containing individual stimulus dicts. + """ + p = { "visual_size": 10, "ppd": 20, @@ -505,10 +513,26 @@ def create_overview(): return stims -def overview(mask=False, save=None): +def overview(mask=False, save=None, extent_key="shape"): + """ + Plot overview with examples from all stimulus-components + + Parameters + ---------- + mask : bool or str, optional + If True, plot mask on top of stimulus image (default: False). + If string is provided, plot this key from stimulus dictionary as mask + save : None or str, optional + If None (default), do not save the plot. + If string is provided, save plot under this name. + extent_key : str, optional + Key to extent which will be used for plotting. + Default is "shape", using the image size in pixels as extent. + + """ from stimupy.utils import plot_stimuli stims = create_overview() # Plotting - plot_stimuli(stims, mask=mask, save=save) + plot_stimuli(stims, mask=mask, save=save, extent_key=extent_key) diff --git a/stimupy/components/mondrians.py b/stimupy/components/mondrians.py index 1ac5a40a..4f79e74a 100644 --- a/stimupy/components/mondrians.py +++ b/stimupy/components/mondrians.py @@ -1,7 +1,7 @@ import numpy as np from stimupy.components.shapes import parallelogram -from stimupy.utils import degrees_to_pixels, resolution +from stimupy.utils import resolution __all__ = [ "mondrians", @@ -88,8 +88,8 @@ def mondrians( except Exception: raise ValueError("Mondrian position tuples should be (ypos, xpos)") - ypos, xpos = degrees_to_pixels(mondrian_positions[m], ppd[0]) - individual_shapes = degrees_to_pixels(mondrian_sizes[m], ppd[0]) + ypos, xpos = resolution.lengths_from_visual_angles_ppd(mondrian_positions[m], ppd[0]) + individual_shapes = resolution.lengths_from_visual_angles_ppd(mondrian_sizes[m], ppd[0]) try: if len(individual_shapes) == 2: diff --git a/stimupy/illusions/__init__.py b/stimupy/illusions/__init__.py index a616031e..d6229930 100644 --- a/stimupy/illusions/__init__.py +++ b/stimupy/illusions/__init__.py @@ -21,6 +21,15 @@ def create_overview(): + """ + Create dictionary with examples from all stimulus-illusions + + Returns + ------- + stims : dict + dict with all stimuli containing individual stimulus dicts. + """ + p = { "visual_size": (10, 10), "ppd": 20, @@ -135,10 +144,26 @@ def create_overview(): return stims -def overview(mask=False, save=None): +def overview(mask=False, save=None, extent_key="shape"): + """ + Plot overview with examples from all stimulus-illusions + + Parameters + ---------- + mask : bool or str, optional + If True, plot mask on top of stimulus image (default: False). + If string is provided, plot this key from stimulus dictionary as mask + save : None or str, optional + If None (default), do not save the plot. + If string is provided, save plot under this name. + extent_key : str, optional + Key to extent which will be used for plotting. + Default is "shape", using the image size in pixels as extent. + + """ from stimupy.utils import plot_stimuli stims = create_overview() # Plotting - plot_stimuli(stims, mask=mask, save=save) + plot_stimuli(stims, mask=mask, save=save, extent_key=extent_key) diff --git a/stimupy/illusions/benarys.py b/stimupy/illusions/benarys.py index 9110aaa2..ed67ae1a 100644 --- a/stimupy/illusions/benarys.py +++ b/stimupy/illusions/benarys.py @@ -4,7 +4,7 @@ from scipy.ndimage import rotate from stimupy.components.shapes import cross, triangle -from stimupy.utils import degrees_to_pixels, resolution +from stimupy.utils import resolution __all__ = [ "cross_generalized", @@ -348,7 +348,7 @@ def todorovic_generalized( raise ValueError("ppd should be equal in x and y direction") L_size = (visual_size[0] / 2, visual_size[0] / 2, L_width, visual_size[1] - L_width) - top, bottom, left, right = degrees_to_pixels(L_size, np.unique(ppd)) + top, bottom, left, right = resolution.lengths_from_visual_angles_ppd(L_size, np.unique(ppd)) width, height = left + right, top + bottom # Create stimulus without targets @@ -632,9 +632,9 @@ def add_targets( "target_type, target_orientation, target_x and target_y need the same length." ) - theight, twidth = degrees_to_pixels(target_size, ppd) - ty = degrees_to_pixels(target_y, ppd) - tx = degrees_to_pixels(target_x, ppd) + theight, twidth = resolution.lengths_from_visual_angles_ppd(target_size, ppd) + ty = resolution.lengths_from_visual_angles_ppd(target_y, ppd) + tx = resolution.lengths_from_visual_angles_ppd(target_x, ppd) if (twidth + np.array(tx)).max() > img.shape[1]: raise ValueError("Rightmost target does not fit in image.") diff --git a/stimupy/illusions/cubes.py b/stimupy/illusions/cubes.py index f6d3b7f8..4eb6b101 100644 --- a/stimupy/illusions/cubes.py +++ b/stimupy/illusions/cubes.py @@ -1,6 +1,6 @@ import numpy as np -from stimupy.utils import degrees_to_pixels, resolution +from stimupy.utils import resolution __all__ = [ "varying_cells", @@ -80,9 +80,9 @@ def varying_cells( cell_spacing = list(cell_spacing) cell_spacing[n_cells - 1] = 0 - cheights = degrees_to_pixels(cell_heights, ppd) - cwidths = degrees_to_pixels(cell_widths, ppd) - cspaces = degrees_to_pixels(cell_spacing, ppd) + cheights = resolution.lengths_from_visual_angles_ppd(cell_heights, np.unique(ppd)) + cwidths = resolution.lengths_from_visual_angles_ppd(cell_widths, np.unique(ppd)) + cspaces = resolution.lengths_from_visual_angles_ppd(cell_spacing, np.unique(ppd)) height = sum(cwidths) + sum(cspaces) width = height @@ -212,8 +212,8 @@ def cube( targets = () height, width = shape - cell_space = degrees_to_pixels(cell_spacing, np.unique(ppd)[0]) - cell_thick = degrees_to_pixels(cell_thickness, np.unique(ppd)[0]) + cell_space = resolution.lengths_from_visual_angles_ppd(cell_spacing, np.unique(ppd)) + cell_thick = resolution.lengths_from_visual_angles_ppd(cell_thickness, np.unique(ppd)) # Initiate image img = np.ones([height, width]) * intensity_background diff --git a/stimupy/illusions/hermanns.py b/stimupy/illusions/hermanns.py index cf8b666a..f31ca222 100644 --- a/stimupy/illusions/hermanns.py +++ b/stimupy/illusions/hermanns.py @@ -1,6 +1,6 @@ import numpy as np -from stimupy.utils import degrees_to_pixels, resolution +from stimupy.utils import resolution __all__ = [ "grid", @@ -53,19 +53,19 @@ def grid( if len(np.unique(ppd)) > 1: raise ValueError("ppd should be equal in x and y direction") - element_height, element_width, element_thick = degrees_to_pixels(element_size, np.unique(ppd)) + eheight, ewidth, ethick = resolution.lengths_from_visual_angles_ppd(element_size, np.unique(ppd)) - if element_height <= element_thick: + if eheight <= ethick: raise ValueError("Element thickness larger than height") - if element_width <= element_thick: + if ewidth <= ethick: raise ValueError("Element thickness larger than width") - if element_thick <= 0: + if ethick <= 0: raise ValueError("Increase element thickness") img = np.ones(shape) * intensity_background - for i in range(element_thick): - img[i::element_height, :] = intensity_grid - img[:, i::element_width] = intensity_grid + for i in range(ethick): + img[i::eheight, :] = intensity_grid + img[:, i::ewidth] = intensity_grid stim = { "img": img, diff --git a/stimupy/illusions/mondrians.py b/stimupy/illusions/mondrians.py index 36d9e748..1bc2779a 100644 --- a/stimupy/illusions/mondrians.py +++ b/stimupy/illusions/mondrians.py @@ -1,7 +1,7 @@ import numpy as np from stimupy.components.mondrians import mondrians -from stimupy.utils import degrees_to_pixels, resolution +from stimupy.utils import resolution __all__ = [ "corrugated_mondrians", @@ -76,7 +76,7 @@ def corrugated_mondrians( ) height, width = visual_size - mdepths_px = degrees_to_pixels(mondrian_depths, ppd[0]) + mdepths_px = resolution.lengths_from_visual_angles_ppd(mondrian_depths, ppd[0]) max_depth = np.abs(np.array(mdepths_px)).max() sum_depth = np.array(mdepths_px).sum() red_depth = np.maximum(max_depth, sum_depth) diff --git a/stimupy/illusions/todorovics.py b/stimupy/illusions/todorovics.py index 9a5305d0..99c07703 100644 --- a/stimupy/illusions/todorovics.py +++ b/stimupy/illusions/todorovics.py @@ -2,7 +2,7 @@ from stimupy.components.shapes import cross as cross_shape from stimupy.components.shapes import rectangle as rectangle_shape -from stimupy.utils import degrees_to_pixels, pad_dict_to_shape, resolution, stack_dicts +from stimupy.utils import pad_dict_to_shape, resolution, stack_dicts __all__ = [ "rectangle_generalized", @@ -111,9 +111,14 @@ def rectangle_generalized( mask = stim["shape_mask"] # Add covers - cheight, cwidth = degrees_to_pixels(covers_size, ppd) - cx = degrees_to_pixels(covers_x, np.unique(ppd)) - cy = degrees_to_pixels(covers_y, np.unique(ppd)) + cheight, cwidth = resolution.lengths_from_visual_angles_ppd(covers_size, np.unique(ppd), round=False) + cx = resolution.lengths_from_visual_angles_ppd(covers_x, np.unique(ppd), round=False) + cy = resolution.lengths_from_visual_angles_ppd(covers_y, np.unique(ppd), round=False) + + cheight = int(np.round(cheight)) + cwidth = int(np.round(cwidth)) + cx = np.round(cx).astype(int) + cy = np.round(cy).astype(int) if np.max(cx) < np.min(cx) + cwidth or np.max(cy) < np.min(cy) + cheight: raise ValueError("Covers overlap") @@ -338,9 +343,14 @@ def cross_generalized( img = stim["img"] mask = stim["shape_mask"] - cheight, cwidth = degrees_to_pixels(covers_size, ppd) - cx = degrees_to_pixels(covers_x, ppd) - cy = degrees_to_pixels(covers_y, ppd) + cheight, cwidth = resolution.lengths_from_visual_angles_ppd(covers_size, np.unique(ppd), round=False) + cx = resolution.lengths_from_visual_angles_ppd(covers_x, np.unique(ppd), round=False) + cy = resolution.lengths_from_visual_angles_ppd(covers_y, np.unique(ppd), round=False) + + cheight = int(np.round(cheight)) + cwidth = int(np.round(cwidth)) + cx = np.round(cx).astype(int) + cy = np.round(cy).astype(int) for i in range(len(covers_x)): img[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = intensity_covers diff --git a/stimupy/illusions/wedding_cakes.py b/stimupy/illusions/wedding_cakes.py index aa606bc2..1f0e1cf6 100644 --- a/stimupy/illusions/wedding_cakes.py +++ b/stimupy/illusions/wedding_cakes.py @@ -1,7 +1,7 @@ import numpy as np from scipy.signal import fftconvolve -from stimupy.utils import degrees_to_pixels, resolution +from stimupy.utils import resolution __all__ = [ "wedding_cake", @@ -69,7 +69,7 @@ def wedding_cake( raise ValueError("ppd should be equal in x and y direction") nY, nX = shape - Ly, Lx, Lw = degrees_to_pixels(L_size, np.unique(ppd)) + Ly, Lx, Lw = resolution.lengths_from_visual_angles_ppd(L_size, np.unique(ppd)) Lyh, Lxh = int(Ly / 2) + 1, int(Lx / 2) + 1 # Create L-shaped patch @@ -97,7 +97,7 @@ def wedding_cake( if target_indices1 is not None and target_height is not None: # Create target patch2 - theight = degrees_to_pixels(target_height, np.unique(ppd)) + theight = resolution.lengths_from_visual_angles_ppd(target_height, np.unique(ppd)) tpatch1 = np.zeros(L_patch.shape) tpatch1[int(Ly / 2 - theight / 2) : int(Ly / 2 + theight / 2), Lx - Lw : :] = ( -intensity_grating[1] + intensity_target @@ -120,7 +120,7 @@ def wedding_cake( if target_indices2 is not None and target_height is not None: # Create target patch2 - theight = degrees_to_pixels(target_height, np.unique(ppd)) + theight = resolution.lengths_from_visual_angles_ppd(target_height, np.unique(ppd)) tpatch2 = np.zeros(L_patch.shape) tpatch2[int(Ly / 2 - theight / 2) : int(Ly / 2 + theight / 2), Lx - Lw : :] = ( -intensity_grating[0] + intensity_target diff --git a/stimupy/illusions/whites.py b/stimupy/illusions/whites.py index bc9352be..d356420b 100644 --- a/stimupy/illusions/whites.py +++ b/stimupy/illusions/whites.py @@ -8,7 +8,7 @@ from stimupy.illusions.angulars import pinwheel as radial from stimupy.illusions.circulars import rings as circular from stimupy.illusions.wedding_cakes import wedding_cake -from stimupy.utils import degrees_to_pixels +from stimupy.utils import resolution __all__ = [ "generalized", @@ -467,17 +467,18 @@ def anderson( img = stim["img"] mask = stim["target_mask"] - stripe_center_offset_px = degrees_to_pixels(stripe_center_offset, ppd) - stripe_size_px = degrees_to_pixels(stripe_height, ppd) - cycle_width_px = degrees_to_pixels(1.0 / (frequency * 2), ppd) * 2 - phase_width_px = cycle_width_px // 2 + soffset = resolution.lengths_from_visual_angles_ppd(stripe_center_offset, np.unique(ppd)[0]) + sheight = resolution.lengths_from_visual_angles_ppd(stripe_height, np.unique(ppd)[0]) + cycle_width = resolution.lengths_from_visual_angles_ppd(1.0 / (frequency * 2), np.unique(ppd)[0]) * 2 + + phase_width_px = cycle_width // 2 height, width = img.shape nbars = width // phase_width_px ttop, tbot = np.array(target_indices_top), np.array(target_indices_bottom) ttop[ttop < 0] = nbars + ttop[ttop < 0] tbot[tbot < 0] = nbars + tbot[tbot < 0] - if stripe_size_px / 2.0 > stripe_center_offset_px: + if sheight / 2.0 > soffset: raise ValueError("Stripes overlap! Increase stripe offset or decrease stripe size.") if (target_height / 2 - target_center_offset + stripe_height / 2 - stripe_center_offset) > 0: raise ValueError( @@ -485,27 +486,27 @@ def anderson( "decrease stripe or target size" ) if stripe_center_offset * ppd % 1 != 0: - offsets_new = stripe_center_offset_px / ppd + offsets_new = soffset / ppd warnings.warn( f"Stripe offsets rounded because of ppd; {stripe_center_offset} -> {offsets_new}" ) # Add stripe at top - ystart = height // 2 - stripe_center_offset_px - stripe_size_px // 2 - img[ystart : ystart + stripe_size_px, 0 : phase_width_px * np.min(ttop)] = intensity_stripes[0] + ystart = height // 2 - soffset - sheight // 2 + img[ystart : ystart + sheight, 0 : phase_width_px * np.min(ttop)] = intensity_stripes[0] img[ - ystart : ystart + stripe_size_px, phase_width_px * (np.max(ttop) + 1) : : + ystart : ystart + sheight, phase_width_px * (np.max(ttop) + 1) : : ] = intensity_stripes[0] - if (ystart < 0) or (ystart + stripe_size_px > height): + if (ystart < 0) or (ystart + sheight > height): raise ValueError("Anderson stripes do not fully fit into stimulus") # Add stripe at bottom - ystart = height // 2 + stripe_center_offset_px - stripe_size_px // 2 - img[ystart : ystart + stripe_size_px, 0 : phase_width_px * np.min(tbot)] = intensity_stripes[1] + ystart = height // 2 + soffset - sheight // 2 + img[ystart : ystart + sheight, 0 : phase_width_px * np.min(tbot)] = intensity_stripes[1] img[ - ystart : ystart + stripe_size_px, phase_width_px * (np.max(tbot) + 1) : : + ystart : ystart + sheight, phase_width_px * (np.max(tbot) + 1) : : ] = intensity_stripes[1] - if (ystart < 0) or (ystart + stripe_size_px > height): + if (ystart < 0) or (ystart + sheight > height): raise ValueError("Anderson stripes do not fully fit into stimulus") stim["img"] = img @@ -698,10 +699,10 @@ def yazdanbakhsh( img = stim["img"] mask = stim["target_mask"] - gap_size_px = degrees_to_pixels(gap_size, ppd) - target_offset_px = degrees_to_pixels(target_center_offset, ppd) - tsize_px = degrees_to_pixels(target_height, ppd) - cycle_width_px = degrees_to_pixels(1.0 / (frequency * 2), ppd) * 2 + gap_size_px = resolution.lengths_from_visual_angles_ppd(gap_size, np.unique(ppd)[0]) + target_offset_px = resolution.lengths_from_visual_angles_ppd(target_center_offset, np.unique(ppd)[0]) + tsize_px = resolution.lengths_from_visual_angles_ppd(target_height, np.unique(ppd)[0]) + cycle_width_px = resolution.lengths_from_visual_angles_ppd(1.0 / (frequency * 2), np.unique(ppd)[0]) * 2 phase_width_px = cycle_width_px // 2 height, width = img.shape nbars = width // phase_width_px diff --git a/stimupy/noises/__init__.py b/stimupy/noises/__init__.py index ca7a656d..86fa945e 100644 --- a/stimupy/noises/__init__.py +++ b/stimupy/noises/__init__.py @@ -6,6 +6,15 @@ def create_overview(): + """ + Create dictionary with examples from all stimulus-noises + + Returns + ------- + stims : dict + dict with all stimuli containing individual stimulus dicts. + """ + params = { "visual_size": 10, "ppd": 10, @@ -31,10 +40,26 @@ def create_overview(): return stims -def overview(mask=False, save=None): +def overview(mask=False, save=None, extent_key="shape"): + """ + Plot overview with examples from all stimulus-noises + + Parameters + ---------- + mask : bool or str, optional + If True, plot mask on top of stimulus image (default: False). + If string is provided, plot this key from stimulus dictionary as mask + save : None or str, optional + If None (default), do not save the plot. + If string is provided, save plot under this name. + extent_key : str, optional + Key to extent which will be used for plotting. + Default is "shape", using the image size in pixels as extent. + + """ from stimupy.utils import plot_stimuli stims = create_overview() # Plotting - plot_stimuli(stims, mask=mask, save=save) + plot_stimuli(stims, mask=mask, save=save, extent_key=extent_key) diff --git a/stimupy/utils/__init__.py b/stimupy/utils/__init__.py index 1b12f03f..44d9dd35 100644 --- a/stimupy/utils/__init__.py +++ b/stimupy/utils/__init__.py @@ -1,5 +1,5 @@ from .filters import * from .pad import * from .plotting import * -from .resolution import degrees_to_pixels +from .resolution import * from .utils import * diff --git a/stimupy/utils/color_conversions.py b/stimupy/utils/color_conversions.py index b2286097..c5f943db 100644 --- a/stimupy/utils/color_conversions.py +++ b/stimupy/utils/color_conversions.py @@ -1,5 +1,10 @@ import numpy as np +__all__ = [ + "luminance2munsell", + "munsell2luminance", +] + def luminance2munsell(lum_values, reference_white): """ diff --git a/stimupy/utils/contrast_conversions.py b/stimupy/utils/contrast_conversions.py index cd5f8e4d..c600bf43 100644 --- a/stimupy/utils/contrast_conversions.py +++ b/stimupy/utils/contrast_conversions.py @@ -1,5 +1,12 @@ import numpy as np +__all__ = [ + "transparency", + "adapt_michelson_contrast", + "adapt_rms_contrast", + "adapt_normalized_rms_contrast", + "adapt_intensity_range", +] def transparency(img, mask=None, alpha=0.5, tau=0.2): """Applies a transparency to image at specified (mask) location if provided @@ -40,9 +47,12 @@ def adapt_michelson_contrast(stim, michelson_contrast, mean_luminance=None): desired mean luminance; if None (default), dont change mean luminance Returns - ------- - Updated stimulus dict with keys "img", "michelson_contrast" and "mean_luminance" - + ---------- + dict[str, Any] + dict with the stimulus (key: "img"), + Michelson contrast (key: "michelson_contrast"), + mean luminance ("mean_luminance") + and additional keys containing stimulus parameters """ if mean_luminance is None: mean_luminance = stim["img"].mean() @@ -72,9 +82,12 @@ def adapt_rms_contrast(stim, rms_contrast, mean_luminance=None): desired mean luminance; if None (default), dont change mean luminance Returns - ------- - Updated stimulus dict with keys "img", "rms_contrast" and "mean_luminance" - + ---------- + dict[str, Any] + dict with the stimulus (key: "img"), + RMS contrast (key: "rms_contrast"), + mean luminance ("mean_luminance") + and additional keys containing stimulus parameters """ if mean_luminance is None: mean_luminance = stim["img"].mean() @@ -102,9 +115,12 @@ def adapt_normalized_rms_contrast(stim, rms_contrast, mean_luminance=None): desired mean luminance; if None (default), dont change mean luminance Returns - ------- - Updated stimulus dict with keys "img", "rms_contrast" and "mean_luminance" - + ---------- + dict[str, Any] + dict with the stimulus (key: "img"), + RMS contrast (key: "rms_contrast"), + mean luminance ("mean_luminance") + and additional keys containing stimulus parameters """ if mean_luminance is None: mean_luminance = stim["img"].mean() @@ -132,15 +148,16 @@ def adapt_intensity_range(stim, intensity_min=0.0, intensity_max=1.0): new maximal intensity value Returns - ------- - Updated stimulus dict with keys "img", "intensity_min" and "intensity_max" - + ---------- + dict[str, Any] + dict with the stimulus (key: "img"), + intensity range (key: "intensity_range"), + and additional keys containing stimulus parameters """ img = (stim["img"] - stim["img"].min()) / (stim["img"].max() - stim["img"].min()) img = img * (intensity_max - intensity_min) + intensity_min stim["img"] = img - stim["intensity_min"] = intensity_min - stim["intensity_max"] = intensity_max + stim["intensity_range"] = (intensity_min, intensity_max) return stim diff --git a/stimupy/utils/export.py b/stimupy/utils/export.py index dc405524..afbe8f7b 100644 --- a/stimupy/utils/export.py +++ b/stimupy/utils/export.py @@ -4,9 +4,32 @@ import numpy as np from PIL import Image +__all__ = [ + "arrs_to_checksum", + "to_json", + "write_array_to_image", +] + def arrs_to_checksum(stim, keys=["img", "mask"]): - # Hash (md5) values, and save only the hex + """ + Hash (md5) values, and save only the hex + + Parameters + ---------- + stim : dict + stimulus dictionary containing keys + keys : str of list of str + keys of dict for which the hashing should be performed + + Returns + ---------- + dict[str, Any] + same as input dict but keys now only contain the hex + """ + if isinstance(keys, str): + keys = [keys,] + for key in keys: stim[key] = md5(np.ascontiguousarray(stim[key])).hexdigest() @@ -14,6 +37,17 @@ def arrs_to_checksum(stim, keys=["img", "mask"]): def to_json(stim, filename): + """ + Stimulus-dict(s) as (pretty) JSON + + Parameters + ---------- + stim : dict + stimulus dictionary containing keys + filename : str + full path to the file to be creaated. + + """ # stimulus-dict(s) as (pretty) JSON with open(filename, "w", encoding="utf-8") as f: json.dump(stim, f, ensure_ascii=False, indent=4) diff --git a/stimupy/utils/masks.py b/stimupy/utils/masks.py index 0c37aa9c..cedcc6d1 100644 --- a/stimupy/utils/masks.py +++ b/stimupy/utils/masks.py @@ -1,16 +1,24 @@ import numpy as np - -def avg_target_values(stim, f_average=np.median): +__all__ = [ + "avg_target_values", + "avg_img_values", + "all_img_values", + "img_values", +] + +def avg_target_values(stim, mask_key="target_mask", f_average=np.median): """Average pixel value in each target region of stimulus Parameters ---------- stim : dict[str: Any] stimulus-dict with at least "img" and "mask" - containing the stimulsu image and integer-mask, respectively. + containing the stimulus image and integer-mask, respectively. + mask_key : str + string with mask-key name f_average: function, default=numpy.median - How to average/summarise the pixels in each target region + How to average/summarise the pixels in each target region Returns ---------- @@ -22,7 +30,7 @@ def avg_target_values(stim, f_average=np.median): -------- avg_img_values """ - return avg_img_values(image=stim["img"], mask=stim["mask"], f_average=f_average) + return avg_img_values(image=stim["img"], mask=stim[mask_key], f_average=f_average) def avg_img_values(image, mask, f_average=np.median): @@ -58,7 +66,7 @@ def avg_img_values(image, mask, f_average=np.median): def all_img_values(img, mask): - """Isolate all image values/pixels, per target region specified in integer masks + """Isolate all image values/pixels, per target region specified in integer mask Parameters ---------- diff --git a/stimupy/utils/pad.py b/stimupy/utils/pad.py index fbf4b8d9..6c5cc7fb 100644 --- a/stimupy/utils/pad.py +++ b/stimupy/utils/pad.py @@ -1,7 +1,18 @@ import copy import numpy as np -from . import resolution +from .utils import resolution + +__all__ = [ + "pad_by_visual_size", + "pad_to_visual_size", + "pad_by_shape", + "pad_to_shape", + "pad_dict_by_visual_size", + "pad_dict_to_visual_size", + "pad_dict_by_shape", + "pad_dict_to_shape", +] def pad_by_visual_size(img, padding, ppd, pad_value=0.0): @@ -42,7 +53,7 @@ def pad_by_visual_size(img, padding, ppd, pad_value=0.0): padding_px = [] for axis in padding_degs: shape = [ - resolution.pix_from_visual_angle_ppd_1D(i, ppd) if i > 0 else 0 + resolution.length_from_visual_angle_ppd(i, ppd) if i > 0 else 0 for i, ppd in zip(axis, ppd) ] padding_px.append(shape) @@ -148,25 +159,6 @@ def pad_to_shape(img, shape, pad_value=0): ) -def resize_array(arr, factor): - """ - Return a copy of an array, resized by the given factor. Every value is - repeated factor[d] times along dimension d. - - Parameters - ---------- - arr : 2D array - the array to be resized - factor : tupel of 2 ints - the resize factor in the y and x dimensions - - Returns - ------- - An array of shape (arr.shape[0] * factor[0], arr.shape[1] * factor[1]) - """ - return np.repeat(np.repeat(arr, factor[0], axis=0), factor[1], axis=1) - - # %% ################################# # Dictionaries # @@ -193,11 +185,9 @@ def pad_dict_by_visual_size(dct, padding, ppd, pad_value=0.0, keys=("img", "*mas Returns ------- - dict with padded images by the specified amount(s) - - See also - --------- - stimupy.utils.resolution + dict[str, Any] + same as input dict but with larger key-arrays and updated keys for + "visual_size" and "shape" """ # Create deepcopy to not override existing dict new_dict = copy.deepcopy(dct) @@ -248,11 +238,9 @@ def pad_dict_to_visual_size(dct, visual_size, ppd, pad_value=0, keys=("img", "*m Returns ------- - dict with padded images by the specified amount(s) - - See also - --------- - stimupy.utils.resolution + dict[str, Any] + same as input dict but with larger key-arrays and updated keys for + "visual_size" and "shape" """ # visual_size to shape @@ -264,7 +252,6 @@ def pad_dict_to_visual_size(dct, visual_size, ppd, pad_value=0, keys=("img", "*m def pad_dict_by_shape(dct, padding, pad_value=0, keys=("img", "*mask")): """Pad images in dictionary by specified amount(s) of pixels - Can specify different amount (before, after) each axis. Parameters @@ -282,7 +269,9 @@ def pad_dict_by_shape(dct, padding, pad_value=0, keys=("img", "*mask")): Returns ------- - dict with padded images by the specified amount(s) + dict[str, Any] + same as input dict but with larger key-arrays and updated keys for + "visual_size" and "shape" """ # Ensure padding is in integers padding = np.array(padding, dtype=np.int32) @@ -335,7 +324,9 @@ def pad_dict_to_shape(dct, shape, pad_value=0, keys=("img", "*mask")): Returns ------- - dict with padded images by the specified amount(s) + dict[str, Any] + same as input dict but with larger key-arrays and updated keys for + "visual_size" and "shape" Raises ------ @@ -380,262 +371,3 @@ def pad_dict_to_shape(dct, shape, pad_value=0, keys=("img", "*mask")): if "ppd" in dct.keys(): new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(shape, dct["ppd"]) return new_dict - - -def resize_dict(dct, factor, keys=("img", "*mask")): - """ - Return a copy of an array, resized by the given factor. Every value is - repeated factor[d] times along dimension d. - - Parameters - ---------- - dct : dict - dict containing arrays to be resized - factor : tupel of 2 ints - the resize factor in the y and x dimensions - keys : Sequence[String, String] or String - keys in dict for images to be padded - - Returns - ------- - dict with arrays of shape (arr.shape[0] * factor[0], arr.shape[1] * factor[1]) - """ - # Create deepcopy to not override existing dict - new_dict = copy.deepcopy(dct) - - if isinstance(keys, str): - keys = (keys,) - - # Find relevant keys - keys = [ - dkey - for key in keys - for dkey in dct.keys() - if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) - ] - - for key in dct.keys(): - if key in keys: - img = dct[key] - if isinstance(img, np.ndarray): - img = np.repeat(np.repeat(img, factor[0], axis=0), factor[1], axis=1) - if key.endswith("mask"): - img = img.astype(int) - new_dict[key] = img - - # Update visual_size and shape-keys - dct["shape"] = resolution.validate_shape(img.shape) - if "ppd" in dct.keys(): - dct["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, dct["ppd"]) - return new_dict - - -def stack_dicts( - dct1, dct2, direction="horizontal", keys=("img", "*mask"), keep_mask_indices=False -): - """ - Return a dict with resized key-arrays by the given factor. Every value is - repeated factor[d] times along dimension d. - - Parameters - ---------- - dct1: dict - dict containing arrays to be stacked - dct2: dict - dict containing arrays to be stacked - direction : str - stack horizontal(ly) or vertical(ly) (default: horizontal) - keys : Sequence[String, String] or String - keys in dict for images to be padded - - Returns - ------- - dict with keys with stacked arrays - """ - - # Create deepcopy to not override existing dict - new_dict = copy.deepcopy(dct1) - - if isinstance(keys, str): - keys = (keys,) - - # Find relevant keys - keys1 = [ - dkey - for key in keys - for dkey in dct1.keys() - if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) - ] - keys2 = [ - dkey - for key in keys - for dkey in dct2.keys() - if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) - ] - - if not keys1 == keys2: - raise ValueError("The requested keys do not exist in both dicts") - - for key in dct1.keys(): - if key in keys1: - img1 = dct1[key] - img2 = dct2[key] - if isinstance(img1, np.ndarray) and isinstance(img2, np.ndarray): - if key.endswith("mask") and not keep_mask_indices: - img2 = np.where(img2 != 0, img2 + img1.max(), 0) - - if direction == "horizontal": - img = np.hstack([img1, img2]) - elif direction == "vertical": - img = np.vstack([img1, img2]) - else: - raise ValueError("direction must be horizontal or vertical") - - if key.endswith("mask"): - img = img.astype(int) - new_dict[key] = img - - # Update visual_size and shape-keys - new_dict["shape"] = resolution.validate_shape(img.shape) - if "ppd" in new_dict.keys(): - new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, new_dict["ppd"]) - return new_dict - - -def rotate_dict(dct, nrots=1, keys=("img", "*mask")): - """ - Return a dict with key-arrays rotated by nrots*90 degrees. - - Parameters - ---------- - dct: dict - dict containing arrays to be stacked - nrot : int - number of rotations by 90 degrees - keys : Sequence[String, String] or String - keys in dict for images to be padded - - Returns - ------- - dict with keys with rotated arrays - """ - - # Create deepcopy to not override existing dict - new_dict = copy.deepcopy(dct) - - # Find relevant keys - keys = [ - dkey - for key in keys - for dkey in dct.keys() - if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) - ] - - for key in dct.keys(): - if key in keys: - img = dct[key] - if isinstance(img, np.ndarray): - if isinstance(nrots, (int, float)): - img = np.rot90(img, nrots) - else: - raise ValueError("nrots must be a number") - - if key.endswith("mask"): - img = img.astype(int) - new_dict[key] = img - - # Update visual_size and shape-keys - new_dict["shape"] = resolution.validate_shape(img.shape) - if "ppd" in new_dict.keys(): - new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, new_dict["ppd"]) - return new_dict - - -def flip_dict(dct, direction="lr", keys=("img", "*mask")): - """ - Return a dict with key-arrays rotated by nrots*90 degrees. - - Parameters - ---------- - dct: dict - dict containing arrays to be stacked - direction : str - "lr" for left-right, "ud" for up-down flipping - keys : Sequence[String, String] or String - keys in dict for images to be padded - - Returns - ------- - dict with keys with rotated arrays - """ - - # Create deepcopy to not override existing dict - new_dict = copy.deepcopy(dct) - - # Find relevant keys - keys = [ - dkey - for key in keys - for dkey in dct.keys() - if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) - ] - - for key in dct.keys(): - if key in keys: - img = dct[key] - if isinstance(img, np.ndarray): - if direction == "lr": - img = np.fliplr(img) - elif direction == "ud": - img = np.flipud(img) - else: - raise ValueError("direction must be lr or ud") - - if key.endswith("mask"): - img = img.astype(int) - new_dict[key] = img - return new_dict - - -def roll_dict(dct, shift, axes, keys=("img", "*mask")): - """ - Return a dict with key-arrays rolled by shift in axes. - - Parameters - ---------- - dct: dict - dict containing arrays to be stacked - shift : int - number of pixels by which to shift - axes : Number or Sequence[Number, ...] - axes in which to shift - keys : Sequence[String, String] or String - keys in dict for images to be padded - - Returns - ------- - dict with keys with rolled arrays - """ - - # Create deepcopy to not override existing dict - new_dict = copy.deepcopy(dct) - shift = np.array(shift).astype(int) - - # Find relevant keys - keys = [ - dkey - for key in keys - for dkey in dct.keys() - if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) - ] - - for key in dct.keys(): - if key in keys: - img = dct[key] - if isinstance(img, np.ndarray): - img = np.roll(img, shift=shift, axis=axes) - - if key.endswith("mask"): - img = img.astype(int) - new_dict[key] = img - return new_dict diff --git a/stimupy/utils/plotting.py b/stimupy/utils/plotting.py index 876abcb0..40d5c0d3 100644 --- a/stimupy/utils/plotting.py +++ b/stimupy/utils/plotting.py @@ -4,8 +4,22 @@ import matplotlib.pyplot as plt import numpy as np +__all__ = [ + "compare_plots", + "plot_stim", + "plot_stimuli", +] def compare_plots(plots): + """ + Plot multiple plots in one plot for comparing. + + Parameters + ---------- + plots : list of plots + List containing plots which should be plotted + + """ M = len(plots) for i, (plot_name, plot) in enumerate(plots.items()): plt.subplot(1, M, i + 1) @@ -24,6 +38,38 @@ def plot_stim( save=None, extent_key="shape", ): + """ + Utility function to plot stimulus array (key: "img") from stim dict and mask (optional) + + Parameters + ---------- + stim : dict + stimulus dict containing stimulus-array (key: "img") + mask : bool or str, optional + If True, plot mask on top of stimulus image (default: False). + If string is provided, plot this key from stimulus dictionary as mask + stim_name : str, optional + Stimulus name used for plotting (default: "stim") + ax : Axis object, optional + If not None (default), plot in the specified Axis object + vmin : float, optional + Minimal intensity value for plotting. The default is 0. + vmax : float, optional + Minimal intensity value for plotting. The default is 1. + save : None or str, optional + If None (default), do not save the plot. + If string is provided, save plot under this name. + extent_key : str, optional + Key to extent which will be used for plotting. + Default is "shape", using the image size in pixels as extent. + + Returns + ------- + ax : Axis object + If ax was passed and plotting is None, returns updated Axis object. + + """ + print("Plotting:", stim_name) single_plot = False if ax is None: @@ -31,7 +77,12 @@ def plot_stim( single_plot = True if extent_key in stim.keys(): - extent = [0, stim[extent_key][0], 0, stim[extent_key][1]] + if len(stim[extent_key]) == 2: + extent = [0, stim[extent_key][0], 0, stim[extent_key][1]] + elif len(stim[extent_key]) == 4: + extent = stim[extent_key] + else: + raise ValueError("extent should either contain 2 or 4 values") else: warnings.warn("extent_key does not exist in dict, using pixel-extent") extent = [0, stim["img"].shape[0], 0, stim["img"].shape[1]] @@ -102,6 +153,28 @@ def plot_stimuli( save=None, extent_key="shape", ): + """ + Utility function to plot multuple stimuli (key: "img") from stim dicts and mask (optional) + + Parameters + ---------- + stims : dict of dicts + dictionary composed of stimulus dicts containing stimulus-array (key: "img") + mask : bool or str, optional + If True, plot mask on top of stimulus image (default: False). + If string is provided, plot this key from stimulus dictionary as mask + vmin : float, optional + Minimal intensity value for plotting. The default is 0. + vmax : float, optional + Minimal intensity value for plotting. The default is 1. + save : None or str, optional + If None (default), do not save the plot. + If string is provided, save plot under this name. + extent_key : str, optional + Key to extent which will be used for plotting. + Default is "shape", using the image size in pixels as extent. + + """ # Plot each stimulus+mask n_stim = int(np.ceil(np.sqrt(len(stims)))) diff --git a/stimupy/utils/resolution.py b/stimupy/utils/resolution.py index e79648bb..7e00e41f 100644 --- a/stimupy/utils/resolution.py +++ b/stimupy/utils/resolution.py @@ -117,11 +117,11 @@ def resolve_1D(length=None, visual_angle=None, ppd=None, round=True): f"Rounding visual angle because of ppd; {visual_angle_old} ->" f" {visual_angle}" ) - length = pix_from_visual_angle_ppd_1D(visual_angle=visual_angle, ppd=ppd, round=round) + length = length_from_visual_angle_ppd(visual_angle=visual_angle, ppd=ppd, round=round) elif visual_angle is None: - visual_angle = visual_angle_from_length_ppd_1D(length=length, ppd=ppd) + visual_angle = visual_angle_from_length_ppd(length=length, ppd=ppd) elif ppd is None: - ppd = ppd_from_length_visual_angle_1D(length=length, visual_angle=visual_angle) + ppd = ppd_from_length_visual_angle(length=length, visual_angle=visual_angle) return length, visual_angle, ppd @@ -151,7 +151,7 @@ def valid_1D(length, visual_angle, ppd): """ # Check by calculating one component - calculated = pix_from_visual_angle_ppd_1D(visual_angle=visual_angle, ppd=ppd) + calculated = length_from_visual_angle_ppd(visual_angle=visual_angle, ppd=ppd) if calculated != length: raise ResolutionError(f"Invalid resolution; {visual_angle},{length},{ppd}") @@ -194,7 +194,20 @@ def valid_resolution(shape, visual_size, ppd): ############################# # Resolve components # ############################# -def visual_angle_from_length_ppd_1D(length, ppd): +def visual_angle_from_length_ppd(length, ppd): + """Calculate visual angle (degrees) from length (pixels) and pixels-per-degree + + Parameters + ---------- + length : int or None + length in pixels + ppd : int or None + pixels per degree + + Returns + ------- + Length (pixels) translated to visual angle (degrees) + """ if length is not None and ppd is not None: visual_angle = length / ppd else: @@ -202,6 +215,35 @@ def visual_angle_from_length_ppd_1D(length, ppd): return visual_angle +def visual_angles_from_lengths_ppd(lengths, ppd): + """Calculate visual sizes (degrees) from given shapes (pixels) and pixels-per-degree + + Parameters + ---------- + lengths : Sequence[int, int, ...] or None + list of lengths + ppd : int or None + pixels per degree + + Returns + ------- + List with lengths (pixels) translated to visual angles (degrees) + """ + if isinstance(lengths, (int, float)): + lengths = [lengths,] + + if lengths is not None and ppd is not None: + visual_angles = [] + for length in lengths: + visual_angles.append(visual_angle_from_length_ppd(length, ppd)) + else: + visual_angles = None + + if len(visual_angles) == 1: + visual_angles = visual_angles[0] + return visual_angles + + def visual_size_from_shape_ppd(shape, ppd): """Calculate visual size (degrees) from given shape (pixels) and pixels-per-degree @@ -227,8 +269,8 @@ def visual_size_from_shape_ppd(shape, ppd): ppd = validate_ppd(ppd) # Calculate width and height in pixels - width = visual_angle_from_length_ppd_1D(shape.width, ppd.horizontal) - height = visual_angle_from_length_ppd_1D(shape.height, ppd.vertical) + width = visual_angle_from_length_ppd(shape.width, ppd.horizontal) + height = visual_angle_from_length_ppd(shape.height, ppd.vertical) # Construct Visual size NamedTuple: visual_size = Visual_size(width=width, height=height) @@ -236,7 +278,22 @@ def visual_size_from_shape_ppd(shape, ppd): return visual_size -def pix_from_visual_angle_ppd_1D(visual_angle, ppd, round=True): +def length_from_visual_angle_ppd(visual_angle, ppd, round=True): + """Calculate length (pixels) from visual angle (degrees) and pixels-per-degree + + Parameters + ---------- + visual_angle : float or None + visual angle in degrees + ppd : int or None + pixels per degree + round : bool + if True, round output length to full pixels + + Returns + ------- + visual angle (degrees) translated to length (pixels) + """ if visual_angle is not None and ppd is not None: fpix = np.round(visual_angle * ppd, 10) @@ -245,12 +302,45 @@ def pix_from_visual_angle_ppd_1D(visual_angle, ppd, round=True): if fpix > 0 and fpix % pix: warnings.warn(f"Rounding shape; {visual_angle} * {ppd} = {fpix} -> {pix}") else: - pix = fpix + pix = float(fpix) else: pix = None return pix +def lengths_from_visual_angles_ppd(visual_angles, ppd, round=True): + """Calculate lengths (pixels) from visual angles (degrees) and pixels-per-degree + + Parameters + ---------- + visual_angles : Sequence[float, float, ...] or None + list of visual angles + ppd : int or None + pixels per degree + round : bool + if True, round output length to full pixels + + Returns + ------- + List with visual angles (degrees) translated to lengths (pixels) + """ + if isinstance(visual_angles, (int, float, np.int64)): + visual_angles = [visual_angles,] + if isinstance(ppd, (list, tuple)): + raise ValueError("ppd should be a single number") + + if visual_angles is not None and ppd is not None: + lengths = [] + for angle in visual_angles: + lengths.append(length_from_visual_angle_ppd(angle, ppd, round=round)) + else: + lengths = None + + if len(lengths) == 1: + lengths = lengths[0] + return lengths + + def shape_from_visual_size_ppd(visual_size, ppd): """Calculate shape (pixels) from given visual size (degrees) and pixels-per-degree @@ -275,8 +365,8 @@ def shape_from_visual_size_ppd(visual_size, ppd): ppd = validate_ppd(ppd) # Calculate width and height in pixels - width = pix_from_visual_angle_ppd_1D(visual_size.width, ppd.horizontal) - height = pix_from_visual_angle_ppd_1D(visual_size.height, ppd.vertical) + width = length_from_visual_angle_ppd(visual_size.width, ppd.horizontal) + height = length_from_visual_angle_ppd(visual_size.height, ppd.vertical) # Construct Shape NamedTuple: shape = Shape(width=width, height=height) @@ -309,8 +399,8 @@ def ppd_from_shape_visual_size(shape, visual_size): visual_size = validate_visual_size(visual_size) # Calculate horizontal and vertical ppds - horizontal = ppd_from_length_visual_angle_1D(shape.width, visual_size.width) - vertical = ppd_from_length_visual_angle_1D(shape.height, visual_size.height) + horizontal = ppd_from_length_visual_angle(shape.width, visual_size.width) + vertical = ppd_from_length_visual_angle(shape.height, visual_size.height) # Construct Ppd NamedTuple ppd = Ppd(horizontal=horizontal, vertical=vertical) @@ -318,7 +408,20 @@ def ppd_from_shape_visual_size(shape, visual_size): return ppd -def ppd_from_length_visual_angle_1D(length, visual_angle): +def ppd_from_length_visual_angle(length, visual_angle): + """Calculate pixels-per-degree from length (pixels) and visual angle (degrees) + + Parameters + ---------- + length : int or None + length in pixels + visual_angle : float or None + visual angle in degrees + + Returns + ------- + visual angle (degrees) translated to length (pixels) + """ if visual_angle is not None and length is not None: ppd = length / visual_angle else: @@ -526,29 +629,6 @@ def validate_visual_size(visual_size): return Visual_size(height=height, width=width) -def degrees_to_pixels(degrees, ppd): - """ - convert degrees of visual angle to pixels, given the number of pixels in - 1deg of visual angle. - - Parameters - ---------- - degrees : number, tuple, list or a ndarray - the degree values to be converted. - ppd : number - the number of pixels in the central 1 degree of visual angle. - - Returns - ------- - pixels : number or ndarray - """ - degrees = np.array(degrees) - return (np.round(degrees * ppd)).astype(int) - - # This is the 'super correct' conversion, but it makes very little difference in practice - # return (np.tan(np.radians(degrees / 2.)) / np.tan(np.radians(.5)) * ppd).astype(int) - - def compute_ppd(screen_size, resolution, distance): """ Compute the pixels per degree, i.e. the number of pixels in the central diff --git a/stimupy/utils/utils.py b/stimupy/utils/utils.py index 1e889ce9..1d10ac2e 100644 --- a/stimupy/utils/utils.py +++ b/stimupy/utils/utils.py @@ -1,35 +1,42 @@ -""" -Provides some functionality for creating and manipulating visual stimuli -represented as numpy arrays. -""" - -import warnings import numpy as np +import copy + +from stimupy.utils import resolution + +__all__ = [ + "round_to_vals", + "int_factorize", + "resize_array", + "resize_dict", + "stack_dicts", + "rotate_dict", + "flip_dict", + "roll_dict", +] -def shift_pixels(img, shift): +def round_to_vals(arr, vals): """ - Shift image by specified number of pixels. The pixels pushed on the edge will reappear on the other side (wrap around) + Round array to provided values (vals) Parameters ---------- - img : 2D array representing the image to be shifted - shift: (x,y) tuple specifying the number of pixels to shift. Positive x specifies shift in the right direction - and positive y shift downwards + arr : np.ndarray + Numpy array which values will be rounded + vals : Sequence(float, ...) + Values to which array will be rounded Returns ------- - img : shifted image - """ - return np.roll(img, shift, (1, 0)) - + out_arr : np.ndarray + Rounded output array -def round_to_vals(input_arr, vals): + """ n_val = len(vals) - input_arr = np.repeat(np.expand_dims(input_arr, -1), n_val, axis=2) - vals_arr = np.ones(input_arr.shape) * np.array(np.expand_dims(vals, [0, 1])) + arr = np.repeat(np.expand_dims(arr, -1), n_val, axis=2) + vals_arr = np.ones(arr.shape) * np.array(np.expand_dims(vals, [0, 1])) - indices = np.argmin(np.abs(input_arr - vals_arr), axis=2) + indices = np.argmin(np.abs(arr - vals_arr), axis=2) out_arr = np.copy(indices).astype(float) for i in range(n_val): @@ -37,23 +44,6 @@ def round_to_vals(input_arr, vals): return out_arr -def to_img(array, save): - from PIL import Image - - if array.min() < 0: - array = array - array.min() - if array.max() > 1: - array = array / array.max() - array = (array * 255).astype(np.uint8) - - im = Image.fromarray(array) - - try: - im.save(save) - except ValueError: - warnings.warn("No file extension provided, saving as png") - im.save(save + ".png", "PNG") - def int_factorize(n): """All integer factors of integer n @@ -82,3 +72,290 @@ def int_factorize(n): factors.add(n // i) return factors + + +def resize_array(arr, factor): + """ + Return a copy of an array, resized by the given factor. Every value is + repeated factor[d] times along dimension d. + + Parameters + ---------- + arr : 2D array + the array to be resized + factor : tupel of 2 ints + the resize factor in the y and x dimensions + + Returns + ------- + An array of shape (arr.shape[0] * factor[0], arr.shape[1] * factor[1]) + """ + return np.repeat(np.repeat(arr, factor[0], axis=0), factor[1], axis=1) + + +def resize_dict(dct, factor, keys=("img", "*mask")): + """ + Return a copy of an array, resized by the given factor. Every value is + repeated factor[d] times along dimension d. + + Parameters + ---------- + dct : dict + dict containing arrays to be resized + factor : tupel of 2 ints + the resize factor in the y and x dimensions + keys : Sequence[String, String] or String + keys in dict for images to be padded + + Returns + ------- + dict[str, Any] + same as input dict but with larger key-arrays according to + "(arr.shape[0] * factor[0], arr.shape[1] * factor[1])" + and updated keys for "visual_size" and "shape" + """ + # Create deepcopy to not override existing dict + new_dict = copy.deepcopy(dct) + + if isinstance(keys, str): + keys = (keys,) + + # Find relevant keys + keys = [ + dkey + for key in keys + for dkey in dct.keys() + if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) + ] + + for key in dct.keys(): + if key in keys: + img = dct[key] + if isinstance(img, np.ndarray): + img = np.repeat(np.repeat(img, factor[0], axis=0), factor[1], axis=1) + if key.endswith("mask"): + img = img.astype(int) + new_dict[key] = img + + # Update visual_size and shape-keys + dct["shape"] = resolution.validate_shape(img.shape) + if "ppd" in dct.keys(): + dct["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, dct["ppd"]) + return new_dict + + +def stack_dicts( + dct1, dct2, direction="horizontal", keys=("img", "*mask"), keep_mask_indices=False +): + """ + Return a dict with resized key-arrays by the given factor. Every value is + repeated factor[d] times along dimension d. + + Parameters + ---------- + dct1: dict + dict containing arrays to be stacked + dct2: dict + dict containing arrays to be stacked + direction : str + stack horizontal(ly) or vertical(ly) (default: horizontal) + keys : Sequence[String, String] or String + keys in dict for images to be padded + + Returns + ------- + dict[str, Any] + same as input dict1 but with stacked key-arrays and updated keys for + "visual_size" and "shape" + """ + + # Create deepcopy to not override existing dict + new_dict = copy.deepcopy(dct1) + + if isinstance(keys, str): + keys = (keys,) + + # Find relevant keys + keys1 = [ + dkey + for key in keys + for dkey in dct1.keys() + if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) + ] + keys2 = [ + dkey + for key in keys + for dkey in dct2.keys() + if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) + ] + + if not keys1 == keys2: + raise ValueError("The requested keys do not exist in both dicts") + + for key in dct1.keys(): + if key in keys1: + img1 = dct1[key] + img2 = dct2[key] + if isinstance(img1, np.ndarray) and isinstance(img2, np.ndarray): + if key.endswith("mask") and not keep_mask_indices: + img2 = np.where(img2 != 0, img2 + img1.max(), 0) + + if direction == "horizontal": + img = np.hstack([img1, img2]) + elif direction == "vertical": + img = np.vstack([img1, img2]) + else: + raise ValueError("direction must be horizontal or vertical") + + if key.endswith("mask"): + img = img.astype(int) + new_dict[key] = img + + # Update visual_size and shape-keys + new_dict["shape"] = resolution.validate_shape(img.shape) + if "ppd" in new_dict.keys(): + new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, new_dict["ppd"]) + return new_dict + + +def rotate_dict(dct, nrots=1, keys=("img", "*mask")): + """ + Return a dict with key-arrays rotated by nrots*90 degrees. + + Parameters + ---------- + dct: dict + dict containing arrays to be stacked + nrot : int + number of rotations by 90 degrees + keys : Sequence[String, String] or String + keys in dict for images to be padded + + Returns + ------- + dict[str, Any] + same as input dict but with rotated key-arrays and updated keys for + "visual_size" and "shape" + """ + + # Create deepcopy to not override existing dict + new_dict = copy.deepcopy(dct) + + # Find relevant keys + keys = [ + dkey + for key in keys + for dkey in dct.keys() + if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) + ] + + for key in dct.keys(): + if key in keys: + img = dct[key] + if isinstance(img, np.ndarray): + if isinstance(nrots, (int, float)): + img = np.rot90(img, nrots) + else: + raise ValueError("nrots must be a number") + + if key.endswith("mask"): + img = img.astype(int) + new_dict[key] = img + + # Update visual_size and shape-keys + new_dict["shape"] = resolution.validate_shape(img.shape) + if "ppd" in new_dict.keys(): + new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, new_dict["ppd"]) + return new_dict + + +def flip_dict(dct, direction="lr", keys=("img", "*mask")): + """ + Return a dict with key-arrays rotated by nrots*90 degrees. + + Parameters + ---------- + dct: dict + dict containing arrays to be stacked + direction : str + "lr" for left-right, "ud" for up-down flipping + keys : Sequence[String, String] or String + keys in dict for images to be padded + + Returns + ------- + dict[str, Any] + same as input dict but with flipped key-arrays + """ + + # Create deepcopy to not override existing dict + new_dict = copy.deepcopy(dct) + + # Find relevant keys + keys = [ + dkey + for key in keys + for dkey in dct.keys() + if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) + ] + + for key in dct.keys(): + if key in keys: + img = dct[key] + if isinstance(img, np.ndarray): + if direction == "lr": + img = np.fliplr(img) + elif direction == "ud": + img = np.flipud(img) + else: + raise ValueError("direction must be lr or ud") + + if key.endswith("mask"): + img = img.astype(int) + new_dict[key] = img + return new_dict + + +def roll_dict(dct, shift, axes, keys=("img", "*mask")): + """ + Return a dict with key-arrays rolled by shift in axes. + + Parameters + ---------- + dct: dict + dict containing arrays to be stacked + shift : int + number of pixels by which to shift + axes : Number or Sequence[Number, ...] + axes in which to shift + keys : Sequence[String, String] or String + keys in dict for images to be padded + + Returns + ------- + dict[str, Any] + same as input dict but with rolled key-arrays + """ + + # Create deepcopy to not override existing dict + new_dict = copy.deepcopy(dct) + shift = np.array(shift).astype(int) + + # Find relevant keys + keys = [ + dkey + for key in keys + for dkey in dct.keys() + if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*")))) + ] + + for key in dct.keys(): + if key in keys: + img = dct[key] + if isinstance(img, np.ndarray): + img = np.roll(img, shift=shift, axis=axes) + + if key.endswith("mask"): + img = img.astype(int) + new_dict[key] = img + return new_dict