From af7afdfb0db2e047b5ea04586a6fab60ab40f239 Mon Sep 17 00:00:00 2001 From: Dave Bunten Date: Mon, 5 Aug 2024 08:54:35 -0600 Subject: [PATCH] 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> --- .gitignore | 2 ++ .pre-commit-config.yaml | 2 +- README.md | 4 +-- media/coverage-badge.svg | 2 +- poetry.lock | 31 +++++++++++++++-- pyproject.toml | 9 +++++ src/cosmicqc/frame.py | 72 ++++++++++++++++++++++++++++++++-------- src/cosmicqc/image.py | 66 ++++++++++++++++++++++++++++++++++++ tests/conftest.py | 23 +++++++++++++ tests/test_image.py | 42 +++++++++++++++++++++++ 10 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 src/cosmicqc/image.py create mode 100644 tests/test_image.py diff --git a/.gitignore b/.gitignore index 84792e1..25fced0 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,5 @@ cython_debug/ *.csv .DS_Store + +tests/data/cytotable/Nuclear_speckles diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 72b4c72..58da34b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/README.md b/README.md index 7077d74..1ac12f2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/media/coverage-badge.svg b/media/coverage-badge.svg index 2ce7beb..de0d485 100644 --- a/media/coverage-badge.svg +++ b/media/coverage-badge.svg @@ -1 +1 @@ -coverage: 91.90%coverage91.90% \ No newline at end of file +coverage: 92.62%coverage92.62% \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 5699fa6..dc43139 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1780,6 +1780,33 @@ files = [ {file = "numpy-2.0.0.tar.gz", hash = "sha256:cf5d1c9e6837f8af9f92b6bd3e86d513cdc11f60fd62185cc49ec7d1aba34864"}, ] +[[package]] +name = "opencv-python" +version = "4.10.0.84" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +files = [ + {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.21.0", markers = "python_version <= \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\" and python_version >= \"3.8\""}, + {version = ">=1.19.3", markers = "platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version >= \"3.8\" and python_version < \"3.10\" or python_version > \"3.9\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_system != \"Darwin\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, + {version = ">=1.17.3", markers = "(platform_system != \"Darwin\" and platform_system != \"Linux\") and python_version >= \"3.8\" and python_version < \"3.9\" or platform_system != \"Darwin\" and python_version >= \"3.8\" and python_version < \"3.9\" and platform_machine != \"aarch64\" or platform_machine != \"arm64\" and python_version >= \"3.8\" and python_version < \"3.9\" and platform_system != \"Linux\" or (platform_machine != \"arm64\" and platform_machine != \"aarch64\") and python_version >= \"3.8\" and python_version < \"3.9\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, + {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, +] + [[package]] name = "overrides" version = "7.7.0" @@ -1906,8 +1933,8 @@ files = [ [package.dependencies] numpy = [ {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -3420,4 +3447,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.13" -content-hash = "aac65648f9c641341669379c49a58a31ba64951739c50ee3e991db341ca40555" +content-hash = "ba949e3d94a7db9d45789de853fd316cf49d382d594dd4fc2cf2986c0da2a9c3" diff --git a/pyproject.toml b/pyproject.toml index 0cfd4d2..e8a435b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 @@ -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 diff --git a/src/cosmicqc/frame.py b/src/cosmicqc/frame.py index 47c4aea..6165b27 100644 --- a/src/cosmicqc/frame.py +++ b/src/cosmicqc/frame.py @@ -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 ( @@ -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") @@ -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") @@ -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}*" ) ) @@ -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, diff --git a/src/cosmicqc/image.py b/src/cosmicqc/image.py new file mode 100644 index 0000000..7ae4efe --- /dev/null +++ b/src/cosmicqc/image.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 4c31798..11d4cfb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") @@ -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) diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 0000000..83539d3 --- /dev/null +++ b/tests/test_image.py @@ -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)