Skip to content

Commit af7afdf

Browse files
Fix for nuclear speckle image display in CytoDataFrame (#64)
* 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 <107513215+jenna-tomkinson@users.noreply.github.com> * fix tests * format before lint --------- Co-authored-by: Jenna Tomkinson <107513215+jenna-tomkinson@users.noreply.github.com>
1 parent 2172952 commit af7afdf

File tree

10 files changed

+234
-19
lines changed

10 files changed

+234
-19
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,5 @@ cython_debug/
142142
*.csv
143143

144144
.DS_Store
145+
146+
tests/data/cytotable/Nuclear_speckles

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ repos:
5151
- repo: https://github.com/astral-sh/ruff-pre-commit
5252
rev: "v0.5.5"
5353
hooks:
54-
- id: ruff
5554
- id: ruff-format
55+
- id: ruff
5656
- repo: local
5757
hooks:
5858
- id: code-cov-gen

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
![PyPI - Version](https://img.shields.io/pypi/v/cosmicqc)
66
[![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)
7-
![Coverage Status](./media/coverage-badge.svg)
7+
![Coverage Status](https://raw.githubusercontent.com/WayScience/coSMicQC/main/media/coverage-badge.svg)
88
[![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)
99
[![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
1010

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

media/coverage-badge.svg

Lines changed: 1 addition & 1 deletion
Loading

poetry.lock

Lines changed: 29 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pywavelets = [
3232
{version = "^1.4.1", python = "<3.9"},
3333
{version = ">1.4.1", python = ">=3.9"}
3434
] # dependency of scikit-image
35+
opencv-python = "^4.10.0.84" # used for image modifications in cytodataframe
3536

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

100+
[tool.coverage.run]
101+
# settings to avoid errors with cv2 and coverage
102+
# see here for more: https://github.com/nedbat/coveragepy/issues/1653
103+
omit = [
104+
"config.py",
105+
"config-3.py",
106+
]
107+
99108
# set dynamic versioning capabilities for project
100109
[tool.poetry-dynamic-versioning]
101110
enable = true

src/cosmicqc/frame.py

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import plotly.express as px
2828
import plotly.graph_objects as go
2929
import skimage
30+
import skimage.io
31+
import skimage.measure
3032
from IPython import get_ipython
3133
from jinja2 import Environment, FileSystemLoader
3234
from pandas._config import (
@@ -37,6 +39,8 @@
3739
)
3840
from PIL import Image, ImageDraw
3941

42+
from .image import adjust_image_brightness, is_image_too_dark
43+
4044
# provide backwards compatibility for Self type in earlier Python versions.
4145
# see: https://peps.python.org/pep-0484/#annotating-instance-and-class-methods
4246
CytoDataFrame_type = TypeVar("CytoDataFrame_type", bound="CytoDataFrame")
@@ -628,7 +632,17 @@ def draw_outline_on_image(actual_image_path: str, mask_image_path: str) -> Image
628632
# Load the TIFF image
629633
tiff_image_array = skimage.io.imread(actual_image_path)
630634
# Convert to PIL Image and then to 'RGBA'
631-
tiff_image = Image.fromarray(np.uint8(tiff_image_array)).convert("RGBA")
635+
636+
# Check if the image is 16-bit and grayscale
637+
if tiff_image_array.dtype == np.uint16:
638+
# Normalize the image to 8-bit for display purposes
639+
tiff_image_array = (tiff_image_array / 256).astype(np.uint8)
640+
641+
tiff_image = Image.fromarray(tiff_image_array).convert("RGBA")
642+
643+
# Check if the image is too dark and adjust brightness if needed
644+
if is_image_too_dark(tiff_image):
645+
tiff_image = adjust_image_brightness(tiff_image)
632646

633647
# Load the mask image and convert it to grayscale
634648
mask_image = Image.open(mask_image_path).convert("L")
@@ -659,19 +673,20 @@ def process_image_data_as_html_display(
659673
bounding_box: Tuple[int, int, int, int],
660674
) -> str:
661675
if not pathlib.Path(data_value).is_file():
662-
if not pathlib.Path(
663-
candidate_path := (
664-
f"{self._custom_attrs['data_context_dir']}/{data_value}"
665-
)
666-
).is_file():
667-
return data_value
676+
# Use rglob to recursively search for a matching file
677+
if candidate_paths := list(
678+
pathlib.Path(self._custom_attrs["data_context_dir"]).rglob(data_value)
679+
):
680+
# if we find a candidate, return the first one
681+
candidate_path = candidate_paths[0]
668682
else:
669-
pass
683+
# we don't have any candidate paths so return the unmodified value
684+
return data_value
670685

671686
try:
672687
if self._custom_attrs["data_mask_context_dir"] is not None and (
673688
matching_mask_file := list(
674-
pathlib.Path(self._custom_attrs["data_mask_context_dir"]).glob(
689+
pathlib.Path(self._custom_attrs["data_mask_context_dir"]).rglob(
675690
f"{pathlib.Path(candidate_path).stem}*"
676691
)
677692
)
@@ -773,15 +788,46 @@ def _repr_html_(
773788
# gather indices which will be displayed based on pandas configuration
774789
display_indices = self.get_displayed_rows()
775790

791+
# gather bounding box columns for use below
792+
bounding_box_cols = self._custom_attrs["data_bounding_box"].columns.tolist()
793+
776794
for image_col in image_cols:
777795
data.loc[display_indices, image_col] = data.loc[display_indices].apply(
778796
lambda row: self.process_image_data_as_html_display(
779797
data_value=row[image_col],
780798
bounding_box=(
781-
row["Cytoplasm_AreaShape_BoundingBoxMinimum_X"],
782-
row["Cytoplasm_AreaShape_BoundingBoxMinimum_Y"],
783-
row["Cytoplasm_AreaShape_BoundingBoxMaximum_X"],
784-
row["Cytoplasm_AreaShape_BoundingBoxMaximum_Y"],
799+
# rows below are specified using the column name to
800+
# determine which part of the bounding box the columns
801+
# relate to (the list of column names could be in
802+
# various order).
803+
row[
804+
next(
805+
col
806+
for col in bounding_box_cols
807+
if "Minimum_X" in col
808+
)
809+
],
810+
row[
811+
next(
812+
col
813+
for col in bounding_box_cols
814+
if "Minimum_Y" in col
815+
)
816+
],
817+
row[
818+
next(
819+
col
820+
for col in bounding_box_cols
821+
if "Maximum_X" in col
822+
)
823+
],
824+
row[
825+
next(
826+
col
827+
for col in bounding_box_cols
828+
if "Maximum_Y" in col
829+
)
830+
],
785831
),
786832
),
787833
axis=1,

src/cosmicqc/image.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Helper functions for working with images in the context of coSMicQC.
3+
"""
4+
5+
import cv2
6+
import numpy as np
7+
from PIL import Image, ImageEnhance
8+
9+
10+
def is_image_too_dark(image: Image, pixel_brightness_threshold: float = 10.0) -> bool:
11+
"""
12+
Check if the image is too dark based on the mean brightness.
13+
By "too dark" we mean not as visible to the human eye.
14+
15+
Args:
16+
image (Image):
17+
The input PIL Image.
18+
threshold (float):
19+
The brightness threshold below which the image is considered too dark.
20+
21+
Returns:
22+
bool:
23+
True if the image is too dark, False otherwise.
24+
"""
25+
# Convert the image to a numpy array and then to grayscale
26+
img_array = np.array(image)
27+
gray_image = cv2.cvtColor(img_array, cv2.COLOR_RGBA2GRAY)
28+
29+
# Calculate the mean brightness
30+
mean_brightness = np.mean(gray_image)
31+
32+
return mean_brightness < pixel_brightness_threshold
33+
34+
35+
def adjust_image_brightness(image: Image) -> Image:
36+
"""
37+
Adjust the brightness of an image using histogram equalization.
38+
39+
Args:
40+
image (Image):
41+
The input PIL Image.
42+
43+
Returns:
44+
Image:
45+
The brightness-adjusted PIL Image.
46+
"""
47+
# Convert the image to numpy array and then to grayscale
48+
img_array = np.array(image)
49+
gray_image = cv2.cvtColor(img_array, cv2.COLOR_RGBA2GRAY)
50+
51+
# Apply histogram equalization to improve the contrast
52+
equalized_image = cv2.equalizeHist(gray_image)
53+
54+
# Convert back to RGBA
55+
img_array[:, :, 0] = equalized_image # Update only the R channel
56+
img_array[:, :, 1] = equalized_image # Update only the G channel
57+
img_array[:, :, 2] = equalized_image # Update only the B channel
58+
59+
# Convert back to PIL Image
60+
enhanced_image = Image.fromarray(img_array)
61+
62+
# Slightly reduce the brightness
63+
enhancer = ImageEnhance.Brightness(enhanced_image)
64+
reduced_brightness_image = enhancer.enhance(0.7)
65+
66+
return reduced_brightness_image

tests/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import pathlib
88

99
import cosmicqc
10+
import numpy as np
1011
import pandas as pd
1112
import plotly.colors as pc
1213
import pytest
14+
from PIL import Image
1315

1416

1517
@pytest.fixture(name="cytotable_CFReT_data_df")
@@ -127,3 +129,24 @@ def fixture_generate_show_report_html_output(cytotable_CFReT_data_df: pd.DataFra
127129
)
128130

129131
return report_path
132+
133+
134+
@pytest.fixture
135+
def fixture_dark_image():
136+
# Create a dark image (50x50 pixels, almost black)
137+
dark_img_array = np.zeros((50, 50, 3), dtype=np.uint8)
138+
return Image.fromarray(dark_img_array)
139+
140+
141+
@pytest.fixture
142+
def fixture_mid_brightness_image():
143+
# Create an image with medium brightness (50x50 pixels, mid gray)
144+
mid_brightness_img_array = np.full((50, 50, 3), 128, dtype=np.uint8)
145+
return Image.fromarray(mid_brightness_img_array)
146+
147+
148+
@pytest.fixture
149+
def fixture_bright_image():
150+
# Create a bright image (50x50 pixels, almost white)
151+
bright_img_array = np.full((50, 50, 3), 255, dtype=np.uint8)
152+
return Image.fromarray(bright_img_array)

tests/test_image.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
Tests cosmicqc image module
3+
"""
4+
5+
from cosmicqc.image import adjust_image_brightness, is_image_too_dark
6+
from PIL import Image
7+
8+
9+
def test_is_image_too_dark_with_dark_image(fixture_dark_image: Image):
10+
assert is_image_too_dark(fixture_dark_image, pixel_brightness_threshold=10.0)
11+
12+
13+
def test_is_image_too_dark_with_bright_image(fixture_bright_image: Image):
14+
assert not is_image_too_dark(fixture_bright_image, pixel_brightness_threshold=10.0)
15+
16+
17+
def test_is_image_too_dark_with_mid_brightness_image(
18+
fixture_mid_brightness_image: Image,
19+
):
20+
assert not is_image_too_dark(
21+
fixture_mid_brightness_image, pixel_brightness_threshold=10.0
22+
)
23+
24+
25+
def test_adjust_image_brightness_with_dark_image(fixture_dark_image: Image):
26+
adjusted_image = adjust_image_brightness(fixture_dark_image)
27+
# we expect that image to be too dark (it's all dark, so there's no adjustments)
28+
assert is_image_too_dark(adjusted_image, pixel_brightness_threshold=10.0)
29+
30+
31+
def test_adjust_image_brightness_with_bright_image(fixture_bright_image: Image):
32+
adjusted_image = adjust_image_brightness(fixture_bright_image)
33+
# Since the image was already bright, it should remain bright
34+
assert not is_image_too_dark(adjusted_image, pixel_brightness_threshold=10.0)
35+
36+
37+
def test_adjust_image_brightness_with_mid_brightness_image(
38+
fixture_mid_brightness_image: Image,
39+
):
40+
adjusted_image = adjust_image_brightness(fixture_mid_brightness_image)
41+
# The image should still not be too dark after adjustment
42+
assert not is_image_too_dark(adjusted_image, pixel_brightness_threshold=10.0)

0 commit comments

Comments
 (0)