-
Notifications
You must be signed in to change notification settings - Fork 41
[fix] saves partial z-stack during mid error #3466
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| import os | ||
| import time | ||
| import unittest | ||
| from concurrent.futures import Future | ||
| from concurrent.futures._base import CancelledError | ||
| from unittest import mock | ||
|
|
||
|
|
@@ -33,11 +34,12 @@ | |
| import odemis.acq.stream as stream | ||
| from odemis import model | ||
| from odemis.acq import acqmng | ||
| from odemis.acq.acqmng import SettingsObserver, acquireZStack | ||
| from odemis.acq.acqmng import SettingsObserver, ZStackAcquisitionTask, acquireZStack | ||
| from odemis.acq.leech import ProbeCurrentAcquirer | ||
| from odemis.acq.move import MicroscopePostureManager, FM_IMAGING, SEM_IMAGING, LOADING | ||
| from odemis.driver import xt_client | ||
| from odemis.driver.test.xt_client_test import CONFIG_FIB_SEM, CONFIG_FIB_SCANNER, CONFIG_DETECTOR | ||
| from odemis.model import InstantaneousFuture | ||
| from odemis.util import testing | ||
| from odemis.util.comp import generate_zlevels | ||
|
|
||
|
|
@@ -856,5 +858,108 @@ def test_settings_observer_metadata_with_zstack(self): | |
| self.assertEqual(data[0].metadata[model.MD_EXTRA_SETTINGS] | ||
| ["Camera"]["exposureTime"], [0.023, "s"]) | ||
|
|
||
|
|
||
| def _make_sim_data_array(shape=(64, 64), dtype=numpy.uint16): | ||
| """ | ||
| Return a minimal 2-D DataArray suitable as a z-level image. | ||
|
|
||
| :param shape: 2-tuple (height, width) | ||
| :param dtype: NumPy dtype for the pixel data | ||
| :return: model.DataArray with pixel-size and position metadata | ||
| """ | ||
| md = { | ||
| model.MD_DIMS: "YX", | ||
| model.MD_PIXEL_SIZE: (1e-7, 1e-7), | ||
| model.MD_POS: (0.0, 0.0), | ||
| } | ||
| return model.DataArray(numpy.zeros(shape, dtype=dtype), md) | ||
|
|
||
|
|
||
| def _make_sim_stream(name="mock_stream"): | ||
| """ | ||
| Build a MagicMock that satisfies the interface used by ZStackAcquisitionTask. | ||
|
|
||
| :param name: human-readable name for the stream mock | ||
| :return: unittest.mock.MagicMock mimicking a Stream | ||
| """ | ||
| s = mock.MagicMock() | ||
| s.name.value = name | ||
| s.estimateAcquisitionTime.return_value = 0.0 | ||
| s.focuser.moveAbs.return_value = InstantaneousFuture(None) | ||
| return s | ||
|
|
||
|
|
||
| def _make_sim_task(stream_mock, zlevels): | ||
| """ | ||
| Construct a ZStackAcquisitionTask with a mock ProgressiveFuture. | ||
|
|
||
| Both guessActuatorMoveDuration (called in __init__) and | ||
| estimate_total_duration (called inside run()) are patched to avoid | ||
| the need for real actuator hardware. | ||
|
|
||
| :param stream_mock: mock Stream object | ||
| :param zlevels: dict mapping stream_mock to list of z positions | ||
| :return: (task, mock_future) tuple ready to call task.run() on | ||
| """ | ||
| future = mock.MagicMock() | ||
| with mock.patch("odemis.acq.acqmng.guessActuatorMoveDuration", return_value=0.0): | ||
| task = ZStackAcquisitionTask(future, [stream_mock], zlevels, settings_obs=None) | ||
| task.estimate_total_duration = mock.MagicMock(return_value=1.0) | ||
| return task, future | ||
|
|
||
|
|
||
| class TestZStackPartialFailureSim(unittest.TestCase): | ||
| """ | ||
| Simulation tests (no hardware) for the fix that saves partial z-stack data | ||
| when a camera error occurs during an acquisition. | ||
|
|
||
| Root cause of the original bug: a camera communication error could return | ||
| an image with the wrong shape. On NumPy < 1.24, numpy.array() on | ||
| silently produces an object-dtype array, instead of returning the expected 3D array | ||
| (WriteDirectory() → AssertionError: 0). | ||
|
|
||
| Shape validation in ZStackAcquisitionTask.run(), plus partial z-stack assembly | ||
| is implemented instead of discarding data on failure. | ||
| """ | ||
|
|
||
| def test_full_success_returns_zcube(self): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add return type hints to the new test methods. Line 925 and Line 945 define new functions without return annotations. Add Proposed change- def test_full_success_returns_zcube(self):
+ def test_full_success_returns_zcube(self) -> None:
@@
- def test_first_zlevel_fails_returns_empty_data(self):
+ def test_first_zlevel_fails_returns_empty_data(self) -> None:As per coding guidelines: " Also applies to: 945-945 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| """ | ||
| When all z-levels succeed, run() returns a single ZYX DataArray and no exception. | ||
| """ | ||
| n = 3 | ||
| zlevels_list = [i * 1e-6 for i in range(n)] | ||
| s = _make_sim_stream("fluo") | ||
| task, _ = _make_sim_task(s, {s: zlevels_list}) | ||
|
|
||
| good_img = _make_sim_data_array((64, 64)) | ||
| acq_futures = [InstantaneousFuture(([good_img], None)) for _ in range(n)] | ||
|
|
||
| with mock.patch("odemis.acq.acqmng.acquire", side_effect=acq_futures): | ||
| data, exp = task.run() | ||
|
|
||
| self.assertIsNone(exp) | ||
| self.assertEqual(len(data), 1) | ||
| self.assertEqual(data[0].shape, (n, 64, 64)) | ||
| self.assertNotEqual(data[0].dtype, object) | ||
|
|
||
| def test_first_zlevel_fails_returns_empty_data(self): | ||
| """ | ||
| When the very first z-level fails, no z-cube can be assembled. | ||
| run() must return an empty data list and the exception. | ||
| """ | ||
| zlevels_list = [0.0e-6, 1.0e-6] | ||
| s = _make_sim_stream("fluo") | ||
| task, _ = _make_sim_task(s, {s: zlevels_list}) | ||
|
|
||
| hw_error = IOError("Camera connection lost") | ||
|
|
||
| with mock.patch("odemis.acq.acqmng.acquire", | ||
| return_value=InstantaneousFuture(([], hw_error))): | ||
| data, exp = task.run() | ||
|
|
||
| self.assertEqual(len(data), 0) | ||
| self.assertIs(exp, hw_error) | ||
|
|
||
|
Comment on lines
+925
to
+962
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add a simulation test for mid-z failure with partial cube output. These tests cover full success and first-z failure, but they don’t assert the PR’s core path: failure after at least one successful z-level should still return a partial z-cube plus the error. Please add a case where z0 succeeds, z1 fails, then verify 🤖 Prompt for AI Agents |
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1286,7 +1286,22 @@ def assembleZCube(images, zlevels): | |
| :param images: (list of DataArray of shape YX) list of z ordered images | ||
| :param zlevels: (list of float) list of focus positions | ||
| :return: (DataArray of shape ZYX) the data array of the xyz cube | ||
| :raises ValueError: if images is empty or the images have inconsistent spatial shapes | ||
| """ | ||
| if not images: | ||
| raise ValueError("Cannot assemble z-cube from an empty image list") | ||
|
|
||
| # Validate that all images have the same spatial (YX) dimensions. | ||
| # With NumPy < 1.24, numpy.array() silently creates an object-dtype array, | ||
| # instead of generating the expected 3D array | ||
| ref_shape = images[0].shape[-2:] | ||
|
Comment on lines
+1289
to
+1297
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject mismatched This new validation block still lets callers pass a non-empty Suggested fix if not images:
raise ValueError("Cannot assemble z-cube from an empty image list")
+ if len(images) != len(zlevels):
+ raise ValueError(
+ "Cannot assemble z-cube from %d images and %d z-levels"
+ % (len(images), len(zlevels))
+ )
# Validate that all images have the same spatial (YX) dimensions.🤖 Prompt for AI Agents |
||
| for idx, im in enumerate(images[1:], start=1): | ||
| if im.shape[-2:] != ref_shape: | ||
| raise ValueError( | ||
| "Z-stack image %d has shape %s, inconsistent with first image shape %s" | ||
| % (idx, im.shape[-2:], ref_shape) | ||
| ) | ||
|
|
||
| # images is a list of 3 dim data arrays. | ||
| # Will fail on purpose if the images contain more than 2 dimensions | ||
| ret = numpy.array([im.reshape(im.shape[-2:]) for im in images]) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.