Skip to content

Commit

Permalink
Fix for nuclear speckle image display in CytoDataFrame (#64)
Browse files Browse the repository at this point in the history
* dynamic bounding box and scale image bit depth

* add opencv

* check images for adjustment; add tests

* linting

* coverage configuration

* add note about configuration

* fix coverage badge reference for pypi

* move to emoji character instead of code for pypi

* more descriptive parameter name

Co-Authored-By: Jenna Tomkinson <[email protected]>

* fix tests

* format before lint

---------

Co-authored-by: Jenna Tomkinson <[email protected]>
  • Loading branch information
d33bs and jenna-tomkinson authored Aug 5, 2024
1 parent 2172952 commit af7afdf
Show file tree
Hide file tree
Showing 10 changed files with 234 additions and 19 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,5 @@ cython_debug/
*.csv

.DS_Store

tests/data/cytotable/Nuclear_speckles
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.5.5"
hooks:
- id: ruff
- id: ruff-format
- id: ruff
- repo: local
hooks:
- id: code-cov-gen
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

![PyPI - Version](https://img.shields.io/pypi/v/cosmicqc)
[![Build Status](https://github.com/WayScience/coSMicQC/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/WayScience/coSMicQC/actions/workflows/run-tests.yml?query=branch%3Amain)
![Coverage Status](./media/coverage-badge.svg)
![Coverage Status](https://raw.githubusercontent.com/WayScience/coSMicQC/main/media/coverage-badge.svg)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)

> :stars: Navigate the cosmos of single-cell morphology with confidence — coSMicQC keeps your data on course!
> 🌠 Navigate the cosmos of single-cell morphology with confidence — coSMicQC keeps your data on course!
coSMicQC is a Python package to evaluate converted single-cell morphology outputs from CytoTable.

Expand Down
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.
31 changes: 29 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pywavelets = [
{version = "^1.4.1", python = "<3.9"},
{version = ">1.4.1", python = ">=3.9"}
] # dependency of scikit-image
opencv-python = "^4.10.0.84" # used for image modifications in cytodataframe

[tool.poetry.group.dev.dependencies]
pytest = "^8.2.0" # provides testing capabilities for project
Expand Down Expand Up @@ -96,6 +97,14 @@ markers = [
"generate_report_image: tests which involve the creation of report images.",
]

[tool.coverage.run]
# settings to avoid errors with cv2 and coverage
# see here for more: https://github.com/nedbat/coveragepy/issues/1653
omit = [
"config.py",
"config-3.py",
]

# set dynamic versioning capabilities for project
[tool.poetry-dynamic-versioning]
enable = true
Expand Down
72 changes: 59 additions & 13 deletions src/cosmicqc/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import plotly.express as px
import plotly.graph_objects as go
import skimage
import skimage.io
import skimage.measure
from IPython import get_ipython
from jinja2 import Environment, FileSystemLoader
from pandas._config import (
Expand All @@ -37,6 +39,8 @@
)
from PIL import Image, ImageDraw

from .image import adjust_image_brightness, is_image_too_dark

# provide backwards compatibility for Self type in earlier Python versions.
# see: https://peps.python.org/pep-0484/#annotating-instance-and-class-methods
CytoDataFrame_type = TypeVar("CytoDataFrame_type", bound="CytoDataFrame")
Expand Down Expand Up @@ -628,7 +632,17 @@ def draw_outline_on_image(actual_image_path: str, mask_image_path: str) -> Image
# Load the TIFF image
tiff_image_array = skimage.io.imread(actual_image_path)
# Convert to PIL Image and then to 'RGBA'
tiff_image = Image.fromarray(np.uint8(tiff_image_array)).convert("RGBA")

# Check if the image is 16-bit and grayscale
if tiff_image_array.dtype == np.uint16:
# Normalize the image to 8-bit for display purposes
tiff_image_array = (tiff_image_array / 256).astype(np.uint8)

tiff_image = Image.fromarray(tiff_image_array).convert("RGBA")

# Check if the image is too dark and adjust brightness if needed
if is_image_too_dark(tiff_image):
tiff_image = adjust_image_brightness(tiff_image)

# Load the mask image and convert it to grayscale
mask_image = Image.open(mask_image_path).convert("L")
Expand Down Expand Up @@ -659,19 +673,20 @@ def process_image_data_as_html_display(
bounding_box: Tuple[int, int, int, int],
) -> str:
if not pathlib.Path(data_value).is_file():
if not pathlib.Path(
candidate_path := (
f"{self._custom_attrs['data_context_dir']}/{data_value}"
)
).is_file():
return data_value
# Use rglob to recursively search for a matching file
if candidate_paths := list(
pathlib.Path(self._custom_attrs["data_context_dir"]).rglob(data_value)
):
# if we find a candidate, return the first one
candidate_path = candidate_paths[0]
else:
pass
# we don't have any candidate paths so return the unmodified value
return data_value

try:
if self._custom_attrs["data_mask_context_dir"] is not None and (
matching_mask_file := list(
pathlib.Path(self._custom_attrs["data_mask_context_dir"]).glob(
pathlib.Path(self._custom_attrs["data_mask_context_dir"]).rglob(
f"{pathlib.Path(candidate_path).stem}*"
)
)
Expand Down Expand Up @@ -773,15 +788,46 @@ def _repr_html_(
# gather indices which will be displayed based on pandas configuration
display_indices = self.get_displayed_rows()

# gather bounding box columns for use below
bounding_box_cols = self._custom_attrs["data_bounding_box"].columns.tolist()

for image_col in image_cols:
data.loc[display_indices, image_col] = data.loc[display_indices].apply(
lambda row: self.process_image_data_as_html_display(
data_value=row[image_col],
bounding_box=(
row["Cytoplasm_AreaShape_BoundingBoxMinimum_X"],
row["Cytoplasm_AreaShape_BoundingBoxMinimum_Y"],
row["Cytoplasm_AreaShape_BoundingBoxMaximum_X"],
row["Cytoplasm_AreaShape_BoundingBoxMaximum_Y"],
# rows below are specified using the column name to
# determine which part of the bounding box the columns
# relate to (the list of column names could be in
# various order).
row[
next(
col
for col in bounding_box_cols
if "Minimum_X" in col
)
],
row[
next(
col
for col in bounding_box_cols
if "Minimum_Y" in col
)
],
row[
next(
col
for col in bounding_box_cols
if "Maximum_X" in col
)
],
row[
next(
col
for col in bounding_box_cols
if "Maximum_Y" in col
)
],
),
),
axis=1,
Expand Down
66 changes: 66 additions & 0 deletions src/cosmicqc/image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Helper functions for working with images in the context of coSMicQC.
"""

import cv2
import numpy as np
from PIL import Image, ImageEnhance


def is_image_too_dark(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.
Args:
image (Image):
The input PIL Image.
threshold (float):
The brightness threshold below which the image is considered too dark.
Returns:
bool:
True if the image is too dark, False otherwise.
"""
# Convert the image to a numpy array and then to grayscale
img_array = np.array(image)
gray_image = cv2.cvtColor(img_array, cv2.COLOR_RGBA2GRAY)

# Calculate the mean brightness
mean_brightness = np.mean(gray_image)

return mean_brightness < pixel_brightness_threshold


def adjust_image_brightness(image: Image) -> Image:
"""
Adjust the brightness of an image using histogram equalization.
Args:
image (Image):
The input PIL Image.
Returns:
Image:
The brightness-adjusted PIL Image.
"""
# Convert the image to numpy array and then to grayscale
img_array = np.array(image)
gray_image = cv2.cvtColor(img_array, cv2.COLOR_RGBA2GRAY)

# Apply histogram equalization to improve the contrast
equalized_image = cv2.equalizeHist(gray_image)

# Convert back to RGBA
img_array[:, :, 0] = equalized_image # Update only the R channel
img_array[:, :, 1] = equalized_image # Update only the G channel
img_array[:, :, 2] = equalized_image # Update only the B channel

# Convert back to PIL Image
enhanced_image = Image.fromarray(img_array)

# Slightly reduce the brightness
enhancer = ImageEnhance.Brightness(enhanced_image)
reduced_brightness_image = enhancer.enhance(0.7)

return reduced_brightness_image
23 changes: 23 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import pathlib

import cosmicqc
import numpy as np
import pandas as pd
import plotly.colors as pc
import pytest
from PIL import Image


@pytest.fixture(name="cytotable_CFReT_data_df")
Expand Down Expand Up @@ -127,3 +129,24 @@ def fixture_generate_show_report_html_output(cytotable_CFReT_data_df: pd.DataFra
)

return report_path


@pytest.fixture
def fixture_dark_image():
# Create a dark image (50x50 pixels, almost black)
dark_img_array = np.zeros((50, 50, 3), dtype=np.uint8)
return Image.fromarray(dark_img_array)


@pytest.fixture
def fixture_mid_brightness_image():
# Create an image with medium brightness (50x50 pixels, mid gray)
mid_brightness_img_array = np.full((50, 50, 3), 128, dtype=np.uint8)
return Image.fromarray(mid_brightness_img_array)


@pytest.fixture
def fixture_bright_image():
# Create a bright image (50x50 pixels, almost white)
bright_img_array = np.full((50, 50, 3), 255, dtype=np.uint8)
return Image.fromarray(bright_img_array)
42 changes: 42 additions & 0 deletions tests/test_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
Tests cosmicqc image module
"""

from cosmicqc.image import adjust_image_brightness, is_image_too_dark
from PIL import Image


def test_is_image_too_dark_with_dark_image(fixture_dark_image: Image):
assert is_image_too_dark(fixture_dark_image, pixel_brightness_threshold=10.0)


def test_is_image_too_dark_with_bright_image(fixture_bright_image: Image):
assert not is_image_too_dark(fixture_bright_image, pixel_brightness_threshold=10.0)


def test_is_image_too_dark_with_mid_brightness_image(
fixture_mid_brightness_image: Image,
):
assert not is_image_too_dark(
fixture_mid_brightness_image, pixel_brightness_threshold=10.0
)


def test_adjust_image_brightness_with_dark_image(fixture_dark_image: Image):
adjusted_image = adjust_image_brightness(fixture_dark_image)
# we expect that image to be too dark (it's all dark, so there's no adjustments)
assert is_image_too_dark(adjusted_image, pixel_brightness_threshold=10.0)


def test_adjust_image_brightness_with_bright_image(fixture_bright_image: Image):
adjusted_image = adjust_image_brightness(fixture_bright_image)
# Since the image was already bright, it should remain bright
assert not is_image_too_dark(adjusted_image, pixel_brightness_threshold=10.0)


def test_adjust_image_brightness_with_mid_brightness_image(
fixture_mid_brightness_image: Image,
):
adjusted_image = adjust_image_brightness(fixture_mid_brightness_image)
# The image should still not be too dark after adjustment
assert not is_image_too_dark(adjusted_image, pixel_brightness_threshold=10.0)

0 comments on commit af7afdf

Please sign in to comment.