Skip to content

Commit 4de5587

Browse files
Adjust tissue detection calculation for stacks (#1219)
* Adjust tissue detection calculation for stacks * small fix * Fix stack tissue detector review feedback Restore legacy positional constructor compatibility for DetectTissueInStack and subclasses by moving the new threshold argument to the end of the signature. Avoid sharing DetectTissueInStackAndRecord position_records across instances by defaulting to None and allocating a fresh list when needed. Clarify detect_tissue3 documentation around its computed Otsu threshold contract and update smart-routine docs for stack-level percentage semantics. Add regression coverage for positional feature-list args, record-list default isolation, stack-level tissue-positive frame decisions, and detect_tissue3 documentation. --------- Co-authored-by: Kevin M. Dean <kevin.dean@utsouthwestern.edu>
1 parent a1e1201 commit 4de5587

3 files changed

Lines changed: 187 additions & 32 deletions

File tree

‎docs/source/01_getting_started/07_smart_routines.rst‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ Step 3: Add Tissue Detection
9292
3. Configure the ``DetectTissueInStackAndReturn`` node with the following parameters:
9393

9494
1. :guilabel:`planes`: number of z-planes checked for tissue.
95-
2. :guilabel:`percentage`: required image fraction containing tissue to return ``true``.
96-
3. :guilabel:`detect_func`: tissue detection function from :doc:`remove_empty_tiles </05_reference/_autosummary/navigate.model.features.remove_empty_tiles>`. If set to ``None``, **navigate** uses ``detect_tissue()``.
95+
2. :guilabel:`percentage`: required fraction of tissue-positive frames in the stack to return ``true``.
96+
3. :guilabel:`detect_func`: tissue detection function from :doc:`remove_empty_tiles </05_reference/_autosummary/navigate.model.features.remove_empty_tiles>`. If set to ``None``, **navigate** uses ``detect_tissue3()``.
97+
4. :guilabel:`threshold`: intensity threshold passed to the tissue detection function.
9798

9899
.. tip::
99100

‎src/navigate/model/features/remove_empty_tiles.py‎

Lines changed: 112 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from queue import Queue
3636

3737
# Third Party Imports
38+
from skimage.filters import threshold_otsu
3839

3940
# Local Imports
4041
from navigate.model.analysis.boundary_detect import find_tissue_boundary_2d
@@ -132,6 +133,30 @@ def detect_tissue2(image_data, percentage=0.0):
132133
return False
133134

134135

136+
def detect_tissue3(image_data, threshold=150):
137+
"""Detect tissue in an image using the computed Otsu threshold.
138+
139+
This function computes the Otsu threshold value for an image and compares that
140+
computed value against a minimum threshold.
141+
142+
Parameters:
143+
-----------
144+
image_data : numpy.ndarray
145+
The input image data as a NumPy array.
146+
threshold : float, optional
147+
The minimum computed Otsu threshold required for tissue detection.
148+
Default is 150.
149+
150+
Returns:
151+
--------
152+
bool
153+
True if the computed Otsu threshold is greater than or equal to
154+
``threshold``; False otherwise.
155+
"""
156+
t = threshold_otsu(image_data)
157+
return t >= threshold
158+
159+
135160
class DetectTissueInStack:
136161
"""Detect Tissue in a Stack of Images.
137162
@@ -140,7 +165,9 @@ class DetectTissueInStack:
140165
presence.
141166
"""
142167

143-
def __init__(self, model, planes=1, percentage=0.75, detect_func=None):
168+
def __init__(
169+
self, model, planes=1, percentage=0.75, detect_func=None, threshold=150
170+
):
144171
"""Initialize the DetectTissueInStack class.
145172
146173
Parameters:
@@ -150,11 +177,14 @@ def __init__(self, model, planes=1, percentage=0.75, detect_func=None):
150177
planes : int, optional
151178
The number of Z planes to capture in the stack. Default is 1.
152179
percentage : float, optional
153-
The minimum percentage of tissue required to consider a frame as having
154-
tissue. Default is 0.75 (75%).
180+
The minimum fraction of tissue-positive frames required for the stack to
181+
be considered as having tissue. Default is 0.75 (75%).
155182
detect_func : function, optional
156183
The custom tissue detection function to use. If not specified, the default
157-
`detect_tissue` function will be used.
184+
`detect_tissue3` function will be used. The function must accept
185+
``(image_data, threshold)`` and return whether the frame contains tissue.
186+
threshold : float, optional
187+
The intensity threshold passed to ``detect_func``. Default is 150.
158188
"""
159189

160190
#: navigate.model.Model: The model object representing the microscope.
@@ -163,14 +193,16 @@ def __init__(self, model, planes=1, percentage=0.75, detect_func=None):
163193
#: int: The number of Z planes to capture in the stack.
164194
self.planes = int(planes)
165195

166-
#: float: The minimum percentage of tissue required to consider a frame as
167-
# having tissue.
196+
#: float: The intensity threshold for tissue detection.
197+
self.threshold = float(threshold)
198+
199+
#: float: The minimum percentage of images having tissue in a stack.
168200
self.percentage = float(percentage)
169201

170202
# if not specify a detect function, use the default one
171203
if detect_func is None:
172204
#: function: The tissue detection function used to analyze image frames.
173-
self.detect_func = detect_tissue
205+
self.detect_func = detect_tissue3
174206
else:
175207
self.detect_func = detect_func
176208

@@ -198,14 +230,26 @@ def pre_func_signal(self):
198230
"""
199231

200232
microscope_config = self.model.configuration["experiment"]["MicroscopeState"]
233+
234+
# primary z and f axes, secondary stack settings
235+
self.primary_z_axis = microscope_config.get("primary_z_axis", "z")
236+
self.primary_f_axis = microscope_config.get("primary_f_axis", "f")
237+
201238
# get current z and f position
202239
pos = self.model.get_stage_position()
203240

241+
#: current stage position
242+
self.starting_pos = pos
243+
204244
#: float: The current Z position of the microscope stage.
205-
self.current_z_pos = pos["z_pos"] + float(microscope_config["start_position"])
245+
self.current_z_pos = pos[f"{self.primary_z_axis}_pos"] + float(
246+
microscope_config["start_position"]
247+
)
206248

207249
#: float: The current F position of the microscope stage.
208-
self.current_f_pos = pos["f_pos"] + float(microscope_config["start_focus"])
250+
self.current_f_pos = pos[f"{self.primary_f_axis}_pos"] + float(
251+
microscope_config["start_focus"]
252+
)
209253

210254
# calculate Z and F stage step sizes
211255
z_pos_range = float(microscope_config["end_position"]) - float(
@@ -239,11 +283,15 @@ def in_func_signal(self):
239283

240284
# move to Z anf F position
241285
self.model.logger.debug(
242-
f"move to position (z, f): ({self.current_z_pos}, {self.current_f_pos}), "
286+
f"move to position ({self.primary_z_axis}, {self.primary_f_axis}): "
287+
f"({self.current_z_pos}, {self.current_f_pos}), "
243288
f"{self.scan_num}, {self.model.frame_id}"
244289
)
245290
self.model.move_stage(
246-
{"z_abs": self.current_z_pos, "f_abs": self.current_f_pos},
291+
{
292+
f"{self.primary_z_axis}_abs": self.current_z_pos,
293+
f"{self.primary_f_axis}_abs": self.current_f_pos,
294+
},
247295
wait_until_done=True,
248296
)
249297
self.scan_num += 1
@@ -260,9 +308,21 @@ def end_func_signal(self):
260308
True if the signal phase should end, False otherwise.
261309
"""
262310
self.model.logger.debug(
263-
f"*** detect tissue signal end function: " f"{self.scan_num}"
311+
f"*** detect tissue signal end function: {self.scan_num}"
264312
)
265313
if self.scan_num >= self.planes:
314+
# move stage back to starting position
315+
self.model.move_stage(
316+
{
317+
f"{self.primary_z_axis}_abs": self.starting_pos[
318+
f"{self.primary_z_axis}_pos"
319+
],
320+
f"{self.primary_f_axis}_abs": self.starting_pos[
321+
f"{self.primary_f_axis}_pos"
322+
],
323+
},
324+
wait_until_done=True,
325+
)
266326
return True
267327
self.current_z_pos += self.z_step
268328
self.current_f_pos += self.f_step
@@ -281,6 +341,9 @@ def pre_func_data(self):
281341
#: int: The number of received frames.
282342
self.received_frames = 0
283343

344+
#: int: The number of images having tissue detected.
345+
self.tissue_frames = 0
346+
284347
#: bool: Flag indicating whether tissue is detected.
285348
self.has_tissue_flag = False
286349

@@ -305,13 +368,16 @@ def in_func_data(self, frame_ids):
305368
if not self.has_tissue_flag:
306369
for frame_id in frame_ids:
307370
# check if the frame has tissue
308-
r = self.detect_func(self.model.data_buffer[frame_id], self.percentage)
371+
r = self.detect_func(self.model.data_buffer[frame_id], self.threshold)
309372
if r:
310-
self.model.logger.debug(
311-
f"*** this frame has enough percentage of tissue!{frame_id}"
312-
)
313-
self.has_tissue_flag = True
314-
break
373+
self.model.logger.debug(f"*** this frame has tissue!{frame_id}")
374+
self.tissue_frames += 1
375+
# self.has_tissue_flag = True
376+
self.has_tissue_flag = (
377+
self.tissue_frames / self.planes
378+
) >= self.percentage
379+
if self.has_tissue_flag:
380+
break
315381
self.received_frames += len(frame_ids)
316382
return self.has_tissue_flag
317383

@@ -330,7 +396,9 @@ def end_func_data(self):
330396

331397

332398
class DetectTissueInStackAndReturn(DetectTissueInStack):
333-
def __init__(self, model, planes=1, percentage=0.75, detect_func=None):
399+
def __init__(
400+
self, model, planes=1, percentage=0.75, detect_func=None, threshold=150
401+
):
334402
"""Initialize the DetectTissueInStackAndReturn class.
335403
336404
Parameters:
@@ -340,13 +408,16 @@ def __init__(self, model, planes=1, percentage=0.75, detect_func=None):
340408
planes : int, optional
341409
The number of Z planes to capture in the stack. Default is 1.
342410
percentage : float, optional
343-
The minimum percentage of tissue required to consider a frame as having
344-
tissue. Default is 0.75 (75%).
411+
The minimum fraction of tissue-positive frames required for the stack to
412+
be considered as having tissue. Default is 0.75 (75%).
345413
detect_func : function, optional
346414
The custom tissue detection function to use. If not specified, the default
347-
`detect_tissue` function will be used.
415+
`detect_tissue3` function will be used. The function must accept
416+
``(image_data, threshold)`` and return whether the frame contains tissue.
417+
threshold : float, optional
418+
The intensity threshold passed to ``detect_func``. Default is 150.
348419
"""
349-
super().__init__(model, planes, percentage, detect_func)
420+
super().__init__(model, planes, percentage, detect_func, threshold)
350421

351422
self.detect_tissue_queue = Queue()
352423
self.result_sent_flag = False
@@ -429,7 +500,13 @@ class DetectTissueInStackAndRecord(DetectTissueInStack):
429500
"""
430501

431502
def __init__(
432-
self, model, planes=1, percentage=0.75, position_records=[], detect_func=None
503+
self,
504+
model,
505+
planes=1,
506+
percentage=0.75,
507+
position_records=None,
508+
detect_func=None,
509+
threshold=150,
433510
):
434511
"""Initialize the DetectTissueInStackAndRecord class.
435512
@@ -440,18 +517,23 @@ def __init__(
440517
planes : int, optional
441518
The number of Z planes to capture in the stack. Default is 1.
442519
percentage : float, optional
443-
The minimum percentage of tissue required to consider a frame as having
444-
tissue. Default is 0.75 (75%).
520+
The minimum fraction of tissue-positive frames required for the stack to
521+
be considered as having tissue. Default is 0.75 (75%).
445522
position_records : list, optional
446-
A list to record positions where tissue was detected. Default is an empty
447-
list.
523+
A list to record positions where tissue was detected. A new list is
524+
created when no list is provided.
448525
detect_func : function, optional
449526
The custom tissue detection function to use. If not specified, the default
450-
`detect_tissue` function will be used.
527+
`detect_tissue3` function will be used. The function must accept
528+
``(image_data, threshold)`` and return whether the frame contains tissue.
529+
threshold : float, optional
530+
The intensity threshold passed to ``detect_func``. Default is 150.
451531
"""
452-
super().__init__(model, planes, percentage, detect_func)
532+
super().__init__(model, planes, percentage, detect_func, threshold)
453533

454534
#: list: A list to record positions where tissue was detected.
535+
if position_records is None:
536+
position_records = []
455537
self.position_records = position_records
456538

457539
def pre_func_data(self):
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import numpy as np
2+
3+
from navigate.model.features.feature_related_functions import SharedList
4+
from navigate.model.features.remove_empty_tiles import (
5+
DetectTissueInStack,
6+
DetectTissueInStackAndRecord,
7+
DetectTissueInStackAndReturn,
8+
detect_tissue3,
9+
)
10+
11+
12+
class _Logger:
13+
def debug(self, *args, **kwargs):
14+
pass
15+
16+
17+
class _Model:
18+
def __init__(self):
19+
self.data_buffer = {}
20+
self.logger = _Logger()
21+
22+
23+
def test_return_detector_preserves_legacy_positional_arguments():
24+
feature = DetectTissueInStackAndReturn(_Model(), 1, 0.5, None)
25+
26+
assert feature.planes == 1
27+
assert feature.percentage == 0.5
28+
assert feature.threshold == 150
29+
assert feature.detect_func is detect_tissue3
30+
31+
32+
def test_record_detector_preserves_legacy_positional_records_argument():
33+
records = SharedList([], "records")
34+
35+
feature = DetectTissueInStackAndRecord(_Model(), 5, 0.75, records)
36+
37+
assert feature.planes == 5
38+
assert feature.percentage == 0.75
39+
assert feature.threshold == 150
40+
assert feature.position_records is records
41+
42+
43+
def test_record_detector_default_records_are_not_shared():
44+
first = DetectTissueInStackAndRecord(_Model())
45+
second = DetectTissueInStackAndRecord(_Model())
46+
47+
first.position_records.append(True)
48+
49+
assert second.position_records == []
50+
51+
52+
def test_stack_decision_uses_percentage_of_tissue_positive_frames():
53+
model = _Model()
54+
model.data_buffer = {0: False, 1: True, 2: True}
55+
feature = DetectTissueInStack(
56+
model,
57+
planes=3,
58+
percentage=2 / 3,
59+
detect_func=lambda frame, threshold: frame,
60+
)
61+
feature.pre_func_data()
62+
63+
assert not feature.in_func_data([0, 1])
64+
assert feature.in_func_data([2])
65+
66+
67+
def test_detect_tissue3_documents_computed_otsu_threshold_contract():
68+
assert "computed Otsu threshold" in (detect_tissue3.__doc__ or "")
69+
70+
image_data = np.full((4, 4), 200, dtype=np.uint16)
71+
72+
assert detect_tissue3(image_data, threshold=150)

0 commit comments

Comments
 (0)