Skip to content

Commit 27f8402

Browse files
committed
liberate cellfinder from the concrete
1 parent 6a883c7 commit 27f8402

File tree

2 files changed

+34
-125
lines changed

2 files changed

+34
-125
lines changed

ulc_mm_package/hardware/scope_routines.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -520,13 +520,6 @@ def find_cells_routine(
520520

521521
img = yield
522522

523-
# Initial check for cells, return current motor position if cells found
524-
cell_finder.add_image(mscope.motor.pos, img)
525-
try:
526-
return cell_finder.get_cells_found_position()
527-
except NoCellsFound:
528-
cell_finder.reset()
529-
530523
# Defensive check, ensure the motor isn't moving (say for example,
531524
# if CellFinder was triggered by an OOF exception and SSAF just triggered a motor move)
532525
while mscope.motor.is_locked():
@@ -582,7 +575,7 @@ def find_cells_routine(
582575
)
583576

584577
self.logger.info("Looking for cells...")
585-
# Perform a full focal stack and get the cross-correlation value for each image
578+
586579
# If we're currently at the bottom, do the bottom-up sweep. Otherwise, do the top-down sweep.
587580
if mscope.motor.pos == 0:
588581
for pos in range(0, mscope.motor.max_pos, steps_per_image):
@@ -603,18 +596,11 @@ def find_cells_routine(
603596
except NoCellsFound:
604597
pass
605598
else:
606-
# Move from the current position to the bottom sweep as we're going down
607-
for pos in range(mscope.motor.pos, 0, -steps_per_image):
608-
mscope.motor.move_abs(pos)
609-
img = yield
610-
cell_finder.add_image(mscope.motor.pos, img)
611-
try:
612-
return cell_finder.get_cells_found_position()
613-
except NoCellsFound:
614-
pass
615-
616-
# If cells not found on the way down, sweep all the way back up
617-
for pos in range(0, mscope.motor.max_pos, steps_per_image):
599+
# Sweep down from current position to bottom, then sweep from bottom to top
600+
positions = list(range(mscope.motor.pos, 0, -steps_per_image)) + list(
601+
range(0, mscope.motor.max_pos, steps_per_image)
602+
)
603+
for pos in positions:
618604
mscope.motor.move_abs(pos)
619605
img = yield
620606
cell_finder.add_image(mscope.motor.pos, img)

ulc_mm_package/image_processing/cell_finder.py

Lines changed: 28 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,6 @@
55

66

77
from ulc_mm_package.image_processing.focus_metrics import downsample_image
8-
from ulc_mm_package.image_processing.processing_constants import (
9-
RBC_THUMBNAIL_PATH,
10-
CELLS_FOUND_THRESHOLD,
11-
MIN_POINTS_ABOVE_THRESH,
12-
)
13-
14-
RBC_THUMBNAIL = cv2.imread(RBC_THUMBNAIL_PATH, 0)
15-
16-
17-
def get_correlation_map(
18-
img: np.ndarray,
19-
template_img: np.ndarray = RBC_THUMBNAIL,
20-
downsample_factor: int = 20,
21-
) -> np.ndarray:
22-
"""Downsamples the image and returns the 2D cross-correlation map.
23-
24-
Side note
25-
----------
26-
The values generated by the cross-correlation map (using TM_CCOEFF)
27-
are sensitive to the dimensions of the image and template. As such, the threshold
28-
used is somewhat specific to the particular downsampling factor used.
29-
30-
TM_CCOEFF_NORMED (which normalizes the values to lie within 0-1) is not used
31-
because we want to do a comparison of how well the template matches _between_ images.
32-
33-
This function has been tested for robustness with a downsample_factor=10
34-
and the current template image (image_processing/thumbnail.png).
35-
36-
Parameters
37-
----------
38-
img: np.ndarray
39-
Base image
40-
template_img: np.ndarray
41-
Image to use as a template for the cross-correlation
42-
downsample_factor: int=10
43-
44-
Returns
45-
-------
46-
np.ndarray:
47-
Map of the 2D cross-correlation values
48-
"""
49-
50-
img_ds = downsample_image(img, downsample_factor)
51-
return cv2.matchTemplate(img_ds, template_img, cv2.TM_CCOEFF)
528

539

5410
class LowDensity(Exception):
@@ -60,80 +16,47 @@ class NoCellsFound(Exception):
6016

6117

6218
class CellFinder:
63-
def __init__(
64-
self, template_path: str = RBC_THUMBNAIL_PATH, downsample_factor: int = 10
65-
):
66-
self.thumbnail = downsample_image(
67-
cv2.imread(template_path, 0), downsample_factor
68-
)
19+
def __init__(self, downsample_factor: int = 10, lookback_window: int = 3):
6920
self.downsample_factor = downsample_factor
21+
self.lookback_window = lookback_window
7022
self.motor_pos: List[int] = []
71-
self.confidences: List[float] = []
72-
self.maps: List[np.ndarray] = []
23+
self.sds: List[float] = []
7324

7425
def add_image(self, motor_pos: int, img: np.ndarray) -> None:
75-
"""Check for cells for the given image, store the result + motor position the image was taken at."""
26+
"""Calculate the standard deviation of the given image (after downsmapling), store the result + motor position the image was taken at."""
27+
28+
img_ds = downsample_image(img, self.downsample_factor)
29+
sd = np.std(img_ds)
7630

7731
self.motor_pos.append(motor_pos)
78-
xcorr_map = get_correlation_map(img, self.thumbnail, self.downsample_factor)
79-
self.confidences.append(np.max(xcorr_map))
80-
self.maps.append(xcorr_map)
32+
self.sds.append(sd)
8133

8234
def get_cells_found_position(self) -> Optional[int]:
83-
"""Check if the cross-correlation value exceeds the threshold for cell detection and there are
84-
a sufficient number of points above the cells found threshold.
85-
86-
Returns
87-
-------
88-
int
89-
Motor position if cells were found
90-
Exceptions
91-
----------
92-
NoCellsFound
93-
Raised if the value of the maximum cross correlation value from the given images
94-
does not exceed a threshold.
95-
"""
96-
97-
argmax = np.argmax(self.confidences)
98-
max_val = self.confidences[argmax]
99-
100-
if max_val >= CELLS_FOUND_THRESHOLD:
101-
return self.motor_pos[np.argmax(self.confidences)]
35+
"""Find the motor position whose image SD stands out compared to the rest.
10236
103-
raise NoCellsFound(
104-
"None of the images at any of the motor positions had a maximum cross-correlation exceeding the CELLS_FOUND threshold"
105-
)
37+
Use the median-absolute-deviation and a monotonic decrease over a given lookback
38+
period to assess whether the peak a) has objects in it and b) is prominent relative to
39+
the surrounding measurements.
40+
"""
41+
sds = np.asarray(self.sds, dtype=float)
42+
if sds.size > self.lookback_window:
43+
argmax = int(np.argmax(sds))
44+
max_val = float(sds[argmax])
10645

107-
def sufficient_points_above_thresh(self, xcorr_map: np.ndarray) -> bool:
108-
"""Check if a sufficient number of points are above the cells found threshold
109-
in the xcorr map.
46+
median = np.median(sds)
47+
mad = np.median(np.abs(sds - median))
11048

111-
Parameters
112-
----------
113-
xcorr_map: np.ndarray
49+
k = 1.4826 # Scale factor for normally distributed data
50+
robust_z = (max_val - median) / (k * mad)
11451

115-
Returns
116-
-------
117-
bool
118-
"""
52+
is_monotonically_decreasing = np.all(
53+
np.sign(np.diff(sds[-self.lookback_window :])) == -1
54+
)
55+
if is_monotonically_decreasing and robust_z >= 3.0:
56+
return self.motor_pos[argmax]
11957

120-
points = np.argwhere(xcorr_map >= CELLS_FOUND_THRESHOLD)
121-
return len(points) > MIN_POINTS_ABOVE_THRESH
58+
raise NoCellsFound("No image stands out as containing cells")
12259

12360
def reset(self) -> None:
12461
self.motor_pos = []
125-
self.confidences = []
126-
127-
def find_cells_cross_corr(self, img: np.ndarray) -> float:
128-
"""Returns the max value of the correlation between the RBC thumbnail and the given image (downsampled)
129-
130-
Returns
131-
-------
132-
float:
133-
Max value of the 2D cross-correlation
134-
"""
135-
136-
cross_corr_map = get_correlation_map(
137-
img, self.thumbnail, self.downsample_factor
138-
)
139-
return np.max(cross_corr_map)
62+
self.sds = []

0 commit comments

Comments
 (0)