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

replace opencv-python with scikit-image for snowball detection #138

Open
wants to merge 31 commits into
base: main
Choose a base branch
from

Conversation

zacharyburnett
Copy link
Collaborator

@zacharyburnett zacharyburnett commented Dec 29, 2022

Closes spacetelescope/jwst#7409

This PR adds fallback functions for replaces ellipse construction using scikit-image and shapely, removing opencv-python entirely. The scikit-image + shapely methods produce slightly different contours, ellipses, and circles than opencv-python.

Checklist

  • added entry in CHANGES.rst (either in Bug Fixes or Changes to API)
  • updated relevant tests
  • updated relevant documentation
  • updated relevant milestone(s)
  • added relevant label(s)

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Dec 29, 2022

Contour-finding also needs to be ported from opencv-python to scikit-image:

ModuleNotFoundError: `opencv-python` required for this functionality
def find_circles(dqplane, bitmask, min_area):
        # Using an input DQ plane this routine will find the groups of pixels with at least the minimum
        # area and return a list of the minimum enclosing circle parameters.
        pixels = np.bitwise_and(dqplane, bitmask)
        if ELLIPSE_PACKAGE == 'opencv-python':
            contours, hierarchy = cv.findContours(pixels, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
            bigcontours = [con for con in contours if cv.contourArea(con) >= min_area]
            circles = [cv.minEnclosingCircle(con) for con in bigcontours]
        else:
            # raise ModuleNotFoundError(ELLIPSE_PACKAGE_WARNING)
           raise ModuleNotFoundError('`opencv-python` required for this functionality')

@codecov
Copy link

codecov bot commented Dec 29, 2022

Codecov Report

Attention: Patch coverage is 88.58025% with 37 lines in your changes missing coverage. Please review.

Project coverage is 83.99%. Comparing base (af5aefb) to head (9fca583).
Report is 10 commits behind head on main.

Files Patch % Lines
src/stcal/jump/jump.py 77.41% 21 Missing ⚠️
src/stcal/jump/circle.py 88.04% 11 Missing ⚠️
tests/test_circle.py 95.08% 3 Missing ⚠️
tests/test_jump.py 97.43% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #138      +/-   ##
==========================================
- Coverage   85.18%   83.99%   -1.19%     
==========================================
  Files          35       37       +2     
  Lines        6797     7211     +414     
==========================================
+ Hits         5790     6057     +267     
- Misses       1007     1154     +147     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Dec 29, 2022

As described in #126 (comment), there are some differences in the methods used to construct ellipses between the two packages which result in different image masks. Perhaps a warning should make this more clear.

looking into replacing shape construction with scikit-image, it appears there is a crucial difference in the drawing method:

import cv2
import numpy
from matplotlib import pyplot
from skimage import draw

image_0 = numpy.full((10, 10), fill_value=0, dtype=numpy.uint8)

center = (4, 4)
radius = 4

disk = draw.disk(center, radius)
image_1 = image_0.copy()
image_1[disk] = 1

image_2 = cv2.circle(image_0.copy(), center, radius, 1, thickness=-1)

figure, axes = pyplot.subplots(nrows=1, ncols=2)

axes[0].imshow(image_1)
axes[0].set_title('scikit-image')

axes[1].imshow(image_2)
axes[1].set_title('opencv')

pyplot.show()

Figure_1

@zacharyburnett zacharyburnett marked this pull request as ready for review December 30, 2022 16:06
@zacharyburnett zacharyburnett marked this pull request as draft December 30, 2022 16:32
@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Dec 30, 2022

It looks like the contours found by scikit-image are consistently larger, by about half a unit, than those found by opencv:

>       assert circle[0][1] == pytest.approx(1.0, 1e-3)
E       assert 1.5 == 1.0 ± 1.0e-03

comparison

I assume this is a difference in methodology in thinking about pixels as blocks vs as points.

@zacharyburnett
Copy link
Collaborator Author

expanding the ellipse test case to a more oblong shape reveals a few edge cases in the scikit-image method; I'll convert this to draft for now and work on that

@zacharyburnett zacharyburnett marked this pull request as draft December 30, 2022 18:22
@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Dec 30, 2022

added some more complexity to the test shape and fixing issues in the ellipse fitting:
comparison

@@ -40,23 +45,25 @@ def test_find_simple_circle():
plane[2, 3] = DQFLAGS['JUMP_DET']
plane[2, 1] = DQFLAGS['JUMP_DET']
circle = find_circles(plane, DQFLAGS['JUMP_DET'], 1)
assert circle[0][0] == pytest.approx((2, 2))
assert circle[0][1] == pytest.approx(1.0, 1e-3)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here is where the fit circle from the scikit-image contour differs from the opencv-python circle; the radius is 1.5 instead of 1

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Jan 3, 2023

Currently, the .minAreaRect() is replaced by .minimum_rotated_rectangle from Shapely; this algorithm could be written without Shapely, like in circle.py, to remove the need for the additional dependency

src/stcal/jump/circle.py Outdated Show resolved Hide resolved
src/stcal/jump/circle.py Show resolved Hide resolved
src/stcal/jump/circle.py Outdated Show resolved Hide resolved
src/stcal/jump/circle.py Outdated Show resolved Hide resolved
src/stcal/jump/circle.py Outdated Show resolved Hide resolved
src/stcal/jump/circle.py Show resolved Hide resolved
@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Jan 4, 2023

we should also get input from INS on whether this alternative is acceptable

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented May 1, 2024

I removed opencv-python entirely in this PR. We can also look into alternative methods using scipy and binary closure / dilation + erosion; however, this change will solve our installation issues with opencv for the short term, until we can figure that out.

@zacharyburnett zacharyburnett marked this pull request as ready for review May 1, 2024 14:48
@zacharyburnett zacharyburnett requested a review from a team as a code owner May 1, 2024 14:48
@zacharyburnett zacharyburnett changed the title add fallback to scikit-image for ellipse functions replace opencv-python with scikit-image for ellipse functions and snowball detection May 1, 2024
@zacharyburnett zacharyburnett changed the title replace opencv-python with scikit-image for ellipse functions and snowball detection replace opencv-python with scikit-image for snowball detection Jun 6, 2024
Copy link
Collaborator Author

@zacharyburnett zacharyburnett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the formatter made some irrelevant edits, so here are the relevant changes to this PR

Comment on lines 676 to 684
ellipse_mask = skimage.draw.ellipse(
r=round(ceny),
c=round(cenx),
r_radius=round(axis1/2),
c_radius=round(axis2/2),
shape=image.shape,
rotation=alpha,
)
image[*ellipse_mask, 2] = 22
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaces cv.ellipse with a mask derived from skimage.draw.ellipse, then applies Blue 22 to the image array (why is the blue channel set to 22 if it's discarded right after? can this just be a simple mask?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No real reason. It's hack to create a mask of the ellipse. Sorry that's it is confusing.

Comment on lines 691 to 699
ellipse_mask = skimage.draw.ellipse(
r=round(ceny),
c=round(cenx),
r_radius=round(ellipse[1][0] / 2),
c_radius=round(ellipse[1][1] / 2),
shape=persist_image.shape,
rotation=alpha,
)
persist_image[*ellipse_mask, 2] = 22
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above; why is this blue?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason, a hack.

Comment on lines +750 to +753
center = (round(ceny), round(cenx))
axes = (round(axis1 / 2), round(axis2 / 2))
rr, cc = skimage.draw.ellipse(*center, *axes, shape=image.shape, rotation=alpha)
image[rr, cc, 2] = 4
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason why this is rounded to the nearest integer?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember right the routine is expecting integers. My guess was this is just an interface to C code.
Eventually we have to more to integers to mask the pixels anyway.

Comment on lines +815 to +871
def minimum_bounding_rectangle(points: np.ndarray) -> np.ndarray:
"""
Find the smallest bounding rectangle for a set of points.
Returns a set of points representing the corners of the bounding box.
https://stackoverflow.com/questions/13542855/algorithm-to-find-the-minimum-area-rectangle-for-given-points-in-order-to-comput

:param points: an nx2 matrix of coordinates
:rval: an nx2 matrix of coordinates
"""
pi2 = np.pi / 2.0

# get the convex hull for the points
hull_points = points[ConvexHull(points).vertices]

# calculate edge angles
edges = np.zeros((len(hull_points) - 1, 2))
edges = hull_points[1:] - hull_points[:-1]

angles = np.zeros(len(edges))
angles = np.arctan2(edges[:, 1], edges[:, 0])

angles = np.abs(np.mod(angles, pi2))
angles = np.unique(angles)

# find rotation matrices
# XXX both work
rotations = np.vstack(
[np.cos(angles), np.cos(angles - pi2), np.cos(angles + pi2), np.cos(angles)]
).T
# rotations = np.vstack([
# np.cos(angles),
# -np.sin(angles),
# np.sin(angles),
# np.cos(angles)]).T
rotations = rotations.reshape((-1, 2, 2))

# apply rotations to the hull
rot_points = np.dot(rotations, hull_points.T)

# find the bounding points
min_x = np.nanmin(rot_points[:, 0], axis=1)
max_x = np.nanmax(rot_points[:, 0], axis=1)
min_y = np.nanmin(rot_points[:, 1], axis=1)
max_y = np.nanmax(rot_points[:, 1], axis=1)

# find the box with the best area
areas = (max_x - min_x) * (max_y - min_y)
best_idx = np.argmin(areas)

# return the best box
x1 = max_x[best_idx]
x2 = min_x[best_idx]
y1 = max_y[best_idx]
y2 = min_y[best_idx]
r = rotations[best_idx]

rval = np.zeros((4, 2))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function replaces shapely.geometry.Polygon.minimum_bounding_box

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember that the goal is to generate an ellipse. The ellipse can be specified by the minimum bounding box. I have to say that the format that CV uses for the bounding ellipse is very confusing. Hopefully, your version will be easier to understand.

Comment on lines +880 to +887
def area_of_polygon(xy: np.ndarray) -> float:
"""
apply shoelace algorithm on collection of xy vertex pairs
https://stackoverflow.com/questions/24467972/calculate-area-of-polygon-given-x-y-coordinates
"""
return 0.5 * np.abs(
np.dot(xy[:, 0], np.roll(xy[:, 1], 1)) - np.dot(xy[:, 1], np.roll(xy[:, 0], 1))
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function replaces shapely.geometry.Polygon.area

)


def find_circles(dqplane: np.ndarray, bitmask: np.ndarray, min_area: float) -> list[Circle]:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added type hints here

Comment on lines +1164 to +1171
ellipses = [
(
tuple(np.mean(rectangle[[0, 2], :], axis=0)),
tuple(np.hypot(*np.diff(rectangle[[0, 1, 2], :], axis=0))),
np.degrees(np.arctan2(*np.flip(np.diff(rectangle[[3, 0], :], axis=0)[0]))),
)
for rectangle in rectangles
]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is where the axis differences comes from; the minor and major radii are larger than those from opencv by 1.5 (?) units

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that's odd. The major and minor axis are what they are. Have you tried an input ellipse and see if you get the same ellipse out?

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Jun 6, 2024

differences in the cube values in test_flag_large_events_withsnowball (opencv on the left, skimage on the right)
flag_large_events_withsnowball

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Jun 6, 2024

comparison of the input data (left) and the data quality layer (right) from test_find_faint_extended (opencv on the left, skimage on the right):
find_faint_extended

it appears that skimage flags less pixels than the opencv method

@zacharyburnett
Copy link
Collaborator Author

to summarize, two tests still show differences between the two methods; test_flag_large_events_withsnowball and test_find_faint_extended. I've added comparison images, above.

@mwregan2
Copy link
Collaborator

mwregan2 commented Jun 6, 2024 via email

@zacharyburnett
Copy link
Collaborator Author

I don’t understand what I’m looking at

Sorry about that! I'll update the comments to be more descriptive

@perrygreenfield
Copy link
Collaborator

To use your first simple example. I find that if I give the radius for draw.disk as 4.0001 I get the same image as cv2. So it wouldn't surprise me that in one the test is an < test and the other <=. Or something like that. Adding a small epsilon may do it.

@zacharyburnett
Copy link
Collaborator Author

zacharyburnett commented Jun 7, 2024

a comparison of the find_contours functions between opencv and skimage:
find_contours

and the minimum_bounding_box analogues:
minimum_rotated_rectangle

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Opencv dependency in stcal being optional causes uncaught error in jump step
5 participants