Skip to content

Commit d0fccdf

Browse files
Enable adaptive image display adjustments and "bring your own image processor" capabilities (#45)
* explore adaptive histogram equalization * fix * Update image.py * move to 16 or 8 bit processing * fixed outlines * linting * add tests * update tests * test updates * coverage and lock * update docs * add matplotlib dep * add note about masks Co-Authored-By: Jenna Tomkinson <[email protected]> --------- Co-authored-by: Jenna Tomkinson <[email protected]>
1 parent c6630d5 commit d0fccdf

File tree

8 files changed

+1016
-480
lines changed

8 files changed

+1016
-480
lines changed

docs/src/examples/cytodataframe_at_a_glance.ipynb

+58-58
Large diffs are not rendered by default.

media/coverage-badge.svg

+1-1
Loading

poetry.lock

+479-93
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ black = "^24.10.0"
5555
isort = "^5.13.2"
5656
jupyterlab-code-formatter = "^3.0.2"
5757
duckdb = "^1.1.3"
58+
matplotlib = "^3.9.3"
5859

5960
[tool.poetry.group.docs.dependencies]
6061
# used for rendering docs into docsite

src/cytodataframe/frame.py

+101-186
Large diffs are not rendered by default.

src/cytodataframe/image.py

+203-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@
44

55
import cv2
66
import numpy as np
7+
import skimage
8+
import skimage.io
9+
import skimage.measure
710
from PIL import Image, ImageEnhance
11+
from skimage import draw, exposure
12+
from skimage.util import img_as_ubyte
813

914

10-
def is_image_too_dark(image: Image, pixel_brightness_threshold: float = 10.0) -> bool:
15+
def is_image_too_dark(
16+
image: Image.Image, pixel_brightness_threshold: float = 10.0
17+
) -> bool:
1118
"""
1219
Check if the image is too dark based on the mean brightness.
1320
By "too dark" we mean not as visible to the human eye.
@@ -32,7 +39,7 @@ def is_image_too_dark(image: Image, pixel_brightness_threshold: float = 10.0) ->
3239
return mean_brightness < pixel_brightness_threshold
3340

3441

35-
def adjust_image_brightness(image: Image) -> Image:
42+
def adjust_image_brightness(image: Image.Image) -> Image.Image:
3643
"""
3744
Adjust the brightness of an image using histogram equalization.
3845
@@ -64,3 +71,197 @@ def adjust_image_brightness(image: Image) -> Image:
6471
reduced_brightness_image = enhancer.enhance(0.7)
6572

6673
return reduced_brightness_image
74+
75+
76+
def draw_outline_on_image_from_outline(
77+
orig_image: np.ndarray, outline_image_path: str
78+
) -> np.ndarray:
79+
"""
80+
Draws green outlines on an image based on a provided outline image and returns
81+
the combined result.
82+
83+
Args:
84+
orig_image (np.ndarray):
85+
The original image on which the outlines will be drawn.
86+
It must be a grayscale or RGB image with shape `(H, W)` for
87+
grayscale or `(H, W, 3)` for RGB.
88+
outline_image_path (str):
89+
The file path to the outline image. This image will be used
90+
to determine the areas where the outlines will be drawn.
91+
It can be grayscale or RGB.
92+
93+
Returns:
94+
np.ndarray:
95+
The original image with green outlines drawn on the non-black areas from
96+
the outline image. The result is returned as an RGB image with shape
97+
`(H, W, 3)`.
98+
"""
99+
100+
# Load the outline image
101+
outline_image = skimage.io.imread(outline_image_path)
102+
103+
# Resize if necessary
104+
if outline_image.shape[:2] != orig_image.shape[:2]:
105+
outline_image = skimage.transform.resize(
106+
outline_image,
107+
orig_image.shape[:2],
108+
preserve_range=True,
109+
anti_aliasing=True,
110+
).astype(orig_image.dtype)
111+
112+
# Create a mask for non-black areas (with threshold)
113+
threshold = 10 # Adjust as needed
114+
# Grayscale
115+
if outline_image.ndim == 2: # noqa: PLR2004
116+
non_black_mask = outline_image > threshold
117+
else: # RGB/RGBA
118+
non_black_mask = np.any(outline_image[..., :3] > threshold, axis=-1)
119+
120+
# Ensure the original image is RGB
121+
if orig_image.ndim == 2: # noqa: PLR2004
122+
orig_image = np.stack([orig_image] * 3, axis=-1)
123+
elif orig_image.shape[-1] != 3: # noqa: PLR2004
124+
raise ValueError("Original image must have 3 channels (RGB).")
125+
126+
# Ensure uint8 data type
127+
if orig_image.dtype != np.uint8:
128+
orig_image = (orig_image * 255).astype(np.uint8)
129+
130+
# Apply the green outline
131+
combined_image = orig_image.copy()
132+
combined_image[non_black_mask] = [0, 255, 0] # Green in uint8
133+
134+
return combined_image
135+
136+
137+
def draw_outline_on_image_from_mask(
138+
orig_image: np.ndarray, mask_image_path: str
139+
) -> np.ndarray:
140+
"""
141+
Draws green outlines on an image based on a binary mask and returns
142+
the combined result.
143+
144+
Please note: masks are inherently challenging to use when working with
145+
multi-compartment datasets and may result in outlines that do not
146+
pertain to the precise compartment. For example, if an object mask
147+
overlaps with one or many other object masks the outlines may not
148+
differentiate between objects.
149+
150+
Args:
151+
orig_image (np.ndarray):
152+
Image which a mask will be applied to. Must be a NumPy array.
153+
mask_image_path (str):
154+
Path to the binary mask image file.
155+
156+
Returns:
157+
np.ndarray:
158+
The resulting image with the green outline applied.
159+
"""
160+
# Load the binary mask image
161+
mask_image = skimage.io.imread(mask_image_path)
162+
163+
# Ensure the original image is RGB
164+
# Grayscale input
165+
if orig_image.ndim == 2: # noqa: PLR2004
166+
orig_image = np.stack([orig_image] * 3, axis=-1)
167+
# Unsupported input
168+
elif orig_image.shape[-1] != 3: # noqa: PLR2004
169+
raise ValueError("Original image must have 3 channels (RGB).")
170+
171+
# Ensure the mask is 2D (binary)
172+
if mask_image.ndim > 2: # noqa: PLR2004
173+
mask_image = mask_image[..., 0] # Take the first channel if multi-channel
174+
175+
# Detect contours from the mask
176+
contours = skimage.measure.find_contours(mask_image, level=0.5)
177+
178+
# Create an outline image with the same shape as the original image
179+
outline_image = np.zeros_like(orig_image)
180+
181+
# Draw contours as green lines
182+
for contour in contours:
183+
rr, cc = draw.polygon_perimeter(
184+
np.round(contour[:, 0]).astype(int),
185+
np.round(contour[:, 1]).astype(int),
186+
shape=orig_image.shape[:2],
187+
)
188+
# Assign green color to the outline in all three channels
189+
outline_image[rr, cc, :] = [0, 255, 0]
190+
191+
# Combine the original image with the green outline
192+
combined_image = orig_image.copy()
193+
mask = np.any(outline_image > 0, axis=-1) # Non-zero pixels in the outline
194+
combined_image[mask] = outline_image[mask]
195+
196+
return combined_image
197+
198+
199+
def adjust_with_adaptive_histogram_equalization(image: np.ndarray) -> np.ndarray:
200+
"""
201+
Adaptive histogram equalization with additional smoothing to reduce graininess.
202+
203+
Parameters:
204+
image (np.ndarray):
205+
The input image to be processed.
206+
207+
Returns:
208+
np.ndarray:
209+
The processed image with enhanced contrast.
210+
"""
211+
# Adjust parameters dynamically
212+
kernel_size = (
213+
max(image.shape[0] // 10, 1), # Ensure the kernel size is at least 1
214+
max(image.shape[1] // 10, 1), # Ensure the kernel size is at least 1
215+
)
216+
clip_limit = 0.02 # Lower clip limit to suppress over-enhancement
217+
nbins = 512 # Increase bins for finer histogram granularity
218+
219+
# Check if the image has an alpha channel (RGBA)
220+
# RGBA image
221+
if image.shape[-1] == 4: # noqa: PLR2004
222+
rgb_np = image[:, :, :3]
223+
alpha_np = image[:, :, 3]
224+
225+
equalized_rgb_np = np.zeros_like(rgb_np, dtype=np.float32)
226+
227+
for channel in range(3):
228+
equalized_rgb_np[:, :, channel] = exposure.equalize_adapthist(
229+
rgb_np[:, :, channel],
230+
kernel_size=kernel_size,
231+
clip_limit=clip_limit,
232+
nbins=nbins,
233+
)
234+
235+
equalized_rgb_np = img_as_ubyte(equalized_rgb_np)
236+
final_image_np = np.dstack([equalized_rgb_np, alpha_np])
237+
238+
# Grayscale image
239+
elif len(image.shape) == 2: # noqa: PLR2004
240+
final_image_np = exposure.equalize_adapthist(
241+
image,
242+
kernel_size=kernel_size,
243+
clip_limit=clip_limit,
244+
nbins=nbins,
245+
)
246+
final_image_np = img_as_ubyte(final_image_np)
247+
248+
# RGB image
249+
elif image.shape[-1] == 3: # noqa: PLR2004
250+
equalized_rgb_np = np.zeros_like(image, dtype=np.float32)
251+
252+
for channel in range(3):
253+
equalized_rgb_np[:, :, channel] = exposure.equalize_adapthist(
254+
image[:, :, channel],
255+
kernel_size=kernel_size,
256+
clip_limit=clip_limit,
257+
nbins=nbins,
258+
)
259+
260+
final_image_np = img_as_ubyte(equalized_rgb_np)
261+
262+
else:
263+
raise ValueError(
264+
"Unsupported image format. Ensure the image is grayscale, RGB, or RGBA."
265+
)
266+
267+
return final_image_np

tests/test_frame.py

-139
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,12 @@
33
"""
44

55
import pathlib
6-
from io import BytesIO
76

8-
import numpy as np
97
import pandas as pd
10-
import pytest
118
from pyarrow import parquet
129

1310
from cytodataframe.frame import CytoDataFrame
1411
from tests.utils import (
15-
create_sample_image,
16-
create_sample_outline,
1712
cytodataframe_image_display_contains_green_pixels,
1813
)
1914

@@ -156,137 +151,3 @@ def test_repr_html(
156151
"Image_FileName_OrigDNA",
157152
],
158153
), "The pediatric cancer atlas speckles images do not contain green outlines."
159-
160-
161-
def test_overlay_with_valid_images():
162-
"""
163-
Tests the `draw_outline_on_image_from_outline` function
164-
with valid images: a base image and an outline image.
165-
166-
Verifies that the resulting image contains the correct
167-
outline color in the expected positions.
168-
"""
169-
# Create a sample base image (black background)
170-
actual_image = create_sample_image(200, 200, (0, 0, 0, 255)) # Black image
171-
outline_image = create_sample_outline(200, 200, (255, 0, 0)) # Red outline
172-
173-
# Save images to bytes buffer (to mimic files)
174-
actual_image_fp = BytesIO()
175-
actual_image.save(actual_image_fp, format="PNG")
176-
actual_image_fp.seek(0)
177-
178-
outline_image_fp = BytesIO()
179-
outline_image.save(outline_image_fp, format="PNG")
180-
outline_image_fp.seek(0)
181-
182-
# Test the function
183-
result_image = CytoDataFrame.draw_outline_on_image_from_outline(
184-
actual_image_fp, outline_image_fp
185-
)
186-
187-
# Convert result to numpy array for comparison
188-
result_array = np.array(result_image)
189-
190-
# Assert that the result image has the outline color
191-
# (e.g., red) in the expected position
192-
assert np.any(
193-
result_array[10:100, 10:100, :3] == [255, 0, 0]
194-
) # Check for red outline
195-
assert np.all(
196-
result_array[0:10, 0:10, :3] == [0, 0, 0]
197-
) # Check for no outline in the black background
198-
199-
200-
def test_overlay_with_no_outline():
201-
"""
202-
Tests the `draw_outline_on_image_from_outline` function
203-
with an outline image that has no outlines (all black).
204-
205-
Verifies that the result is the same as the original
206-
image when no outlines are provided.
207-
"""
208-
# Create a sample base image
209-
actual_image = create_sample_image(200, 200, (0, 0, 255, 255))
210-
# Black image with no outline
211-
outline_image = create_sample_image(200, 200, (0, 0, 0, 255))
212-
213-
actual_image_fp = BytesIO()
214-
actual_image.save(actual_image_fp, format="PNG")
215-
actual_image_fp.seek(0)
216-
217-
outline_image_fp = BytesIO()
218-
outline_image.save(outline_image_fp, format="PNG")
219-
outline_image_fp.seek(0)
220-
221-
# Test the function
222-
result_image = CytoDataFrame.draw_outline_on_image_from_outline(
223-
actual_image_fp, outline_image_fp
224-
)
225-
226-
# Convert result to numpy array for comparison
227-
result_array = np.array(result_image)
228-
229-
# Assert that the result image is still blue (no outline overlay)
230-
assert np.all(result_array[:, :, :3] == [0, 0, 255])
231-
232-
233-
def test_overlay_with_transparent_outline():
234-
"""
235-
Tests the `draw_outline_on_image_from_outline` function
236-
with a fully transparent outline image.
237-
238-
Verifies that the result image is unchanged when the
239-
outline image is fully transparent.
240-
"""
241-
# Create a sample base image
242-
actual_image = create_sample_image(200, 200, (0, 255, 0, 255))
243-
# Fully transparent image
244-
outline_image = create_sample_image(200, 200, (0, 0, 0, 0))
245-
246-
actual_image_fp = BytesIO()
247-
actual_image.save(actual_image_fp, format="PNG")
248-
actual_image_fp.seek(0)
249-
250-
outline_image_fp = BytesIO()
251-
outline_image.save(outline_image_fp, format="PNG")
252-
outline_image_fp.seek(0)
253-
254-
# Test the function
255-
result_image = CytoDataFrame.draw_outline_on_image_from_outline(
256-
actual_image_fp, outline_image_fp
257-
)
258-
259-
# Convert result to numpy array for comparison
260-
result_array = np.array(result_image)
261-
262-
# Assert that the result image is still green
263-
# (transparent outline should not affect the image)
264-
assert np.all(result_array[:, :, :3] == [0, 255, 0])
265-
266-
267-
def test_invalid_image_path():
268-
"""
269-
Tests the `draw_outline_on_image_from_outline` function
270-
when the image path is invalid.
271-
272-
Verifies that a FileNotFoundError is raised when the
273-
specified image does not exist.
274-
"""
275-
with pytest.raises(FileNotFoundError):
276-
CytoDataFrame.draw_outline_on_image_from_outline(
277-
"invalid_image.png", "valid_outline.png"
278-
)
279-
280-
281-
def test_invalid_outline_path():
282-
"""
283-
Tests the `draw_outline_on_image_from_outline` function
284-
when the outline image path is invalid.
285-
286-
Verifies that a FileNotFoundError is raised when the
287-
specified outline image does not exist.
288-
"""
289-
with pytest.raises(FileNotFoundError):
290-
CytoDataFrame.draw_outline_on_image_from_outline(
291-
"valid_image.png", "invalid_outline.png"
292-
)

0 commit comments

Comments
 (0)