-
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 |
|---|---|---|
|
|
@@ -2068,6 +2068,33 @@ def test_assemble_zcube_z_order(self): | |
| self.assertGreater(output_rev_z.metadata[model.MD_PIXEL_SIZE][2], 0) | ||
| numpy.testing.assert_array_equal(output_da_after, output_rev_z) | ||
|
|
||
| def test_assemble_zcube_empty_list_raises(self): | ||
| """ | ||
| assembleZCube() must raise ValueError when given an empty image list. | ||
| """ | ||
| with self.assertRaises(ValueError): | ||
| img.assembleZCube([], []) | ||
|
|
||
| def test_assemble_zcube_inconsistent_shapes_raises(self): | ||
| """ | ||
| assembleZCube() must raise ValueError when z-level images have different shapes. | ||
|
|
||
| On NumPy < 1.24, numpy.array() silently creates an | ||
| object-dtype array, instead of returning the expected 3D array. The fix detects this | ||
| early and raises explicitly. | ||
| """ | ||
| images = [ | ||
| model.DataArray(numpy.zeros(self.size, dtype=numpy.uint16), self.md), | ||
| model.DataArray(numpy.zeros(self.size, dtype=numpy.uint16), self.md), | ||
| model.DataArray(numpy.zeros((self.size[0] // 2, self.size[1]), dtype=numpy.uint16), self.md), | ||
|
Member
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. I don't think it makes a lot of sense to test this, because there is no reason such issue ever happens. I'm pretty certain that the initial error reported by the user wasn't caused by the image changing resolution, but more that the camera failed to send an image. (ie, some sort of Timeout). |
||
| ] | ||
| zlevels = self.z_list[:3] | ||
|
|
||
| with self.assertRaises(ValueError) as ctx: | ||
| img.assembleZCube(images, zlevels) | ||
|
|
||
| self.assertIn("shape", str(ctx.exception).lower()) | ||
|
|
||
|
|
||
| class TestFloodFill(unittest.TestCase): | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This only works correctly if missing images occur at the end of the z-stack. If an image is missing in the middle, the Z metadata will be shifted. Should we keep a separate list of z-levels for the images that were actually acquired?