Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable adaptive image display adjustments and "bring your own image processor" capabilities #45

Merged
merged 14 commits into from
Dec 9, 2024
116 changes: 58 additions & 58 deletions docs/src/examples/cytodataframe_at_a_glance.ipynb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion media/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
572 changes: 479 additions & 93 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ black = "^24.10.0"
isort = "^5.13.2"
jupyterlab-code-formatter = "^3.0.2"
duckdb = "^1.1.3"
matplotlib = "^3.9.3"

[tool.poetry.group.docs.dependencies]
# used for rendering docs into docsite
Expand Down
287 changes: 101 additions & 186 deletions src/cytodataframe/frame.py

Large diffs are not rendered by default.

205 changes: 203 additions & 2 deletions src/cytodataframe/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@

import cv2
import numpy as np
import skimage
import skimage.io
import skimage.measure
from PIL import Image, ImageEnhance
from skimage import draw, exposure
from skimage.util import img_as_ubyte


def is_image_too_dark(image: Image, pixel_brightness_threshold: float = 10.0) -> bool:
def is_image_too_dark(
image: Image.Image, pixel_brightness_threshold: float = 10.0
) -> bool:
"""
Check if the image is too dark based on the mean brightness.
By "too dark" we mean not as visible to the human eye.
Expand All @@ -32,7 +39,7 @@ def is_image_too_dark(image: Image, pixel_brightness_threshold: float = 10.0) ->
return mean_brightness < pixel_brightness_threshold


def adjust_image_brightness(image: Image) -> Image:
def adjust_image_brightness(image: Image.Image) -> Image.Image:
"""
Adjust the brightness of an image using histogram equalization.

Expand Down Expand Up @@ -64,3 +71,197 @@ def adjust_image_brightness(image: Image) -> Image:
reduced_brightness_image = enhancer.enhance(0.7)

return reduced_brightness_image


def draw_outline_on_image_from_outline(
orig_image: np.ndarray, outline_image_path: str
) -> np.ndarray:
"""
Draws green outlines on an image based on a provided outline image and returns
the combined result.

Args:
orig_image (np.ndarray):
The original image on which the outlines will be drawn.
It must be a grayscale or RGB image with shape `(H, W)` for
grayscale or `(H, W, 3)` for RGB.
outline_image_path (str):
The file path to the outline image. This image will be used
to determine the areas where the outlines will be drawn.
It can be grayscale or RGB.

Returns:
np.ndarray:
The original image with green outlines drawn on the non-black areas from
the outline image. The result is returned as an RGB image with shape
`(H, W, 3)`.
"""

# Load the outline image
outline_image = skimage.io.imread(outline_image_path)

# Resize if necessary
if outline_image.shape[:2] != orig_image.shape[:2]:
outline_image = skimage.transform.resize(
outline_image,
orig_image.shape[:2],
preserve_range=True,
anti_aliasing=True,
).astype(orig_image.dtype)

# Create a mask for non-black areas (with threshold)
threshold = 10 # Adjust as needed
# Grayscale
if outline_image.ndim == 2: # noqa: PLR2004
non_black_mask = outline_image > threshold
else: # RGB/RGBA
non_black_mask = np.any(outline_image[..., :3] > threshold, axis=-1)

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

# Ensure uint8 data type
if orig_image.dtype != np.uint8:
orig_image = (orig_image * 255).astype(np.uint8)

# Apply the green outline
combined_image = orig_image.copy()
combined_image[non_black_mask] = [0, 255, 0] # Green in uint8

return combined_image


def draw_outline_on_image_from_mask(
orig_image: np.ndarray, mask_image_path: str
) -> np.ndarray:
"""
Draws green outlines on an image based on a binary mask and returns
the combined result.

Please note: masks are inherently challenging to use when working with
multi-compartment datasets and may result in outlines that do not
pertain to the precise compartment. For example, if an object mask
overlaps with one or many other object masks the outlines may not
differentiate between objects.

Args:
orig_image (np.ndarray):
Image which a mask will be applied to. Must be a NumPy array.
mask_image_path (str):
Path to the binary mask image file.

Returns:
np.ndarray:
The resulting image with the green outline applied.
"""
# Load the binary mask image
mask_image = skimage.io.imread(mask_image_path)

# Ensure the original image is RGB
# Grayscale input
if orig_image.ndim == 2: # noqa: PLR2004
orig_image = np.stack([orig_image] * 3, axis=-1)
# Unsupported input
elif orig_image.shape[-1] != 3: # noqa: PLR2004
raise ValueError("Original image must have 3 channels (RGB).")

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

# Detect contours from the mask
contours = skimage.measure.find_contours(mask_image, level=0.5)

# Create an outline image with the same shape as the original image
outline_image = np.zeros_like(orig_image)

# Draw contours as green lines
for contour in contours:
rr, cc = draw.polygon_perimeter(
np.round(contour[:, 0]).astype(int),
np.round(contour[:, 1]).astype(int),
shape=orig_image.shape[:2],
)
# Assign green color to the outline in all three channels
outline_image[rr, cc, :] = [0, 255, 0]

# Combine the original image with the green outline
combined_image = orig_image.copy()
mask = np.any(outline_image > 0, axis=-1) # Non-zero pixels in the outline
combined_image[mask] = outline_image[mask]

return combined_image


def adjust_with_adaptive_histogram_equalization(image: np.ndarray) -> np.ndarray:
d33bs marked this conversation as resolved.
Show resolved Hide resolved
"""
Adaptive histogram equalization with additional smoothing to reduce graininess.

Parameters:
image (np.ndarray):
The input image to be processed.

Returns:
np.ndarray:
The processed image with enhanced contrast.
"""
# Adjust parameters dynamically
kernel_size = (
max(image.shape[0] // 10, 1), # Ensure the kernel size is at least 1
max(image.shape[1] // 10, 1), # Ensure the kernel size is at least 1
)
clip_limit = 0.02 # Lower clip limit to suppress over-enhancement
nbins = 512 # Increase bins for finer histogram granularity

# Check if the image has an alpha channel (RGBA)
# RGBA image
if image.shape[-1] == 4: # noqa: PLR2004
rgb_np = image[:, :, :3]
alpha_np = image[:, :, 3]

equalized_rgb_np = np.zeros_like(rgb_np, dtype=np.float32)

for channel in range(3):
equalized_rgb_np[:, :, channel] = exposure.equalize_adapthist(
rgb_np[:, :, channel],
kernel_size=kernel_size,
clip_limit=clip_limit,
nbins=nbins,
)

equalized_rgb_np = img_as_ubyte(equalized_rgb_np)
final_image_np = np.dstack([equalized_rgb_np, alpha_np])

# Grayscale image
elif len(image.shape) == 2: # noqa: PLR2004
final_image_np = exposure.equalize_adapthist(
image,
kernel_size=kernel_size,
clip_limit=clip_limit,
nbins=nbins,
)
final_image_np = img_as_ubyte(final_image_np)

# RGB image
elif image.shape[-1] == 3: # noqa: PLR2004
equalized_rgb_np = np.zeros_like(image, dtype=np.float32)

for channel in range(3):
equalized_rgb_np[:, :, channel] = exposure.equalize_adapthist(
image[:, :, channel],
kernel_size=kernel_size,
clip_limit=clip_limit,
nbins=nbins,
)

final_image_np = img_as_ubyte(equalized_rgb_np)

else:
raise ValueError(
"Unsupported image format. Ensure the image is grayscale, RGB, or RGBA."
)

return final_image_np
139 changes: 0 additions & 139 deletions tests/test_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,12 @@
"""

import pathlib
from io import BytesIO

import numpy as np
import pandas as pd
import pytest
from pyarrow import parquet

from cytodataframe.frame import CytoDataFrame
from tests.utils import (
create_sample_image,
create_sample_outline,
cytodataframe_image_display_contains_green_pixels,
)

Expand Down Expand Up @@ -156,137 +151,3 @@ def test_repr_html(
"Image_FileName_OrigDNA",
],
), "The pediatric cancer atlas speckles images do not contain green outlines."


def test_overlay_with_valid_images():
"""
Tests the `draw_outline_on_image_from_outline` function
with valid images: a base image and an outline image.

Verifies that the resulting image contains the correct
outline color in the expected positions.
"""
# Create a sample base image (black background)
actual_image = create_sample_image(200, 200, (0, 0, 0, 255)) # Black image
outline_image = create_sample_outline(200, 200, (255, 0, 0)) # Red outline

# Save images to bytes buffer (to mimic files)
actual_image_fp = BytesIO()
actual_image.save(actual_image_fp, format="PNG")
actual_image_fp.seek(0)

outline_image_fp = BytesIO()
outline_image.save(outline_image_fp, format="PNG")
outline_image_fp.seek(0)

# Test the function
result_image = CytoDataFrame.draw_outline_on_image_from_outline(
actual_image_fp, outline_image_fp
)

# Convert result to numpy array for comparison
result_array = np.array(result_image)

# Assert that the result image has the outline color
# (e.g., red) in the expected position
assert np.any(
result_array[10:100, 10:100, :3] == [255, 0, 0]
) # Check for red outline
assert np.all(
result_array[0:10, 0:10, :3] == [0, 0, 0]
) # Check for no outline in the black background


def test_overlay_with_no_outline():
"""
Tests the `draw_outline_on_image_from_outline` function
with an outline image that has no outlines (all black).

Verifies that the result is the same as the original
image when no outlines are provided.
"""
# Create a sample base image
actual_image = create_sample_image(200, 200, (0, 0, 255, 255))
# Black image with no outline
outline_image = create_sample_image(200, 200, (0, 0, 0, 255))

actual_image_fp = BytesIO()
actual_image.save(actual_image_fp, format="PNG")
actual_image_fp.seek(0)

outline_image_fp = BytesIO()
outline_image.save(outline_image_fp, format="PNG")
outline_image_fp.seek(0)

# Test the function
result_image = CytoDataFrame.draw_outline_on_image_from_outline(
actual_image_fp, outline_image_fp
)

# Convert result to numpy array for comparison
result_array = np.array(result_image)

# Assert that the result image is still blue (no outline overlay)
assert np.all(result_array[:, :, :3] == [0, 0, 255])


def test_overlay_with_transparent_outline():
"""
Tests the `draw_outline_on_image_from_outline` function
with a fully transparent outline image.

Verifies that the result image is unchanged when the
outline image is fully transparent.
"""
# Create a sample base image
actual_image = create_sample_image(200, 200, (0, 255, 0, 255))
# Fully transparent image
outline_image = create_sample_image(200, 200, (0, 0, 0, 0))

actual_image_fp = BytesIO()
actual_image.save(actual_image_fp, format="PNG")
actual_image_fp.seek(0)

outline_image_fp = BytesIO()
outline_image.save(outline_image_fp, format="PNG")
outline_image_fp.seek(0)

# Test the function
result_image = CytoDataFrame.draw_outline_on_image_from_outline(
actual_image_fp, outline_image_fp
)

# Convert result to numpy array for comparison
result_array = np.array(result_image)

# Assert that the result image is still green
# (transparent outline should not affect the image)
assert np.all(result_array[:, :, :3] == [0, 255, 0])


def test_invalid_image_path():
"""
Tests the `draw_outline_on_image_from_outline` function
when the image path is invalid.

Verifies that a FileNotFoundError is raised when the
specified image does not exist.
"""
with pytest.raises(FileNotFoundError):
CytoDataFrame.draw_outline_on_image_from_outline(
"invalid_image.png", "valid_outline.png"
)


def test_invalid_outline_path():
"""
Tests the `draw_outline_on_image_from_outline` function
when the outline image path is invalid.

Verifies that a FileNotFoundError is raised when the
specified outline image does not exist.
"""
with pytest.raises(FileNotFoundError):
CytoDataFrame.draw_outline_on_image_from_outline(
"valid_image.png", "invalid_outline.png"
)
Loading