datalab-platform 1.0.2__py3-none-any.whl → 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. datalab/__init__.py +1 -1
  2. datalab/adapters_metadata/common.py +2 -2
  3. datalab/config.py +86 -26
  4. datalab/control/baseproxy.py +70 -0
  5. datalab/control/proxy.py +33 -0
  6. datalab/control/remote.py +35 -0
  7. datalab/data/doc/DataLab_en.pdf +0 -0
  8. datalab/data/doc/DataLab_fr.pdf +0 -0
  9. datalab/data/icons/create/linear_chirp.svg +1 -1
  10. datalab/data/icons/create/logistic.svg +1 -1
  11. datalab/gui/actionhandler.py +13 -0
  12. datalab/gui/docks.py +3 -2
  13. datalab/gui/h5io.py +25 -0
  14. datalab/gui/macroeditor.py +19 -5
  15. datalab/gui/main.py +60 -5
  16. datalab/gui/objectview.py +18 -3
  17. datalab/gui/panel/base.py +24 -18
  18. datalab/gui/panel/macro.py +26 -0
  19. datalab/gui/plothandler.py +10 -1
  20. datalab/gui/processor/base.py +43 -10
  21. datalab/gui/processor/image.py +6 -2
  22. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  23. datalab/locale/fr/LC_MESSAGES/datalab.po +3296 -0
  24. datalab/objectmodel.py +1 -1
  25. datalab/tests/features/common/auto_analysis_recompute_unit_test.py +81 -0
  26. datalab/tests/features/common/coordutils_unit_test.py +1 -1
  27. datalab/tests/features/common/result_deletion_unit_test.py +121 -1
  28. datalab/tests/features/common/update_tree_robustness_test.py +65 -0
  29. datalab/tests/features/control/remoteclient_unit.py +10 -0
  30. datalab/tests/features/hdf5/h5workspace_unit_test.py +133 -0
  31. datalab/tests/features/image/roigrid_unit_test.py +75 -0
  32. datalab/tests/features/macro/macroeditor_unit_test.py +2 -2
  33. datalab/widgets/imagebackground.py +13 -4
  34. datalab/widgets/instconfviewer.py +2 -2
  35. datalab/widgets/signalcursor.py +7 -2
  36. datalab/widgets/signaldeltax.py +4 -1
  37. {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.4.dist-info}/METADATA +7 -7
  38. {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.4.dist-info}/RECORD +42 -38
  39. {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.4.dist-info}/WHEEL +1 -1
  40. {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.4.dist-info}/entry_points.txt +0 -0
  41. {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.4.dist-info}/licenses/LICENSE +0 -0
  42. {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.4.dist-info}/top_level.txt +0 -0
datalab/objectmodel.py CHANGED
@@ -189,7 +189,7 @@ class ObjectGroup:
189
189
 
190
190
  def get_object_ids(self) -> list[str]:
191
191
  """Return object ids in group"""
192
- return self.__objects
192
+ return self.__objects.copy()
193
193
 
194
194
 
195
195
  class ObjectModel:
@@ -235,6 +235,87 @@ def test_analysis_recompute_after_recompute_1_to_1():
235
235
  print("\n✓ Recompute_1_to_1 auto-analysis test passed!")
236
236
 
237
237
 
238
+ def test_analysis_recompute_avoids_redundant_calculations():
239
+ """Test that auto-recompute doesn't cause O(n²) redundant calculations.
240
+
241
+ This test verifies that when multiple objects have ROIs modified simultaneously,
242
+ the analysis is recomputed only once per object, not once per object × number
243
+ of selected objects.
244
+
245
+ Regression test for bug: When N images were selected with statistics computed,
246
+ adding a ROI would trigger N × N = N² calculations instead of N.
247
+ """
248
+ with datalab_test_app_context(console=False) as win:
249
+ panel = win.imagepanel
250
+
251
+ # Create multiple images (N = 5 for this test)
252
+ n_images = 5
253
+ size = 100
254
+ images = []
255
+ for i in range(n_images):
256
+ param = Gauss2DParam.create(height=size, width=size, sigma=15)
257
+ img = create_image_from_param(param)
258
+ img.title = f"Test image {i + 1}"
259
+ panel.add_object(img)
260
+ images.append(img)
261
+
262
+ # Select all images
263
+ panel.objview.select_objects(images)
264
+ selected = panel.objview.get_sel_objects()
265
+ assert len(selected) == n_images, f"Should have {n_images} selected objects"
266
+
267
+ # Compute statistics on all selected images
268
+ with Conf.proc.show_result_dialog.temp(False):
269
+ panel.processor.run_feature("centroid")
270
+
271
+ # Verify all images have centroid results
272
+ for img in images:
273
+ centroid = get_centroid_coords(img)
274
+ assert centroid is not None, f"Image '{img.title}' should have centroid"
275
+
276
+ print(f"\nInitial statistics computed for {n_images} images")
277
+
278
+ # Track how many times compute_1_to_0 is called during auto-recompute
279
+ # by counting the calls via a wrapper
280
+ call_count = [0] # Use list to allow modification in closure
281
+ original_compute_1_to_0 = panel.processor.compute_1_to_0
282
+
283
+ def counting_compute_1_to_0(*args, **kwargs):
284
+ call_count[0] += 1
285
+ return original_compute_1_to_0(*args, **kwargs)
286
+
287
+ panel.processor.compute_1_to_0 = counting_compute_1_to_0
288
+
289
+ try:
290
+ # Add ROI to all selected images via edit_roi_graphically flow
291
+ # Simulating what happens when user adds ROI in the editor
292
+ roi = create_image_roi("rectangle", [25, 25, 50, 50])
293
+ for img in images:
294
+ img.roi = roi
295
+ # Simulate the auto-recompute that happens after ROI modification
296
+ panel.processor.auto_recompute_analysis(img)
297
+
298
+ # With the fix, compute_1_to_0 should be called exactly N times
299
+ # (once per object), not N² times
300
+ print(f"compute_1_to_0 was called {call_count[0]} times")
301
+ assert call_count[0] == n_images, (
302
+ f"compute_1_to_0 should be called exactly {n_images} times "
303
+ f"(once per object), but was called {call_count[0]} times. "
304
+ f"This suggests O(n²) redundant calculations."
305
+ )
306
+
307
+ # Verify the target_objs parameter is being used correctly:
308
+ # Each call should process only 1 object, not all selected objects
309
+ # This is verified by checking that the call count matches n_images
310
+
311
+ finally:
312
+ # Restore the original method
313
+ panel.processor.compute_1_to_0 = original_compute_1_to_0
314
+
315
+ print(f"\n✓ Auto-recompute correctly called {n_images} times (no O(n²) issue)")
316
+
317
+
238
318
  if __name__ == "__main__":
239
319
  test_analysis_recompute_after_roi_change()
240
320
  test_analysis_recompute_after_recompute_1_to_1()
321
+ test_analysis_recompute_avoids_redundant_calculations()
@@ -75,7 +75,7 @@ def test_round_image_coords():
75
75
  assert rounded == [10.0, 21.0, 31.0, 40.0]
76
76
 
77
77
  # Test with empty coords
78
- assert round_image_coords(img, []) == []
78
+ assert not round_image_coords(img, [])
79
79
 
80
80
  # Test error for odd number of coordinates
81
81
  with pytest.raises(ValueError, match="even number of elements"):
@@ -12,12 +12,13 @@ Test the deletion of analysis results from objects.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from sigima.objects import Gauss2DParam, create_image_from_param
15
+ from sigima.objects import Gauss2DParam, create_image_from_param, create_image_roi
16
16
  from sigima.tests.data import create_paracetamol_signal
17
17
 
18
18
  from datalab.adapters_metadata import GeometryAdapter, TableAdapter
19
19
  from datalab.config import Conf
20
20
  from datalab.env import execenv
21
+ from datalab.gui.processor.base import extract_analysis_parameters
21
22
  from datalab.objectmodel import get_uuid
22
23
  from datalab.tests import datalab_test_app_context
23
24
 
@@ -91,6 +92,125 @@ def test_delete_results_signal():
91
92
  execenv.print(" ✓ Stats result deleted")
92
93
 
93
94
 
95
+ def test_delete_results_clears_analysis_parameters():
96
+ """Test that deleting results also clears analysis parameters.
97
+
98
+ This prevents auto_recompute_analysis from attempting to recompute
99
+ deleted analyses when ROI changes.
100
+ """
101
+ with datalab_test_app_context(console=False) as win:
102
+ execenv.print("Test delete_results clears analysis parameters:")
103
+ panel = win.imagepanel
104
+
105
+ # Create a test image
106
+ param = Gauss2DParam.create(height=200, width=200, sigma=20)
107
+ img = create_image_from_param(param)
108
+ panel.add_object(img)
109
+
110
+ # Run centroid analysis to create results and store analysis parameters
111
+ execenv.print(" Running centroid analysis...")
112
+ with Conf.proc.show_result_dialog.temp(False):
113
+ panel.processor.run_feature("centroid")
114
+
115
+ # Verify that analysis parameters exist
116
+ img_refreshed = panel.objmodel[get_uuid(img)]
117
+ analysis_params = extract_analysis_parameters(img_refreshed)
118
+ assert analysis_params is not None, (
119
+ "Analysis parameters should exist after running centroid"
120
+ )
121
+ assert analysis_params.func_name == "centroid", (
122
+ "Analysis parameters should store the centroid function name"
123
+ )
124
+ execenv.print(" ✓ Analysis parameters stored")
125
+
126
+ # Delete all results
127
+ execenv.print(" Deleting all results...")
128
+ panel.objview.select_objects([get_uuid(img)])
129
+ panel.delete_results()
130
+
131
+ # Verify that analysis parameters were also cleared
132
+ img_after = panel.objmodel[get_uuid(img)]
133
+ analysis_params_after = extract_analysis_parameters(img_after)
134
+ assert analysis_params_after is None, (
135
+ "Analysis parameters should be cleared after deleting results"
136
+ )
137
+ execenv.print(" ✓ Analysis parameters cleared")
138
+
139
+ # Now add a ROI and verify no auto-recompute happens (no new results)
140
+ execenv.print(" Adding ROI to verify no auto-recompute...")
141
+ roi = create_image_roi("rectangle", [25, 25, 100, 100])
142
+ img_after.roi = roi
143
+ panel.processor.auto_recompute_analysis(img_after)
144
+
145
+ # Verify that no new results were created
146
+ adapter_after_roi = GeometryAdapter.from_obj(img_after, "centroid")
147
+ assert adapter_after_roi is None, (
148
+ "No centroid result should be created after ROI change "
149
+ "because analysis parameters were cleared"
150
+ )
151
+ execenv.print(
152
+ " ✓ No auto-recompute after ROI change (analysis params cleared)"
153
+ )
154
+ execenv.print("\n✓ All tests passed!")
155
+
156
+
157
+ def test_delete_results_after_roi_removed():
158
+ """Test that deleting results works when ROI was removed from object.
159
+
160
+ This tests the fix for the bug where deleting results fails with
161
+ AttributeError when results contain ROI information but the ROI
162
+ was subsequently removed from the object.
163
+ """
164
+ with datalab_test_app_context(console=False) as win:
165
+ execenv.print("Test delete_results after ROI removed:")
166
+ panel = win.imagepanel
167
+
168
+ # Create a test image with ROI
169
+ param = Gauss2DParam.create(height=200, width=200, sigma=20)
170
+ img = create_image_from_param(param)
171
+ roi = create_image_roi("rectangle", [25, 25, 100, 100])
172
+ img.roi = roi
173
+ panel.add_object(img)
174
+ execenv.print(" ✓ Created image with ROI")
175
+
176
+ # Run centroid analysis - this stores ROI index in results
177
+ execenv.print(" Running centroid analysis with ROI...")
178
+ with Conf.proc.show_result_dialog.temp(False):
179
+ panel.processor.run_feature("centroid")
180
+
181
+ # Verify that results exist and contain ROI information
182
+ img_refreshed = panel.objmodel[get_uuid(img)]
183
+ adapter_before = GeometryAdapter.from_obj(img_refreshed, "centroid")
184
+ assert adapter_before is not None, "Centroid result should exist"
185
+ df = adapter_before.to_dataframe()
186
+ assert "roi_index" in df.columns, "Results should contain roi_index"
187
+ execenv.print(" ✓ Centroid result created with ROI information")
188
+
189
+ # Now remove the ROI from the object (simulating user action)
190
+ img_refreshed.roi = None
191
+ execenv.print(" ✓ Removed ROI from object")
192
+
193
+ # Try to delete all results - this should NOT raise an error
194
+ execenv.print(" Deleting all results (with ROI removed)...")
195
+ panel.objview.select_objects([get_uuid(img)])
196
+ try:
197
+ panel.delete_results()
198
+ execenv.print(" ✓ Delete results succeeded (no AttributeError)")
199
+ except AttributeError as e:
200
+ raise AssertionError(
201
+ f"delete_results should not raise AttributeError when ROI is None: {e}"
202
+ ) from e
203
+
204
+ # Verify that results were deleted
205
+ img_after = panel.objmodel[get_uuid(img)]
206
+ adapter_after = GeometryAdapter.from_obj(img_after, "centroid")
207
+ assert adapter_after is None, "Centroid result should be deleted"
208
+ execenv.print(" ✓ Centroid result deleted successfully")
209
+ execenv.print("\n✓ Test passed!")
210
+
211
+
94
212
  if __name__ == "__main__":
95
213
  test_delete_results_image()
96
214
  test_delete_results_signal()
215
+ test_delete_results_clears_analysis_parameters()
216
+ test_delete_results_after_roi_removed()
@@ -0,0 +1,65 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Update tree robustness test
5
+ ---------------------------
6
+
7
+ This test verifies that the object tree view handles edge cases gracefully,
8
+ specifically when the tree becomes out of sync with the model.
9
+
10
+ This is a regression test for a bug where update_tree() crashed with
11
+ AttributeError: 'NoneType' object has no attribute 'setText' when an
12
+ object existed in the model but had no corresponding tree item.
13
+
14
+ The fix makes update_tree() defensive by calling populate_tree() if
15
+ an item is not found.
16
+ """
17
+
18
+ # pylint: disable=invalid-name # Allows short reference names like x, y, ...
19
+ # guitest: show
20
+
21
+ import numpy as np
22
+ import sigima.objects
23
+
24
+ from datalab.tests import datalab_test_app_context
25
+
26
+
27
+ def test_update_tree_with_model_tree_desync():
28
+ """Test that update_tree handles model/tree desynchronization gracefully.
29
+
30
+ This test simulates a scenario where an object exists in the model
31
+ but doesn't have a corresponding tree item, which previously caused
32
+ an AttributeError in update_tree().
33
+
34
+ The fix ensures update_tree() detects this and calls populate_tree()
35
+ to restore consistency.
36
+ """
37
+ with datalab_test_app_context(console=False) as win:
38
+ win.set_current_panel("image")
39
+
40
+ # Create and add a base image
41
+ data = np.random.rand(100, 100).astype(np.float64)
42
+ img1 = sigima.objects.create_image("Base Image", data)
43
+ win.imagepanel.add_object(img1)
44
+
45
+ # Create a second image and add it to the MODEL only (not to the tree)
46
+ # This simulates a desync between model and tree
47
+ data2 = np.random.rand(100, 100).astype(np.float64)
48
+ img2 = sigima.objects.create_image("Second Image", data2)
49
+
50
+ # Get current group ID
51
+ group_id = win.imagepanel.objview.get_current_group_id()
52
+
53
+ # Add to model ONLY (bypassing add_object_item which adds to tree)
54
+ win.imagepanel.objmodel.add_object(img2, group_id)
55
+
56
+ # Now the model has 2 objects but the tree only shows 1
57
+ # Calling update_tree() should NOT crash
58
+ # With the fix, it should call populate_tree() to resync
59
+
60
+ # This is the call that previously caused:
61
+ # AttributeError: 'NoneType' object has no attribute 'setText'
62
+ win.imagepanel.objview.update_tree()
63
+
64
+ # Verify objects are now in sync
65
+ assert len(win.imagepanel) == 2
@@ -48,6 +48,16 @@ def multiple_commands(remote: RemoteProxy):
48
48
  remote.reset_all()
49
49
  remote.open_h5_files([fname], True, False)
50
50
  remote.import_h5_file(fname, True)
51
+
52
+ # Test new headless workspace API methods (Issue #275)
53
+ fname_workspace = osp.join(tmpdir, "workspace_test.h5")
54
+ remote.save_h5_workspace(fname_workspace)
55
+ assert osp.exists(fname_workspace), "Workspace file was not created"
56
+ remote.reset_all()
57
+ remote.load_h5_workspace([fname_workspace], reset_all=True)
58
+ # Verify objects were restored
59
+ assert len(remote.get_object_titles()) > 0, "No objects after load_h5_workspace"
60
+
51
61
  remote.set_current_panel("signal")
52
62
  assert remote.get_current_panel() == "signal"
53
63
  remote.calc("log10")
@@ -0,0 +1,133 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ HDF5 workspace API unit tests
5
+ -----------------------------
6
+
7
+ Tests for the headless HDF5 workspace API methods:
8
+ - load_h5_workspace: Load native DataLab HDF5 files without GUI elements
9
+ - save_h5_workspace: Save workspace to native DataLab HDF5 file without GUI elements
10
+
11
+ These methods are designed for use from the internal console where Qt GUI elements
12
+ would cause thread-safety issues.
13
+ """
14
+
15
+ # guitest: show
16
+
17
+ import os.path as osp
18
+
19
+ import h5py
20
+ import pytest
21
+ from sigima.tests.data import create_noisy_gaussian_image, create_paracetamol_signal
22
+
23
+ from datalab.tests import datalab_test_app_context, helpers
24
+
25
+
26
+ def test_save_and_load_h5_workspace():
27
+ """Test save_h5_workspace and load_h5_workspace methods"""
28
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
29
+ with datalab_test_app_context(console=False) as win:
30
+ # === Create test objects
31
+ sig1 = create_paracetamol_signal()
32
+ win.signalpanel.add_object(sig1)
33
+
34
+ ima1 = create_noisy_gaussian_image()
35
+ win.imagepanel.add_object(ima1)
36
+
37
+ # Store object counts and titles for verification
38
+ sig_count_before = len(win.signalpanel.objmodel)
39
+ ima_count_before = len(win.imagepanel.objmodel)
40
+ sig_title = sig1.title
41
+ ima_title = ima1.title
42
+
43
+ # === Test save_h5_workspace
44
+ fname = osp.join(tmpdir, "test_workspace.h5")
45
+ win.save_h5_workspace(fname)
46
+ assert osp.exists(fname), "HDF5 file was not created"
47
+
48
+ # === Clear workspace
49
+ for panel in win.panels:
50
+ panel.remove_all_objects()
51
+
52
+ assert len(win.signalpanel.objmodel) == 0
53
+ assert len(win.imagepanel.objmodel) == 0
54
+
55
+ # === Test load_h5_workspace
56
+ win.load_h5_workspace([fname], reset_all=True)
57
+
58
+ # Verify objects were restored
59
+ assert len(win.signalpanel.objmodel) == sig_count_before
60
+ assert len(win.imagepanel.objmodel) == ima_count_before
61
+
62
+ # Verify titles (get objects in order from groups)
63
+ loaded_sig = win.signalpanel.objmodel.get_all_objects()[0]
64
+ loaded_ima = win.imagepanel.objmodel.get_all_objects()[0]
65
+ assert loaded_sig.title == sig_title
66
+ assert loaded_ima.title == ima_title
67
+
68
+
69
+ def test_load_h5_workspace_invalid_file():
70
+ """Test load_h5_workspace raises ValueError for non-native HDF5 files"""
71
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
72
+ with datalab_test_app_context(console=False) as win:
73
+ # Create a non-native HDF5 file (just an empty HDF5)
74
+ fname = osp.join(tmpdir, "not_native.h5")
75
+ with h5py.File(fname, "w") as f:
76
+ f.create_dataset("dummy", data=[1, 2, 3])
77
+
78
+ # Should raise ValueError for non-native file
79
+ with pytest.raises(ValueError, match="not a native DataLab HDF5 file"):
80
+ win.load_h5_workspace([fname])
81
+
82
+
83
+ def test_load_h5_workspace_append():
84
+ """Test load_h5_workspace with reset_all=False (append mode)"""
85
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
86
+ with datalab_test_app_context(console=False) as win:
87
+ # Create and save first signal
88
+ sig1 = create_paracetamol_signal()
89
+ sig1.title = "Signal 1"
90
+ win.signalpanel.add_object(sig1)
91
+
92
+ fname = osp.join(tmpdir, "workspace1.h5")
93
+ win.save_h5_workspace(fname)
94
+
95
+ # Clear and create second signal
96
+ win.signalpanel.remove_all_objects()
97
+ sig2 = create_paracetamol_signal()
98
+ sig2.title = "Signal 2"
99
+ win.signalpanel.add_object(sig2)
100
+
101
+ assert len(win.signalpanel.objmodel) == 1
102
+
103
+ # Load first workspace with reset_all=False (append)
104
+ win.load_h5_workspace([fname], reset_all=False)
105
+
106
+ # Should now have both signals (appended)
107
+ assert len(win.signalpanel.objmodel) == 2
108
+
109
+
110
+ def test_save_h5_workspace_modified_flag():
111
+ """Test that save_h5_workspace clears the modified flag"""
112
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
113
+ with datalab_test_app_context(console=False) as win:
114
+ # Create an object (this sets modified flag)
115
+ sig1 = create_paracetamol_signal()
116
+ win.signalpanel.add_object(sig1)
117
+
118
+ # Workspace should be modified
119
+ assert win.is_modified()
120
+
121
+ # Save workspace
122
+ fname = osp.join(tmpdir, "test.h5")
123
+ win.save_h5_workspace(fname)
124
+
125
+ # Modified flag should be cleared
126
+ assert not win.is_modified()
127
+
128
+
129
+ if __name__ == "__main__":
130
+ test_save_and_load_h5_workspace()
131
+ test_load_h5_workspace_invalid_file()
132
+ test_load_h5_workspace_append()
133
+ test_save_h5_workspace_modified_flag()
@@ -55,6 +55,81 @@ def test_roi_grid_geometry_headless() -> None:
55
55
  assert dy == img.height / 2 * 0.5
56
56
 
57
57
 
58
+ def test_roi_grid_custom_step() -> None:
59
+ """Test ROI grid with custom xstep/ystep parameters.
60
+
61
+ This test verifies the bug fix for Issue #XXX where grid ROI extraction
62
+ was not working correctly for images with non-uniformly distributed features
63
+ (e.g., laser spot arrays with gaps between spots).
64
+
65
+ The bug was that xstep and ystep parameters were missing, so users couldn't
66
+ adjust the spacing between ROIs when spots don't fill the entire image.
67
+ """
68
+ img = create_grid_of_gaussian_images()
69
+
70
+ # Test Case 1: Custom step to simulate tighter spacing (e.g., laser spots)
71
+ gp = ROIGridParam()
72
+ gp.nx, gp.ny = 3, 3
73
+ gp.xsize = gp.ysize = 20 # Small ROI size (20% of cell)
74
+ gp.xtranslation = gp.ytranslation = 50 # Centered
75
+ gp.xstep = gp.ystep = 75 # Tighter spacing (75% instead of 100%)
76
+ gp.xdirection = gp.ydirection = Direction.INCREASING
77
+
78
+ with qt_app_context():
79
+ dlg = ImageGridROIEditor(parent=None, obj=img, gridparam=gp)
80
+ dlg.update_obj(update_item=False)
81
+ roi = dlg.get_roi()
82
+ assert roi is not None
83
+ # 9 ROIs for 3x3 grid
84
+ assert len(list(roi)) == 9
85
+
86
+ # Verify spacing is correctly applied
87
+ r11 = next(r for r in roi if r.title == "ROI(1,1)")
88
+ r12 = next(r for r in roi if r.title == "ROI(1,2)")
89
+ x0_r11, _, _, _ = r11.get_physical_coords(img)
90
+ x0_r12, _, _, _ = r12.get_physical_coords(img)
91
+
92
+ # Expected spacing: (width / nx) * (xstep / 100)
93
+ expected_spacing = (img.width / gp.nx) * (gp.xstep / 100.0)
94
+ actual_spacing = x0_r12 - x0_r11
95
+
96
+ # Allow 1% tolerance for numerical precision
97
+ assert abs(actual_spacing - expected_spacing) / expected_spacing < 0.01
98
+
99
+ # Test Case 2: Different X and Y steps
100
+ gp2 = ROIGridParam()
101
+ gp2.nx, gp2.ny = 2, 2
102
+ gp2.xsize = gp2.ysize = 30
103
+ gp2.xtranslation = gp2.ytranslation = 50
104
+ gp2.xstep = 80 # Tighter X spacing
105
+ gp2.ystep = 120 # Wider Y spacing
106
+ gp2.xdirection = gp2.ydirection = Direction.INCREASING
107
+
108
+ with qt_app_context():
109
+ dlg2 = ImageGridROIEditor(parent=None, obj=img, gridparam=gp2)
110
+ dlg2.update_obj(update_item=False)
111
+ roi2 = dlg2.get_roi()
112
+ assert roi2 is not None
113
+ assert len(list(roi2)) == 4
114
+
115
+ # Verify X spacing (80%)
116
+ r11 = next(r for r in roi2 if r.title == "ROI(1,1)")
117
+ r12 = next(r for r in roi2 if r.title == "ROI(1,2)")
118
+ x0_r11, y0_r11, _, _ = r11.get_physical_coords(img)
119
+ x0_r12, _, _, _ = r12.get_physical_coords(img)
120
+ expected_x_spacing = (img.width / gp2.nx) * 0.8
121
+ actual_x_spacing = x0_r12 - x0_r11
122
+ assert abs(actual_x_spacing - expected_x_spacing) / expected_x_spacing < 0.01
123
+
124
+ # Verify Y spacing (120%)
125
+ r21 = next(r for r in roi2 if r.title == "ROI(2,1)")
126
+ _, y0_r21, _, _ = r21.get_physical_coords(img)
127
+ expected_y_spacing = (img.height / gp2.ny) * 1.2
128
+ actual_y_spacing = y0_r21 - y0_r11
129
+ assert abs(actual_y_spacing - expected_y_spacing) / expected_y_spacing < 0.01
130
+
131
+
58
132
  if __name__ == "__main__":
59
133
  test_roi_grid_geometry_headless()
134
+ test_roi_grid_custom_step()
60
135
  test_roi_grid(screenshots=True)
@@ -25,7 +25,7 @@ from qtpy import QtWidgets as QW
25
25
 
26
26
  from datalab.env import execenv
27
27
  from datalab.gui.macroeditor import Macro
28
- from datalab.gui.panel import macro
28
+ from datalab.gui.panel.macro import MacroPanel
29
29
  from datalab.tests import datalab_test_app_context, helpers
30
30
 
31
31
 
@@ -56,7 +56,7 @@ print("All done! :)")
56
56
  def test_macro_editor():
57
57
  """Test dep viewer window"""
58
58
  with qt_app_context(exec_loop=True):
59
- widget = macro.MacroPanel(None)
59
+ widget = MacroPanel(None)
60
60
  widget.resize(800, 600)
61
61
  widget.show()
62
62
 
@@ -6,12 +6,12 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import TYPE_CHECKING
9
+ from typing import TYPE_CHECKING, Any
10
10
 
11
11
  import numpy as np
12
12
  from guidata.configtools import get_icon
13
13
  from plotpy.builder import make
14
- from plotpy.plot import PlotDialog
14
+ from plotpy.plot import PlotDialog, PlotOptions
15
15
 
16
16
  from datalab.adapters_plotpy import create_adapter_from_object
17
17
  from datalab.config import _
@@ -29,16 +29,25 @@ class ImageBackgroundDialog(PlotDialog):
29
29
  Args:
30
30
  image: image object
31
31
  parent: parent widget. Defaults to None.
32
+ options: plot options. Defaults to None.
32
33
  """
33
34
 
34
- def __init__(self, image: ImageObj, parent: QWidget | None = None) -> None:
35
+ def __init__(
36
+ self,
37
+ image: ImageObj,
38
+ parent: QWidget | None = None,
39
+ options: PlotOptions | dict[str, Any] | None = None,
40
+ ) -> None:
35
41
  self.__background: float | None = None
36
42
  self.__rect_coords: tuple[float, float, float, float] | None = None
37
43
  self.imageitem: MaskedXYImageItem | None = None
38
44
  self.rectarea: RectangleShape | None = None
39
45
  self.comput2d: RangeComputation2d | None = None
40
46
  super().__init__(
41
- title=_("Image background selection"), edit=True, parent=parent
47
+ title=_("Image background selection"),
48
+ edit=True,
49
+ parent=parent,
50
+ options=options,
42
51
  )
43
52
  self.setObjectName("backgroundselection")
44
53
  if parent is None:
@@ -89,8 +89,8 @@ def get_manifest_package_info(manifest_path: Path) -> str:
89
89
  result_lines.append(f"{name:{name_width}} {version:{version_width}}")
90
90
 
91
91
  return os.linesep.join(result_lines)
92
- except Exception as e:
93
- return f"Error reading manifest file: {e}"
92
+ except Exception as exc: # pylint: disable=broad-except
93
+ return f"Error reading manifest file: {exc}"
94
94
 
95
95
 
96
96
  def get_install_info() -> str:
@@ -12,6 +12,7 @@ import numpy as np
12
12
  from guidata.configtools import get_icon
13
13
  from plotpy.builder import make
14
14
  from plotpy.plot import PlotDialog
15
+ from qtpy import QtCore as QC
15
16
  from qtpy import QtGui as QG
16
17
  from qtpy import QtWidgets as QW
17
18
  from sigima.tools.signal.features import find_x_values_at_y
@@ -76,10 +77,14 @@ class SignalCursorDialog(PlotDialog):
76
77
  ylabel = QW.QLabel("Y=")
77
78
  self.xlineedit = QW.QLineEdit()
78
79
  self.xlineedit.editingFinished.connect(self.xlineedit_editing_finished)
79
- self.xlineedit.setValidator(QG.QDoubleValidator())
80
+ x_validator = QG.QDoubleValidator()
81
+ x_validator.setLocale(QC.QLocale("C"))
82
+ self.xlineedit.setValidator(x_validator)
80
83
  self.ylineedit = QW.QLineEdit()
81
84
  self.ylineedit.editingFinished.connect(self.ylineedit_editing_finished)
82
- self.ylineedit.setValidator(QG.QDoubleValidator())
85
+ y_validator = QG.QDoubleValidator()
86
+ y_validator.setLocale(QC.QLocale("C"))
87
+ self.ylineedit.setValidator(y_validator)
83
88
  self.xlineedit.setReadOnly(self.__cursor_orientation == "horizontal")
84
89
  self.xlineedit.setDisabled(self.__cursor_orientation == "horizontal")
85
90
  self.ylineedit.setReadOnly(self.__cursor_orientation == "vertical")
@@ -15,6 +15,7 @@ import numpy as np
15
15
  from guidata.configtools import get_icon
16
16
  from plotpy.builder import make
17
17
  from plotpy.plot import PlotDialog
18
+ from qtpy import QtCore as QC
18
19
  from qtpy import QtGui as QG
19
20
  from qtpy import QtWidgets as QW
20
21
  from sigima.tools.signal.pulse import full_width_at_y
@@ -68,7 +69,9 @@ class SignalDeltaXDialog(PlotDialog):
68
69
  self.deltaxlineedit.setDisabled(True)
69
70
  self.ylineedit = QW.QLineEdit()
70
71
  self.ylineedit.editingFinished.connect(self.ylineedit_editing_finished)
71
- self.ylineedit.setValidator(QG.QDoubleValidator())
72
+ y_validator = QG.QDoubleValidator()
73
+ y_validator.setLocale(QC.QLocale("C"))
74
+ self.ylineedit.setValidator(y_validator)
72
75
  xygroup = QW.QGroupBox(_("Cursor position"))
73
76
  xylayout = QW.QHBoxLayout()
74
77
  xylayout.addWidget(xlabel)