🖼️ image#

This module provides little utilities for image processing.

  • relative_luminance() - Perceived brightness of a colour, as the sRGB relative luminance.

  • readable_text_color() - Pick the text colour that stays readable on a given background.

  • auto_crop() - Automatically crop an image to remove zero/uniform regions.

  • numpy_segments_to_obj() - Generate a ‘.obj’ file from several 3D segmentation arrays, one color each.

  • numpy_to_gif() - Generate a ‘.gif’ file from a 3D numpy array for visualization.

  • numpy_to_obj() - Generate a ‘.obj’ file from a 3D numpy array using marching cubes.

  • image_resize() - Resize an image while preserving its aspect ratio by default.

import numpy as np
import stouputils as stp

# A black 800x600 image with a white square in the middle
image = np.zeros((600, 800), dtype=np.uint8)
image[150:450, 200:500] = 255

# Cut away the black border
cropped = stp.auto_crop(image)
stp.info("Cropped", image.shape, "to", cropped.shape)

# Shrink it so its longest side is 128 pixels
resized = stp.image_resize(cropped, 128)
stp.info("Resized to", resized.shape)

# Pick black or white text so it stays readable on a background color (red, green, blue from 0 to 1)
for background in [(0.1, 0.2, 0.5), (0.95, 0.9, 0.4)]:
    stp.info(f"On {background}, write in", stp.readable_text_color(background))
Terminal output of the example

colors#

Colour maths for deciding what to draw on top of a filled shape.

Charts, heatmaps and legends all face the same question: black text or white text on this patch? Answering it from the sRGB luminance keeps every annotation readable, whatever colormap produced the patch.

relative_luminance(
color: Iterable[float],
) → float[source]#

Perceived brightness of a colour, as the sRGB relative luminance (https://en.wikipedia.org/wiki/Relative_luminance).

Channels are linearised before being weighted, since sRGB stores them gamma-encoded. Averaging the raw channels instead would rate a saturated blue as bright as a saturated green.

Parameters:

color – RGB or RGBA channels in [0.0, 1.0]; anything past the third is ignored.

Returns:

Luminance in [0.0, 1.0], 0.0 being black and 1.0 white.

>>> relative_luminance((0.0, 0.0, 0.0))
0.0
>>> relative_luminance((1.0, 1.0, 1.0, 1.0))
1.0
>>> round(relative_luminance((1.0, 0.0, 0.0)), 4)
0.2126
>>> round(relative_luminance((0.0, 0.0, 1.0)), 4)
0.0722
readable_text_color(
color: Iterable[float],
on_light: str = 'black',
on_dark: str = 'white',
threshold: float = 0.4,
) → str[source]#

Pick the text colour that stays readable on top of a given background colour.

Parameters:
  • color – Background RGB or RGBA channels in [0.0, 1.0].

  • on_light – Returned when the background is light, ex: “black” or “#222222”.

  • on_dark – Returned when the background is dark.

  • threshold – Luminance above which the background counts as light.

Returns:

Either on_light or on_dark, untouched, so any colour syntax the caller uses goes through.

>>> readable_text_color((1.0, 1.0, 1.0))
'black'
>>> readable_text_color((0.27, 0.0, 0.33, 1.0))
'white'
>>> readable_text_color((0.99, 0.91, 0.14), on_light=".1")
'.1'

cropping#

class T[source]#

Any numpy numeric type, boolean included.

alias of TypeVar(‘T’, bound=np.number | np.bool_)

auto_crop(
image: Image.Image | NDArray[T],
mask: NDArray[np.bool_] | None = None,
threshold: int | float | Callable[[NDArray[T]], int | float] | None = None,
return_type: type | str = 'same',
contiguous: bool = True,
padding: int | tuple[int, ...] = 0,
return_offsets: bool = False,
) → Any[source]#

Automatically crop an image to remove zero or uniform regions.

This function crops the image to keep only the region where pixels are non-zero (or above a threshold). It can work with a mask or directly analyze the image.

Parameters:
  • image – The image to crop.

  • mask – Optional binary mask indicating regions to keep.

  • threshold – Threshold value or function (default: np.min).

  • return_type – Type of the return value (Image.Image, NDArray[np.number], or “same” to match input type).

  • contiguous – If True (default), crop to bounding box. If False, remove entire rows/columns with no content.

  • padding – Extra pixels/slices to keep around detected content. Use one int for all axes or one value per axis.

  • return_offsets – If True, return a tuple of (cropped_image, (lower_offsets, upper_offsets)) where lower_offsets and upper_offsets are lists of ints (one per axis) describing how many pixels were removed from each side. For non-contiguous crops, offsets reflect the first and last retained index on each axis.

Returns:

The cropped image, or (cropped_image, (lower_offsets, upper_offsets)) when return_offsets is True.

>>> # Test with numpy array with zeros on edges
>>> import numpy as np
>>> array = np.zeros((100, 100, 3), dtype=np.uint8)
>>> array[20:80, 30:70] = 255  # White rectangle in center
>>> cropped = auto_crop(array, return_type=np.ndarray)
>>> cropped.shape
(60, 40, 3)
>>> # Test with custom mask
>>> mask = np.zeros((100, 100), dtype=bool)
>>> mask[10:90, 10:90] = True
>>> cropped_with_mask = auto_crop(array, mask=mask, return_type=np.ndarray)
>>> cropped_with_mask.shape
(80, 80, 3)
>>> # Test with PIL Image
>>> from PIL import Image
>>> pil_image = Image.new('RGB', (100, 100), (0, 0, 0))
>>> from PIL import ImageDraw
>>> draw = ImageDraw.Draw(pil_image)
>>> draw.rectangle([25, 25, 75, 75], fill=(255, 255, 255))
>>> cropped_pil = auto_crop(pil_image)
>>> cropped_pil.size
(51, 51)
>>> # Test with threshold
>>> array_gray = np.ones((100, 100), dtype=np.uint8) * 10
>>> array_gray[20:80, 30:70] = 255
>>> cropped_threshold = auto_crop(array_gray, threshold=50, return_type=np.ndarray)
>>> cropped_threshold.shape
(60, 40)
>>> # Test with callable threshold (using lambda to avoid min value)
>>> array_gray2 = np.ones((100, 100), dtype=np.uint8) * 10
>>> array_gray2[20:80, 30:70] = 255
>>> cropped_max = auto_crop(array_gray2, threshold=lambda x: 50, return_type=np.ndarray)
>>> cropped_max.shape
(60, 40)
>>> # Test with non-contiguous crop
>>> array_sparse = np.zeros((100, 100, 3), dtype=np.uint8)
>>> array_sparse[10, 10] = 255
>>> array_sparse[50, 50] = 255
>>> array_sparse[90, 90] = 255
>>> cropped_contiguous = auto_crop(array_sparse, contiguous=True, return_type=np.ndarray)
>>> cropped_contiguous.shape  # Bounding box from (10,10) to (90,90)
(81, 81, 3)
>>> cropped_non_contiguous = auto_crop(array_sparse, contiguous=False, return_type=np.ndarray)
>>> cropped_non_contiguous.shape  # Only rows/cols 10, 50, 90
(3, 3, 3)
>>> # Test with 3D crop on depth dimension
>>> array_3d = np.zeros((50, 50, 10), dtype=np.uint8)
>>> array_3d[10:40, 10:40, 2:8] = 255  # Content only in depth slices 2-7
>>> cropped_3d = auto_crop(array_3d, contiguous=True, return_type=np.ndarray)
>>> cropped_3d.shape  # Should crop all 3 dimensions
(30, 30, 6)
>>> # Test with padding around detected content
>>> array_padded = np.zeros((20, 20), dtype=np.uint8)
>>> array_padded[8:12, 8:12] = 255
>>> cropped_padded = auto_crop(array_padded, padding=2, return_type=np.ndarray)
>>> cropped_padded.shape
(8, 8)
>>> # Test return_offsets
>>> array_off = np.zeros((20, 20), dtype=np.uint8)
>>> array_off[5:15, 4:16] = 255
>>> cropped_off, (lo, hi) = auto_crop(array_off, return_type=np.ndarray, return_offsets=True)
>>> lo  # pixels removed from the top and left
[5, 4]
>>> hi  # pixels removed from the bottom and right
[5, 4]
>>> # Test return_offsets with padding
>>> array_off_pad = np.zeros((20, 20), dtype=np.uint8)
>>> array_off_pad[5:15, 5:15] = 255
>>> cropped_off_pad, (lo_pad, hi_pad) = auto_crop(array_off_pad, padding=2, return_offsets=True)
>>> lo_pad  # pixels removed from the top and left (3 instead of 5 due to padding)
[3, 3]
>>> hi_pad  # pixels removed from the bottom and right (3 instead of 5 due to padding)
[3, 3]
>>> # Test return_offsets with non-contiguous crop
>>> array_off_nc = np.zeros((20, 20), dtype=np.uint8)
>>> array_off_nc[5, 5] = 255
>>> array_off_nc[10, 10] = 255
>>> cropped_off_nc, (lo_nc, hi_nc) = auto_crop(array_off_nc, contiguous=False, return_type=np.ndarray, return_offsets=True)
>>> lo_nc  # first retained index on each axis (5 for both)
[5, 5]
>>> hi_nc  # pixels removed from the end (20 - 1 - last retained index)
[9, 9]
padding_per_axis(
padding: int | tuple[int, ...],
ndim: int,
) → tuple[int, ...][source]#

One padding value per axis, from a single value shared by all of them or a tuple already holding one each.

>>> padding_per_axis(2, 3)
(2, 2, 2)
>>> padding_per_axis((1, 0), 2)
(1, 0)
crop_to_content(
array: NDArray[T],
contents: list[NDArray[np.bool_]],
padding: tuple[int, ...],
contiguous: bool,
) → tuple[NDArray[T], tuple[list[int], list[int]]][source]#

Crop the leading axes of an array to the indices holding content, plus padding.

Parameters:
  • contents – One mask per cropped axis, True where that index holds content. The array is returned whole when one is empty.

  • padding – Indices kept on each side of the content, one value per axis.

  • contiguous – True keeps the padded bounding box as a view, False keeps only the padded content indices.

Returns:

The cropped array, and the number of indices removed before and after the kept ones on each axis.

>>> import numpy as np
>>> array = np.zeros((6, 6))
>>> content = np.array([False, True, False, False, True, False])
>>> crop_to_content(array, [content, content], (0, 1), contiguous=True)[1]
([1, 0], [1, 0])
>>> crop_to_content(array, [content, content], (0, 0), contiguous=False)[0].shape
(2, 2)

gif_export#

numpy_to_gif(
path: str,
array: NDArray[Any],
duration: int = 100,
loop: int = 0,
mkdir: bool = True,
**kwargs: Any,
) → None[source]#

Generate a ‘.gif’ file from a numpy array for 3D/4D visualization.

Parameters:
  • path – Path to the output .gif file.

  • array – Numpy array to be dumped (must be 3D or 4D). 3D: (depth, height, width) - e.g. (64, 1024, 1024) 4D: (depth, height, width, channels) - e.g. (50, 64, 1024, 3)

  • duration – Duration between frames in milliseconds.

  • loop – Number of loops (0 = infinite).

  • mkdir – Create the directory if it does not exist.

  • **kwargs – Additional keyword arguments for PIL.Image.save().

> # 3D array example
> array = np.random.randint(0, 256, (10, 100, 100), dtype=np.uint8)
> numpy_to_gif("output_10_frames_100x100.gif", array, duration=200, loop=0)

> # 4D array example (batch of 3D images)
> array_4d = np.random.randint(0, 256, (5, 10, 100, 3), dtype=np.uint8)
> numpy_to_gif("output_50_frames_100x100.gif", array_4d, duration=200)

> total_duration = 1000  # 1 second
> numpy_to_gif("output_1s.gif", array, duration=total_duration // len(array))

obj_export#

extract_verts_faces_from_segment(
array: NDArray[Any],
spacing: tuple[float, float, float],
threshold: float,
step_size: int,
pad_array: bool,
) → tuple[NDArray[np.floating], NDArray[np.integer]][source]#

Extract vertices and faces from a single segmentation array using marching cubes.

Parameters:
  • array – 3D numpy array containing the segmentation mask.

  • spacing – Voxel spacing along each axis, passed to marching cubes.

  • threshold – Threshold level for marching cubes (0.5 for binary data).

  • step_size – Step size for marching cubes (higher = simpler mesh, faster generation).

  • pad_array – If True, pad array with zeros to ensure closed volumes for border cells.

Returns:

Vertices and faces of the extracted mesh.

numpy_to_obj(
path: str,
array: NDArray[Any],
spacing: tuple[float, float, float] = (1.0, 1.0, 1.0),
threshold: float = 0.5,
step_size: int = 1,
pad_array: bool = True,
verbose: int = 0,
) → None[source]#

Generate a ‘.obj’ file from a numpy array for 3D visualization using marching cubes.

Parameters:
  • path – Path to the output .obj file.

  • array – Numpy array to be dumped (must be 3D).

  • spacing – Voxel spacing along each axis, passed to marching cubes.

  • threshold – Threshold level for marching cubes (0.5 for binary data).

  • step_size – Step size for marching cubes (higher = simpler mesh, faster generation).

  • pad_array – If True, pad array with zeros to ensure closed volumes for border cells.

  • verbose – Verbosity level (0 = no output, 1 = some output, 2 = full output).

> array = np.random.rand(64, 64, 64) > 0.5
> numpy_to_obj("output_mesh.obj", array, threshold=0.5, step_size=2, pad_array=True, verbose=1)

> array = my_3d_data
> numpy_to_obj("output_mesh.obj", array, spacing=(1.0, 1.0, 2.5), threshold=0.3)

resize#

image_resize(image: T, max_result_size: int, resampling: Image.Resampling | None = None, min_or_max: ~collections.abc.Callable[[int, int], int] = <built-in function max>, return_type: type[T] | str = 'same', keep_aspect_ratio: bool = True) → Any[source]#

Resize an image while preserving its aspect ratio by default. Scales the image so that its largest dimension equals max_result_size.

Parameters:
  • image – The image to resize.

  • max_result_size – Maximum size for the largest dimension.

  • resampling – PIL resampling filter to use (default: Image.Resampling.LANCZOS).

  • min_or_max – Function to use to get the minimum or maximum of the two ratios.

  • return_type – Type of the return value (Image.Image, np.ndarray, or “same” to match input type).

  • keep_aspect_ratio – Whether to keep the aspect ratio.

Returns:

The resized image with preserved aspect ratio.

>>> # Test with (height x width x channels) numpy array
>>> import numpy as np
>>> array = np.random.randint(0, 255, (100, 50, 3), dtype=np.uint8)
>>> image_resize(array, 100).shape
(100, 50, 3)
>>> image_resize(array, 100, min_or_max=max).shape
(100, 50, 3)
>>> image_resize(array, 100, min_or_max=min).shape
(200, 100, 3)
>>> # Test with PIL Image
>>> from PIL import Image
>>> pil_image: Image.Image = Image.new('RGB', (200, 100))
>>> image_resize(pil_image, 50).size
(50, 25)
>>> # Test with different return types
>>> resized_array = image_resize(array, 50, return_type=np.ndarray)
>>> isinstance(resized_array, np.ndarray)
True
>>> resized_array.shape
(50, 25, 3)
>>> # Test with different resampling methods
>>> image_resize(pil_image, 50, resampling=Image.Resampling.NEAREST).size
(50, 25)

segments_export#

add_default_colors_to_segments(
segments: list[NDArray[Any]],
skip_unique_color: bool = False,
) → list[tuple[NDArray[Any], tuple[float, float, float, float]]][source]#

Ensure all segments have an associated RGB color. If a segment is provided as a bare array, assign it a default color.

Parameters:
  • segments – List of segments, where each segment is either a 3D array or a tuple of (array, rgba_color).

  • skip_unique_color – If True, do not assign a unique color to the first segment.

Returns:

List of segments as (array, rgba_color) tuples, with default colors assigned where needed.

numpy_segments_to_obj(
path: str,
segments: list[tuple[NDArray[Any], tuple[float, float, float, float]]] | list[NDArray[Any]],
spacing: tuple[float, float, float] = (1.0, 1.0, 1.0),
threshold: float = 0.5,
step_size: int = 1,
pad_array: bool = True,
verbose: int = 0,
) → None[source]#

Generate a ‘.obj’ file from multiple segmentation arrays, each with its own color.

Parameters:
  • path – Path to the output .obj file (a .mtl file is created alongside it).

  • segments – List of (array, rgb_color) tuples. Each array is a 3D segmentation mask. RGB values should be floats in [0.0, 1.0].

  • spacing – Voxel spacing along each axis, passed to marching cubes.

  • threshold – Iso-surface threshold for marching cubes (0.5 for binary masks).

  • step_size – Step size for marching cubes (higher = coarser mesh, faster).

  • pad_array – Pad each array with zeros to close border surfaces.

  • verbose – Verbosity level.

> numpy_segments_to_obj(
>     "brain_segs.obj",
>     segments=[
>         (white_matter, (1.0, 1.0, 1.0)),
>         (grey_matter,  (0.7, 0.5, 0.5)),
>         (tumor,        (1.0, 0.0, 0.0)),
>     ],
>     spacing=(0.5, 0.5, 1.0),
> )