datalab-platform 1.0.2__py3-none-any.whl → 1.0.3__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.
- datalab/__init__.py +1 -1
- datalab/adapters_metadata/common.py +2 -2
- datalab/config.py +86 -26
- datalab/control/baseproxy.py +70 -0
- datalab/control/proxy.py +33 -0
- datalab/control/remote.py +35 -0
- datalab/data/doc/DataLab_en.pdf +0 -0
- datalab/data/doc/DataLab_fr.pdf +0 -0
- datalab/data/icons/create/linear_chirp.svg +1 -1
- datalab/data/icons/create/logistic.svg +1 -1
- datalab/gui/actionhandler.py +13 -0
- datalab/gui/h5io.py +25 -0
- datalab/gui/macroeditor.py +19 -5
- datalab/gui/main.py +60 -5
- datalab/gui/objectview.py +18 -3
- datalab/gui/panel/base.py +24 -18
- datalab/gui/panel/macro.py +26 -0
- datalab/gui/plothandler.py +10 -1
- datalab/gui/processor/base.py +43 -10
- datalab/gui/processor/image.py +6 -2
- datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
- datalab/locale/fr/LC_MESSAGES/datalab.po +3288 -0
- datalab/objectmodel.py +1 -1
- datalab/tests/features/common/auto_analysis_recompute_unit_test.py +81 -0
- datalab/tests/features/common/coordutils_unit_test.py +1 -1
- datalab/tests/features/common/result_deletion_unit_test.py +121 -1
- datalab/tests/features/common/update_tree_robustness_test.py +65 -0
- datalab/tests/features/control/remoteclient_unit.py +10 -0
- datalab/tests/features/hdf5/h5workspace_unit_test.py +133 -0
- datalab/tests/features/image/roigrid_unit_test.py +75 -0
- datalab/tests/features/macro/macroeditor_unit_test.py +2 -2
- datalab/widgets/imagebackground.py +13 -4
- datalab/widgets/instconfviewer.py +2 -2
- datalab/widgets/signalcursor.py +7 -2
- datalab/widgets/signaldeltax.py +4 -1
- {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.3.dist-info}/METADATA +2 -2
- {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.3.dist-info}/RECORD +41 -37
- {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.3.dist-info}/WHEEL +0 -0
- {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.3.dist-info}/entry_points.txt +0 -0
- {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {datalab_platform-1.0.2.dist-info → datalab_platform-1.0.3.dist-info}/top_level.txt +0 -0
datalab/objectmodel.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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__(
|
|
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"),
|
|
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
|
|
93
|
-
return f"Error reading manifest file: {
|
|
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:
|
datalab/widgets/signalcursor.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
datalab/widgets/signaldeltax.py
CHANGED
|
@@ -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
|
-
|
|
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)
|