Skip to content

Commit 717e47c

Browse files
authored
Avoid skimage matmul divide by zero warnings (#148)
* avoid skimage matmul divide by zero * Update coverage-badge.svg
1 parent d2d8a9f commit 717e47c

File tree

4 files changed

+155
-20
lines changed

4 files changed

+155
-20
lines changed

media/coverage-badge.svg

Lines changed: 1 addition & 1 deletion
Loading

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ lint.select = [
116116
]
117117
# Ignore `E402` and `F401` (unused imports) in all `__init__.py` files
118118
lint.per-file-ignores."__init__.py" = [ "E402", "F401" ]
119+
lint.per-file-ignores."src/cytodataframe/image.py" = [ "PLR2004" ]
119120
# ignore typing rules for tests
120-
lint.per-file-ignores."tests/*" = [ "ANN201", "PLR0913", "PLR2004" ]
121+
lint.per-file-ignores."tests/*" = [ "ANN201", "PLR0913", "PLR2004", "SIM105" ]
121122

122123
[tool.coverage.run]
123124
# settings to avoid errors with cv2 and coverage

src/cytodataframe/image.py

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import skimage.measure
1212
from PIL import Image, ImageEnhance
1313
from skimage import draw, exposure
14+
from skimage import draw as skdraw
1415
from skimage.util import img_as_ubyte
1516

1617

@@ -116,15 +117,15 @@ def draw_outline_on_image_from_outline(
116117
# Create a mask for non-black areas (with threshold)
117118
threshold = 10 # Adjust as needed
118119
# Grayscale
119-
if outline_image.ndim == 2: # noqa: PLR2004
120+
if outline_image.ndim == 2:
120121
non_black_mask = outline_image > threshold
121122
else: # RGB/RGBA
122123
non_black_mask = np.any(outline_image[..., :3] > threshold, axis=-1)
123124

124125
# Ensure the original image is RGB
125-
if orig_image.ndim == 2: # noqa: PLR2004
126+
if orig_image.ndim == 2:
126127
orig_image = np.stack([orig_image] * 3, axis=-1)
127-
elif orig_image.shape[-1] != 3: # noqa: PLR2004
128+
elif orig_image.shape[-1] != 3:
128129
raise ValueError("Original image must have 3 channels (RGB).")
129130

130131
# Ensure uint8 data type
@@ -168,14 +169,14 @@ def draw_outline_on_image_from_mask(
168169

169170
# Ensure the original image is RGB
170171
# Grayscale input
171-
if orig_image.ndim == 2: # noqa: PLR2004
172+
if orig_image.ndim == 2:
172173
orig_image = np.stack([orig_image] * 3, axis=-1)
173174
# Unsupported input
174-
elif orig_image.shape[-1] != 3: # noqa: PLR2004
175+
elif orig_image.shape[-1] != 3:
175176
raise ValueError("Original image must have 3 channels (RGB).")
176177

177178
# Ensure the mask is 2D (binary)
178-
if mask_image.ndim > 2: # noqa: PLR2004
179+
if mask_image.ndim > 2:
179180
mask_image = mask_image[..., 0] # Take the first channel if multi-channel
180181

181182
# Detect contours from the mask
@@ -248,17 +249,17 @@ def equalize_and_adjust(channel: np.ndarray) -> np.ndarray:
248249
gamma = 1.0 - brightness_shift * 0.8 # e.g. 1.8 → dark, 0.2 → bright
249250
return exposure.adjust_gamma(eq, gamma=gamma)
250251

251-
if image.ndim == 2: # noqa: PLR2004
252+
if image.ndim == 2:
252253
result = equalize_and_adjust(image)
253254
return img_as_ubyte(result)
254255

255-
elif image.ndim == 3 and image.shape[2] == 3: # noqa: PLR2004
256+
elif image.ndim == 3 and image.shape[2] == 3:
256257
result = np.stack(
257258
[equalize_and_adjust(image[:, :, i]) for i in range(3)], axis=-1
258259
)
259260
return img_as_ubyte(result)
260261

261-
elif image.ndim == 3 and image.shape[2] == 4: # noqa: PLR2004
262+
elif image.ndim == 3 and image.shape[2] == 4:
262263
rgb = image[:, :, :3]
263264
alpha = image[:, :, 3]
264265
result = np.stack(
@@ -322,17 +323,78 @@ def add_image_scale_bar( # noqa: PLR0913
322323
margin_px: int = 10,
323324
**_: Dict[Any, Any],
324325
) -> np.ndarray:
326+
"""
327+
Add a scale bar to the lower or upper corner of an image.
328+
329+
The function overlays a solid rectangular scale bar onto a grayscale,
330+
RGB, or RGBA image. The bar's physical length in micrometers is
331+
converted to pixels using the provided microns-per-pixel value.
332+
Non-finite or out-of-range input values are sanitized before drawing.
333+
334+
Args:
335+
img (np.ndarray):
336+
Input image, either 2-D grayscale or 3-D RGB/RGBA.
337+
um_per_pixel (float):
338+
Micrometers per pixel. If None or ≤ 0, the image is returned
339+
unchanged.
340+
length_um (float, optional):
341+
Desired length of the scale bar in micrometers. Defaults to
342+
10.0.
343+
thickness_px (int, optional):
344+
Thickness of the bar in pixels. Defaults to 4.
345+
color (Tuple[int, int, int], optional):
346+
RGB color of the bar. Defaults to white ``(255, 255, 255)``.
347+
location (str, optional):
348+
Placement of the bar: ``"lower right"``, ``"lower left"``,
349+
``"upper right"``, or ``"upper left"``. Defaults to
350+
``"lower right"``.
351+
margin_px (int, optional):
352+
Distance in pixels between the bar and the image edges.
353+
Defaults to 10.
354+
**_ (Dict[Any, Any]):
355+
Additional keyword arguments ignored for forward
356+
compatibility.
357+
358+
Returns:
359+
np.ndarray:
360+
A new RGB image with the scale bar drawn. The array is always
361+
of type ``uint8`` and has shape ``(H, W, 3)``.
362+
363+
Raises:
364+
ValueError: If the input image has an unsupported number of
365+
channels.
366+
367+
Notes:
368+
The bar length is clamped to fit within image bounds after
369+
margins. The function does not rely on ``skimage.color`` and is
370+
safe against NaN or Inf input values.
371+
"""
372+
325373
if um_per_pixel is None or um_per_pixel <= 0:
326374
return img
327375

328-
out = img.copy()
329-
# ensure RGB uint8
330-
if out.ndim == 2: # noqa: PLR2004
331-
out = skimage.color.gray2rgb(out)
332-
elif out.ndim == 3 and out.shape[2] == 4: # noqa: PLR2004
333-
out = out[:, :, :3]
376+
# --- Sanitize input first: replace non-finite, clip to valid range, cast to uint8
377+
out = np.nan_to_num(img, nan=0.0, posinf=255.0, neginf=0.0)
378+
379+
# If float image, try to guess range and bring to 0..255
380+
if np.issubdtype(out.dtype, np.floating):
381+
# If values look like 0..1, scale to 0..255; otherwise clip to 0..255
382+
if out.max() <= 1.0:
383+
out = out * 255.0
384+
out = np.clip(out, 0, 255)
385+
334386
out = out.astype(np.uint8, copy=False)
335387

388+
# ensure RGB without using skimage.color.* conversions
389+
if out.ndim == 2: # grayscale -> stack
390+
out = np.dstack([out, out, out])
391+
elif out.ndim == 3 and out.shape[2] == 4: # RGBA -> drop alpha for drawing
392+
out = out[:, :, :3]
393+
elif out.ndim != 3 or out.shape[2] != 3:
394+
raise ValueError(
395+
"Unsupported image shape for scale bar; expected gray/RGB/RGBA."
396+
)
397+
336398
H, W = out.shape[:2]
337399
length_px = max(1, round(length_um / um_per_pixel))
338400
thickness_px = max(1, int(thickness_px))
@@ -343,8 +405,6 @@ def add_image_scale_bar( # noqa: PLR0913
343405

344406
loc = location.lower().strip()
345407

346-
# Compute starting corner (y0, x0) and use rectangle
347-
# extent = (thickness_px, length_px)
348408
y0 = (
349409
max(0, H - margin_px - thickness_px)
350410
if "lower" in loc
@@ -358,7 +418,7 @@ def add_image_scale_bar( # noqa: PLR0913
358418
if x0 + length_px > W:
359419
length_px = W - x0
360420

361-
rr, cc = skimage.draw.rectangle(
421+
rr, cc = skdraw.rectangle(
362422
start=(y0, x0),
363423
extent=(thickness_px, length_px),
364424
shape=out.shape[:2],

tests/test_image.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
import os
66
import pathlib
7+
import warnings
78

89
import imageio.v2 as imageio
910
import numpy as np
1011
import pytest
12+
import skimage
1113
from PIL import Image
1214
from skimage.draw import disk
1315

@@ -357,3 +359,75 @@ def test_add_image_scale_bar_lower_right_bbox_and_area(tmp_path: pathlib.Path):
357359
# optionally show (useful locally; safe-off in CI)
358360
if os.environ.get("SHOW_TEST_IMAGES") == "1":
359361
Image.fromarray(out).show() # opens Preview/Photos/etc.
362+
363+
364+
def test_colorconv_control_may_emit_warning():
365+
"""
366+
CONTROL: Try a few skimage color conversions on a bad array to see if any
367+
emit the classic matmul divide-by-zero warning. If none do on this version
368+
of skimage, skip the control.
369+
"""
370+
bad = np.array([[0.0, np.inf], [np.nan, 1.0]], dtype=float)
371+
372+
funcs = [
373+
lambda a: skimage.color.gray2rgb(a),
374+
lambda a: skimage.color.rgb2lab(np.dstack([a, a, a])),
375+
lambda a: skimage.color.lab2rgb(np.dstack([a * 100.0, a * 255.0, a * 255.0])),
376+
lambda a: skimage.color.rgb2hsv(np.dstack([a, a, a])),
377+
]
378+
379+
saw_warning = False
380+
with warnings.catch_warnings(record=True) as caught:
381+
warnings.simplefilter("always", category=RuntimeWarning)
382+
for fn in funcs:
383+
try:
384+
_ = fn(bad)
385+
except Exception:
386+
# We only care about warnings, not exceptions
387+
pass
388+
389+
for w in caught:
390+
if issubclass(
391+
w.category, RuntimeWarning
392+
) and "divide by zero encountered in matmul" in str(w.message):
393+
saw_warning = True
394+
break
395+
396+
if not saw_warning:
397+
pytest.skip(
398+
"No skimage colorconv matmul warning emitted in this environment; "
399+
"control not applicable."
400+
)
401+
402+
403+
def test_add_image_scale_bar_avoids_colorconv_warning_with_bad_values():
404+
"""add_image_scale_bar should not emit the colorconv matmul warning."""
405+
H, W = 60, 50
406+
img = np.zeros((H, W), dtype=float)
407+
img[0, 0] = np.nan
408+
img[0, 1] = np.inf
409+
img[1, 0] = -np.inf
410+
img[1, 1] = 2.0
411+
412+
with warnings.catch_warnings(record=True) as caught:
413+
warnings.simplefilter("always", category=RuntimeWarning)
414+
out = add_image_scale_bar(
415+
img,
416+
um_per_pixel=0.5,
417+
length_um=10.0,
418+
thickness_px=3,
419+
color=(255, 255, 255),
420+
location="lower right",
421+
margin_px=5,
422+
label=False, # ignored via **kwargs
423+
)
424+
425+
assert not any(
426+
(
427+
issubclass(w.category, RuntimeWarning)
428+
and "divide by zero encountered in matmul" in str(w.message)
429+
)
430+
for w in caught
431+
), "Should not trigger the skimage colorconv matmul warning."
432+
433+
assert out.ndim == 3 and out.shape == (H, W, 3) and out.dtype == np.uint8

0 commit comments

Comments
 (0)