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

Image Processing Class from PR 143 #171

Merged
merged 19 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[flake8]
max-line-length = 120
ignore = E266, W503
exclude = ./docs/source/conf.py
exclude = ./docs/source/conf.py
per-file-ignores =
__init__.py:F401
17 changes: 17 additions & 0 deletions docs/source/image.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
================
Image Processing
================

Image
=====
.. automodule:: lcls_tools.common.image.image
:members:
:undoc-members:

.. automodule:: lcls_tools.common.image.processing
:members:
:undoc-members:

.. automodule:: lcls_tools.common.image.roi
:members:
eloiseyang marked this conversation as resolved.
Show resolved Hide resolved
:undoc-members:
10 changes: 0 additions & 10 deletions docs/source/image_processing.rst

This file was deleted.

1 change: 1 addition & 0 deletions lcls_tools/common/image/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from lcls_tools.common.image.image import Image
56 changes: 56 additions & 0 deletions lcls_tools/common/image/processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import numpy as np
from scipy.ndimage import gaussian_filter
from pydantic import BaseModel, PositiveFloat, ConfigDict
from lcls_tools.common.image.roi import ROI


class ImageProcessor(BaseModel):
"""
Image Processing class that allows for background subtraction and roi cropping
------------------------
Arguments:
roi: ROI (roi object either Circular or Rectangular),
background_image: np.ndarray (optional image that will be used in
background subtraction if passed),
threshold: Positive Float (value of pixel intensity to be subtracted
if background_image is None, default value = 0.0)
visualize: bool (plots processed image)
------------------------
Methods:
subtract_background: takes a raw image and does pixel intensity subtraction
process: takes raw image and calls subtract_background, passes to result
to the roi object for cropping.
"""

model_config = ConfigDict(arbitrary_types_allowed=True)
roi: ROI = None
background_image: np.ndarray = None
threshold: PositiveFloat = 0.0

def subtract_background(self, raw_image: np.ndarray) -> np.ndarray:
"""Subtract background pixel intensity from a raw image"""
if self.background_image is not None:
image = raw_image - self.background_image
else:
image = raw_image - self.threshold
return image

def clip_image(self, image):
return np.clip(image, 0, None)

def auto_process(self, raw_image: np.ndarray) -> np.ndarray:
"""Process image by subtracting background pixel intensity
eloiseyang marked this conversation as resolved.
Show resolved Hide resolved
from a raw image, crop, and filter"""
image = self.subtract_background(raw_image)
clipped_image = self.clip_image(image)
if self.roi is not None:
cropped_image = self.roi.crop_image(clipped_image)
else:
cropped_image = clipped_image
processed_image = self.filter(cropped_image)
return processed_image

def filter(self, unfiltered_image: np.ndarray, sigma=5) -> np.ndarray:
# TODO: extend to other types of filters? Change the way we pass sigma?
filtered_data = gaussian_filter(unfiltered_image, sigma)
return filtered_data
87 changes: 87 additions & 0 deletions lcls_tools/common/image/roi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import numpy as np
from pydantic import (
BaseModel,
model_validator,
PositiveFloat
)
from typing import Any, Dict, List


class ROI(BaseModel):
center: List[PositiveFloat]
extent: List[PositiveFloat]

@property
def box(self):
return [
int(self.center[0] - int(self.extent[0] / 2)),
int(self.center[1] - int(self.extent[1] / 2)),
int(self.center[0] + int(self.extent[0] / 2)),
int(self.center[1] + int(self.extent[1] / 2))
]

def crop_image(self, img) -> np.ndarray:
"""Crop image using the ROI center and bounding extent."""
x_size, y_size = img.shape
if self.extent[0] > x_size or self.extent[1] > y_size:
raise ValueError(
f"must pass image that is larger than ROI, "
f"image size is {img.shape}, "
)
img = img[self.box[0]:self.box[2],
self.box[1]:self.box[3]]
return img
eloiseyang marked this conversation as resolved.
Show resolved Hide resolved


class EllipticalROI(ROI):
"""
Define an elliptical region of interest (ROI) for an image.
"""
radius: List[PositiveFloat]

@model_validator(mode='before')
def __set_radius_and_extent__(cls, data: Any) -> Any:
# The caret key '^' is logical xor in this case.
if not ('radius' in data) ^ ('extent' in data):
raise ValueError('enter extent or radius field but not both')
if 'radius' in data:
data['extent'] = [r * 2 for r in data['radius']]
if 'extent' in data:
data['radius'] = [w / 2 for w in data['extent']]
return data

def negative_fill(self, img, fill_value):
""" Fill the region outside the defined ellipse. """
r = self.radius
c = self.center
height, extent = img.shape
for y in range(height):
for x in range(extent):
distance = (((x - c[0]) / r[0]) ** 2
+ ((y - c[1]) / r[1]) ** 2)
if distance > 1:
img[y, x] = fill_value
return img

def crop_image(self, img, **kwargs) -> np.ndarray:
"""
Crop the pixels outside a bounding box and set the boundary to a fill
value (usually zero).
"""
img = super().crop_image(img)
fill_value = kwargs.get("fill_value", 0.0)
img = self.negative_fill(img, fill_value)
return img


class CircularROI(EllipticalROI):
"""
Define a circular region of interest (ROI) for an image.
"""
@model_validator(mode='before')
def __set_radius_and_extent__(cls, data: Dict[str, Any]) -> Any:
if 'radius' in data:
data['radius'] = [data['radius'], data['radius']]
if 'extent' in data:
data['extent'] = [data['extent'], data['extent']]
return super().__set_radius_and_extent__(data)
45 changes: 0 additions & 45 deletions lcls_tools/common/image_processing/image_processing.py

This file was deleted.

2 changes: 1 addition & 1 deletion lcls_tools/common/matlab2py/mat_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import matplotlib.pyplot as plt
import os

from lcls_tools.common.image_processing.image import Image
from lcls_tools.common.image import Image


class MatImage(object):
Expand Down
Binary file added tests/datasets/h5py/test_image.h5
Binary file not shown.
Binary file not shown.
Binary file added tests/datasets/images/numpy/test_roi_image.npy
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import os
import unittest
import numpy as np
from lcls_tools.common.image_processing.image import Image

FILE = "test_image.npy"
from lcls_tools.common.image import Image


class ImageTest(unittest.TestCase):
data_location: str = "/tests/datasets/images/numpy/"
data_location: str = "tests/datasets/images/numpy/"

def setUp(self):
self.file = os.path.join(self.data_location, "test_image.npy")
Expand All @@ -16,11 +14,11 @@ def setUp(self):
raise FileNotFoundError(f"Could not find {self.file}, aborting test.")
except FileNotFoundError:
self.skipTest("Invalid dataset location")
self.image_obj = Image(np.load(FILE))
self.image_obj = Image(np.load(self.file))

def test_image(self):
"""Make sure image array not altered after initialization"""
test_img = np.load("test_image.npy")
test_img = np.load(self.file)
obj_img = self.image_obj.image
self.assertEqual(np.array_equal(test_img, obj_img), True)

Expand Down
90 changes: 90 additions & 0 deletions tests/unit_tests/lcls_tools/common/image/test_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import unittest
import numpy as np

from lcls_tools.common.image.processing import ImageProcessor
from lcls_tools.common.image.roi import ROI


class TestImageProcessing(unittest.TestCase):
data_location: str = "tests/datasets/images/numpy/"

def __init__(self, methodName: str = "runTest") -> None:
super().__init__(methodName)
self.center = [400, 400]
self.size = (800, 800)
self.extent = [350, 300]
self.radius = 50
self.image = np.load(self.data_location + "test_roi_image.npy")

def test_process(self):
"""
Given an np.ndarray and roi process
and assert the return in an np.ndarray
"""
image_processor = ImageProcessor()
image = image_processor.auto_process(self.image)
self.assertIsInstance(
image, np.ndarray,
msg="expected image to be an instance of np.ndarray"
)
roi = ROI(center=self.center, extent=self.extent)
image_processor = ImageProcessor(roi=roi)
image = image_processor.auto_process(self.image)
self.assertIsInstance(
image, np.ndarray,
msg="expected image to be an instance of np.ndarray"
)
imageShape = image.shape
roiShape = tuple(roi.extent)
self.assertEqual(
imageShape, roiShape,
msg=(f"expected image shape {imageShape} "
+ f"to equal roi {roiShape}")
)

eloiseyang marked this conversation as resolved.
Show resolved Hide resolved
def test_subtract_background(self):
"""
Given an np.ndarray, check that when the image_processor
is passed a background_image. the subtract_background function
call subtracts the returns an np.ndarray
that is the difference between the two np.ndarrays
"""
background_image = np.ones(self.size)
image_processor = ImageProcessor(background_image=background_image)
image = image_processor.subtract_background(self.image)
image = image
background = (self.image - 1)
np.testing.assert_array_equal(
image, background,
err_msg=("expected image to equal background "
+ "during background subtraction")
)

"""
Given an np.ndarray check that when the image_processor
is passed a threshold check that subtraction occurs correctly
"""
image_processor = ImageProcessor(threshold=1)
image = image_processor.subtract_background(self.image)
image = image
background = (self.image - 1)
np.testing.assert_array_equal(
image, background,
err_msg=("expected image to equal background "
+ "when applying threshold")
)

def test_clip(self):
"""
Given an np.ndarray check that when the image_processor
is passed a threshold check that the np.ndarray elements
are clipped at to not drop below zero
"""
image_processor = ImageProcessor(threshold=100)
image = image_processor.subtract_background(self.image)
clipped_image = image_processor.clip_image(image)
np.testing.assert_array_equal(
clipped_image, np.zeros(self.size),
err_msg=("expected clipped image to equal zero "
+ "when subtracting background with threshold")
)
Loading
Loading