datalab-platform 0.0.1.dev0__py3-none-any.whl → 1.0.0__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 +35 -2
- datalab/adapters_metadata/__init__.py +31 -0
- datalab/adapters_metadata/base_adapter.py +316 -0
- datalab/adapters_metadata/common.py +422 -0
- datalab/adapters_metadata/geometry_adapter.py +98 -0
- datalab/adapters_metadata/table_adapter.py +84 -0
- datalab/adapters_plotpy/__init__.py +54 -0
- datalab/adapters_plotpy/annotations.py +124 -0
- datalab/adapters_plotpy/base.py +110 -0
- datalab/adapters_plotpy/converters.py +86 -0
- datalab/adapters_plotpy/factories.py +80 -0
- datalab/adapters_plotpy/objects/__init__.py +0 -0
- datalab/adapters_plotpy/objects/base.py +197 -0
- datalab/adapters_plotpy/objects/image.py +157 -0
- datalab/adapters_plotpy/objects/scalar.py +565 -0
- datalab/adapters_plotpy/objects/signal.py +264 -0
- datalab/adapters_plotpy/roi/__init__.py +0 -0
- datalab/adapters_plotpy/roi/base.py +146 -0
- datalab/adapters_plotpy/roi/factory.py +93 -0
- datalab/adapters_plotpy/roi/image.py +207 -0
- datalab/adapters_plotpy/roi/signal.py +72 -0
- datalab/app.py +98 -0
- datalab/config.py +817 -0
- datalab/control/__init__.py +0 -0
- datalab/control/baseproxy.py +776 -0
- datalab/control/proxy.py +343 -0
- datalab/control/remote.py +1005 -0
- datalab/data/doc/DataLab_en.pdf +0 -0
- datalab/data/doc/DataLab_fr.pdf +0 -0
- datalab/data/icons/analysis/delete_results.svg +109 -0
- datalab/data/icons/analysis/fw1e2.svg +156 -0
- datalab/data/icons/analysis/fwhm.svg +156 -0
- datalab/data/icons/analysis/histogram.svg +49 -0
- datalab/data/icons/analysis/peak_detect.svg +160 -0
- datalab/data/icons/analysis/plot_results.svg +151 -0
- datalab/data/icons/analysis/show_results.svg +83 -0
- datalab/data/icons/analysis/stats.svg +49 -0
- datalab/data/icons/analysis.svg +120 -0
- datalab/data/icons/apply.svg +3 -0
- datalab/data/icons/check_all.svg +15 -0
- datalab/data/icons/collapse.svg +44 -0
- datalab/data/icons/collapse_selection.svg +63 -0
- datalab/data/icons/console.svg +101 -0
- datalab/data/icons/create/1d-normal.svg +8 -0
- datalab/data/icons/create/1d-poisson.svg +9 -0
- datalab/data/icons/create/1d-uniform.svg +8 -0
- datalab/data/icons/create/1d-zero.svg +57 -0
- datalab/data/icons/create/2d-gaussian.svg +56 -0
- datalab/data/icons/create/2d-normal.svg +38 -0
- datalab/data/icons/create/2d-poisson.svg +38 -0
- datalab/data/icons/create/2d-ramp.svg +90 -0
- datalab/data/icons/create/2d-sinc.svg +62 -0
- datalab/data/icons/create/2d-uniform.svg +38 -0
- datalab/data/icons/create/2d-zero.svg +13 -0
- datalab/data/icons/create/checkerboard.svg +39 -0
- datalab/data/icons/create/cosine.svg +12 -0
- datalab/data/icons/create/exponential.svg +55 -0
- datalab/data/icons/create/gaussian.svg +12 -0
- datalab/data/icons/create/grating.svg +29 -0
- datalab/data/icons/create/linear_chirp.svg +7 -0
- datalab/data/icons/create/logistic.svg +7 -0
- datalab/data/icons/create/lorentzian.svg +12 -0
- datalab/data/icons/create/planck.svg +12 -0
- datalab/data/icons/create/polynomial.svg +7 -0
- datalab/data/icons/create/pulse.svg +12 -0
- datalab/data/icons/create/ring.svg +18 -0
- datalab/data/icons/create/sawtooth.svg +7 -0
- datalab/data/icons/create/siemens.svg +35 -0
- datalab/data/icons/create/sinc.svg +12 -0
- datalab/data/icons/create/sine.svg +7 -0
- datalab/data/icons/create/square.svg +7 -0
- datalab/data/icons/create/square_pulse.svg +7 -0
- datalab/data/icons/create/step.svg +7 -0
- datalab/data/icons/create/step_pulse.svg +12 -0
- datalab/data/icons/create/triangle.svg +7 -0
- datalab/data/icons/create/voigt.svg +12 -0
- datalab/data/icons/edit/annotations.svg +72 -0
- datalab/data/icons/edit/annotations_copy.svg +114 -0
- datalab/data/icons/edit/annotations_delete.svg +83 -0
- datalab/data/icons/edit/annotations_edit.svg +98 -0
- datalab/data/icons/edit/annotations_export.svg +85 -0
- datalab/data/icons/edit/annotations_import.svg +85 -0
- datalab/data/icons/edit/annotations_paste.svg +100 -0
- datalab/data/icons/edit/copy_titles.svg +109 -0
- datalab/data/icons/edit/delete.svg +84 -0
- datalab/data/icons/edit/delete_all.svg +214 -0
- datalab/data/icons/edit/duplicate.svg +64 -0
- datalab/data/icons/edit/goto_source.svg +60 -0
- datalab/data/icons/edit/metadata.svg +60 -0
- datalab/data/icons/edit/metadata_add.svg +80 -0
- datalab/data/icons/edit/metadata_copy.svg +96 -0
- datalab/data/icons/edit/metadata_delete.svg +62 -0
- datalab/data/icons/edit/metadata_export.svg +68 -0
- datalab/data/icons/edit/metadata_import.svg +68 -0
- datalab/data/icons/edit/metadata_paste.svg +79 -0
- datalab/data/icons/edit/move_down.svg +55 -0
- datalab/data/icons/edit/move_up.svg +54 -0
- datalab/data/icons/edit/new_group.svg +76 -0
- datalab/data/icons/edit/recompute.svg +60 -0
- datalab/data/icons/edit/rename.svg +49 -0
- datalab/data/icons/edit.svg +16 -0
- datalab/data/icons/expand.svg +44 -0
- datalab/data/icons/expand_selection.svg +63 -0
- datalab/data/icons/fit/cdf_fit.svg +56 -0
- datalab/data/icons/fit/exponential_fit.svg +55 -0
- datalab/data/icons/fit/gaussian_fit.svg +62 -0
- datalab/data/icons/fit/interactive_fit.svg +101 -0
- datalab/data/icons/fit/linear_fit.svg +57 -0
- datalab/data/icons/fit/lorentzian_fit.svg +209 -0
- datalab/data/icons/fit/multigaussian_fit.svg +85 -0
- datalab/data/icons/fit/multilorentzian_fit.svg +85 -0
- datalab/data/icons/fit/piecewiseexponential_fit.svg +209 -0
- datalab/data/icons/fit/planckian_fit.svg +62 -0
- datalab/data/icons/fit/polynomial_fit.svg +59 -0
- datalab/data/icons/fit/sigmoid_fit.svg +56 -0
- datalab/data/icons/fit/sinusoidal_fit.svg +72 -0
- datalab/data/icons/fit/twohalfgaussian_fit.svg +63 -0
- datalab/data/icons/fit/voigt_fit.svg +57 -0
- datalab/data/icons/group.svg +56 -0
- datalab/data/icons/h5/h5array.svg +59 -0
- datalab/data/icons/h5/h5attrs.svg +75 -0
- datalab/data/icons/h5/h5browser.svg +133 -0
- datalab/data/icons/h5/h5file.svg +69 -0
- datalab/data/icons/h5/h5group.svg +49 -0
- datalab/data/icons/h5/h5scalar.svg +1 -0
- datalab/data/icons/help_pdf.svg +46 -0
- datalab/data/icons/history.svg +7 -0
- datalab/data/icons/image.svg +135 -0
- datalab/data/icons/io/fileopen_directory.svg +60 -0
- datalab/data/icons/io/fileopen_h5.svg +84 -0
- datalab/data/icons/io/fileopen_ima.svg +187 -0
- datalab/data/icons/io/fileopen_py.svg +123 -0
- datalab/data/icons/io/fileopen_sig.svg +138 -0
- datalab/data/icons/io/filesave_h5.svg +97 -0
- datalab/data/icons/io/filesave_ima.svg +200 -0
- datalab/data/icons/io/filesave_py.svg +136 -0
- datalab/data/icons/io/filesave_sig.svg +151 -0
- datalab/data/icons/io/import_text.svg +144 -0
- datalab/data/icons/io/save_to_directory.svg +134 -0
- datalab/data/icons/io.svg +84 -0
- datalab/data/icons/libre-camera-flash-off.svg +1 -0
- datalab/data/icons/libre-camera-flash-on.svg +1 -0
- datalab/data/icons/libre-gui-about.svg +1 -0
- datalab/data/icons/libre-gui-action-delete.svg +1 -0
- datalab/data/icons/libre-gui-add.svg +1 -0
- datalab/data/icons/libre-gui-arrow-down.svg +1 -0
- datalab/data/icons/libre-gui-arrow-left.svg +1 -0
- datalab/data/icons/libre-gui-arrow-right.svg +1 -0
- datalab/data/icons/libre-gui-arrow-up.svg +1 -0
- datalab/data/icons/libre-gui-close.svg +40 -0
- datalab/data/icons/libre-gui-cogs.svg +1 -0
- datalab/data/icons/libre-gui-globe.svg +1 -0
- datalab/data/icons/libre-gui-help.svg +1 -0
- datalab/data/icons/libre-gui-link.svg +1 -0
- datalab/data/icons/libre-gui-menu.svg +1 -0
- datalab/data/icons/libre-gui-pencil.svg +1 -0
- datalab/data/icons/libre-gui-plugin.svg +1 -0
- datalab/data/icons/libre-gui-questions.svg +1 -0
- datalab/data/icons/libre-gui-settings.svg +1 -0
- datalab/data/icons/libre-gui-unlink.svg +1 -0
- datalab/data/icons/libre-tech-ram.svg +1 -0
- datalab/data/icons/libre-toolbox.svg +1 -0
- datalab/data/icons/logs.svg +1 -0
- datalab/data/icons/markers.svg +74 -0
- datalab/data/icons/menu.svg +13 -0
- datalab/data/icons/new_ima.svg +148 -0
- datalab/data/icons/new_sig.svg +123 -0
- datalab/data/icons/operations/abs.svg +116 -0
- datalab/data/icons/operations/arithmetic.svg +123 -0
- datalab/data/icons/operations/average.svg +124 -0
- datalab/data/icons/operations/complex_from_magnitude_phase.svg +116 -0
- datalab/data/icons/operations/complex_from_real_imag.svg +124 -0
- datalab/data/icons/operations/constant.svg +116 -0
- datalab/data/icons/operations/constant_add.svg +109 -0
- datalab/data/icons/operations/constant_divide.svg +109 -0
- datalab/data/icons/operations/constant_multiply.svg +109 -0
- datalab/data/icons/operations/constant_subtract.svg +109 -0
- datalab/data/icons/operations/convert_dtype.svg +117 -0
- datalab/data/icons/operations/convolution.svg +46 -0
- datalab/data/icons/operations/deconvolution.svg +57 -0
- datalab/data/icons/operations/derivative.svg +127 -0
- datalab/data/icons/operations/difference.svg +52 -0
- datalab/data/icons/operations/division.svg +139 -0
- datalab/data/icons/operations/exp.svg +116 -0
- datalab/data/icons/operations/flip_horizontally.svg +69 -0
- datalab/data/icons/operations/flip_vertically.svg +74 -0
- datalab/data/icons/operations/im.svg +124 -0
- datalab/data/icons/operations/integral.svg +50 -0
- datalab/data/icons/operations/inverse.svg +143 -0
- datalab/data/icons/operations/log10.svg +109 -0
- datalab/data/icons/operations/phase.svg +116 -0
- datalab/data/icons/operations/power.svg +118 -0
- datalab/data/icons/operations/product.svg +124 -0
- datalab/data/icons/operations/profile.svg +379 -0
- datalab/data/icons/operations/profile_average.svg +399 -0
- datalab/data/icons/operations/profile_radial.svg +261 -0
- datalab/data/icons/operations/profile_segment.svg +262 -0
- datalab/data/icons/operations/quadratic_difference.svg +84 -0
- datalab/data/icons/operations/re.svg +124 -0
- datalab/data/icons/operations/rotate_left.svg +72 -0
- datalab/data/icons/operations/rotate_right.svg +72 -0
- datalab/data/icons/operations/signals_to_image.svg +314 -0
- datalab/data/icons/operations/sqrt.svg +110 -0
- datalab/data/icons/operations/std.svg +124 -0
- datalab/data/icons/operations/sum.svg +102 -0
- datalab/data/icons/play_demo.svg +9 -0
- datalab/data/icons/processing/axis_transform.svg +62 -0
- datalab/data/icons/processing/bandpass.svg +79 -0
- datalab/data/icons/processing/bandstop.svg +71 -0
- datalab/data/icons/processing/binning.svg +126 -0
- datalab/data/icons/processing/clip.svg +119 -0
- datalab/data/icons/processing/detrending.svg +173 -0
- datalab/data/icons/processing/distribute_on_grid.svg +769 -0
- datalab/data/icons/processing/edge_detection.svg +46 -0
- datalab/data/icons/processing/erase.svg +1 -0
- datalab/data/icons/processing/exposure.svg +143 -0
- datalab/data/icons/processing/fourier.svg +104 -0
- datalab/data/icons/processing/highpass.svg +59 -0
- datalab/data/icons/processing/interpolation.svg +71 -0
- datalab/data/icons/processing/level_adjustment.svg +70 -0
- datalab/data/icons/processing/lowpass.svg +60 -0
- datalab/data/icons/processing/morphology.svg +49 -0
- datalab/data/icons/processing/noise_addition.svg +114 -0
- datalab/data/icons/processing/noise_reduction.svg +38 -0
- datalab/data/icons/processing/normalize.svg +84 -0
- datalab/data/icons/processing/offset_correction.svg +131 -0
- datalab/data/icons/processing/resampling1d.svg +101 -0
- datalab/data/icons/processing/resampling2d.svg +240 -0
- datalab/data/icons/processing/reset_positions.svg +185 -0
- datalab/data/icons/processing/resize.svg +9 -0
- datalab/data/icons/processing/reverse_signal_x.svg +171 -0
- datalab/data/icons/processing/stability.svg +11 -0
- datalab/data/icons/processing/swap_x_y.svg +65 -0
- datalab/data/icons/processing/thresholding.svg +63 -0
- datalab/data/icons/processing/windowing.svg +45 -0
- datalab/data/icons/properties.svg +26 -0
- datalab/data/icons/reset.svg +9 -0
- datalab/data/icons/restore.svg +40 -0
- datalab/data/icons/roi/roi.svg +76 -0
- datalab/data/icons/roi/roi_coordinate.svg +78 -0
- datalab/data/icons/roi/roi_copy.svg +112 -0
- datalab/data/icons/roi/roi_delete.svg +81 -0
- datalab/data/icons/roi/roi_export.svg +87 -0
- datalab/data/icons/roi/roi_graphical.svg +78 -0
- datalab/data/icons/roi/roi_grid.svg +67 -0
- datalab/data/icons/roi/roi_ima.svg +188 -0
- datalab/data/icons/roi/roi_import.svg +87 -0
- datalab/data/icons/roi/roi_new.svg +81 -0
- datalab/data/icons/roi/roi_new_circle.svg +95 -0
- datalab/data/icons/roi/roi_new_polygon.svg +110 -0
- datalab/data/icons/roi/roi_new_rectangle.svg +70 -0
- datalab/data/icons/roi/roi_paste.svg +98 -0
- datalab/data/icons/roi/roi_sig.svg +124 -0
- datalab/data/icons/shapes.svg +134 -0
- datalab/data/icons/signal.svg +103 -0
- datalab/data/icons/table.svg +85 -0
- datalab/data/icons/table_unavailable.svg +102 -0
- datalab/data/icons/to_signal.svg +124 -0
- datalab/data/icons/tour/next.svg +44 -0
- datalab/data/icons/tour/previous.svg +44 -0
- datalab/data/icons/tour/rewind.svg +51 -0
- datalab/data/icons/tour/stop.svg +47 -0
- datalab/data/icons/tour/tour.svg +16 -0
- datalab/data/icons/uncheck_all.svg +78 -0
- datalab/data/icons/view/curve_antialiasing.svg +50 -0
- datalab/data/icons/view/new_window.svg +98 -0
- datalab/data/icons/view/refresh-auto.svg +57 -0
- datalab/data/icons/view/refresh-manual.svg +51 -0
- datalab/data/icons/view/reset_curve_styles.svg +96 -0
- datalab/data/icons/view/show_first.svg +55 -0
- datalab/data/icons/view/show_titles.svg +46 -0
- datalab/data/icons/visualization.svg +51 -0
- datalab/data/logo/DataLab-Banner-150.png +0 -0
- datalab/data/logo/DataLab-Banner-200.png +0 -0
- datalab/data/logo/DataLab-Banner2-100.png +0 -0
- datalab/data/logo/DataLab-Splash.png +0 -0
- datalab/data/logo/DataLab-watermark.png +0 -0
- datalab/data/logo/DataLab.svg +83 -0
- datalab/data/tests/reordering_test.h5 +0 -0
- datalab/data/tutorials/fabry_perot/fabry-perot1.jpg +0 -0
- datalab/data/tutorials/fabry_perot/fabry-perot2.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_13.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_18.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_23.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_30.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_35.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_40.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_45.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_50.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_55.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_60.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_65.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_70.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_75.jpg +0 -0
- datalab/data/tutorials/laser_beam/TEM00_z_80.jpg +0 -0
- datalab/env.py +542 -0
- datalab/gui/__init__.py +89 -0
- datalab/gui/actionhandler.py +1701 -0
- datalab/gui/docks.py +473 -0
- datalab/gui/h5io.py +150 -0
- datalab/gui/macroeditor.py +310 -0
- datalab/gui/main.py +2081 -0
- datalab/gui/newobject.py +217 -0
- datalab/gui/objectview.py +766 -0
- datalab/gui/panel/__init__.py +48 -0
- datalab/gui/panel/base.py +3254 -0
- datalab/gui/panel/image.py +157 -0
- datalab/gui/panel/macro.py +607 -0
- datalab/gui/panel/signal.py +164 -0
- datalab/gui/plothandler.py +800 -0
- datalab/gui/processor/__init__.py +84 -0
- datalab/gui/processor/base.py +2456 -0
- datalab/gui/processor/catcher.py +75 -0
- datalab/gui/processor/image.py +1214 -0
- datalab/gui/processor/signal.py +755 -0
- datalab/gui/profiledialog.py +333 -0
- datalab/gui/roieditor.py +633 -0
- datalab/gui/roigrideditor.py +208 -0
- datalab/gui/settings.py +612 -0
- datalab/gui/tour.py +908 -0
- datalab/h5/__init__.py +12 -0
- datalab/h5/common.py +314 -0
- datalab/h5/generic.py +580 -0
- datalab/h5/native.py +39 -0
- datalab/h5/utils.py +95 -0
- datalab/objectmodel.py +640 -0
- datalab/plugins/_readme_.txt +9 -0
- datalab/plugins/datalab_imageformats.py +175 -0
- datalab/plugins/datalab_testdata.py +190 -0
- datalab/plugins.py +355 -0
- datalab/tests/__init__.py +199 -0
- datalab/tests/backbone/__init__.py +1 -0
- datalab/tests/backbone/config_unit_test.py +170 -0
- datalab/tests/backbone/config_versioning_unit_test.py +34 -0
- datalab/tests/backbone/dictlistserial_app_test.py +38 -0
- datalab/tests/backbone/errorcatcher_unit_test.py +69 -0
- datalab/tests/backbone/errormsgbox_unit_test.py +50 -0
- datalab/tests/backbone/execenv_unit.py +262 -0
- datalab/tests/backbone/loadtest_gdi.py +147 -0
- datalab/tests/backbone/long_callback.py +96 -0
- datalab/tests/backbone/main_app_test.py +137 -0
- datalab/tests/backbone/memory_leak.py +43 -0
- datalab/tests/backbone/procisolation1_unit.py +128 -0
- datalab/tests/backbone/procisolation2_unit.py +171 -0
- datalab/tests/backbone/procisolation_unit_test.py +22 -0
- datalab/tests/backbone/profiling_app.py +27 -0
- datalab/tests/backbone/strings_unit_test.py +65 -0
- datalab/tests/backbone/title_formatting_unit_test.py +82 -0
- datalab/tests/conftest.py +131 -0
- datalab/tests/features/__init__.py +1 -0
- datalab/tests/features/applauncher/__init__.py +1 -0
- datalab/tests/features/applauncher/launcher1_app_test.py +28 -0
- datalab/tests/features/applauncher/launcher2_app_test.py +30 -0
- datalab/tests/features/common/__init__.py +1 -0
- datalab/tests/features/common/add_metadata_app_test.py +134 -0
- datalab/tests/features/common/add_metadata_unit_test.py +267 -0
- datalab/tests/features/common/annotations_management_unit_test.py +152 -0
- datalab/tests/features/common/auto_analysis_recompute_unit_test.py +240 -0
- datalab/tests/features/common/createobject_unit_test.py +50 -0
- datalab/tests/features/common/geometry_results_app_test.py +135 -0
- datalab/tests/features/common/interactive_processing_test.py +1109 -0
- datalab/tests/features/common/io_app_test.py +75 -0
- datalab/tests/features/common/large_results_app_test.py +187 -0
- datalab/tests/features/common/metadata_all_patterns_test.py +103 -0
- datalab/tests/features/common/metadata_app_test.py +139 -0
- datalab/tests/features/common/metadata_io_unit_test.py +60 -0
- datalab/tests/features/common/misc_app_test.py +236 -0
- datalab/tests/features/common/multiple_geometry_results_unit_test.py +122 -0
- datalab/tests/features/common/multiple_table_results_unit_test.py +64 -0
- datalab/tests/features/common/operation_modes_app_test.py +392 -0
- datalab/tests/features/common/plot_results_app_test.py +278 -0
- datalab/tests/features/common/reorder_app_test.py +75 -0
- datalab/tests/features/common/result_deletion_unit_test.py +96 -0
- datalab/tests/features/common/result_merged_label_unit_test.py +154 -0
- datalab/tests/features/common/result_shape_settings_unit_test.py +223 -0
- datalab/tests/features/common/roi_plotitem_unit_test.py +64 -0
- datalab/tests/features/common/roieditor_unit_test.py +102 -0
- datalab/tests/features/common/save_to_dir_app_test.py +163 -0
- datalab/tests/features/common/save_to_dir_unit_test.py +474 -0
- datalab/tests/features/common/stat_app_test.py +40 -0
- datalab/tests/features/common/stats_tools_unit_test.py +77 -0
- datalab/tests/features/common/table_results_app_test.py +52 -0
- datalab/tests/features/common/textimport_unit_test.py +131 -0
- datalab/tests/features/common/uuid_preservation_test.py +281 -0
- datalab/tests/features/common/worker_unit_test.py +402 -0
- datalab/tests/features/control/__init__.py +1 -0
- datalab/tests/features/control/connect_dialog.py +28 -0
- datalab/tests/features/control/embedded1_unit_test.py +304 -0
- datalab/tests/features/control/embedded2_unit_test.py +52 -0
- datalab/tests/features/control/remoteclient_app_test.py +219 -0
- datalab/tests/features/control/remoteclient_unit.py +75 -0
- datalab/tests/features/control/simpleclient_unit_test.py +321 -0
- datalab/tests/features/hdf5/__init__.py +1 -0
- datalab/tests/features/hdf5/h5browser1_unit_test.py +31 -0
- datalab/tests/features/hdf5/h5browser2_unit.py +55 -0
- datalab/tests/features/hdf5/h5browser_app_test.py +77 -0
- datalab/tests/features/hdf5/h5import_app_test.py +25 -0
- datalab/tests/features/hdf5/h5importer_app_test.py +34 -0
- datalab/tests/features/image/__init__.py +1 -0
- datalab/tests/features/image/annotations_app_test.py +28 -0
- datalab/tests/features/image/annotations_unit_test.py +80 -0
- datalab/tests/features/image/average_app_test.py +46 -0
- datalab/tests/features/image/background_dialog_test.py +70 -0
- datalab/tests/features/image/blobs_app_test.py +50 -0
- datalab/tests/features/image/contour_app_test.py +42 -0
- datalab/tests/features/image/contour_fabryperot_app_test.py +51 -0
- datalab/tests/features/image/denoise_app_test.py +31 -0
- datalab/tests/features/image/distribute_on_grid_app_test.py +95 -0
- datalab/tests/features/image/edges_app_test.py +31 -0
- datalab/tests/features/image/erase_app_test.py +21 -0
- datalab/tests/features/image/fft2d_app_test.py +27 -0
- datalab/tests/features/image/flatfield_app_test.py +40 -0
- datalab/tests/features/image/geometry_transform_unit_test.py +396 -0
- datalab/tests/features/image/imagetools_app_test.py +51 -0
- datalab/tests/features/image/imagetools_unit_test.py +27 -0
- datalab/tests/features/image/load_app_test.py +73 -0
- datalab/tests/features/image/morph_app_test.py +32 -0
- datalab/tests/features/image/offsetcorrection_app_test.py +30 -0
- datalab/tests/features/image/peak2d_app_test.py +53 -0
- datalab/tests/features/image/profile_app_test.py +73 -0
- datalab/tests/features/image/profile_dialog_test.py +56 -0
- datalab/tests/features/image/roi_app_test.py +98 -0
- datalab/tests/features/image/roi_circ_app_test.py +62 -0
- datalab/tests/features/image/roi_manipulation_app_test.py +268 -0
- datalab/tests/features/image/roigrid_unit_test.py +60 -0
- datalab/tests/features/image/side_by_side_app_test.py +52 -0
- datalab/tests/features/macro/__init__.py +1 -0
- datalab/tests/features/macro/macro_app_test.py +28 -0
- datalab/tests/features/macro/macroeditor_unit_test.py +102 -0
- datalab/tests/features/signal/__init__.py +1 -0
- datalab/tests/features/signal/baseline_dialog_test.py +53 -0
- datalab/tests/features/signal/deltax_dialog_unit_test.py +34 -0
- datalab/tests/features/signal/fft1d_app_test.py +26 -0
- datalab/tests/features/signal/filter_app_test.py +44 -0
- datalab/tests/features/signal/fitdialog_unit_test.py +50 -0
- datalab/tests/features/signal/interpolation_app_test.py +110 -0
- datalab/tests/features/signal/loadbigsignal_app_test.py +80 -0
- datalab/tests/features/signal/multiple_rois_unit_test.py +132 -0
- datalab/tests/features/signal/pulse_features_app_test.py +118 -0
- datalab/tests/features/signal/pulse_features_roi_app_test.py +55 -0
- datalab/tests/features/signal/roi_app_test.py +78 -0
- datalab/tests/features/signal/roi_manipulation_app_test.py +261 -0
- datalab/tests/features/signal/select_xy_cursor_unit_test.py +46 -0
- datalab/tests/features/signal/signalpeakdetection_dialog_test.py +33 -0
- datalab/tests/features/signal/signals_to_image_app_test.py +98 -0
- datalab/tests/features/signal/xarray_compat_app_test.py +128 -0
- datalab/tests/features/tour_unit_test.py +22 -0
- datalab/tests/features/utilities/__init__.py +1 -0
- datalab/tests/features/utilities/installconf_unit_test.py +21 -0
- datalab/tests/features/utilities/logview_app_test.py +21 -0
- datalab/tests/features/utilities/logview_error.py +24 -0
- datalab/tests/features/utilities/logview_unit_test.py +21 -0
- datalab/tests/features/utilities/memstatus_app_test.py +42 -0
- datalab/tests/features/utilities/settings_unit_test.py +88 -0
- datalab/tests/scenarios/__init__.py +1 -0
- datalab/tests/scenarios/beautiful_app.py +121 -0
- datalab/tests/scenarios/common.py +463 -0
- datalab/tests/scenarios/demo.py +212 -0
- datalab/tests/scenarios/example_app_test.py +47 -0
- datalab/tests/scenarios/scenario_h5_app_test.py +75 -0
- datalab/tests/scenarios/scenario_ima1_app_test.py +34 -0
- datalab/tests/scenarios/scenario_ima2_app_test.py +34 -0
- datalab/tests/scenarios/scenario_mac_app_test.py +58 -0
- datalab/tests/scenarios/scenario_sig1_app_test.py +36 -0
- datalab/tests/scenarios/scenario_sig2_app_test.py +35 -0
- datalab/utils/__init__.py +1 -0
- datalab/utils/conf.py +304 -0
- datalab/utils/dephash.py +105 -0
- datalab/utils/qthelpers.py +633 -0
- datalab/utils/strings.py +34 -0
- datalab/utils/tests.py +0 -0
- datalab/widgets/__init__.py +1 -0
- datalab/widgets/connection.py +138 -0
- datalab/widgets/filedialog.py +91 -0
- datalab/widgets/fileviewer.py +84 -0
- datalab/widgets/fitdialog.py +788 -0
- datalab/widgets/h5browser.py +1048 -0
- datalab/widgets/imagebackground.py +111 -0
- datalab/widgets/instconfviewer.py +175 -0
- datalab/widgets/logviewer.py +80 -0
- datalab/widgets/signalbaseline.py +90 -0
- datalab/widgets/signalcursor.py +208 -0
- datalab/widgets/signaldeltax.py +151 -0
- datalab/widgets/signalpeak.py +199 -0
- datalab/widgets/status.py +249 -0
- datalab/widgets/textimport.py +786 -0
- datalab/widgets/warningerror.py +223 -0
- datalab/widgets/wizard.py +286 -0
- datalab_platform-1.0.0.dist-info/METADATA +121 -0
- datalab_platform-1.0.0.dist-info/RECORD +494 -0
- datalab_platform-0.0.1.dev0.dist-info/METADATA +0 -67
- datalab_platform-0.0.1.dev0.dist-info/RECORD +0 -7
- {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/WHEEL +0 -0
- {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/entry_points.txt +0 -0
- {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,3254 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
.. Base panel objects (see parent package :mod:`datalab.gui.panel`)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import abc
|
|
12
|
+
import glob
|
|
13
|
+
import os
|
|
14
|
+
import os.path as osp
|
|
15
|
+
import re
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Generator, Generic, Literal, Type
|
|
19
|
+
|
|
20
|
+
import guidata.dataset as gds
|
|
21
|
+
import guidata.dataset.qtwidgets as gdq
|
|
22
|
+
import h5py
|
|
23
|
+
import numpy as np
|
|
24
|
+
import plotpy.io
|
|
25
|
+
from guidata.configtools import get_icon
|
|
26
|
+
from guidata.dataset import restore_dataset, update_dataset
|
|
27
|
+
from guidata.qthelpers import add_actions, create_action, exec_dialog
|
|
28
|
+
from plotpy.plot import BasePlot, BasePlotOptions, PlotDialog, SyncPlotDialog
|
|
29
|
+
from plotpy.tools import ActionTool
|
|
30
|
+
from qtpy import QtCore as QC # type: ignore[import]
|
|
31
|
+
from qtpy import QtWidgets as QW
|
|
32
|
+
from qtpy.compat import (
|
|
33
|
+
getexistingdirectory,
|
|
34
|
+
getopenfilename,
|
|
35
|
+
getopenfilenames,
|
|
36
|
+
getsavefilename,
|
|
37
|
+
)
|
|
38
|
+
from sigima.io import (
|
|
39
|
+
read_annotations,
|
|
40
|
+
read_metadata,
|
|
41
|
+
read_roi,
|
|
42
|
+
write_annotations,
|
|
43
|
+
write_metadata,
|
|
44
|
+
write_roi,
|
|
45
|
+
)
|
|
46
|
+
from sigima.io.base import get_file_extensions
|
|
47
|
+
from sigima.io.common.basename import format_basenames
|
|
48
|
+
from sigima.objects import (
|
|
49
|
+
ImageObj,
|
|
50
|
+
NewImageParam,
|
|
51
|
+
SignalObj,
|
|
52
|
+
TypeObj,
|
|
53
|
+
TypeROI,
|
|
54
|
+
create_image_from_param,
|
|
55
|
+
create_signal,
|
|
56
|
+
create_signal_from_param,
|
|
57
|
+
)
|
|
58
|
+
from sigima.objects.base import ROI_KEY
|
|
59
|
+
from sigima.params import SaveToDirectoryParam
|
|
60
|
+
|
|
61
|
+
from datalab import objectmodel
|
|
62
|
+
from datalab.adapters_metadata import (
|
|
63
|
+
GeometryAdapter,
|
|
64
|
+
ResultData,
|
|
65
|
+
TableAdapter,
|
|
66
|
+
create_resultdata_dict,
|
|
67
|
+
show_resultdata,
|
|
68
|
+
)
|
|
69
|
+
from datalab.adapters_plotpy import create_adapter_from_object
|
|
70
|
+
from datalab.config import APP_NAME, Conf, _
|
|
71
|
+
from datalab.env import execenv
|
|
72
|
+
from datalab.gui import actionhandler, objectview
|
|
73
|
+
from datalab.gui.newobject import (
|
|
74
|
+
CREATION_PARAMETERS_OPTION,
|
|
75
|
+
NewSignalParam,
|
|
76
|
+
extract_creation_parameters,
|
|
77
|
+
insert_creation_parameters,
|
|
78
|
+
)
|
|
79
|
+
from datalab.gui.processor.base import (
|
|
80
|
+
PROCESSING_PARAMETERS_OPTION,
|
|
81
|
+
ProcessingParameters,
|
|
82
|
+
extract_processing_parameters,
|
|
83
|
+
insert_processing_parameters,
|
|
84
|
+
)
|
|
85
|
+
from datalab.gui.roieditor import TypeROIEditor
|
|
86
|
+
from datalab.objectmodel import ObjectGroup, get_short_id, get_uuid, set_uuid
|
|
87
|
+
from datalab.utils.qthelpers import (
|
|
88
|
+
CallbackWorker,
|
|
89
|
+
create_progress_bar,
|
|
90
|
+
qt_long_callback,
|
|
91
|
+
qt_try_except,
|
|
92
|
+
qt_try_loadsave_file,
|
|
93
|
+
save_restore_stds,
|
|
94
|
+
)
|
|
95
|
+
from datalab.widgets.textimport import TextImportWizard
|
|
96
|
+
|
|
97
|
+
if TYPE_CHECKING:
|
|
98
|
+
from plotpy.items import CurveItem, LabelItem, MaskedXYImageItem
|
|
99
|
+
from sigima.io.image import ImageIORegistry
|
|
100
|
+
from sigima.io.signal import SignalIORegistry
|
|
101
|
+
|
|
102
|
+
from datalab.gui import ObjItf
|
|
103
|
+
from datalab.gui.main import DLMainWindow
|
|
104
|
+
from datalab.gui.plothandler import ImagePlotHandler, SignalPlotHandler
|
|
105
|
+
from datalab.gui.processor.image import ImageProcessor
|
|
106
|
+
from datalab.gui.processor.signal import SignalProcessor
|
|
107
|
+
from datalab.h5.native import NativeH5Reader, NativeH5Writer
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Metadata keys that should not be pasted when copying metadata between objects
|
|
111
|
+
METADATA_PASTE_EXCLUSIONS = {
|
|
112
|
+
ROI_KEY, # ROI has dedicated copy/paste operations
|
|
113
|
+
"__uuid", # Each object must have a unique identifier
|
|
114
|
+
f"__{PROCESSING_PARAMETERS_OPTION}", # Object-specific processing history
|
|
115
|
+
f"__{CREATION_PARAMETERS_OPTION}", # Object-specific creation parameters
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_plot_item_serializable(item: Any) -> bool:
|
|
120
|
+
"""Return True if plot item is serializable"""
|
|
121
|
+
try:
|
|
122
|
+
plotpy.io.item_class_from_name(item.__class__.__name__)
|
|
123
|
+
return True
|
|
124
|
+
except AssertionError:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_hdf5_file(filename: str, check_content: bool = False) -> bool:
|
|
129
|
+
"""Return True if filename has an HDF5 extension or is an HDF5 file.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
filename: Path to the file to check
|
|
133
|
+
check_content: If True, also attempts to open the file to verify it's a
|
|
134
|
+
valid HDF5 file. If False, only checks the file extension.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if the file is (likely) an HDF5 file, False otherwise.
|
|
138
|
+
"""
|
|
139
|
+
# First, check by extension (fast)
|
|
140
|
+
has_hdf5_extension = filename.lower().endswith((".h5", ".hdf5", ".hdf", ".he5"))
|
|
141
|
+
|
|
142
|
+
if not check_content:
|
|
143
|
+
return has_hdf5_extension
|
|
144
|
+
|
|
145
|
+
# If checking content, try to open as HDF5 file
|
|
146
|
+
if has_hdf5_extension:
|
|
147
|
+
return True # Trust common HDF5 extensions
|
|
148
|
+
|
|
149
|
+
# For other extensions, attempt to open the file to verify it's HDF5
|
|
150
|
+
try:
|
|
151
|
+
with h5py.File(filename, "r"):
|
|
152
|
+
return True
|
|
153
|
+
except (OSError, IOError, ValueError):
|
|
154
|
+
# Not a valid HDF5 file
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class ProcessingReport:
|
|
160
|
+
"""Report of processing operation
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
success: True if processing succeeded
|
|
164
|
+
obj_uuid: UUID of the processed object
|
|
165
|
+
message: Optional message (error or info)
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
success: bool
|
|
169
|
+
obj_uuid: str | None = None
|
|
170
|
+
message: str | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ObjectProp(QW.QWidget):
|
|
174
|
+
"""Object handling panel properties
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
panel: parent data panel
|
|
178
|
+
objclass: class of the object handled by the panel (SignalObj or ImageObj)
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, panel: BaseDataPanel, objclass: SignalObj | ImageObj) -> None:
|
|
182
|
+
super().__init__(panel)
|
|
183
|
+
|
|
184
|
+
# Create the tab widget
|
|
185
|
+
self.tabwidget = QW.QTabWidget(self)
|
|
186
|
+
self.tabwidget.setTabBarAutoHide(True)
|
|
187
|
+
self.tabwidget.setTabPosition(QW.QTabWidget.West)
|
|
188
|
+
|
|
189
|
+
self.panel = panel
|
|
190
|
+
self.objclass = objclass
|
|
191
|
+
|
|
192
|
+
# Object creation tab
|
|
193
|
+
self.creation_param_editor: gdq.DataSetEditGroupBox | None = None
|
|
194
|
+
self.current_creation_obj: SignalObj | ImageObj | None = None
|
|
195
|
+
self.creation_scroll: QW.QScrollArea | None = None
|
|
196
|
+
|
|
197
|
+
# Object processing tab
|
|
198
|
+
self.processing_param_editor: gdq.DataSetEditGroupBox | None = None
|
|
199
|
+
self.current_processing_obj: SignalObj | ImageObj | None = None
|
|
200
|
+
self.processing_scroll: QW.QScrollArea | None = None
|
|
201
|
+
|
|
202
|
+
# Properties tab
|
|
203
|
+
self.properties = gdq.DataSetEditGroupBox("", objclass)
|
|
204
|
+
self.properties.SIG_APPLY_BUTTON_CLICKED.connect(panel.properties_changed)
|
|
205
|
+
self.properties.setEnabled(False)
|
|
206
|
+
self.__original_values: dict[str, Any] = {}
|
|
207
|
+
|
|
208
|
+
# Create Analysis and History widgets
|
|
209
|
+
font = Conf.proc.small_mono_font.get_font()
|
|
210
|
+
|
|
211
|
+
self.processing_history = QW.QTextEdit()
|
|
212
|
+
self.processing_history.setReadOnly(True)
|
|
213
|
+
self.processing_history.setFont(font)
|
|
214
|
+
|
|
215
|
+
self.analysis_parameters = QW.QTextEdit()
|
|
216
|
+
self.analysis_parameters.setReadOnly(True)
|
|
217
|
+
self.analysis_parameters.setFont(font)
|
|
218
|
+
|
|
219
|
+
self.tabwidget.addTab(
|
|
220
|
+
self.processing_history, get_icon("history.svg"), _("History")
|
|
221
|
+
)
|
|
222
|
+
self.tabwidget.addTab(
|
|
223
|
+
self.analysis_parameters, get_icon("analysis.svg"), _("Analysis parameters")
|
|
224
|
+
)
|
|
225
|
+
self.tabwidget.addTab(
|
|
226
|
+
self.properties, get_icon("properties.svg"), _("Properties")
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
self.processing_history.textChanged.connect(self._update_tab_visibility)
|
|
230
|
+
self.analysis_parameters.textChanged.connect(self._update_tab_visibility)
|
|
231
|
+
|
|
232
|
+
# Create vertical layout for the container
|
|
233
|
+
layout = QW.QVBoxLayout(self)
|
|
234
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
235
|
+
layout.setSpacing(0)
|
|
236
|
+
# Add tab widget and button area to main layout
|
|
237
|
+
layout.addWidget(self.tabwidget)
|
|
238
|
+
# Here we could add another widget or layout if needed (in DataLab v0.20, we
|
|
239
|
+
# had a permanent button area here, but it was removed to avoid clutter)
|
|
240
|
+
|
|
241
|
+
def _update_tab_visibility(self) -> None:
|
|
242
|
+
"""Update visibility of tabs based on their content."""
|
|
243
|
+
for textedit in (self.processing_history, self.analysis_parameters):
|
|
244
|
+
tab_index = self.tabwidget.indexOf(textedit)
|
|
245
|
+
if tab_index >= 0:
|
|
246
|
+
has_content = bool(textedit.toPlainText().strip())
|
|
247
|
+
self.tabwidget.setTabVisible(tab_index, has_content)
|
|
248
|
+
|
|
249
|
+
def display_analysis_parameters(self, obj: SignalObj | ImageObj) -> bool:
|
|
250
|
+
"""Set analysis parameter label.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
obj: Signal or Image object
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if analysis parameters were found and displayed, False otherwise.
|
|
257
|
+
"""
|
|
258
|
+
text = ""
|
|
259
|
+
# Iterate through all result adapters and extract parameter info
|
|
260
|
+
for adapter_class in (GeometryAdapter, TableAdapter):
|
|
261
|
+
for adapter in adapter_class.iterate_from_obj(obj):
|
|
262
|
+
param = adapter.get_param()
|
|
263
|
+
if param is not None:
|
|
264
|
+
if text:
|
|
265
|
+
text += "<br><br>"
|
|
266
|
+
# Get function name for context
|
|
267
|
+
func_name = adapter.func_name
|
|
268
|
+
if func_name:
|
|
269
|
+
# Add function name as a header for better context
|
|
270
|
+
param.set_comment(
|
|
271
|
+
"(" + _("Parameters for function `%s`") % func_name + ")"
|
|
272
|
+
)
|
|
273
|
+
text += param.to_html()
|
|
274
|
+
self.analysis_parameters.setText(text)
|
|
275
|
+
return bool(text)
|
|
276
|
+
|
|
277
|
+
def _build_processing_history(self, obj: SignalObj | ImageObj) -> str:
|
|
278
|
+
"""Build processing history as a simple text list.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
obj: Signal or Image object
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Processing history as text
|
|
285
|
+
"""
|
|
286
|
+
history_items = []
|
|
287
|
+
current_obj = obj
|
|
288
|
+
max_depth = 20 # Prevent infinite loops
|
|
289
|
+
|
|
290
|
+
# Walk backwards through processing chain, collecting items
|
|
291
|
+
while current_obj is not None and len(history_items) < max_depth:
|
|
292
|
+
proc_params = extract_processing_parameters(current_obj)
|
|
293
|
+
|
|
294
|
+
if proc_params is None:
|
|
295
|
+
# Check for creation parameters
|
|
296
|
+
creation_params = extract_creation_parameters(current_obj)
|
|
297
|
+
if creation_params is not None:
|
|
298
|
+
text = f"{_('Created')}: {creation_params.title}"
|
|
299
|
+
history_items.append(text)
|
|
300
|
+
else:
|
|
301
|
+
history_items.append(_("Original object"))
|
|
302
|
+
break
|
|
303
|
+
|
|
304
|
+
# Skip 1-to-0 operations (analysis) as they don't transform the object
|
|
305
|
+
# They just add metadata, so they shouldn't appear in processing history
|
|
306
|
+
if proc_params.pattern == "1-to-0":
|
|
307
|
+
# For 1-to-0 operations, there's no processing history to show
|
|
308
|
+
# (they analyze but don't transform the object)
|
|
309
|
+
# Check if there's any earlier processing
|
|
310
|
+
creation_params = extract_creation_parameters(current_obj)
|
|
311
|
+
if creation_params is not None:
|
|
312
|
+
text = f"{_('Created')}: {creation_params.title}"
|
|
313
|
+
history_items.append(text)
|
|
314
|
+
else:
|
|
315
|
+
history_items.append(_("Original object"))
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
# Add current processing step
|
|
319
|
+
func_name = proc_params.func_name.replace("_", " ").title()
|
|
320
|
+
history_items.append(func_name)
|
|
321
|
+
|
|
322
|
+
# Try to find source object
|
|
323
|
+
if proc_params.source_uuid:
|
|
324
|
+
current_obj = self.panel.mainwindow.find_object_by_uuid(
|
|
325
|
+
proc_params.source_uuid
|
|
326
|
+
)
|
|
327
|
+
if current_obj is None:
|
|
328
|
+
history_items.append(_("(source deleted)"))
|
|
329
|
+
break
|
|
330
|
+
else:
|
|
331
|
+
if proc_params.source_uuids:
|
|
332
|
+
# Multiple sources (n-to-1 or 2-to-1 pattern)
|
|
333
|
+
history_items.append(_("(multiple sources)"))
|
|
334
|
+
break
|
|
335
|
+
|
|
336
|
+
if len(history_items) <= 1:
|
|
337
|
+
return "" # Shows the history tab only when there is some history
|
|
338
|
+
|
|
339
|
+
# Reverse to show from oldest to newest, then add indentation
|
|
340
|
+
history_items.reverse()
|
|
341
|
+
history_lines = []
|
|
342
|
+
for i, item in enumerate(history_items):
|
|
343
|
+
indent = " " * i
|
|
344
|
+
history_lines.append(f"{indent}└─ {item}")
|
|
345
|
+
|
|
346
|
+
return "\n".join(history_lines)
|
|
347
|
+
|
|
348
|
+
def display_processing_history(self, obj: SignalObj | ImageObj) -> bool:
|
|
349
|
+
"""Display processing history.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
obj: Signal or Image object
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True if processing history was found and displayed, False otherwise.
|
|
356
|
+
"""
|
|
357
|
+
history_text = self._build_processing_history(obj)
|
|
358
|
+
self.processing_history.setText(history_text)
|
|
359
|
+
return bool(history_text)
|
|
360
|
+
|
|
361
|
+
def __update_properties_dataset(self, obj: SignalObj | ImageObj) -> None:
|
|
362
|
+
"""Update properties dataset from signal/image dataset
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
obj: Signal or Image object
|
|
366
|
+
"""
|
|
367
|
+
dataset: SignalObj | ImageObj = self.properties.dataset
|
|
368
|
+
dataset.set_defaults()
|
|
369
|
+
update_dataset(dataset, obj)
|
|
370
|
+
self.properties.get()
|
|
371
|
+
self.properties.apply_button.setEnabled(False)
|
|
372
|
+
|
|
373
|
+
def update_properties_from(self, obj: SignalObj | ImageObj | None = None) -> None:
|
|
374
|
+
"""Update properties panel (properties, creation, processing) from object.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
obj: Signal or Image object
|
|
378
|
+
"""
|
|
379
|
+
self.properties.setDisabled(obj is None)
|
|
380
|
+
if obj is None:
|
|
381
|
+
obj = self.objclass()
|
|
382
|
+
|
|
383
|
+
# Update the properties dataset
|
|
384
|
+
self.__update_properties_dataset(obj)
|
|
385
|
+
# Store original values to detect which properties have changed
|
|
386
|
+
# (using `restore_dataset` to convert the dataset to a dictionary)
|
|
387
|
+
self.__original_values = {}
|
|
388
|
+
restore_dataset(self.properties.dataset, self.__original_values)
|
|
389
|
+
|
|
390
|
+
# Display analysis parameters and processing history
|
|
391
|
+
has_analysis_parameters = self.display_analysis_parameters(obj)
|
|
392
|
+
self.display_processing_history(obj)
|
|
393
|
+
|
|
394
|
+
# Remove only Creation and Processing tabs (dynamic tabs)
|
|
395
|
+
# Use widget references instead of text labels for reliable identification
|
|
396
|
+
if self.creation_scroll is not None:
|
|
397
|
+
index = self.tabwidget.indexOf(self.creation_scroll)
|
|
398
|
+
if index >= 0:
|
|
399
|
+
self.tabwidget.removeTab(index)
|
|
400
|
+
if self.processing_scroll is not None:
|
|
401
|
+
index = self.tabwidget.indexOf(self.processing_scroll)
|
|
402
|
+
if index >= 0:
|
|
403
|
+
self.tabwidget.removeTab(index)
|
|
404
|
+
|
|
405
|
+
# Reset references for dynamic tabs
|
|
406
|
+
self.creation_param_editor = None
|
|
407
|
+
self.current_creation_obj = None
|
|
408
|
+
self.creation_scroll = None
|
|
409
|
+
self.processing_param_editor = None
|
|
410
|
+
self.current_processing_obj = None
|
|
411
|
+
self.processing_scroll = None
|
|
412
|
+
|
|
413
|
+
# Setup Creation and Processing tabs (if applicable)
|
|
414
|
+
has_creation_tab = has_processing_tab = False
|
|
415
|
+
if obj is not None:
|
|
416
|
+
has_creation_tab = self.setup_creation_tab(obj)
|
|
417
|
+
has_processing_tab = self.setup_processing_tab(obj)
|
|
418
|
+
|
|
419
|
+
# Trigger visibility update for History and Analysis parameters tabs
|
|
420
|
+
# (will be called via textChanged signals, but we call explicitly
|
|
421
|
+
# here to ensure initial state is correct)
|
|
422
|
+
self._update_tab_visibility()
|
|
423
|
+
|
|
424
|
+
# Handle priority regarding the tab to set as current:
|
|
425
|
+
# 1. Analysis parameters if content exists
|
|
426
|
+
# 2. Creation tab if it exists
|
|
427
|
+
# 3. Processing tab if it exists
|
|
428
|
+
# 4. Properties tab
|
|
429
|
+
if has_analysis_parameters:
|
|
430
|
+
self.tabwidget.setCurrentWidget(self.analysis_parameters)
|
|
431
|
+
elif has_creation_tab:
|
|
432
|
+
self.tabwidget.setCurrentWidget(self.creation_scroll)
|
|
433
|
+
elif has_processing_tab:
|
|
434
|
+
self.tabwidget.setCurrentWidget(self.processing_scroll)
|
|
435
|
+
else:
|
|
436
|
+
self.tabwidget.setCurrentWidget(self.properties)
|
|
437
|
+
|
|
438
|
+
def get_changed_properties(self) -> dict[str, Any]:
|
|
439
|
+
"""Get dictionary of properties that have changed from original values.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Dictionary mapping property names to their new values, containing only
|
|
443
|
+
the properties that were modified by the user.
|
|
444
|
+
"""
|
|
445
|
+
dataset = self.properties.dataset
|
|
446
|
+
changed = {}
|
|
447
|
+
|
|
448
|
+
# Get current values as a dictionary
|
|
449
|
+
current_values = {}
|
|
450
|
+
restore_dataset(dataset, current_values)
|
|
451
|
+
|
|
452
|
+
# Compare with original values
|
|
453
|
+
for key, current_value in current_values.items():
|
|
454
|
+
original_value = self.__original_values.get(key)
|
|
455
|
+
# Check if value has changed
|
|
456
|
+
if not self._values_equal(current_value, original_value):
|
|
457
|
+
changed[key] = current_value
|
|
458
|
+
return changed
|
|
459
|
+
|
|
460
|
+
def update_original_values(self) -> None:
|
|
461
|
+
"""Update the stored original values to the current dataset values.
|
|
462
|
+
|
|
463
|
+
This should be called after applying changes to reset the baseline
|
|
464
|
+
for detecting future changes.
|
|
465
|
+
"""
|
|
466
|
+
dataset = self.properties.dataset
|
|
467
|
+
self.__original_values = {}
|
|
468
|
+
restore_dataset(dataset, self.__original_values)
|
|
469
|
+
|
|
470
|
+
@staticmethod
|
|
471
|
+
def _values_equal(val1: Any, val2: Any) -> bool:
|
|
472
|
+
"""Compare two values, handling special cases like numpy arrays.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
val1: first value
|
|
476
|
+
val2: second value
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
True if values are equal
|
|
480
|
+
"""
|
|
481
|
+
# Handle numpy arrays
|
|
482
|
+
if isinstance(val1, np.ndarray) or isinstance(val2, np.ndarray):
|
|
483
|
+
if not isinstance(val1, np.ndarray) or not isinstance(val2, np.ndarray):
|
|
484
|
+
return False
|
|
485
|
+
return np.array_equal(val1, val2)
|
|
486
|
+
# Handle regular comparison
|
|
487
|
+
return val1 == val2
|
|
488
|
+
|
|
489
|
+
def setup_creation_tab(
|
|
490
|
+
self, obj: SignalObj | ImageObj, set_current: bool = False
|
|
491
|
+
) -> bool:
|
|
492
|
+
"""Setup the Creation tab with parameter editor for interactive object creation.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
obj: Signal or Image object
|
|
496
|
+
set_current: If True, set the Creation tab as current after creation
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
True if Creation tab was set up, False otherwise
|
|
500
|
+
"""
|
|
501
|
+
param = extract_creation_parameters(obj)
|
|
502
|
+
if param is None:
|
|
503
|
+
return False
|
|
504
|
+
|
|
505
|
+
# Create parameter editor widget using the actual parameter class
|
|
506
|
+
# (which is a subclass of NewSignalParam or NewImageParam)
|
|
507
|
+
editor = gdq.DataSetEditGroupBox(_("Creation Parameters"), param.__class__)
|
|
508
|
+
update_dataset(editor.dataset, param)
|
|
509
|
+
editor.get()
|
|
510
|
+
|
|
511
|
+
# Connect Apply button to recreation handler
|
|
512
|
+
editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_creation_parameters)
|
|
513
|
+
editor.set_apply_button_state(False)
|
|
514
|
+
|
|
515
|
+
# Store reference to be able to retrieve it later
|
|
516
|
+
self.creation_param_editor = editor
|
|
517
|
+
self.current_creation_obj = obj
|
|
518
|
+
|
|
519
|
+
# Remove existing Creation tab if it exists
|
|
520
|
+
if self.creation_scroll is not None:
|
|
521
|
+
index = self.tabwidget.indexOf(self.creation_scroll)
|
|
522
|
+
if index >= 0:
|
|
523
|
+
self.tabwidget.removeTab(index)
|
|
524
|
+
|
|
525
|
+
# Set the parameter editor as the scroll area widget
|
|
526
|
+
# Creation tab is always at index 0 (before all other tabs)
|
|
527
|
+
self.creation_scroll = QW.QScrollArea()
|
|
528
|
+
self.creation_scroll.setWidgetResizable(True)
|
|
529
|
+
self.creation_scroll.setWidget(editor)
|
|
530
|
+
icon_name = "new_sig.svg" if isinstance(obj, SignalObj) else "new_ima.svg"
|
|
531
|
+
self.tabwidget.insertTab(
|
|
532
|
+
0, self.creation_scroll, get_icon(icon_name), _("Creation")
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Set as current tab if requested
|
|
536
|
+
if set_current:
|
|
537
|
+
self.tabwidget.setCurrentWidget(self.creation_scroll)
|
|
538
|
+
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
def apply_creation_parameters(self) -> None:
|
|
542
|
+
"""Apply creation parameters: recreate object with updated parameters."""
|
|
543
|
+
editor = self.creation_param_editor
|
|
544
|
+
if editor is None or self.current_creation_obj is None:
|
|
545
|
+
return
|
|
546
|
+
if isinstance(self.current_creation_obj, SignalObj):
|
|
547
|
+
otext = _("Signal was modified in-place.")
|
|
548
|
+
else:
|
|
549
|
+
otext = _("Image was modified in-place.")
|
|
550
|
+
text = f"⚠️ {otext} ⚠️ "
|
|
551
|
+
text += _(
|
|
552
|
+
"If computation were performed based on this object, "
|
|
553
|
+
"they may need to be redone."
|
|
554
|
+
)
|
|
555
|
+
self.panel.SIG_STATUS_MESSAGE.emit(text, 20000)
|
|
556
|
+
|
|
557
|
+
# Recreate object with new parameters
|
|
558
|
+
# (serialization is done automatically in create_signal/image_from_param)
|
|
559
|
+
param = editor.dataset
|
|
560
|
+
try:
|
|
561
|
+
if isinstance(self.current_creation_obj, SignalObj):
|
|
562
|
+
new_obj = create_signal_from_param(param)
|
|
563
|
+
else: # ImageObj
|
|
564
|
+
new_obj = create_image_from_param(param)
|
|
565
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
566
|
+
if execenv.unattended:
|
|
567
|
+
raise exc
|
|
568
|
+
QW.QMessageBox.warning(
|
|
569
|
+
self,
|
|
570
|
+
_("Error"),
|
|
571
|
+
_("Failed to recreate object with new parameters:\n%s") % str(exc),
|
|
572
|
+
)
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
# Update the current object in-place
|
|
576
|
+
obj_uuid = get_uuid(self.current_creation_obj)
|
|
577
|
+
self.current_creation_obj.title = new_obj.title
|
|
578
|
+
if isinstance(self.current_creation_obj, SignalObj):
|
|
579
|
+
self.current_creation_obj.xydata = new_obj.xydata
|
|
580
|
+
else: # ImageObj
|
|
581
|
+
self.current_creation_obj.data = new_obj.data
|
|
582
|
+
# Invalidate ROI mask cache when image dimensions change
|
|
583
|
+
# (the mask is computed based on image shape, so it must be recomputed)
|
|
584
|
+
self.current_creation_obj.invalidate_maskdata_cache()
|
|
585
|
+
# Update metadata with new creation parameters
|
|
586
|
+
insert_creation_parameters(self.current_creation_obj, param)
|
|
587
|
+
|
|
588
|
+
# Auto-recompute analysis if the object had analysis parameters
|
|
589
|
+
# Since the data has changed, any analysis results are now invalid
|
|
590
|
+
# Use the processor for the current object's type
|
|
591
|
+
obj_processor = self.__get_processor_associated_to(self.current_creation_obj)
|
|
592
|
+
obj_processor.auto_recompute_analysis(self.current_creation_obj)
|
|
593
|
+
|
|
594
|
+
# Update the tree view item (to show new title if it changed)
|
|
595
|
+
self.panel.objview.update_item(obj_uuid)
|
|
596
|
+
|
|
597
|
+
# Refresh only the plot, not the entire panel
|
|
598
|
+
# (avoid calling selection_changed which would trigger a full refresh
|
|
599
|
+
# of the Properties tab and could cause recursion issues)
|
|
600
|
+
self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
|
|
601
|
+
|
|
602
|
+
# Update the Properties tab to reflect the new object properties
|
|
603
|
+
# (e.g., data type, dimensions, etc.)
|
|
604
|
+
self.__update_properties_dataset(self.current_creation_obj)
|
|
605
|
+
|
|
606
|
+
# Refresh the Creation tab with the new parameters
|
|
607
|
+
# Use QTimer to defer this until after the current event is processed
|
|
608
|
+
# Set the Creation tab as current to keep it visible after refresh
|
|
609
|
+
QC.QTimer.singleShot(
|
|
610
|
+
0,
|
|
611
|
+
lambda: self.setup_creation_tab(
|
|
612
|
+
self.current_creation_obj, set_current=True
|
|
613
|
+
),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def setup_processing_tab(
|
|
617
|
+
self,
|
|
618
|
+
obj: SignalObj | ImageObj,
|
|
619
|
+
reset_params: bool = True,
|
|
620
|
+
set_current: bool = False,
|
|
621
|
+
) -> bool:
|
|
622
|
+
"""Setup the Processing tab with parameter editor for re-processing.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
obj: Signal or Image object
|
|
626
|
+
reset_params: If True, call update_from_obj() to reset parameters from
|
|
627
|
+
source object. If False, use parameters as stored in metadata.
|
|
628
|
+
set_current: If True, set the Processing tab as current after creation
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
True if Processing tab was set up, False otherwise
|
|
632
|
+
"""
|
|
633
|
+
# Extract processing parameters
|
|
634
|
+
proc_params = extract_processing_parameters(obj)
|
|
635
|
+
if proc_params is None:
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
# Check if the pattern type is 1-to-1 (only interactive pattern)
|
|
639
|
+
if proc_params.pattern != "1-to-1":
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
# Store reference to be able to retrieve it later
|
|
643
|
+
self.current_processing_obj = obj
|
|
644
|
+
|
|
645
|
+
# Check if object has processing parameter
|
|
646
|
+
param = proc_params.param
|
|
647
|
+
if param is None:
|
|
648
|
+
return False
|
|
649
|
+
|
|
650
|
+
# Skip interactive processing for list of parameters
|
|
651
|
+
# (e.g., ROI extraction, erase operations)
|
|
652
|
+
if isinstance(param, list):
|
|
653
|
+
return False
|
|
654
|
+
|
|
655
|
+
# Eventually call the `update_from_obj` method to properly initialize
|
|
656
|
+
# the parameter object from the current object state.
|
|
657
|
+
# Only do this when reset_params is True (initial setup), not when
|
|
658
|
+
# refreshing after user has modified parameters.
|
|
659
|
+
if reset_params and hasattr(param, "update_from_obj"):
|
|
660
|
+
# Warning: the `update_from_obj` method takes the input object as argument,
|
|
661
|
+
# not the output object (`obj` is the processed object here):
|
|
662
|
+
# Retrieve the input object from the source UUID
|
|
663
|
+
if proc_params.source_uuid is not None:
|
|
664
|
+
source_obj = self.panel.mainwindow.find_object_by_uuid(
|
|
665
|
+
proc_params.source_uuid
|
|
666
|
+
)
|
|
667
|
+
if source_obj is not None:
|
|
668
|
+
param.update_from_obj(source_obj)
|
|
669
|
+
|
|
670
|
+
# Create parameter editor widget
|
|
671
|
+
editor = gdq.DataSetEditGroupBox(
|
|
672
|
+
_("Processing Parameters"), param.__class__, wordwrap=True
|
|
673
|
+
)
|
|
674
|
+
update_dataset(editor.dataset, param)
|
|
675
|
+
editor.get()
|
|
676
|
+
|
|
677
|
+
# Connect Apply button to reprocessing handler
|
|
678
|
+
editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_processing_parameters)
|
|
679
|
+
editor.set_apply_button_state(False)
|
|
680
|
+
|
|
681
|
+
# Store reference to be able to retrieve it later
|
|
682
|
+
self.processing_param_editor = editor
|
|
683
|
+
|
|
684
|
+
# Remove existing Processing tab if it exists
|
|
685
|
+
if self.processing_scroll is not None:
|
|
686
|
+
index = self.tabwidget.indexOf(self.processing_scroll)
|
|
687
|
+
if index >= 0:
|
|
688
|
+
self.tabwidget.removeTab(index)
|
|
689
|
+
|
|
690
|
+
# Processing tab comes after Creation tab (if it exists)
|
|
691
|
+
# Find the correct insertion index: after Creation (index 0) if it exists,
|
|
692
|
+
# otherwise at index 0
|
|
693
|
+
has_creation = (
|
|
694
|
+
self.creation_scroll is not None
|
|
695
|
+
and self.tabwidget.indexOf(self.creation_scroll) >= 0
|
|
696
|
+
)
|
|
697
|
+
insert_index = 1 if has_creation else 0
|
|
698
|
+
|
|
699
|
+
# Create new processing scroll area and tab
|
|
700
|
+
self.processing_scroll = QW.QScrollArea()
|
|
701
|
+
self.processing_scroll.setWidgetResizable(True)
|
|
702
|
+
self.processing_scroll.setHorizontalScrollBarPolicy(QC.Qt.ScrollBarAlwaysOff)
|
|
703
|
+
self.processing_scroll.setSizePolicy(
|
|
704
|
+
QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
self.processing_scroll.setWidget(editor)
|
|
708
|
+
self.tabwidget.insertTab(
|
|
709
|
+
insert_index,
|
|
710
|
+
self.processing_scroll,
|
|
711
|
+
get_icon("libre-tech-ram.svg"),
|
|
712
|
+
_("Processing"),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# Set as current tab if requested
|
|
716
|
+
if set_current:
|
|
717
|
+
self.tabwidget.setCurrentWidget(self.processing_scroll)
|
|
718
|
+
|
|
719
|
+
return True
|
|
720
|
+
|
|
721
|
+
def __get_processor_associated_to(
|
|
722
|
+
self, obj: SignalObj | ImageObj
|
|
723
|
+
) -> SignalProcessor | ImageProcessor:
|
|
724
|
+
"""Get the processor associated to the given object type.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
obj: Signal or Image object
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
Processor associated to the object's type
|
|
731
|
+
"""
|
|
732
|
+
assert isinstance(obj, (SignalObj, ImageObj))
|
|
733
|
+
if isinstance(obj, SignalObj):
|
|
734
|
+
return self.panel.mainwindow.signalpanel.processor
|
|
735
|
+
return self.panel.mainwindow.imagepanel.processor
|
|
736
|
+
|
|
737
|
+
def apply_processing_parameters(
|
|
738
|
+
self, obj: SignalObj | ImageObj | None = None, interactive: bool = True
|
|
739
|
+
) -> ProcessingReport:
|
|
740
|
+
"""Apply processing parameters: re-run processing with updated parameters.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
obj: Signal or Image object to reprocess. If None, uses the current object.
|
|
744
|
+
interactive: If True, show progress and error messages in the UI.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
ProcessingReport with success status, object UUID, and optional message.
|
|
748
|
+
"""
|
|
749
|
+
if execenv.unattended:
|
|
750
|
+
interactive = False
|
|
751
|
+
|
|
752
|
+
report = ProcessingReport(success=False)
|
|
753
|
+
editor = self.processing_param_editor
|
|
754
|
+
obj = obj or self.current_processing_obj
|
|
755
|
+
if obj is None:
|
|
756
|
+
report.message = _("No processing object available.")
|
|
757
|
+
return report
|
|
758
|
+
|
|
759
|
+
report.obj_uuid = get_uuid(obj)
|
|
760
|
+
|
|
761
|
+
# Extract processing parameters
|
|
762
|
+
proc_params = extract_processing_parameters(obj)
|
|
763
|
+
if proc_params is None:
|
|
764
|
+
report.message = _("Processing metadata is incomplete.")
|
|
765
|
+
if interactive:
|
|
766
|
+
QW.QMessageBox.critical(self, _("Error"), report.message)
|
|
767
|
+
return report
|
|
768
|
+
|
|
769
|
+
# Check if source object still exists
|
|
770
|
+
if proc_params.source_uuid is None:
|
|
771
|
+
report.message = _(
|
|
772
|
+
"Processing metadata is incomplete (missing source UUID)."
|
|
773
|
+
)
|
|
774
|
+
if interactive:
|
|
775
|
+
QW.QMessageBox.critical(self, _("Error"), report.message)
|
|
776
|
+
return report
|
|
777
|
+
|
|
778
|
+
# Find source object
|
|
779
|
+
source_obj = self.panel.mainwindow.find_object_by_uuid(proc_params.source_uuid)
|
|
780
|
+
if source_obj is None:
|
|
781
|
+
report.message = _("Source object no longer exists.")
|
|
782
|
+
if interactive:
|
|
783
|
+
QW.QMessageBox.critical(
|
|
784
|
+
self,
|
|
785
|
+
_("Error"),
|
|
786
|
+
report.message
|
|
787
|
+
+ "\n\n"
|
|
788
|
+
+ _(
|
|
789
|
+
"The object that was used to create this processed object "
|
|
790
|
+
"has been deleted and cannot be used for reprocessing."
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
return report
|
|
794
|
+
|
|
795
|
+
# Get updated parameters from editor
|
|
796
|
+
param = editor.dataset if editor is not None else proc_params.param
|
|
797
|
+
|
|
798
|
+
# For cross-panel computations, we need to use the processor from the panel
|
|
799
|
+
# that owns the source object (e.g., radial_profile is in ImageProcessor)
|
|
800
|
+
source_processor = self.__get_processor_associated_to(source_obj)
|
|
801
|
+
|
|
802
|
+
# Recompute using the dedicated method (with multiprocessing support)
|
|
803
|
+
try:
|
|
804
|
+
new_obj = source_processor.recompute_1_to_1(
|
|
805
|
+
proc_params.func_name, source_obj, param
|
|
806
|
+
)
|
|
807
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
808
|
+
report.message = _("Failed to reprocess object:\n%s") % str(exc)
|
|
809
|
+
if interactive:
|
|
810
|
+
QW.QMessageBox.warning(self, _("Error"), report.message)
|
|
811
|
+
return report
|
|
812
|
+
|
|
813
|
+
if new_obj is None:
|
|
814
|
+
# User cancelled the operation
|
|
815
|
+
report.message = _("Processing was cancelled.")
|
|
816
|
+
|
|
817
|
+
else:
|
|
818
|
+
report.success = True
|
|
819
|
+
|
|
820
|
+
# Update the current object in-place with data from new object
|
|
821
|
+
obj.title = new_obj.title
|
|
822
|
+
if isinstance(obj, SignalObj):
|
|
823
|
+
obj.xydata = new_obj.xydata
|
|
824
|
+
else: # ImageObj
|
|
825
|
+
obj.data = new_obj.data
|
|
826
|
+
# Invalidate ROI mask cache when image dimensions may have changed
|
|
827
|
+
# (the mask is computed based on image shape, so it must be recomputed)
|
|
828
|
+
obj.invalidate_maskdata_cache()
|
|
829
|
+
|
|
830
|
+
# Update metadata with new processing parameters
|
|
831
|
+
updated_proc_params = ProcessingParameters(
|
|
832
|
+
func_name=proc_params.func_name,
|
|
833
|
+
pattern=proc_params.pattern,
|
|
834
|
+
param=param,
|
|
835
|
+
source_uuid=proc_params.source_uuid,
|
|
836
|
+
)
|
|
837
|
+
insert_processing_parameters(obj, updated_proc_params)
|
|
838
|
+
|
|
839
|
+
# Auto-recompute analysis if the object had analysis parameters
|
|
840
|
+
# Since the data has changed, any analysis results are now invalid
|
|
841
|
+
# Use the processor for the current object's type (not source object's type)
|
|
842
|
+
obj_processor = self.__get_processor_associated_to(obj)
|
|
843
|
+
obj_processor.auto_recompute_analysis(obj)
|
|
844
|
+
|
|
845
|
+
# Update the tree view item and refresh plot
|
|
846
|
+
obj_uuid = get_uuid(obj)
|
|
847
|
+
self.panel.objview.update_item(obj_uuid)
|
|
848
|
+
self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
|
|
849
|
+
|
|
850
|
+
# Update the Properties tab to reflect the new object properties
|
|
851
|
+
# (e.g., data type, dimensions, etc.)
|
|
852
|
+
self.__update_properties_dataset(obj)
|
|
853
|
+
|
|
854
|
+
# Refresh the Processing tab with the new parameters
|
|
855
|
+
# Don't reset parameters from source object - keep the user's values
|
|
856
|
+
# Set the Processing tab as current to keep it visible after refresh
|
|
857
|
+
QC.QTimer.singleShot(
|
|
858
|
+
0,
|
|
859
|
+
lambda: self.setup_processing_tab(
|
|
860
|
+
obj, reset_params=False, set_current=True
|
|
861
|
+
),
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
if isinstance(obj, SignalObj):
|
|
865
|
+
report.message = _("Signal was reprocessed.")
|
|
866
|
+
else:
|
|
867
|
+
report.message = _("Image was reprocessed.")
|
|
868
|
+
self.panel.SIG_STATUS_MESSAGE.emit("✅ " + report.message, 5000)
|
|
869
|
+
|
|
870
|
+
return report
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
class AbstractPanelMeta(type(QW.QSplitter), abc.ABCMeta):
|
|
874
|
+
"""Mixed metaclass to avoid conflicts"""
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
class AbstractPanel(QW.QSplitter, metaclass=AbstractPanelMeta):
|
|
878
|
+
"""Object defining DataLab panel interface,
|
|
879
|
+
based on a vertical QSplitter widget
|
|
880
|
+
|
|
881
|
+
A panel handle an object list (objects are signals, images, macros...).
|
|
882
|
+
Each object must implement ``datalab.gui.ObjItf`` interface
|
|
883
|
+
"""
|
|
884
|
+
|
|
885
|
+
H5_PREFIX = ""
|
|
886
|
+
SIG_OBJECT_ADDED = QC.Signal()
|
|
887
|
+
SIG_OBJECT_REMOVED = QC.Signal()
|
|
888
|
+
|
|
889
|
+
@abc.abstractmethod
|
|
890
|
+
def __init__(self, parent):
|
|
891
|
+
super().__init__(QC.Qt.Vertical, parent)
|
|
892
|
+
self.setObjectName(self.__class__.__name__[0].lower())
|
|
893
|
+
# Check if the class implements __len__, __getitem__ and __iter__
|
|
894
|
+
for method in ("__len__", "__getitem__", "__iter__"):
|
|
895
|
+
if not hasattr(self, method):
|
|
896
|
+
raise NotImplementedError(
|
|
897
|
+
f"Class {self.__class__.__name__} must implement method {method}"
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# pylint: disable=unused-argument
|
|
901
|
+
def get_serializable_name(self, obj: ObjItf) -> str:
|
|
902
|
+
"""Return serializable name of object"""
|
|
903
|
+
title = re.sub("[^-a-zA-Z0-9_.() ]+", "", obj.title.replace("/", "_"))
|
|
904
|
+
name = f"{get_short_id(obj)}: {title}"
|
|
905
|
+
return name
|
|
906
|
+
|
|
907
|
+
def serialize_object_to_hdf5(self, obj: ObjItf, writer: NativeH5Writer) -> None:
|
|
908
|
+
"""Serialize object to HDF5 file"""
|
|
909
|
+
with writer.group(self.get_serializable_name(obj)):
|
|
910
|
+
obj.serialize(writer)
|
|
911
|
+
|
|
912
|
+
def deserialize_object_from_hdf5(
|
|
913
|
+
self, reader: NativeH5Reader, name: str, reset_all: bool = False
|
|
914
|
+
) -> ObjItf:
|
|
915
|
+
"""Deserialize object from a HDF5 file
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
reader: HDF5 reader
|
|
919
|
+
name: Object name in HDF5 file
|
|
920
|
+
reset_all: If True, preserve original UUIDs (workspace reload).
|
|
921
|
+
If False, regenerate UUIDs (importing objects).
|
|
922
|
+
"""
|
|
923
|
+
with reader.group(name):
|
|
924
|
+
obj = self.create_object()
|
|
925
|
+
obj.deserialize(reader)
|
|
926
|
+
# Only regenerate UUIDs when importing objects (reset_all=False).
|
|
927
|
+
# When reopening a workspace (reset_all=True), preserve original UUIDs
|
|
928
|
+
# so that processing parameter references (source_uuid, source_uuids)
|
|
929
|
+
# remain valid and features like "Show source" and "Recompute" work.
|
|
930
|
+
# When importing, only regenerate UUID if it conflicts with an existing one.
|
|
931
|
+
if not reset_all and isinstance(obj, (SignalObj, ImageObj, ObjectGroup)):
|
|
932
|
+
if self.objmodel.has_uuid(get_uuid(obj)):
|
|
933
|
+
set_uuid(obj)
|
|
934
|
+
return obj
|
|
935
|
+
|
|
936
|
+
@abc.abstractmethod
|
|
937
|
+
def serialize_to_hdf5(self, writer: NativeH5Writer) -> None:
|
|
938
|
+
"""Serialize whole panel to a HDF5 file"""
|
|
939
|
+
|
|
940
|
+
@abc.abstractmethod
|
|
941
|
+
def deserialize_from_hdf5(
|
|
942
|
+
self, reader: NativeH5Reader, reset_all: bool = False
|
|
943
|
+
) -> None:
|
|
944
|
+
"""Deserialize whole panel from a HDF5 file
|
|
945
|
+
|
|
946
|
+
Args:
|
|
947
|
+
reader: HDF5 reader
|
|
948
|
+
reset_all: If True, preserve original UUIDs (workspace reload).
|
|
949
|
+
If False, regenerate UUIDs (importing objects).
|
|
950
|
+
"""
|
|
951
|
+
|
|
952
|
+
@abc.abstractmethod
|
|
953
|
+
def create_object(self) -> ObjItf:
|
|
954
|
+
"""Create and return object"""
|
|
955
|
+
|
|
956
|
+
@abc.abstractmethod
|
|
957
|
+
def add_object(self, obj: ObjItf) -> None:
|
|
958
|
+
"""Add object to panel"""
|
|
959
|
+
|
|
960
|
+
@abc.abstractmethod
|
|
961
|
+
def remove_all_objects(self):
|
|
962
|
+
"""Remove all objects"""
|
|
963
|
+
self.SIG_OBJECT_REMOVED.emit()
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
class PasteMetadataParam(gds.DataSet):
|
|
967
|
+
"""Paste metadata parameters"""
|
|
968
|
+
|
|
969
|
+
keep_roi = gds.BoolItem(_("Regions of interest"), default=True)
|
|
970
|
+
keep_geometry = gds.BoolItem(_("Geometry results"), default=False).set_pos(col=1)
|
|
971
|
+
keep_tables = gds.BoolItem(_("Table results"), default=False).set_pos(col=1)
|
|
972
|
+
keep_other = gds.BoolItem(_("Other metadata"), default=True)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
class NonModalInfoDialog(QW.QMessageBox):
|
|
976
|
+
"""Non-modal information message box with selectable text.
|
|
977
|
+
|
|
978
|
+
This widget displays an information message in a message dialog box, allowing users
|
|
979
|
+
to select and copy the text content.
|
|
980
|
+
"""
|
|
981
|
+
|
|
982
|
+
def __init__(self, parent: QW.QWidget, title: str, text: str) -> None:
|
|
983
|
+
"""Create a non-modal information message box with selectable text.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
parent: The parent widget.
|
|
987
|
+
title: The title of the message box.
|
|
988
|
+
text: The text to display in the message box.
|
|
989
|
+
"""
|
|
990
|
+
super().__init__(parent)
|
|
991
|
+
self.setIcon(QW.QMessageBox.Information)
|
|
992
|
+
self.setWindowTitle(title)
|
|
993
|
+
if re.search(r"<[a-zA-Z/][^>]*>", text):
|
|
994
|
+
self.setTextFormat(QC.Qt.RichText) # type: ignore[attr-defined]
|
|
995
|
+
self.setTextInteractionFlags(
|
|
996
|
+
QC.Qt.TextBrowserInteraction # type: ignore[attr-defined]
|
|
997
|
+
)
|
|
998
|
+
else:
|
|
999
|
+
self.setTextFormat(QC.Qt.PlainText) # type: ignore[attr-defined]
|
|
1000
|
+
self.setTextInteractionFlags(
|
|
1001
|
+
QC.Qt.TextSelectableByMouse # type: ignore[attr-defined]
|
|
1002
|
+
| QC.Qt.TextSelectableByKeyboard # type: ignore[attr-defined]
|
|
1003
|
+
)
|
|
1004
|
+
self.setText(text)
|
|
1005
|
+
self.setStandardButtons(QW.QMessageBox.Close)
|
|
1006
|
+
self.setDefaultButton(QW.QMessageBox.Close)
|
|
1007
|
+
# ! Necessary only on non-Windows platforms
|
|
1008
|
+
self.setWindowFlags(QC.Qt.Window) # type: ignore[attr-defined]
|
|
1009
|
+
self.setModal(False)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
class SaveToDirectoryGUIParam(gds.DataSet, title=_("Save to directory")):
|
|
1013
|
+
"""Save to directory parameters"""
|
|
1014
|
+
|
|
1015
|
+
def __init__(
|
|
1016
|
+
self, objs: list[TypeObj] | None = None, extensions: list[str] | None = None
|
|
1017
|
+
) -> None:
|
|
1018
|
+
super().__init__()
|
|
1019
|
+
self.__objs = objs or []
|
|
1020
|
+
self.__extensions = extensions or []
|
|
1021
|
+
|
|
1022
|
+
def on_button_click(
|
|
1023
|
+
self: SaveToDirectoryGUIParam,
|
|
1024
|
+
_item: gds.ButtonItem,
|
|
1025
|
+
_value: None,
|
|
1026
|
+
parent: QW.QWidget,
|
|
1027
|
+
) -> None:
|
|
1028
|
+
"""Help button callback."""
|
|
1029
|
+
text = "<br>".join(
|
|
1030
|
+
[
|
|
1031
|
+
"""Pattern accepts a Python format string. Standard Python format
|
|
1032
|
+
specifiers apply. Two extra modifiers are supported: 'upper' for
|
|
1033
|
+
uppercase and 'lower' for lowercase.""",
|
|
1034
|
+
"",
|
|
1035
|
+
"<b>Available placeholders:</b>",
|
|
1036
|
+
"""
|
|
1037
|
+
<table border="1" cellspacing="0" cellpadding="4">
|
|
1038
|
+
<tr><th>Keyword</th><th>Description</th></tr>
|
|
1039
|
+
<tr><td>{title}</td><td>Title</td></tr>
|
|
1040
|
+
<tr><td>{index}</td><td>1-based index</td></tr>
|
|
1041
|
+
<tr><td>{count}</td><td>Total number of selected objects</td></tr>
|
|
1042
|
+
<tr><td>{xlabel}, {xunit}, {ylabel}, {yunit}</td>
|
|
1043
|
+
<td>Axis information for signals</td></tr>
|
|
1044
|
+
<tr><td>{metadata[key]}</td><td>Specific metadata value<br>
|
|
1045
|
+
<i>(direct {metadata} use is ignored)</i></td></tr>
|
|
1046
|
+
</table>
|
|
1047
|
+
""",
|
|
1048
|
+
"",
|
|
1049
|
+
"<b>Examples:</b>",
|
|
1050
|
+
"""
|
|
1051
|
+
<table border="1" cellspacing="0" cellpadding="4">
|
|
1052
|
+
<tr><th>Pattern</th><th>Description</th></tr>
|
|
1053
|
+
<tr>
|
|
1054
|
+
<td>{index:03d}</td>
|
|
1055
|
+
<td>3-digit index with leading zeros</td>
|
|
1056
|
+
</tr>
|
|
1057
|
+
<tr>
|
|
1058
|
+
<td>{title:20.20}</td>
|
|
1059
|
+
<td>Title truncated to 20 characters</td>
|
|
1060
|
+
</tr>
|
|
1061
|
+
<tr>
|
|
1062
|
+
<td>{title:20.20upper}</td>
|
|
1063
|
+
<td>Title truncated to 20 characters, upper case</td>
|
|
1064
|
+
</tr>
|
|
1065
|
+
<tr>
|
|
1066
|
+
<td>{title:20.20lower}</td>
|
|
1067
|
+
<td>Title truncated to 20 characters, lower case</td>
|
|
1068
|
+
</tr>
|
|
1069
|
+
</table>
|
|
1070
|
+
""",
|
|
1071
|
+
]
|
|
1072
|
+
)
|
|
1073
|
+
NonModalInfoDialog(parent, "Pattern help", text).show()
|
|
1074
|
+
|
|
1075
|
+
def get_extension_choices(self, _item=None, _value=None):
|
|
1076
|
+
"""Return list of available extensions for choice item."""
|
|
1077
|
+
return [("." + ext, "." + ext, None) for ext in self.__extensions]
|
|
1078
|
+
|
|
1079
|
+
def build_filenames(self, objs: list[TypeObj] | None = None) -> list[str]:
|
|
1080
|
+
"""Build filenames according to current parameters."""
|
|
1081
|
+
objs = objs or self.__objs
|
|
1082
|
+
extension = self.extension if self.extension is not None else ""
|
|
1083
|
+
filenames = format_basenames(objs, self.basename + extension)
|
|
1084
|
+
used: set[str] = set() # Ensure all filenames are unique.
|
|
1085
|
+
for i, filename in enumerate(filenames):
|
|
1086
|
+
root, ext = osp.splitext(filename)
|
|
1087
|
+
filepath = osp.join(self.directory, filename)
|
|
1088
|
+
k = 1
|
|
1089
|
+
while (filename in used) or (not self.overwrite and osp.exists(filepath)):
|
|
1090
|
+
filename = f"{root}_{k}{ext}"
|
|
1091
|
+
filepath = osp.join(self.directory, filename)
|
|
1092
|
+
k += 1
|
|
1093
|
+
used.add(filename)
|
|
1094
|
+
filenames[i] = filename
|
|
1095
|
+
return filenames
|
|
1096
|
+
|
|
1097
|
+
def generate_filepath_obj_pairs(
|
|
1098
|
+
self, objs: list[TypeObj]
|
|
1099
|
+
) -> Generator[tuple[str, TypeObj], None, None]:
|
|
1100
|
+
"""Iterate over (filepath, object) pairs to be saved."""
|
|
1101
|
+
for filename, obj in zip(self.build_filenames(objs), objs):
|
|
1102
|
+
yield osp.join(self.directory, filename), obj
|
|
1103
|
+
|
|
1104
|
+
def update_preview(self, _item=None, _value=None) -> None:
|
|
1105
|
+
"""Update preview."""
|
|
1106
|
+
try:
|
|
1107
|
+
filenames = self.build_filenames()
|
|
1108
|
+
preview_lines = []
|
|
1109
|
+
for i, (obj, filename) in enumerate(zip(self.__objs, filenames), start=1):
|
|
1110
|
+
# Try to get short ID if object has been added to panel
|
|
1111
|
+
try:
|
|
1112
|
+
obj_id = get_short_id(obj)
|
|
1113
|
+
except (ValueError, KeyError):
|
|
1114
|
+
# Fallback to simple index for objects not yet in panel
|
|
1115
|
+
obj_id = str(i)
|
|
1116
|
+
preview_lines.append(f"{obj_id}: {filename}")
|
|
1117
|
+
self.preview = "\n".join(preview_lines)
|
|
1118
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
1119
|
+
# Handle formatting errors gracefully (e.g., incomplete format string)
|
|
1120
|
+
self.preview = f"Invalid pattern:{os.linesep}{exc}"
|
|
1121
|
+
|
|
1122
|
+
directory = gds.DirectoryItem(_("Directory"), default=Conf.main.base_dir.get())
|
|
1123
|
+
|
|
1124
|
+
basename = gds.StringItem(
|
|
1125
|
+
_("Basename pattern"),
|
|
1126
|
+
default="{title}",
|
|
1127
|
+
help=_("Python format string. See description for details."),
|
|
1128
|
+
).set_prop("display", callback=update_preview)
|
|
1129
|
+
|
|
1130
|
+
help = gds.ButtonItem(_("Help"), on_button_click, "MessageBoxInformation").set_pos(
|
|
1131
|
+
col=1
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
extension = gds.ChoiceItem(_("Extension"), get_extension_choices).set_prop(
|
|
1135
|
+
"display", callback=update_preview
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
overwrite = gds.BoolItem(
|
|
1139
|
+
_("Overwrite"), default=False, help=_("Overwrite existing files")
|
|
1140
|
+
).set_pos(col=1)
|
|
1141
|
+
|
|
1142
|
+
preview = gds.TextItem(
|
|
1143
|
+
_("Preview"), default=None, regexp=r"^(?!Invalid).*"
|
|
1144
|
+
).set_prop("display", readonly=True)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
class AddMetadataParam(
|
|
1148
|
+
gds.DataSet,
|
|
1149
|
+
title=_("Add metadata"),
|
|
1150
|
+
comment=_(
|
|
1151
|
+
"Add a new metadata item to the selected objects.<br><br>"
|
|
1152
|
+
"The metadata key will be the same for all objects, "
|
|
1153
|
+
"but the value can use a pattern to generate different values.<br>"
|
|
1154
|
+
"Click the <b>Help</b> button for details on the pattern syntax.<br>"
|
|
1155
|
+
),
|
|
1156
|
+
):
|
|
1157
|
+
"""Add metadata parameters"""
|
|
1158
|
+
|
|
1159
|
+
def __init__(self, objs: list[TypeObj] | None = None) -> None:
|
|
1160
|
+
super().__init__()
|
|
1161
|
+
self.__objs = objs or []
|
|
1162
|
+
|
|
1163
|
+
def on_help_button_click(
|
|
1164
|
+
self: AddMetadataParam,
|
|
1165
|
+
_item: gds.ButtonItem,
|
|
1166
|
+
_value: None,
|
|
1167
|
+
parent: QW.QWidget,
|
|
1168
|
+
) -> None:
|
|
1169
|
+
"""Help button callback."""
|
|
1170
|
+
text = "<br>".join(
|
|
1171
|
+
[
|
|
1172
|
+
"""Pattern accepts a Python format string. Standard Python format
|
|
1173
|
+
specifiers apply. Two extra modifiers are supported: 'upper' for
|
|
1174
|
+
uppercase and 'lower' for lowercase.""",
|
|
1175
|
+
"",
|
|
1176
|
+
"<b>Available placeholders:</b>",
|
|
1177
|
+
"""
|
|
1178
|
+
<table border="1" cellspacing="0" cellpadding="4">
|
|
1179
|
+
<tr><th>Keyword</th><th>Description</th></tr>
|
|
1180
|
+
<tr><td>{title}</td><td>Title</td></tr>
|
|
1181
|
+
<tr><td>{index}</td><td>1-based index</td></tr>
|
|
1182
|
+
<tr><td>{count}</td><td>Total number of selected objects</td></tr>
|
|
1183
|
+
<tr><td>{xlabel}, {xunit}, {ylabel}, {yunit}</td>
|
|
1184
|
+
<td>Axis information for signals</td></tr>
|
|
1185
|
+
<tr><td>{metadata[key]}</td><td>Specific metadata value<br>
|
|
1186
|
+
<i>(direct {metadata} use is ignored)</i></td></tr>
|
|
1187
|
+
</table>
|
|
1188
|
+
""",
|
|
1189
|
+
"",
|
|
1190
|
+
"<b>Examples:</b>",
|
|
1191
|
+
"""
|
|
1192
|
+
<table border="1" cellspacing="0" cellpadding="4">
|
|
1193
|
+
<tr><th>Pattern</th><th>Description</th></tr>
|
|
1194
|
+
<tr>
|
|
1195
|
+
<td>{index:03d}</td>
|
|
1196
|
+
<td>3-digit index with leading zeros</td>
|
|
1197
|
+
</tr>
|
|
1198
|
+
<tr>
|
|
1199
|
+
<td>{title:20.20}</td>
|
|
1200
|
+
<td>Title truncated to 20 characters</td>
|
|
1201
|
+
</tr>
|
|
1202
|
+
<tr>
|
|
1203
|
+
<td>{title:20.20upper}</td>
|
|
1204
|
+
<td>Title truncated to 20 characters, upper case</td>
|
|
1205
|
+
</tr>
|
|
1206
|
+
<tr>
|
|
1207
|
+
<td>{title:20.20lower}</td>
|
|
1208
|
+
<td>Title truncated to 20 characters, lower case</td>
|
|
1209
|
+
</tr>
|
|
1210
|
+
</table>
|
|
1211
|
+
""",
|
|
1212
|
+
]
|
|
1213
|
+
)
|
|
1214
|
+
NonModalInfoDialog(parent, "Pattern help", text).show()
|
|
1215
|
+
|
|
1216
|
+
def get_conversion_choices(self, _item=None, _value=None):
|
|
1217
|
+
"""Return list of available conversion choices."""
|
|
1218
|
+
return [
|
|
1219
|
+
("string", _("String"), None),
|
|
1220
|
+
("float", _("Float"), None),
|
|
1221
|
+
("int", _("Integer"), None),
|
|
1222
|
+
("bool", _("Boolean"), None),
|
|
1223
|
+
]
|
|
1224
|
+
|
|
1225
|
+
def build_values(
|
|
1226
|
+
self, objs: list[TypeObj] | None = None
|
|
1227
|
+
) -> list[str | float | int | bool]:
|
|
1228
|
+
"""Build values according to current parameters.
|
|
1229
|
+
|
|
1230
|
+
Raises:
|
|
1231
|
+
ValueError: If a value cannot be converted to the target type.
|
|
1232
|
+
"""
|
|
1233
|
+
objs = objs or self.__objs
|
|
1234
|
+
# Generate values using the pattern
|
|
1235
|
+
raw_values = format_basenames(objs, self.value_pattern)
|
|
1236
|
+
|
|
1237
|
+
# Convert values according to the selected conversion type
|
|
1238
|
+
converted_values = []
|
|
1239
|
+
for i, value_str in enumerate(raw_values, start=1):
|
|
1240
|
+
if self.conversion == "string":
|
|
1241
|
+
converted_values.append(value_str)
|
|
1242
|
+
elif self.conversion == "float":
|
|
1243
|
+
try:
|
|
1244
|
+
converted_values.append(float(value_str))
|
|
1245
|
+
except ValueError as exc:
|
|
1246
|
+
raise ValueError(
|
|
1247
|
+
f"Cannot convert value at index {i} to float: '{value_str}'"
|
|
1248
|
+
) from exc
|
|
1249
|
+
elif self.conversion == "int":
|
|
1250
|
+
try:
|
|
1251
|
+
converted_values.append(int(value_str))
|
|
1252
|
+
except ValueError as exc:
|
|
1253
|
+
raise ValueError(
|
|
1254
|
+
f"Cannot convert value at index {i} to integer: '{value_str}'"
|
|
1255
|
+
) from exc
|
|
1256
|
+
elif self.conversion == "bool":
|
|
1257
|
+
# Convert to boolean: "true", "1", "yes" -> True, others -> False
|
|
1258
|
+
lower_val = value_str.lower()
|
|
1259
|
+
converted_values.append(lower_val in ("true", "1", "yes", "on"))
|
|
1260
|
+
|
|
1261
|
+
return converted_values
|
|
1262
|
+
|
|
1263
|
+
def update_preview(self, _item=None, _value=None) -> None:
|
|
1264
|
+
"""Update preview."""
|
|
1265
|
+
try:
|
|
1266
|
+
values = self.build_values()
|
|
1267
|
+
preview_lines = []
|
|
1268
|
+
for i, (obj, value) in enumerate(zip(self.__objs, values), start=1):
|
|
1269
|
+
# Try to get short ID if object has been added to panel
|
|
1270
|
+
try:
|
|
1271
|
+
obj_id = get_short_id(obj)
|
|
1272
|
+
except (ValueError, KeyError):
|
|
1273
|
+
# Fallback to simple index for objects not yet in panel
|
|
1274
|
+
obj_id = str(i)
|
|
1275
|
+
preview_lines.append(f"{obj_id}: {self.metadata_key} = {value!r}")
|
|
1276
|
+
self.preview = "\n".join(preview_lines)
|
|
1277
|
+
except ValueError as exc:
|
|
1278
|
+
# Handle conversion errors
|
|
1279
|
+
self.preview = f"Invalid conversion:{os.linesep}{exc}"
|
|
1280
|
+
except (KeyError, TypeError) as exc:
|
|
1281
|
+
# Handle formatting errors (e.g., incomplete format string)
|
|
1282
|
+
self.preview = f"Invalid pattern:{os.linesep}{exc}"
|
|
1283
|
+
|
|
1284
|
+
metadata_key = gds.StringItem(
|
|
1285
|
+
_("Metadata key"),
|
|
1286
|
+
default="custom_key",
|
|
1287
|
+
notempty=True,
|
|
1288
|
+
regexp=r"^[a-zA-Z_][a-zA-Z0-9_]*$",
|
|
1289
|
+
help=_("The key name for the metadata item"),
|
|
1290
|
+
).set_prop("display", callback=update_preview)
|
|
1291
|
+
|
|
1292
|
+
value_pattern = gds.StringItem(
|
|
1293
|
+
_("Value pattern"),
|
|
1294
|
+
default="{index}",
|
|
1295
|
+
help=_("Python format string. See description for details."),
|
|
1296
|
+
).set_prop("display", callback=update_preview)
|
|
1297
|
+
|
|
1298
|
+
help = gds.ButtonItem(
|
|
1299
|
+
_("Help"), on_help_button_click, "MessageBoxInformation"
|
|
1300
|
+
).set_pos(col=1)
|
|
1301
|
+
|
|
1302
|
+
conversion = gds.ChoiceItem(
|
|
1303
|
+
_("Conversion"), get_conversion_choices, default="string"
|
|
1304
|
+
).set_prop("display", callback=update_preview)
|
|
1305
|
+
|
|
1306
|
+
preview = gds.TextItem(_("Preview"), default="", regexp=r"^(?!Invalid).*").set_prop(
|
|
1307
|
+
"display", readonly=True
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
class BaseDataPanel(AbstractPanel, Generic[TypeObj, TypeROI, TypeROIEditor]):
|
|
1312
|
+
"""Object handling the item list, the selected item properties and plot"""
|
|
1313
|
+
|
|
1314
|
+
PANEL_STR = "" # e.g. "Signal Panel"
|
|
1315
|
+
PANEL_STR_ID = "" # e.g. "signal"
|
|
1316
|
+
PARAMCLASS: TypeObj = None # Replaced in child object
|
|
1317
|
+
ANNOTATION_TOOLS = ()
|
|
1318
|
+
MINDIALOGSIZE = (800, 600)
|
|
1319
|
+
MAXDIALOGSIZE = 0.95 # % of DataLab's main window size
|
|
1320
|
+
# Replaced by the right class in child object:
|
|
1321
|
+
IO_REGISTRY: SignalIORegistry | ImageIORegistry | None = None
|
|
1322
|
+
SIG_STATUS_MESSAGE = QC.Signal(str, int) # emitted by "qt_try_except" decorator
|
|
1323
|
+
SIG_REFRESH_PLOT = QC.Signal(
|
|
1324
|
+
str, bool, bool, bool, bool
|
|
1325
|
+
) # Connected to PlotHandler.refresh_plot
|
|
1326
|
+
|
|
1327
|
+
@staticmethod
|
|
1328
|
+
@abc.abstractmethod
|
|
1329
|
+
def get_roi_class() -> Type[TypeROI]:
|
|
1330
|
+
"""Return ROI class"""
|
|
1331
|
+
|
|
1332
|
+
@staticmethod
|
|
1333
|
+
@abc.abstractmethod
|
|
1334
|
+
def get_roieditor_class() -> Type[TypeROIEditor]:
|
|
1335
|
+
"""Return ROI editor class"""
|
|
1336
|
+
|
|
1337
|
+
@abc.abstractmethod
|
|
1338
|
+
def __init__(self, parent: QW.QWidget) -> None:
|
|
1339
|
+
super().__init__(parent)
|
|
1340
|
+
self.mainwindow: DLMainWindow = parent
|
|
1341
|
+
self.objprop = ObjectProp(self, self.PARAMCLASS)
|
|
1342
|
+
self.objmodel = objectmodel.ObjectModel(f"g{self.PARAMCLASS.PREFIX}")
|
|
1343
|
+
self.objview = objectview.ObjectView(self, self.objmodel)
|
|
1344
|
+
self.objview.SIG_IMPORT_FILES.connect(self.handle_dropped_files)
|
|
1345
|
+
self.objview.populate_tree()
|
|
1346
|
+
self.plothandler: SignalPlotHandler | ImagePlotHandler = None
|
|
1347
|
+
self.processor: SignalProcessor | ImageProcessor = None
|
|
1348
|
+
self.acthandler: actionhandler.BaseActionHandler = None
|
|
1349
|
+
self.metadata_clipboard = {}
|
|
1350
|
+
self.annotations_clipboard: list[dict[str, Any]] = []
|
|
1351
|
+
self.__roi_clipboard: TypeROI | None = None
|
|
1352
|
+
self.context_menu = QW.QMenu()
|
|
1353
|
+
self.__separate_views: dict[QW.QDialog, TypeObj] = {}
|
|
1354
|
+
|
|
1355
|
+
def closeEvent(self, event):
|
|
1356
|
+
"""Reimplement QMainWindow method"""
|
|
1357
|
+
self.processor.close()
|
|
1358
|
+
super().closeEvent(event)
|
|
1359
|
+
|
|
1360
|
+
# ------AbstractPanel interface-----------------------------------------------------
|
|
1361
|
+
def plot_item_parameters_changed(
|
|
1362
|
+
self, item: CurveItem | MaskedXYImageItem | LabelItem
|
|
1363
|
+
) -> None:
|
|
1364
|
+
"""Plot items changed: update metadata of all objects from plot items"""
|
|
1365
|
+
# Find the object corresponding to the plot item
|
|
1366
|
+
obj = self.plothandler.get_obj_from_item(item)
|
|
1367
|
+
if obj is not None:
|
|
1368
|
+
# Unselect the item in the plot so that we update the item parameters
|
|
1369
|
+
# in the right state (fix issue #184):
|
|
1370
|
+
item.unselect()
|
|
1371
|
+
# Ensure that item's parameters are up-to-date:
|
|
1372
|
+
item.param.update_param(item)
|
|
1373
|
+
# Update object metadata from plot item parameters
|
|
1374
|
+
create_adapter_from_object(obj).update_metadata_from_plot_item(item)
|
|
1375
|
+
if obj is self.objview.get_current_object():
|
|
1376
|
+
self.objprop.update_properties_from(obj)
|
|
1377
|
+
self.plothandler.update_resultproperty_from_plot_item(item)
|
|
1378
|
+
|
|
1379
|
+
# pylint: disable=unused-argument
|
|
1380
|
+
def plot_item_moved(
|
|
1381
|
+
self, item: LabelItem, x0: float, y0: float, x1: float, y1: float
|
|
1382
|
+
) -> None:
|
|
1383
|
+
"""Plot item moved: update metadata of all objects from plot items
|
|
1384
|
+
|
|
1385
|
+
Args:
|
|
1386
|
+
item: Plot item
|
|
1387
|
+
x0: new x0 coordinate
|
|
1388
|
+
y0: new y0 coordinate
|
|
1389
|
+
x1: new x1 coordinate
|
|
1390
|
+
y1: new y1 coordinate
|
|
1391
|
+
"""
|
|
1392
|
+
self.plothandler.update_resultproperty_from_plot_item(item)
|
|
1393
|
+
|
|
1394
|
+
def serialize_object_to_hdf5(self, obj: TypeObj, writer: NativeH5Writer) -> None:
|
|
1395
|
+
"""Serialize object to HDF5 file"""
|
|
1396
|
+
# Before serializing, update metadata from plot item parameters, in order to
|
|
1397
|
+
# save the latest visualization settings:
|
|
1398
|
+
try:
|
|
1399
|
+
item = self.plothandler[get_uuid(obj)]
|
|
1400
|
+
create_adapter_from_object(obj).update_metadata_from_plot_item(item)
|
|
1401
|
+
except KeyError:
|
|
1402
|
+
# Plot item has not been created yet (this happens when auto-refresh has
|
|
1403
|
+
# been disabled)
|
|
1404
|
+
pass
|
|
1405
|
+
super().serialize_object_to_hdf5(obj, writer)
|
|
1406
|
+
|
|
1407
|
+
def serialize_to_hdf5(self, writer: NativeH5Writer) -> None:
|
|
1408
|
+
"""Serialize whole panel to a HDF5 file"""
|
|
1409
|
+
with writer.group(self.H5_PREFIX):
|
|
1410
|
+
for group in self.objmodel.get_groups():
|
|
1411
|
+
with writer.group(self.get_serializable_name(group)):
|
|
1412
|
+
with writer.group("title"):
|
|
1413
|
+
writer.write_str(group.title)
|
|
1414
|
+
for obj in group.get_objects():
|
|
1415
|
+
self.serialize_object_to_hdf5(obj, writer)
|
|
1416
|
+
|
|
1417
|
+
def deserialize_from_hdf5(
|
|
1418
|
+
self, reader: NativeH5Reader, reset_all: bool = False
|
|
1419
|
+
) -> None:
|
|
1420
|
+
"""Deserialize whole panel from a HDF5 file
|
|
1421
|
+
|
|
1422
|
+
Args:
|
|
1423
|
+
reader: HDF5 reader
|
|
1424
|
+
reset_all: If True, preserve original UUIDs (workspace reload).
|
|
1425
|
+
If False, regenerate UUIDs (importing objects).
|
|
1426
|
+
"""
|
|
1427
|
+
with reader.group(self.H5_PREFIX):
|
|
1428
|
+
for name in reader.h5.get(self.H5_PREFIX, []):
|
|
1429
|
+
with reader.group(name):
|
|
1430
|
+
group = self.add_group("")
|
|
1431
|
+
with reader.group("title"):
|
|
1432
|
+
group.title = reader.read_str()
|
|
1433
|
+
for obj_name in reader.h5.get(f"{self.H5_PREFIX}/{name}", []):
|
|
1434
|
+
obj = self.deserialize_object_from_hdf5(
|
|
1435
|
+
reader, obj_name, reset_all
|
|
1436
|
+
)
|
|
1437
|
+
self.add_object(obj, get_uuid(group), set_current=False)
|
|
1438
|
+
self.selection_changed()
|
|
1439
|
+
|
|
1440
|
+
def __len__(self) -> int:
|
|
1441
|
+
"""Return number of objects"""
|
|
1442
|
+
return len(self.objmodel)
|
|
1443
|
+
|
|
1444
|
+
def __getitem__(self, nb: int) -> TypeObj:
|
|
1445
|
+
"""Return object from its number (1 to N)"""
|
|
1446
|
+
return self.objmodel.get_object_from_number(nb)
|
|
1447
|
+
|
|
1448
|
+
def __iter__(self):
|
|
1449
|
+
"""Iterate over objects"""
|
|
1450
|
+
return iter(self.objmodel)
|
|
1451
|
+
|
|
1452
|
+
def create_object(self) -> TypeObj:
|
|
1453
|
+
"""Create object (signal or image)
|
|
1454
|
+
|
|
1455
|
+
Returns:
|
|
1456
|
+
SignalObj or ImageObj object
|
|
1457
|
+
"""
|
|
1458
|
+
return self.PARAMCLASS() # pylint: disable=not-callable
|
|
1459
|
+
|
|
1460
|
+
@qt_try_except()
|
|
1461
|
+
def add_object(
|
|
1462
|
+
self,
|
|
1463
|
+
obj: TypeObj,
|
|
1464
|
+
group_id: str | None = None,
|
|
1465
|
+
set_current: bool = True,
|
|
1466
|
+
) -> None:
|
|
1467
|
+
"""Add object
|
|
1468
|
+
|
|
1469
|
+
Args:
|
|
1470
|
+
obj: SignalObj or ImageObj object
|
|
1471
|
+
group_id: group id to which the object belongs. If None or empty string,
|
|
1472
|
+
the object is added to the current group.
|
|
1473
|
+
set_current: if True, set the added object as current
|
|
1474
|
+
"""
|
|
1475
|
+
if obj in self.objmodel:
|
|
1476
|
+
# Prevent adding the same object twice
|
|
1477
|
+
raise ValueError(
|
|
1478
|
+
f"Object {hex(id(obj))} already in panel. "
|
|
1479
|
+
f"The same object cannot be added twice: "
|
|
1480
|
+
f"please use a copy of the object."
|
|
1481
|
+
)
|
|
1482
|
+
if group_id is None or group_id == "":
|
|
1483
|
+
group_id = self.objview.get_current_group_id()
|
|
1484
|
+
if group_id is None:
|
|
1485
|
+
groups = self.objmodel.get_groups()
|
|
1486
|
+
if groups:
|
|
1487
|
+
group_id = get_uuid(groups[0])
|
|
1488
|
+
else:
|
|
1489
|
+
group_id = get_uuid(self.add_group(""))
|
|
1490
|
+
obj.check_data()
|
|
1491
|
+
self.objmodel.add_object(obj, group_id)
|
|
1492
|
+
|
|
1493
|
+
# Block signals to avoid updating the plot (unnecessary refresh)
|
|
1494
|
+
self.objview.blockSignals(True)
|
|
1495
|
+
self.objview.add_object_item(obj, group_id, set_current=set_current)
|
|
1496
|
+
self.objview.blockSignals(False)
|
|
1497
|
+
|
|
1498
|
+
# Emit signal to ensure that the data panel is shown in the main window and
|
|
1499
|
+
# that the plot is updated (trigger a refresh of the plot)
|
|
1500
|
+
self.SIG_OBJECT_ADDED.emit()
|
|
1501
|
+
|
|
1502
|
+
self.objview.update_tree()
|
|
1503
|
+
|
|
1504
|
+
def remove_all_objects(self) -> None:
|
|
1505
|
+
"""Remove all objects"""
|
|
1506
|
+
# iterate over a copy of self.__separate_views dict keys to avoid RuntimeError:
|
|
1507
|
+
# dictionary changed size during iteration
|
|
1508
|
+
for dlg in list(self.__separate_views):
|
|
1509
|
+
dlg.done(QW.QDialog.DialogCode.Rejected)
|
|
1510
|
+
self.objmodel.clear()
|
|
1511
|
+
self.plothandler.clear()
|
|
1512
|
+
self.objview.populate_tree()
|
|
1513
|
+
self.refresh_plot("selected", True, False)
|
|
1514
|
+
super().remove_all_objects()
|
|
1515
|
+
# Update object properties panel to clear creation/processing tabs
|
|
1516
|
+
self.selection_changed()
|
|
1517
|
+
|
|
1518
|
+
# ---- Signal/Image Panel API ------------------------------------------------------
|
|
1519
|
+
def setup_panel(self) -> None:
|
|
1520
|
+
"""Setup panel"""
|
|
1521
|
+
self.acthandler.create_all_actions()
|
|
1522
|
+
self.processor.SIG_ADD_SHAPE.connect(self.plothandler.add_shapes)
|
|
1523
|
+
self.SIG_REFRESH_PLOT.connect(self.plothandler.refresh_plot)
|
|
1524
|
+
self.objview.SIG_SELECTION_CHANGED.connect(self.selection_changed)
|
|
1525
|
+
self.objview.SIG_ITEM_DOUBLECLICKED.connect(
|
|
1526
|
+
lambda oid: self.open_separate_view([oid])
|
|
1527
|
+
)
|
|
1528
|
+
self.objview.SIG_CONTEXT_MENU.connect(self.__popup_contextmenu)
|
|
1529
|
+
self.objprop.properties.SIG_APPLY_BUTTON_CLICKED.connect(
|
|
1530
|
+
self.properties_changed
|
|
1531
|
+
)
|
|
1532
|
+
self.addWidget(self.objview)
|
|
1533
|
+
self.addWidget(self.objprop)
|
|
1534
|
+
|
|
1535
|
+
def refresh_plot(
|
|
1536
|
+
self,
|
|
1537
|
+
what: str,
|
|
1538
|
+
update_items: bool = True,
|
|
1539
|
+
force: bool = False,
|
|
1540
|
+
only_visible: bool = True,
|
|
1541
|
+
only_existing: bool = False,
|
|
1542
|
+
) -> None:
|
|
1543
|
+
"""Refresh plot.
|
|
1544
|
+
|
|
1545
|
+
Args:
|
|
1546
|
+
what: string describing the objects to refresh.
|
|
1547
|
+
Valid values are "selected" (refresh the selected objects),
|
|
1548
|
+
"all" (refresh all objects), "existing" (refresh existing plot items),
|
|
1549
|
+
or an object uuid.
|
|
1550
|
+
update_items: if True, update the items.
|
|
1551
|
+
If False, only show the items (do not update them).
|
|
1552
|
+
Defaults to True.
|
|
1553
|
+
force: if True, force refresh even if auto refresh is disabled.
|
|
1554
|
+
Defaults to False.
|
|
1555
|
+
only_visible: if True, only refresh visible items. Defaults to True.
|
|
1556
|
+
Visible items are the ones that are not hidden by other items or the items
|
|
1557
|
+
except the first one if the option "Show first only" is enabled.
|
|
1558
|
+
This is useful for images, where the last image is the one that is shown.
|
|
1559
|
+
If False, all items are refreshed.
|
|
1560
|
+
only_existing: if True, only refresh existing items. Defaults to False.
|
|
1561
|
+
Existing items are the ones that have already been created and are
|
|
1562
|
+
associated to the object uuid. If False, create new items for the
|
|
1563
|
+
objects that do not have an item yet.
|
|
1564
|
+
|
|
1565
|
+
Raises:
|
|
1566
|
+
ValueError: if `what` is not a valid value
|
|
1567
|
+
"""
|
|
1568
|
+
if what not in ("selected", "all", "existing") and not isinstance(what, str):
|
|
1569
|
+
raise ValueError(f"Invalid value for 'what': {what}")
|
|
1570
|
+
self.SIG_REFRESH_PLOT.emit(
|
|
1571
|
+
what, update_items, force, only_visible, only_existing
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
def manual_refresh(self) -> None:
|
|
1575
|
+
"""Manual refresh"""
|
|
1576
|
+
self.refresh_plot("selected", True, True)
|
|
1577
|
+
|
|
1578
|
+
def get_category_actions(
|
|
1579
|
+
self, category: actionhandler.ActionCategory
|
|
1580
|
+
) -> list[QW.QAction]: # pragma: no cover
|
|
1581
|
+
"""Return actions for category"""
|
|
1582
|
+
return self.acthandler.feature_actions.get(category, [])
|
|
1583
|
+
|
|
1584
|
+
def get_context_menu(self) -> QW.QMenu:
|
|
1585
|
+
"""Update and return context menu"""
|
|
1586
|
+
# Note: For now, this is completely unnecessary to clear context menu everytime,
|
|
1587
|
+
# but implementing it this way could be useful in the future in menu contents
|
|
1588
|
+
# should take into account current object selection
|
|
1589
|
+
self.context_menu.clear()
|
|
1590
|
+
actions = self.get_category_actions(actionhandler.ActionCategory.CONTEXT_MENU)
|
|
1591
|
+
add_actions(self.context_menu, actions)
|
|
1592
|
+
return self.context_menu
|
|
1593
|
+
|
|
1594
|
+
def __popup_contextmenu(self, position: QC.QPoint) -> None: # pragma: no cover
|
|
1595
|
+
"""Popup context menu at position"""
|
|
1596
|
+
menu = self.get_context_menu()
|
|
1597
|
+
menu.popup(position)
|
|
1598
|
+
|
|
1599
|
+
# ------Creating, adding, removing objects------------------------------------------
|
|
1600
|
+
def add_group(self, title: str, select: bool = False) -> objectmodel.ObjectGroup:
|
|
1601
|
+
"""Add group
|
|
1602
|
+
|
|
1603
|
+
Args:
|
|
1604
|
+
title: group title
|
|
1605
|
+
select: if True, select the group in the tree view. Defaults to False.
|
|
1606
|
+
|
|
1607
|
+
Returns:
|
|
1608
|
+
Created group object
|
|
1609
|
+
"""
|
|
1610
|
+
group = self.objmodel.add_group(title)
|
|
1611
|
+
self.objview.add_group_item(group)
|
|
1612
|
+
if select:
|
|
1613
|
+
self.objview.select_groups([group])
|
|
1614
|
+
return group
|
|
1615
|
+
|
|
1616
|
+
def __duplicate_individual_obj(
|
|
1617
|
+
self, oid: str, new_group_id: str | None = None, set_current: bool = True
|
|
1618
|
+
) -> None:
|
|
1619
|
+
"""Duplicate individual object"""
|
|
1620
|
+
obj = self.objmodel[oid]
|
|
1621
|
+
if new_group_id is None:
|
|
1622
|
+
new_group_id = self.objmodel.get_object_group_id(obj)
|
|
1623
|
+
self.add_object(obj.copy(), group_id=new_group_id, set_current=set_current)
|
|
1624
|
+
|
|
1625
|
+
def duplicate_object(self) -> None:
|
|
1626
|
+
"""Duplication signal/image object"""
|
|
1627
|
+
if not self.mainwindow.confirm_memory_state():
|
|
1628
|
+
return
|
|
1629
|
+
# Duplicate individual objects (exclusive with respect to groups)
|
|
1630
|
+
for oid in self.objview.get_sel_object_uuids():
|
|
1631
|
+
self.__duplicate_individual_obj(oid, set_current=False)
|
|
1632
|
+
# Duplicate groups (exclusive with respect to individual objects)
|
|
1633
|
+
for group in self.objview.get_sel_groups():
|
|
1634
|
+
new_group = self.add_group(group.title)
|
|
1635
|
+
for oid in self.objmodel.get_group_object_ids(get_uuid(group)):
|
|
1636
|
+
self.__duplicate_individual_obj(
|
|
1637
|
+
oid, get_uuid(new_group), set_current=False
|
|
1638
|
+
)
|
|
1639
|
+
self.selection_changed(update_items=True)
|
|
1640
|
+
|
|
1641
|
+
def copy_metadata(self) -> None:
|
|
1642
|
+
"""Copy object metadata"""
|
|
1643
|
+
obj = self.objview.get_sel_objects()[0]
|
|
1644
|
+
self.metadata_clipboard = obj.metadata.copy()
|
|
1645
|
+
|
|
1646
|
+
# Rename geometry results to avoid conflicts when pasting to same object type
|
|
1647
|
+
new_pref = get_short_id(obj) + "_"
|
|
1648
|
+
self._rename_results_in_clipboard(new_pref)
|
|
1649
|
+
|
|
1650
|
+
# Update action states (e.g., "Paste metadata" should now be enabled)
|
|
1651
|
+
self.selection_changed()
|
|
1652
|
+
|
|
1653
|
+
def _rename_results_in_clipboard(self, prefix: str) -> None:
|
|
1654
|
+
"""Rename geometry and table results in clipboard to avoid conflicts.
|
|
1655
|
+
|
|
1656
|
+
Args:
|
|
1657
|
+
prefix: Prefix to add to result titles
|
|
1658
|
+
"""
|
|
1659
|
+
for aclass in (GeometryAdapter, TableAdapter):
|
|
1660
|
+
result_keys = [
|
|
1661
|
+
k for k, v in self.metadata_clipboard.items() if aclass.match(k, v)
|
|
1662
|
+
]
|
|
1663
|
+
for dict_key in result_keys:
|
|
1664
|
+
try:
|
|
1665
|
+
# Get the result data
|
|
1666
|
+
result_data = self.metadata_clipboard[dict_key]
|
|
1667
|
+
|
|
1668
|
+
# Update the title in the result data
|
|
1669
|
+
if isinstance(result_data, dict) and "title" in result_data:
|
|
1670
|
+
result_data = result_data.copy() # Don't modify original
|
|
1671
|
+
result_data["title"] = prefix + result_data["title"]
|
|
1672
|
+
|
|
1673
|
+
# Create new key with updated title
|
|
1674
|
+
new_dict_key = dict_key.replace(
|
|
1675
|
+
aclass.META_PREFIX, aclass.META_PREFIX + prefix, 1
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
# Remove old entry and add new one
|
|
1679
|
+
del self.metadata_clipboard[dict_key]
|
|
1680
|
+
self.metadata_clipboard[new_dict_key] = result_data
|
|
1681
|
+
|
|
1682
|
+
except (KeyError, ValueError, IndexError, TypeError):
|
|
1683
|
+
# If we can't process this result, leave it as is
|
|
1684
|
+
continue
|
|
1685
|
+
|
|
1686
|
+
def paste_metadata(self, param: PasteMetadataParam | None = None) -> None:
|
|
1687
|
+
"""Paste metadata to selected object(s)"""
|
|
1688
|
+
if param is None:
|
|
1689
|
+
param = PasteMetadataParam(
|
|
1690
|
+
_("Paste metadata"),
|
|
1691
|
+
comment=_(
|
|
1692
|
+
"Select what to keep from the clipboard.<br><br>"
|
|
1693
|
+
"Result shapes and annotations, if kept, will be merged with "
|
|
1694
|
+
"existing ones. <u>All other metadata will be replaced</u>."
|
|
1695
|
+
),
|
|
1696
|
+
)
|
|
1697
|
+
if not param.edit(parent=self.parentWidget()):
|
|
1698
|
+
return
|
|
1699
|
+
metadata = {}
|
|
1700
|
+
if param.keep_roi and ROI_KEY in self.metadata_clipboard:
|
|
1701
|
+
metadata[ROI_KEY] = self.metadata_clipboard[ROI_KEY]
|
|
1702
|
+
if param.keep_geometry:
|
|
1703
|
+
for key, value in self.metadata_clipboard.items():
|
|
1704
|
+
if GeometryAdapter.match(key, value):
|
|
1705
|
+
metadata[key] = value
|
|
1706
|
+
if param.keep_tables:
|
|
1707
|
+
for key, value in self.metadata_clipboard.items():
|
|
1708
|
+
if TableAdapter.match(key, value):
|
|
1709
|
+
metadata[key] = value
|
|
1710
|
+
if param.keep_other:
|
|
1711
|
+
for key, value in self.metadata_clipboard.items():
|
|
1712
|
+
if (
|
|
1713
|
+
not GeometryAdapter.match(key, value)
|
|
1714
|
+
and not TableAdapter.match(key, value)
|
|
1715
|
+
and key not in METADATA_PASTE_EXCLUSIONS
|
|
1716
|
+
):
|
|
1717
|
+
metadata[key] = value
|
|
1718
|
+
sel_objects = self.objview.get_sel_objects(include_groups=True)
|
|
1719
|
+
for obj in sorted(sel_objects, key=get_short_id, reverse=True):
|
|
1720
|
+
obj.update_metadata_from(metadata)
|
|
1721
|
+
# We have to do a special refresh in order to force the plot handler to update
|
|
1722
|
+
# all plot items, even the ones that are not visible (otherwise, image masks
|
|
1723
|
+
# would not be updated after pasting the metadata: see issue #123)
|
|
1724
|
+
self.refresh_plot(
|
|
1725
|
+
"selected", update_items=True, only_visible=False, only_existing=True
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
def add_metadata(self, param: AddMetadataParam | None = None) -> None:
|
|
1729
|
+
"""Add metadata item to selected object(s)
|
|
1730
|
+
|
|
1731
|
+
Args:
|
|
1732
|
+
param: Add metadata parameters
|
|
1733
|
+
"""
|
|
1734
|
+
sel_objects = self.objview.get_sel_objects(include_groups=True)
|
|
1735
|
+
if not sel_objects:
|
|
1736
|
+
return
|
|
1737
|
+
|
|
1738
|
+
if param is None:
|
|
1739
|
+
param = AddMetadataParam(sel_objects)
|
|
1740
|
+
# Restore settings from config
|
|
1741
|
+
saved_param = Conf.io.add_metadata_settings.get(default=AddMetadataParam())
|
|
1742
|
+
update_dataset(param, saved_param)
|
|
1743
|
+
with warnings.catch_warnings():
|
|
1744
|
+
warnings.simplefilter("ignore", category=gds.DataItemValidationWarning)
|
|
1745
|
+
if not param.edit(parent=self.parentWidget(), wordwrap=False):
|
|
1746
|
+
return
|
|
1747
|
+
|
|
1748
|
+
# Save settings to config
|
|
1749
|
+
Conf.io.add_metadata_settings.set(param)
|
|
1750
|
+
|
|
1751
|
+
# Build values for all selected objects
|
|
1752
|
+
values = param.build_values(sel_objects)
|
|
1753
|
+
|
|
1754
|
+
# Add metadata to each object
|
|
1755
|
+
for obj, value in zip(sel_objects, values):
|
|
1756
|
+
obj.metadata[param.metadata_key] = value
|
|
1757
|
+
|
|
1758
|
+
# Refresh the plot to update any changes
|
|
1759
|
+
self.refresh_plot(
|
|
1760
|
+
"selected", update_items=True, only_visible=False, only_existing=True
|
|
1761
|
+
)
|
|
1762
|
+
|
|
1763
|
+
def copy_roi(self) -> None:
|
|
1764
|
+
"""Copy regions of interest"""
|
|
1765
|
+
obj = self.objview.get_sel_objects()[0]
|
|
1766
|
+
self.__roi_clipboard = obj.roi.copy()
|
|
1767
|
+
|
|
1768
|
+
def paste_roi(self) -> None:
|
|
1769
|
+
"""Paste regions of interest"""
|
|
1770
|
+
sel_objects = self.objview.get_sel_objects(include_groups=True)
|
|
1771
|
+
for obj in sel_objects:
|
|
1772
|
+
if obj.roi is None:
|
|
1773
|
+
obj.roi = self.__roi_clipboard.copy()
|
|
1774
|
+
else:
|
|
1775
|
+
obj.roi = obj.roi.combine_with(self.__roi_clipboard)
|
|
1776
|
+
self.selection_changed(update_items=True)
|
|
1777
|
+
self.refresh_plot(
|
|
1778
|
+
"selected", update_items=True, only_visible=False, only_existing=True
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
def remove_object(self, force: bool = False) -> None:
|
|
1782
|
+
"""Remove signal/image object
|
|
1783
|
+
|
|
1784
|
+
Args:
|
|
1785
|
+
force: if True, remove object without confirmation. Defaults to False.
|
|
1786
|
+
"""
|
|
1787
|
+
sel_groups = self.objview.get_sel_groups()
|
|
1788
|
+
if sel_groups and not force and not execenv.unattended:
|
|
1789
|
+
answer = QW.QMessageBox.warning(
|
|
1790
|
+
self,
|
|
1791
|
+
_("Delete group(s)"),
|
|
1792
|
+
_("Are you sure you want to delete the selected group(s)?"),
|
|
1793
|
+
QW.QMessageBox.Yes | QW.QMessageBox.No,
|
|
1794
|
+
)
|
|
1795
|
+
if answer == QW.QMessageBox.No:
|
|
1796
|
+
return
|
|
1797
|
+
sel_objects = self.objview.get_sel_objects(include_groups=True)
|
|
1798
|
+
for obj in sorted(sel_objects, key=get_short_id, reverse=True):
|
|
1799
|
+
dlg_list: list[QW.QDialog] = []
|
|
1800
|
+
for dlg, obj_i in self.__separate_views.items():
|
|
1801
|
+
if obj_i is obj:
|
|
1802
|
+
dlg_list.append(dlg)
|
|
1803
|
+
for dlg in dlg_list:
|
|
1804
|
+
dlg.done(QW.QDialog.DialogCode.Rejected)
|
|
1805
|
+
self.plothandler.remove_item(get_uuid(obj))
|
|
1806
|
+
self.objview.remove_item(get_uuid(obj), refresh=False)
|
|
1807
|
+
self.objmodel.remove_object(obj)
|
|
1808
|
+
for group in sel_groups:
|
|
1809
|
+
self.objview.remove_item(get_uuid(group), refresh=False)
|
|
1810
|
+
self.objmodel.remove_group(group)
|
|
1811
|
+
self.objview.update_tree()
|
|
1812
|
+
self.selection_changed(update_items=True)
|
|
1813
|
+
self.SIG_OBJECT_REMOVED.emit()
|
|
1814
|
+
|
|
1815
|
+
def delete_all_objects(self) -> None: # pragma: no cover
|
|
1816
|
+
"""Confirm before removing all objects"""
|
|
1817
|
+
if len(self) == 0:
|
|
1818
|
+
return
|
|
1819
|
+
if execenv.unattended:
|
|
1820
|
+
raise RuntimeError(
|
|
1821
|
+
"This method should not be executed in unattended mode: "
|
|
1822
|
+
"call remove_all_objects instead."
|
|
1823
|
+
)
|
|
1824
|
+
answer = QW.QMessageBox.warning(
|
|
1825
|
+
self,
|
|
1826
|
+
_("Delete all"),
|
|
1827
|
+
_("Do you want to delete all objects (%s)?") % self.PANEL_STR,
|
|
1828
|
+
QW.QMessageBox.Yes | QW.QMessageBox.No,
|
|
1829
|
+
)
|
|
1830
|
+
if answer == QW.QMessageBox.Yes:
|
|
1831
|
+
self.remove_all_objects()
|
|
1832
|
+
|
|
1833
|
+
def delete_metadata(
|
|
1834
|
+
self, refresh_plot: bool = True, keep_roi: bool | None = None
|
|
1835
|
+
) -> None:
|
|
1836
|
+
"""Delete metadata of selected objects
|
|
1837
|
+
|
|
1838
|
+
Args:
|
|
1839
|
+
refresh_plot: Refresh plot. Defaults to True.
|
|
1840
|
+
keep_roi: Keep regions of interest, if any. Defaults to None (ask user).
|
|
1841
|
+
"""
|
|
1842
|
+
sel_objs = self.objview.get_sel_objects(include_groups=True)
|
|
1843
|
+
# Check if there are regions of interest first:
|
|
1844
|
+
roi_backup: dict[TypeObj, np.ndarray] = {}
|
|
1845
|
+
if any(obj.roi is not None for obj in sel_objs):
|
|
1846
|
+
if execenv.unattended and keep_roi is None:
|
|
1847
|
+
keep_roi = False
|
|
1848
|
+
elif keep_roi is None:
|
|
1849
|
+
answer = QW.QMessageBox.warning(
|
|
1850
|
+
self,
|
|
1851
|
+
_("Delete metadata"),
|
|
1852
|
+
_(
|
|
1853
|
+
"Some selected objects have regions of interest.<br>"
|
|
1854
|
+
"Do you want to delete them as well?"
|
|
1855
|
+
),
|
|
1856
|
+
QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Cancel,
|
|
1857
|
+
)
|
|
1858
|
+
if answer == QW.QMessageBox.Cancel:
|
|
1859
|
+
return
|
|
1860
|
+
keep_roi = answer == QW.QMessageBox.No
|
|
1861
|
+
if keep_roi:
|
|
1862
|
+
for obj in sel_objs:
|
|
1863
|
+
if obj.roi is not None:
|
|
1864
|
+
roi_backup[obj] = obj.roi
|
|
1865
|
+
|
|
1866
|
+
# Delete metadata:
|
|
1867
|
+
for index, obj in enumerate(sel_objs):
|
|
1868
|
+
obj.reset_metadata_to_defaults()
|
|
1869
|
+
if not keep_roi:
|
|
1870
|
+
obj.mark_roi_as_changed()
|
|
1871
|
+
if obj in roi_backup:
|
|
1872
|
+
obj.roi = roi_backup[obj]
|
|
1873
|
+
if index == 0:
|
|
1874
|
+
self.selection_changed()
|
|
1875
|
+
|
|
1876
|
+
# When calling object `reset_metadata_to_defaults` method, we removed all
|
|
1877
|
+
# metadata application options, among them the object number which is used
|
|
1878
|
+
# to compute the short ID of the object.
|
|
1879
|
+
# So we have to reset the short IDs of all objects in the model to recalculate
|
|
1880
|
+
# the object numbers:
|
|
1881
|
+
self.objmodel.reset_short_ids()
|
|
1882
|
+
|
|
1883
|
+
if refresh_plot:
|
|
1884
|
+
# We have to do a special refresh in order to force the plot handler to
|
|
1885
|
+
# update all plot items, even the ones that are not visible (otherwise,
|
|
1886
|
+
# image masks would remained visible after deleting the ROI for example:
|
|
1887
|
+
# see issue #122)
|
|
1888
|
+
self.refresh_plot(
|
|
1889
|
+
"selected", update_items=True, only_visible=False, only_existing=True
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
def add_annotations_from_items(
|
|
1893
|
+
self, items: list, refresh_plot: bool = True
|
|
1894
|
+
) -> None:
|
|
1895
|
+
"""Add object annotations (annotation plot items).
|
|
1896
|
+
|
|
1897
|
+
Args:
|
|
1898
|
+
items: annotation plot items
|
|
1899
|
+
refresh_plot: refresh plot. Defaults to True.
|
|
1900
|
+
"""
|
|
1901
|
+
for obj in self.objview.get_sel_objects(include_groups=True):
|
|
1902
|
+
create_adapter_from_object(obj).add_annotations_from_items(items)
|
|
1903
|
+
if refresh_plot:
|
|
1904
|
+
self.refresh_plot("selected", True, False)
|
|
1905
|
+
|
|
1906
|
+
def update_metadata_view_settings(self) -> None:
|
|
1907
|
+
"""Update metadata view settings"""
|
|
1908
|
+
def_dict = Conf.view.get_def_dict(self.__class__.__name__[:3].lower())
|
|
1909
|
+
for obj in self.objmodel:
|
|
1910
|
+
obj.set_metadata_options_defaults(def_dict, overwrite=True)
|
|
1911
|
+
self.refresh_plot("all", True, False)
|
|
1912
|
+
|
|
1913
|
+
def copy_titles_to_clipboard(self) -> None:
|
|
1914
|
+
"""Copy object titles to clipboard (for reproducibility)"""
|
|
1915
|
+
QW.QApplication.clipboard().setText(str(self.objview))
|
|
1916
|
+
|
|
1917
|
+
def new_group(self) -> None:
|
|
1918
|
+
"""Create a new group"""
|
|
1919
|
+
# Open a message box to enter the group name
|
|
1920
|
+
group_name, ok = QW.QInputDialog.getText(self, _("New group"), _("Group name:"))
|
|
1921
|
+
if ok:
|
|
1922
|
+
self.add_group(group_name)
|
|
1923
|
+
|
|
1924
|
+
def rename_selected_object_or_group(self, new_name: str | None = None) -> None:
|
|
1925
|
+
"""Rename selected object or group
|
|
1926
|
+
|
|
1927
|
+
Args:
|
|
1928
|
+
new_name: new name (default: None, i.e. ask user)
|
|
1929
|
+
"""
|
|
1930
|
+
sel_objects = self.objview.get_sel_objects(include_groups=False)
|
|
1931
|
+
sel_groups = self.objview.get_sel_groups()
|
|
1932
|
+
if (not sel_objects and not sel_groups) or len(sel_objects) + len(
|
|
1933
|
+
sel_groups
|
|
1934
|
+
) > 1:
|
|
1935
|
+
# Won't happen in the application, but could happen in tests or using the
|
|
1936
|
+
# API directly
|
|
1937
|
+
raise ValueError("Select one object or group to rename")
|
|
1938
|
+
if sel_objects:
|
|
1939
|
+
obj = sel_objects[0]
|
|
1940
|
+
if new_name is None:
|
|
1941
|
+
new_name, ok = QW.QInputDialog.getText(
|
|
1942
|
+
self,
|
|
1943
|
+
_("Rename object"),
|
|
1944
|
+
_("Object name:"),
|
|
1945
|
+
QW.QLineEdit.Normal,
|
|
1946
|
+
obj.title,
|
|
1947
|
+
)
|
|
1948
|
+
if not ok:
|
|
1949
|
+
return
|
|
1950
|
+
obj.title = new_name
|
|
1951
|
+
self.objview.update_item(get_uuid(obj))
|
|
1952
|
+
self.objprop.update_properties_from(obj)
|
|
1953
|
+
elif sel_groups:
|
|
1954
|
+
group = sel_groups[0]
|
|
1955
|
+
if new_name is None:
|
|
1956
|
+
new_name, ok = QW.QInputDialog.getText(
|
|
1957
|
+
self,
|
|
1958
|
+
_("Rename group"),
|
|
1959
|
+
_("Group name:"),
|
|
1960
|
+
QW.QLineEdit.Normal,
|
|
1961
|
+
group.title,
|
|
1962
|
+
)
|
|
1963
|
+
if not ok:
|
|
1964
|
+
return
|
|
1965
|
+
group.title = new_name
|
|
1966
|
+
self.objview.update_item(get_uuid(group))
|
|
1967
|
+
|
|
1968
|
+
@abc.abstractmethod
|
|
1969
|
+
def get_newparam_from_current(
|
|
1970
|
+
self, newparam: NewSignalParam | NewImageParam | None = None
|
|
1971
|
+
) -> NewSignalParam | NewImageParam | None:
|
|
1972
|
+
"""Get new object parameters from the current object.
|
|
1973
|
+
|
|
1974
|
+
Args:
|
|
1975
|
+
newparam: new object parameters. If None, create a new one.
|
|
1976
|
+
|
|
1977
|
+
Returns:
|
|
1978
|
+
New object parameters
|
|
1979
|
+
"""
|
|
1980
|
+
|
|
1981
|
+
@abc.abstractmethod
|
|
1982
|
+
def new_object(
|
|
1983
|
+
self,
|
|
1984
|
+
param: NewSignalParam | NewImageParam | None = None,
|
|
1985
|
+
edit: bool = False,
|
|
1986
|
+
add_to_panel: bool = True,
|
|
1987
|
+
) -> TypeObj | None:
|
|
1988
|
+
"""Create a new object (signal/image).
|
|
1989
|
+
|
|
1990
|
+
Args:
|
|
1991
|
+
param: new object parameters
|
|
1992
|
+
edit: Open a dialog box to edit parameters (default: False).
|
|
1993
|
+
When False, the object is created with default parameters and creation
|
|
1994
|
+
parameters are stored in metadata for interactive editing.
|
|
1995
|
+
add_to_panel: Add object to panel (default: True)
|
|
1996
|
+
|
|
1997
|
+
Returns:
|
|
1998
|
+
New object
|
|
1999
|
+
"""
|
|
2000
|
+
|
|
2001
|
+
def set_current_object_title(self, title: str) -> None:
|
|
2002
|
+
"""Set current object title"""
|
|
2003
|
+
obj = self.objview.get_current_object()
|
|
2004
|
+
obj.title = title
|
|
2005
|
+
self.objview.update_item(get_uuid(obj))
|
|
2006
|
+
|
|
2007
|
+
def __load_from_file(
|
|
2008
|
+
self, filename: str, create_group: bool = True, add_objects: bool = True
|
|
2009
|
+
) -> list[SignalObj] | list[ImageObj]:
|
|
2010
|
+
"""Open objects from file (signal/image), add them to DataLab and return them.
|
|
2011
|
+
|
|
2012
|
+
Args:
|
|
2013
|
+
filename: file name
|
|
2014
|
+
create_group: if True, create a new group if more than one object is loaded.
|
|
2015
|
+
Defaults to True. If False, all objects are added to the current group.
|
|
2016
|
+
add_objects: if True, add objects to the panel. Defaults to True.
|
|
2017
|
+
|
|
2018
|
+
Returns:
|
|
2019
|
+
New object or list of new objects
|
|
2020
|
+
"""
|
|
2021
|
+
worker = CallbackWorker(lambda worker: self.IO_REGISTRY.read(filename, worker))
|
|
2022
|
+
objs = qt_long_callback(self, _("Reading objects from file"), worker, True)
|
|
2023
|
+
group_id = None
|
|
2024
|
+
if len(objs) > 1 and create_group:
|
|
2025
|
+
# Create a new group if more than one object is loaded
|
|
2026
|
+
group_id = get_uuid(self.add_group(osp.basename(filename)))
|
|
2027
|
+
with create_progress_bar(
|
|
2028
|
+
self, _("Adding objects to workspace"), max_=len(objs) - 1
|
|
2029
|
+
) as progress:
|
|
2030
|
+
for i_obj, obj in enumerate(objs):
|
|
2031
|
+
progress.setValue(i_obj + 1)
|
|
2032
|
+
if progress.wasCanceled():
|
|
2033
|
+
break
|
|
2034
|
+
if add_objects:
|
|
2035
|
+
set_uuid(obj) # In case the object UUID was serialized in the file,
|
|
2036
|
+
# we need to reset it to a new UUID to avoid conflicts
|
|
2037
|
+
# (e.g. HDF5 file)
|
|
2038
|
+
self.add_object(obj, group_id=group_id, set_current=obj is objs[-1])
|
|
2039
|
+
self.selection_changed()
|
|
2040
|
+
return objs
|
|
2041
|
+
|
|
2042
|
+
def __save_to_file(self, obj: TypeObj, filename: str) -> None:
|
|
2043
|
+
"""Save object to file (signal/image).
|
|
2044
|
+
|
|
2045
|
+
Args:
|
|
2046
|
+
obj: object
|
|
2047
|
+
filename: file name
|
|
2048
|
+
"""
|
|
2049
|
+
self.IO_REGISTRY.write(filename, obj)
|
|
2050
|
+
|
|
2051
|
+
def load_from_directory(self, directory: str | None = None) -> list[TypeObj]:
|
|
2052
|
+
"""Open objects from directory (signals or images, depending on the panel),
|
|
2053
|
+
add them to DataLab and return them.
|
|
2054
|
+
If the directory is not specified, ask the user to select a directory.
|
|
2055
|
+
|
|
2056
|
+
Args:
|
|
2057
|
+
directory: directory name
|
|
2058
|
+
|
|
2059
|
+
Returns:
|
|
2060
|
+
list of new objects
|
|
2061
|
+
"""
|
|
2062
|
+
if not self.mainwindow.confirm_memory_state():
|
|
2063
|
+
return []
|
|
2064
|
+
if directory is None: # pragma: no cover
|
|
2065
|
+
basedir = Conf.main.base_dir.get()
|
|
2066
|
+
with save_restore_stds():
|
|
2067
|
+
directory = getexistingdirectory(self, _("Open"), basedir)
|
|
2068
|
+
if not directory:
|
|
2069
|
+
return []
|
|
2070
|
+
folders = [
|
|
2071
|
+
path
|
|
2072
|
+
for path in glob.glob(osp.join(directory, "**"), recursive=True)
|
|
2073
|
+
if osp.isdir(path) and len(os.listdir(path)) > 0
|
|
2074
|
+
]
|
|
2075
|
+
objs = []
|
|
2076
|
+
with create_progress_bar(
|
|
2077
|
+
self, _("Scanning directory"), max_=len(folders) - 1
|
|
2078
|
+
) as progress:
|
|
2079
|
+
# Iterate over all subfolders in the directory:
|
|
2080
|
+
for i_path, path in enumerate(folders):
|
|
2081
|
+
progress.setValue(i_path + 1)
|
|
2082
|
+
if progress.wasCanceled():
|
|
2083
|
+
break
|
|
2084
|
+
path = osp.normpath(path)
|
|
2085
|
+
fnames = sorted(
|
|
2086
|
+
[
|
|
2087
|
+
osp.join(path, fname)
|
|
2088
|
+
for fname in os.listdir(path)
|
|
2089
|
+
if osp.isfile(osp.join(path, fname))
|
|
2090
|
+
]
|
|
2091
|
+
)
|
|
2092
|
+
new_objs = self.load_from_files(
|
|
2093
|
+
fnames,
|
|
2094
|
+
create_group=False,
|
|
2095
|
+
add_objects=False,
|
|
2096
|
+
ignore_errors=True,
|
|
2097
|
+
)
|
|
2098
|
+
if new_objs:
|
|
2099
|
+
objs += new_objs
|
|
2100
|
+
grp_name = osp.relpath(path, directory)
|
|
2101
|
+
if grp_name == ".":
|
|
2102
|
+
grp_name = osp.basename(path)
|
|
2103
|
+
grp = self.add_group(grp_name)
|
|
2104
|
+
for obj in new_objs:
|
|
2105
|
+
self.add_object(obj, group_id=get_uuid(grp), set_current=False)
|
|
2106
|
+
return objs
|
|
2107
|
+
|
|
2108
|
+
def load_from_files(
|
|
2109
|
+
self,
|
|
2110
|
+
filenames: list[str] | None = None,
|
|
2111
|
+
create_group: bool = False,
|
|
2112
|
+
add_objects: bool = True,
|
|
2113
|
+
ignore_errors: bool = False,
|
|
2114
|
+
) -> list[TypeObj]:
|
|
2115
|
+
"""Open objects from file (signals/images), add them to DataLab and return them.
|
|
2116
|
+
|
|
2117
|
+
Args:
|
|
2118
|
+
filenames: File names
|
|
2119
|
+
create_group: if True, create a new group if more than one object is loaded
|
|
2120
|
+
for a single file. Defaults to False: all objects are added to the current
|
|
2121
|
+
group.
|
|
2122
|
+
add_objects: if True, add objects to the panel. Defaults to True.
|
|
2123
|
+
ignore_errors: if True, ignore errors when loading files. Defaults to False.
|
|
2124
|
+
|
|
2125
|
+
Returns:
|
|
2126
|
+
list of new objects
|
|
2127
|
+
"""
|
|
2128
|
+
if not self.mainwindow.confirm_memory_state():
|
|
2129
|
+
return []
|
|
2130
|
+
if filenames is None: # pragma: no cover
|
|
2131
|
+
basedir = Conf.main.base_dir.get()
|
|
2132
|
+
filters = self.IO_REGISTRY.get_read_filters()
|
|
2133
|
+
with save_restore_stds():
|
|
2134
|
+
filenames, _filt = getopenfilenames(self, _("Open"), basedir, filters)
|
|
2135
|
+
# Sort filenames to ensure consistent alphabetical order across all platforms
|
|
2136
|
+
filenames = sorted(filenames)
|
|
2137
|
+
objs = []
|
|
2138
|
+
for filename in filenames:
|
|
2139
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
|
|
2140
|
+
Conf.main.base_dir.set(filename)
|
|
2141
|
+
try:
|
|
2142
|
+
objs += self.__load_from_file(
|
|
2143
|
+
filename, create_group=create_group, add_objects=add_objects
|
|
2144
|
+
)
|
|
2145
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
2146
|
+
if ignore_errors:
|
|
2147
|
+
# Ignore unknown file types
|
|
2148
|
+
pass
|
|
2149
|
+
else:
|
|
2150
|
+
raise exc
|
|
2151
|
+
return objs
|
|
2152
|
+
|
|
2153
|
+
def save_to_files(self, filenames: list[str] | str | None = None) -> None:
|
|
2154
|
+
"""Save selected objects to files (signal/image).
|
|
2155
|
+
|
|
2156
|
+
Args:
|
|
2157
|
+
filenames: File names
|
|
2158
|
+
"""
|
|
2159
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
2160
|
+
if filenames is None: # pragma: no cover
|
|
2161
|
+
filenames = [None] * len(objs)
|
|
2162
|
+
assert len(filenames) == len(objs), (
|
|
2163
|
+
"Number of filenames must match number of objects"
|
|
2164
|
+
)
|
|
2165
|
+
for index, obj in enumerate(objs):
|
|
2166
|
+
filename = filenames[index]
|
|
2167
|
+
if filename is None:
|
|
2168
|
+
basedir = Conf.main.base_dir.get()
|
|
2169
|
+
filters = self.IO_REGISTRY.get_write_filters()
|
|
2170
|
+
with save_restore_stds():
|
|
2171
|
+
filename, _filt = getsavefilename(
|
|
2172
|
+
self, _("Save as"), basedir, filters
|
|
2173
|
+
)
|
|
2174
|
+
if filename:
|
|
2175
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "save"):
|
|
2176
|
+
Conf.main.base_dir.set(filename)
|
|
2177
|
+
self.__save_to_file(obj, filename)
|
|
2178
|
+
|
|
2179
|
+
def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None:
|
|
2180
|
+
"""Save signals or images to directory using a filename pattern.
|
|
2181
|
+
|
|
2182
|
+
Opens a dialog to select the output directory, the basename pattern and the
|
|
2183
|
+
extension.
|
|
2184
|
+
|
|
2185
|
+
Args:
|
|
2186
|
+
param: parameters.
|
|
2187
|
+
"""
|
|
2188
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
2189
|
+
|
|
2190
|
+
if param is None:
|
|
2191
|
+
extensions = get_file_extensions(self.IO_REGISTRY.get_write_filters())
|
|
2192
|
+
with warnings.catch_warnings():
|
|
2193
|
+
warnings.simplefilter("ignore", category=gds.DataItemValidationWarning)
|
|
2194
|
+
guiparam = SaveToDirectoryGUIParam(objs, extensions)
|
|
2195
|
+
# Restore settings from config
|
|
2196
|
+
saved_param = Conf.io.save_to_directory_settings.get(
|
|
2197
|
+
default=SaveToDirectoryParam()
|
|
2198
|
+
)
|
|
2199
|
+
update_dataset(guiparam, saved_param)
|
|
2200
|
+
# Validate extension: set to first if None or not in available list
|
|
2201
|
+
# Note: extensions list has no dots, but guiparam.extension has dot
|
|
2202
|
+
extensions_with_dot = ["." + ext for ext in extensions]
|
|
2203
|
+
if (
|
|
2204
|
+
guiparam.extension is None
|
|
2205
|
+
or guiparam.extension not in extensions_with_dot
|
|
2206
|
+
):
|
|
2207
|
+
guiparam.extension = extensions_with_dot[0] if extensions else ""
|
|
2208
|
+
if not guiparam.edit(parent=self.parentWidget()):
|
|
2209
|
+
return
|
|
2210
|
+
param = SaveToDirectoryParam()
|
|
2211
|
+
update_dataset(param, guiparam)
|
|
2212
|
+
|
|
2213
|
+
# Save settings to config
|
|
2214
|
+
Conf.io.save_to_directory_settings.set(param)
|
|
2215
|
+
|
|
2216
|
+
Conf.main.base_dir.set(param.directory)
|
|
2217
|
+
|
|
2218
|
+
with create_progress_bar(self, _("Saving..."), max_=len(objs)) as progress:
|
|
2219
|
+
for i, (path, obj) in enumerate(param.generate_filepath_obj_pairs(objs)):
|
|
2220
|
+
progress.setValue(i + 1)
|
|
2221
|
+
if progress.wasCanceled():
|
|
2222
|
+
break
|
|
2223
|
+
with qt_try_loadsave_file(self.parentWidget(), path, "save"):
|
|
2224
|
+
self.__save_to_file(obj, path)
|
|
2225
|
+
|
|
2226
|
+
def handle_dropped_files(self, filenames: list[str] | None = None) -> None:
|
|
2227
|
+
"""Handle dropped files
|
|
2228
|
+
|
|
2229
|
+
Args:
|
|
2230
|
+
filenames: File names
|
|
2231
|
+
|
|
2232
|
+
Returns:
|
|
2233
|
+
None
|
|
2234
|
+
"""
|
|
2235
|
+
dirnames = [fname for fname in filenames if osp.isdir(fname)]
|
|
2236
|
+
h5_fnames = [
|
|
2237
|
+
fname for fname in filenames if is_hdf5_file(fname, check_content=True)
|
|
2238
|
+
]
|
|
2239
|
+
other_fnames = sorted(list(set(filenames) - set(h5_fnames) - set(dirnames)))
|
|
2240
|
+
if dirnames:
|
|
2241
|
+
for dirname in dirnames:
|
|
2242
|
+
self.load_from_directory(dirname)
|
|
2243
|
+
if h5_fnames:
|
|
2244
|
+
self.mainwindow.open_h5_files(h5_fnames, import_all=True)
|
|
2245
|
+
if other_fnames:
|
|
2246
|
+
self.load_from_files(other_fnames)
|
|
2247
|
+
|
|
2248
|
+
def exec_import_wizard(self) -> None:
|
|
2249
|
+
"""Execute import wizard"""
|
|
2250
|
+
wizard = TextImportWizard(self.PANEL_STR_ID, parent=self.parentWidget())
|
|
2251
|
+
if exec_dialog(wizard):
|
|
2252
|
+
objs = wizard.get_objs()
|
|
2253
|
+
if objs:
|
|
2254
|
+
with create_progress_bar(
|
|
2255
|
+
self, _("Adding objects to workspace"), max_=len(objs) - 1
|
|
2256
|
+
) as progress:
|
|
2257
|
+
for idx, obj in enumerate(objs):
|
|
2258
|
+
progress.setValue(idx)
|
|
2259
|
+
QW.QApplication.processEvents()
|
|
2260
|
+
if progress.wasCanceled():
|
|
2261
|
+
break
|
|
2262
|
+
self.add_object(obj)
|
|
2263
|
+
|
|
2264
|
+
def import_metadata_from_file(self, filename: str | None = None) -> None:
|
|
2265
|
+
"""Import metadata from file (JSON).
|
|
2266
|
+
|
|
2267
|
+
Args:
|
|
2268
|
+
filename: File name
|
|
2269
|
+
"""
|
|
2270
|
+
if filename is None: # pragma: no cover
|
|
2271
|
+
basedir = Conf.main.base_dir.get()
|
|
2272
|
+
with save_restore_stds():
|
|
2273
|
+
filename, _filter = getopenfilename(
|
|
2274
|
+
self, _("Import metadata"), basedir, "*.dlabmeta"
|
|
2275
|
+
)
|
|
2276
|
+
if filename:
|
|
2277
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
|
|
2278
|
+
Conf.main.base_dir.set(filename)
|
|
2279
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2280
|
+
obj.metadata = read_metadata(filename)
|
|
2281
|
+
self.refresh_plot("selected", True, False)
|
|
2282
|
+
|
|
2283
|
+
def export_metadata_from_file(self, filename: str | None = None) -> None:
|
|
2284
|
+
"""Export metadata to file (JSON).
|
|
2285
|
+
|
|
2286
|
+
Args:
|
|
2287
|
+
filename: File name
|
|
2288
|
+
"""
|
|
2289
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2290
|
+
if filename is None: # pragma: no cover
|
|
2291
|
+
basedir = Conf.main.base_dir.get()
|
|
2292
|
+
with save_restore_stds():
|
|
2293
|
+
filename, _filt = getsavefilename(
|
|
2294
|
+
self, _("Export metadata"), basedir, "*.dlabmeta"
|
|
2295
|
+
)
|
|
2296
|
+
if filename:
|
|
2297
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "save"):
|
|
2298
|
+
Conf.main.base_dir.set(filename)
|
|
2299
|
+
write_metadata(filename, obj.metadata)
|
|
2300
|
+
|
|
2301
|
+
def copy_annotations(self) -> None:
|
|
2302
|
+
"""Copy annotations from selected object"""
|
|
2303
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2304
|
+
self.annotations_clipboard = obj.get_annotations()
|
|
2305
|
+
# Update action states (e.g., "Paste annotations" should now be enabled)
|
|
2306
|
+
self.selection_changed()
|
|
2307
|
+
|
|
2308
|
+
def paste_annotations(self) -> None:
|
|
2309
|
+
"""Paste annotations to selected object(s)"""
|
|
2310
|
+
if not self.annotations_clipboard:
|
|
2311
|
+
return
|
|
2312
|
+
sel_objects = self.objview.get_sel_objects(include_groups=True)
|
|
2313
|
+
for obj in sel_objects:
|
|
2314
|
+
obj.set_annotations(self.annotations_clipboard)
|
|
2315
|
+
self.refresh_plot("selected", True, False)
|
|
2316
|
+
# Update action states (e.g., annotation-related actions should now be enabled)
|
|
2317
|
+
self.selection_changed()
|
|
2318
|
+
|
|
2319
|
+
def import_annotations_from_file(self, filename: str | None = None) -> None:
|
|
2320
|
+
"""Import annotations from file (JSON).
|
|
2321
|
+
|
|
2322
|
+
Args:
|
|
2323
|
+
filename: File name
|
|
2324
|
+
"""
|
|
2325
|
+
if filename is None: # pragma: no cover
|
|
2326
|
+
basedir = Conf.main.base_dir.get()
|
|
2327
|
+
with save_restore_stds():
|
|
2328
|
+
filename, _filter = getopenfilename(
|
|
2329
|
+
self, _("Import annotations"), basedir, "*.dlabann"
|
|
2330
|
+
)
|
|
2331
|
+
if filename:
|
|
2332
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
|
|
2333
|
+
Conf.main.base_dir.set(filename)
|
|
2334
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2335
|
+
annotations = read_annotations(filename)
|
|
2336
|
+
obj.set_annotations(annotations)
|
|
2337
|
+
self.refresh_plot("selected", True, False)
|
|
2338
|
+
# Update action states (annotation-related actions should now be enabled)
|
|
2339
|
+
self.selection_changed()
|
|
2340
|
+
|
|
2341
|
+
def export_annotations_from_file(self, filename: str | None = None) -> None:
|
|
2342
|
+
"""Export annotations to file (JSON).
|
|
2343
|
+
|
|
2344
|
+
Args:
|
|
2345
|
+
filename: File name
|
|
2346
|
+
"""
|
|
2347
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2348
|
+
if filename is None: # pragma: no cover
|
|
2349
|
+
basedir = Conf.main.base_dir.get()
|
|
2350
|
+
with save_restore_stds():
|
|
2351
|
+
filename, _filt = getsavefilename(
|
|
2352
|
+
self, _("Export annotations"), basedir, "*.dlabann"
|
|
2353
|
+
)
|
|
2354
|
+
if filename:
|
|
2355
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "save"):
|
|
2356
|
+
Conf.main.base_dir.set(filename)
|
|
2357
|
+
annotations = obj.get_annotations()
|
|
2358
|
+
write_annotations(filename, annotations)
|
|
2359
|
+
|
|
2360
|
+
def delete_annotations(self) -> None:
|
|
2361
|
+
"""Delete all annotations from selected object(s)"""
|
|
2362
|
+
sel_objects = self.objview.get_sel_objects(include_groups=True)
|
|
2363
|
+
for obj in sel_objects:
|
|
2364
|
+
obj.clear_annotations()
|
|
2365
|
+
self.refresh_plot("selected", True, False)
|
|
2366
|
+
# Update action states (annotation-related actions should now be disabled)
|
|
2367
|
+
self.selection_changed()
|
|
2368
|
+
|
|
2369
|
+
def import_roi_from_file(self, filename: str | None = None) -> None:
|
|
2370
|
+
"""Import regions of interest from file (JSON).
|
|
2371
|
+
|
|
2372
|
+
Args:
|
|
2373
|
+
filename: File name
|
|
2374
|
+
"""
|
|
2375
|
+
if filename is None: # pragma: no cover
|
|
2376
|
+
basedir = Conf.main.base_dir.get()
|
|
2377
|
+
with save_restore_stds():
|
|
2378
|
+
filename, _filter = getopenfilename(
|
|
2379
|
+
self, _("Import ROI"), basedir, "*.dlabroi"
|
|
2380
|
+
)
|
|
2381
|
+
if filename:
|
|
2382
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
|
|
2383
|
+
Conf.main.base_dir.set(filename)
|
|
2384
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2385
|
+
roi = read_roi(filename)
|
|
2386
|
+
if obj.roi is None:
|
|
2387
|
+
obj.roi = roi
|
|
2388
|
+
else:
|
|
2389
|
+
obj.roi = obj.roi.combine_with(roi)
|
|
2390
|
+
self.selection_changed(update_items=True)
|
|
2391
|
+
self.refresh_plot("selected", True, False)
|
|
2392
|
+
|
|
2393
|
+
def export_roi_to_file(self, filename: str | None = None) -> None:
|
|
2394
|
+
"""Export regions of interest to file (JSON).
|
|
2395
|
+
|
|
2396
|
+
Args:
|
|
2397
|
+
filename: File name
|
|
2398
|
+
"""
|
|
2399
|
+
obj = self.objview.get_sel_objects(include_groups=True)[0]
|
|
2400
|
+
assert obj.roi is not None
|
|
2401
|
+
if filename is None: # pragma: no cover
|
|
2402
|
+
basedir = Conf.main.base_dir.get()
|
|
2403
|
+
with save_restore_stds():
|
|
2404
|
+
filename, _filt = getsavefilename(
|
|
2405
|
+
self, _("Export ROI"), basedir, "*.dlabroi"
|
|
2406
|
+
)
|
|
2407
|
+
if filename:
|
|
2408
|
+
with qt_try_loadsave_file(self.parentWidget(), filename, "save"):
|
|
2409
|
+
Conf.main.base_dir.set(filename)
|
|
2410
|
+
write_roi(filename, obj.roi)
|
|
2411
|
+
|
|
2412
|
+
# ------Refreshing GUI--------------------------------------------------------------
|
|
2413
|
+
def selection_changed(self, update_items: bool = False) -> None:
|
|
2414
|
+
"""Object selection changed: update object properties, refresh plot and update
|
|
2415
|
+
object view.
|
|
2416
|
+
|
|
2417
|
+
Args:
|
|
2418
|
+
update_items: Update plot items (default: False)
|
|
2419
|
+
"""
|
|
2420
|
+
selected_objects = self.objview.get_sel_objects(include_groups=True)
|
|
2421
|
+
selected_groups = self.objview.get_sel_groups()
|
|
2422
|
+
self.objprop.update_properties_from(self.objview.get_current_object())
|
|
2423
|
+
self.acthandler.selected_objects_changed(selected_groups, selected_objects)
|
|
2424
|
+
self.refresh_plot("selected", update_items, False)
|
|
2425
|
+
|
|
2426
|
+
def properties_changed(self) -> None:
|
|
2427
|
+
"""The properties 'Apply' button was clicked: update object properties,
|
|
2428
|
+
refresh plot and update object view."""
|
|
2429
|
+
# Get only the properties that have changed from the original values
|
|
2430
|
+
changed_props = self.objprop.get_changed_properties()
|
|
2431
|
+
|
|
2432
|
+
# Apply only the changed properties to all selected objects
|
|
2433
|
+
for obj in self.objview.get_sel_objects(include_groups=True):
|
|
2434
|
+
obj.mark_roi_as_changed()
|
|
2435
|
+
# Update only the changed properties instead of all properties
|
|
2436
|
+
update_dataset(obj, changed_props)
|
|
2437
|
+
self.objview.update_item(get_uuid(obj))
|
|
2438
|
+
|
|
2439
|
+
# Auto-recompute analysis if the object had analysis parameters
|
|
2440
|
+
# Since properties have changed, any analysis results may now be invalid
|
|
2441
|
+
self.processor.auto_recompute_analysis(obj)
|
|
2442
|
+
|
|
2443
|
+
# Refresh all selected items, including non-visible ones (only_visible=False)
|
|
2444
|
+
# This ensures that plot items are updated for all selected objects, even if
|
|
2445
|
+
# they are temporarily hidden behind other objects
|
|
2446
|
+
self.refresh_plot(
|
|
2447
|
+
"selected", update_items=True, force=False, only_visible=False
|
|
2448
|
+
)
|
|
2449
|
+
|
|
2450
|
+
# Update the stored original values to reflect the new state
|
|
2451
|
+
# This ensures subsequent changes are compared against the current values
|
|
2452
|
+
self.objprop.update_original_values()
|
|
2453
|
+
|
|
2454
|
+
def recompute_processing(self) -> None:
|
|
2455
|
+
"""Recompute/rerun selected objects or group with stored processing parameters.
|
|
2456
|
+
|
|
2457
|
+
This method handles both single objects and groups. For each object, it checks
|
|
2458
|
+
if it has 1-to-1 processing parameters that can be recomputed. Objects without
|
|
2459
|
+
recomputable parameters are skipped.
|
|
2460
|
+
"""
|
|
2461
|
+
# Get selected objects (handles both individual selection and groups)
|
|
2462
|
+
objects = self.objview.get_sel_objects(include_groups=True)
|
|
2463
|
+
if not objects:
|
|
2464
|
+
return
|
|
2465
|
+
|
|
2466
|
+
# Filter objects that have recomputable processing parameters
|
|
2467
|
+
recomputable_objects: list[SignalObj | ImageObj] = []
|
|
2468
|
+
for obj in objects:
|
|
2469
|
+
proc_params = extract_processing_parameters(obj)
|
|
2470
|
+
if proc_params is not None and proc_params.pattern == "1-to-1":
|
|
2471
|
+
recomputable_objects.append(obj)
|
|
2472
|
+
|
|
2473
|
+
if not recomputable_objects:
|
|
2474
|
+
if not execenv.unattended:
|
|
2475
|
+
QW.QMessageBox.information(
|
|
2476
|
+
self,
|
|
2477
|
+
_("Recompute"),
|
|
2478
|
+
_(
|
|
2479
|
+
"Selected object(s) do not have processing parameters "
|
|
2480
|
+
"that can be recomputed."
|
|
2481
|
+
),
|
|
2482
|
+
)
|
|
2483
|
+
return
|
|
2484
|
+
|
|
2485
|
+
# Recompute each object
|
|
2486
|
+
with create_progress_bar(
|
|
2487
|
+
self, _("Recomputing objects"), max_=len(recomputable_objects)
|
|
2488
|
+
) as progress:
|
|
2489
|
+
for index, obj in enumerate(recomputable_objects):
|
|
2490
|
+
progress.setValue(index + 1)
|
|
2491
|
+
QW.QApplication.processEvents()
|
|
2492
|
+
if progress.wasCanceled():
|
|
2493
|
+
break
|
|
2494
|
+
|
|
2495
|
+
# Temporarily set this object as current to use existing infrastructure
|
|
2496
|
+
self.objview.set_current_object(obj)
|
|
2497
|
+
report = self.objprop.apply_processing_parameters(
|
|
2498
|
+
obj=obj, interactive=False
|
|
2499
|
+
)
|
|
2500
|
+
if not report.success and not execenv.unattended:
|
|
2501
|
+
failtxt = _("Failed to recompute object")
|
|
2502
|
+
if index == len(recomputable_objects) - 1:
|
|
2503
|
+
QW.QMessageBox.warning(
|
|
2504
|
+
self,
|
|
2505
|
+
_("Recompute"),
|
|
2506
|
+
f"{failtxt} '{obj.title}':\n{report.message}",
|
|
2507
|
+
)
|
|
2508
|
+
else:
|
|
2509
|
+
conttxt = _("Do you want to continue with the next object?")
|
|
2510
|
+
answer = QW.QMessageBox.warning(
|
|
2511
|
+
self,
|
|
2512
|
+
_("Recompute"),
|
|
2513
|
+
f"{failtxt} '{obj.title}':\n{report.message}\n\n{conttxt}",
|
|
2514
|
+
QW.QMessageBox.Yes | QW.QMessageBox.No,
|
|
2515
|
+
)
|
|
2516
|
+
if answer == QW.QMessageBox.No:
|
|
2517
|
+
break
|
|
2518
|
+
|
|
2519
|
+
def select_source_objects(self) -> None:
|
|
2520
|
+
"""Select source objects associated with the selected object's processing.
|
|
2521
|
+
|
|
2522
|
+
This method retrieves the source object UUIDs from the selected object's
|
|
2523
|
+
processing parameters and selects them in the object view.
|
|
2524
|
+
"""
|
|
2525
|
+
# Get the selected object (should be exactly one)
|
|
2526
|
+
objects = self.objview.get_sel_objects(include_groups=False)
|
|
2527
|
+
if len(objects) != 1:
|
|
2528
|
+
return
|
|
2529
|
+
|
|
2530
|
+
obj = objects[0]
|
|
2531
|
+
|
|
2532
|
+
# Extract processing parameters
|
|
2533
|
+
proc_params = extract_processing_parameters(obj)
|
|
2534
|
+
if proc_params is None:
|
|
2535
|
+
if not execenv.unattended:
|
|
2536
|
+
QW.QMessageBox.information(
|
|
2537
|
+
self,
|
|
2538
|
+
_("Select source objects"),
|
|
2539
|
+
_("Selected object does not have processing metadata."),
|
|
2540
|
+
)
|
|
2541
|
+
return
|
|
2542
|
+
|
|
2543
|
+
# Get source UUIDs
|
|
2544
|
+
source_uuids = []
|
|
2545
|
+
if proc_params.source_uuid:
|
|
2546
|
+
source_uuids.append(proc_params.source_uuid)
|
|
2547
|
+
if proc_params.source_uuids:
|
|
2548
|
+
source_uuids.extend(proc_params.source_uuids)
|
|
2549
|
+
|
|
2550
|
+
if not source_uuids:
|
|
2551
|
+
if not execenv.unattended:
|
|
2552
|
+
QW.QMessageBox.information(
|
|
2553
|
+
self,
|
|
2554
|
+
_("Select source objects"),
|
|
2555
|
+
_("Selected object does not have source object references."),
|
|
2556
|
+
)
|
|
2557
|
+
return
|
|
2558
|
+
|
|
2559
|
+
# Check if source objects still exist (search across all panels)
|
|
2560
|
+
existing_objs = []
|
|
2561
|
+
for uuid in source_uuids:
|
|
2562
|
+
obj = self.mainwindow.find_object_by_uuid(uuid)
|
|
2563
|
+
if obj is not None:
|
|
2564
|
+
existing_objs.append(obj)
|
|
2565
|
+
|
|
2566
|
+
if not existing_objs:
|
|
2567
|
+
if not execenv.unattended:
|
|
2568
|
+
if len(source_uuids) == 1:
|
|
2569
|
+
msg = _("Source object no longer exists.")
|
|
2570
|
+
else:
|
|
2571
|
+
msg = _("Source objects no longer exist.")
|
|
2572
|
+
QW.QMessageBox.warning(self, _("Select source objects"), msg)
|
|
2573
|
+
return
|
|
2574
|
+
|
|
2575
|
+
# Determine which panel contains the source objects
|
|
2576
|
+
# Source objects are always in the same panel (either all signals or all images)
|
|
2577
|
+
if all(uuid in self.objmodel.get_object_ids() for uuid in source_uuids):
|
|
2578
|
+
source_panel = self
|
|
2579
|
+
elif isinstance(existing_objs[0], SignalObj):
|
|
2580
|
+
source_panel = self.mainwindow.signalpanel
|
|
2581
|
+
else: # ImageObj
|
|
2582
|
+
source_panel = self.mainwindow.imagepanel
|
|
2583
|
+
|
|
2584
|
+
# Switch to the source panel if needed
|
|
2585
|
+
if source_panel is not self:
|
|
2586
|
+
self.mainwindow.set_current_panel(source_panel)
|
|
2587
|
+
|
|
2588
|
+
# Select the existing source objects
|
|
2589
|
+
# Note: Since all sources are in the same panel, all UUIDs in existing_objs
|
|
2590
|
+
# are guaranteed to be in source_panel
|
|
2591
|
+
source_panel.objview.clearSelection()
|
|
2592
|
+
for obj in existing_objs:
|
|
2593
|
+
source_panel.objview.set_current_item_id(get_uuid(obj), extend=True)
|
|
2594
|
+
|
|
2595
|
+
# Show info if some sources were deleted
|
|
2596
|
+
missing_count = len(source_uuids) - len(existing_objs)
|
|
2597
|
+
if missing_count > 0 and not execenv.unattended:
|
|
2598
|
+
if len(existing_objs) == 1:
|
|
2599
|
+
msg = _("Selected a single source object")
|
|
2600
|
+
else:
|
|
2601
|
+
msg = _("Selected %d source objects") % len(existing_objs)
|
|
2602
|
+
msg += " ("
|
|
2603
|
+
if missing_count == 1:
|
|
2604
|
+
msg += _("1 source object no longer exists")
|
|
2605
|
+
else:
|
|
2606
|
+
msg += _("%d source objects no longer exist") % missing_count
|
|
2607
|
+
msg += ")"
|
|
2608
|
+
QW.QMessageBox.warning(self, _("Select source objects"), msg)
|
|
2609
|
+
|
|
2610
|
+
# ------Plotting data in modal dialogs----------------------------------------------
|
|
2611
|
+
def add_plot_items_to_dialog(self, dlg: PlotDialog, oids: list[str]) -> None:
|
|
2612
|
+
"""Add plot items to dialog
|
|
2613
|
+
|
|
2614
|
+
Args:
|
|
2615
|
+
dlg: Dialog
|
|
2616
|
+
oids: Object IDs
|
|
2617
|
+
"""
|
|
2618
|
+
objs = self.objmodel.get_objects(oids)
|
|
2619
|
+
plot = dlg.get_plot()
|
|
2620
|
+
with create_progress_bar(
|
|
2621
|
+
self, _("Creating plot items"), max_=len(objs)
|
|
2622
|
+
) as progress:
|
|
2623
|
+
for index, obj in enumerate(objs):
|
|
2624
|
+
progress.setValue(index + 1)
|
|
2625
|
+
QW.QApplication.processEvents()
|
|
2626
|
+
if progress.wasCanceled():
|
|
2627
|
+
return None
|
|
2628
|
+
item = create_adapter_from_object(obj).make_item(
|
|
2629
|
+
update_from=self.plothandler[get_uuid(obj)]
|
|
2630
|
+
)
|
|
2631
|
+
item.set_readonly(True)
|
|
2632
|
+
plot.add_item(item, z=0)
|
|
2633
|
+
plot.set_active_item(item)
|
|
2634
|
+
item.unselect()
|
|
2635
|
+
plot.replot()
|
|
2636
|
+
return dlg
|
|
2637
|
+
|
|
2638
|
+
def open_separate_view(
|
|
2639
|
+
self, oids: list[str] | None = None, edit_annotations: bool = False
|
|
2640
|
+
) -> PlotDialog | None:
|
|
2641
|
+
"""
|
|
2642
|
+
Open separate view for visualizing selected objects
|
|
2643
|
+
|
|
2644
|
+
Args:
|
|
2645
|
+
oids: Object IDs (default: None)
|
|
2646
|
+
edit_annotations: Edit annotations (default: False)
|
|
2647
|
+
|
|
2648
|
+
Returns:
|
|
2649
|
+
Instance of PlotDialog
|
|
2650
|
+
"""
|
|
2651
|
+
if oids is None:
|
|
2652
|
+
oids = self.objview.get_sel_object_uuids(include_groups=True)
|
|
2653
|
+
obj = self.objmodel[oids[-1]] # last selected object
|
|
2654
|
+
|
|
2655
|
+
if not all(oid in self.plothandler for oid in oids):
|
|
2656
|
+
# This happens for example when opening an already saved workspace with
|
|
2657
|
+
# multiple images, and if the user tries to view in a new window a group of
|
|
2658
|
+
# images without having selected any object yet. In this case, only the
|
|
2659
|
+
# last image is actually plotted (because if the other have the same size
|
|
2660
|
+
# and position, they are hidden), and the plot item of every other image is
|
|
2661
|
+
# not created yet. So we need to refresh the plot to create the plot item of
|
|
2662
|
+
# those images.
|
|
2663
|
+
self.plothandler.refresh_plot(
|
|
2664
|
+
"selected", update_items=True, force=True, only_visible=False
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
# Create a new dialog and add plot items to it
|
|
2668
|
+
dlg = self.create_new_dialog(
|
|
2669
|
+
title=obj.title if len(oids) == 1 else None,
|
|
2670
|
+
edit=True,
|
|
2671
|
+
name=f"{obj.PREFIX}_new_window",
|
|
2672
|
+
)
|
|
2673
|
+
if dlg is None:
|
|
2674
|
+
return None
|
|
2675
|
+
self.add_plot_items_to_dialog(dlg, oids)
|
|
2676
|
+
|
|
2677
|
+
mgr = dlg.get_manager()
|
|
2678
|
+
toolbar = QW.QToolBar(_("Annotations"), self)
|
|
2679
|
+
dlg.button_layout.insertWidget(0, toolbar)
|
|
2680
|
+
mgr.add_toolbar(toolbar, id(toolbar))
|
|
2681
|
+
toolbar.setToolButtonStyle(QC.Qt.ToolButtonTextUnderIcon)
|
|
2682
|
+
for tool in self.ANNOTATION_TOOLS:
|
|
2683
|
+
mgr.add_tool(tool, toolbar_id=id(toolbar))
|
|
2684
|
+
|
|
2685
|
+
def toggle_annotations(enabled: bool):
|
|
2686
|
+
"""Toggle annotation tools / edit buttons visibility"""
|
|
2687
|
+
for widget in (dlg.button_box, toolbar, mgr.get_itemlist_panel()):
|
|
2688
|
+
if enabled:
|
|
2689
|
+
widget.show()
|
|
2690
|
+
else:
|
|
2691
|
+
widget.hide()
|
|
2692
|
+
|
|
2693
|
+
edit_ann_action = create_action(
|
|
2694
|
+
dlg,
|
|
2695
|
+
_("Annotations"),
|
|
2696
|
+
toggled=toggle_annotations,
|
|
2697
|
+
icon=get_icon("annotations.svg"),
|
|
2698
|
+
)
|
|
2699
|
+
mgr.add_tool(ActionTool, edit_ann_action)
|
|
2700
|
+
default_toolbar = mgr.get_default_toolbar()
|
|
2701
|
+
action_btn = default_toolbar.widgetForAction(edit_ann_action)
|
|
2702
|
+
action_btn.setToolButtonStyle(QC.Qt.ToolButtonTextBesideIcon)
|
|
2703
|
+
plot = dlg.get_plot()
|
|
2704
|
+
for item in plot.items:
|
|
2705
|
+
item.set_selectable(False)
|
|
2706
|
+
for item in create_adapter_from_object(obj).iterate_shape_items(editable=True):
|
|
2707
|
+
plot.add_item(item)
|
|
2708
|
+
self.__separate_views[dlg] = obj
|
|
2709
|
+
toggle_annotations(edit_annotations)
|
|
2710
|
+
if len(oids) > 1:
|
|
2711
|
+
# If multiple objects are displayed, show the item list panel
|
|
2712
|
+
# (otherwise, it is hidden by default to lighten the dialog, except
|
|
2713
|
+
# if `edit_annotations` is True):
|
|
2714
|
+
plot.manager.get_itemlist_panel().show()
|
|
2715
|
+
if edit_annotations:
|
|
2716
|
+
edit_ann_action.setChecked(True)
|
|
2717
|
+
dlg.show()
|
|
2718
|
+
dlg.finished.connect(self.__separate_view_finished)
|
|
2719
|
+
return dlg
|
|
2720
|
+
|
|
2721
|
+
def __separate_view_finished(self, result: int) -> None:
|
|
2722
|
+
"""Separate view was closed
|
|
2723
|
+
|
|
2724
|
+
Args:
|
|
2725
|
+
result: result
|
|
2726
|
+
"""
|
|
2727
|
+
dlg: PlotDialog = self.sender()
|
|
2728
|
+
if result == QW.QDialog.DialogCode.Accepted:
|
|
2729
|
+
rw_items = []
|
|
2730
|
+
for item in dlg.get_plot().get_items():
|
|
2731
|
+
if not item.is_readonly() and is_plot_item_serializable(item):
|
|
2732
|
+
rw_items.append(item)
|
|
2733
|
+
obj = self.__separate_views[dlg]
|
|
2734
|
+
# Use the annotation adapter to set annotations in the new format
|
|
2735
|
+
adapter = create_adapter_from_object(obj)
|
|
2736
|
+
adapter.set_annotations_from_items(rw_items)
|
|
2737
|
+
self.selection_changed(update_items=True)
|
|
2738
|
+
self.__separate_views.pop(dlg)
|
|
2739
|
+
dlg.deleteLater()
|
|
2740
|
+
|
|
2741
|
+
def view_images_side_by_side(self, oids: list[str] | None = None) -> None:
|
|
2742
|
+
"""
|
|
2743
|
+
View selected images side-by-side in a grid layout
|
|
2744
|
+
|
|
2745
|
+
Args:
|
|
2746
|
+
oids: Object IDs (default: None, uses selected objects)
|
|
2747
|
+
"""
|
|
2748
|
+
if oids is None:
|
|
2749
|
+
oids = self.objview.get_sel_object_uuids(include_groups=True)
|
|
2750
|
+
|
|
2751
|
+
if len(oids) < 2:
|
|
2752
|
+
return
|
|
2753
|
+
|
|
2754
|
+
objs = self.objmodel.get_objects(oids)
|
|
2755
|
+
|
|
2756
|
+
# Compute grid dimensions
|
|
2757
|
+
max_cols = 4
|
|
2758
|
+
num_cols = min(len(objs), max_cols)
|
|
2759
|
+
num_rows = (len(objs) + num_cols - 1) // num_cols
|
|
2760
|
+
|
|
2761
|
+
# Create dialog with synchronized plots
|
|
2762
|
+
dlg = SyncPlotDialog(title=_("View images side-by-side"), toolbar=False)
|
|
2763
|
+
dlg.setObjectName(f"{self.PANEL_STR_ID}_side_by_side")
|
|
2764
|
+
|
|
2765
|
+
# Add each image to the grid
|
|
2766
|
+
for idx, obj in enumerate(objs):
|
|
2767
|
+
row = idx // num_cols
|
|
2768
|
+
col = idx % num_cols
|
|
2769
|
+
|
|
2770
|
+
# Create plot with title
|
|
2771
|
+
plot = BasePlot(options=BasePlotOptions(title=obj.title, type="image"))
|
|
2772
|
+
|
|
2773
|
+
# Create plot item from object
|
|
2774
|
+
adapter = create_adapter_from_object(obj)
|
|
2775
|
+
item = adapter.make_item()
|
|
2776
|
+
item.set_readonly(True)
|
|
2777
|
+
plot.add_item(item, z=0)
|
|
2778
|
+
|
|
2779
|
+
# Add ROI items if available
|
|
2780
|
+
if obj.roi is not None:
|
|
2781
|
+
for roi_item in adapter.iterate_roi_items(editable=False):
|
|
2782
|
+
plot.add_item(roi_item)
|
|
2783
|
+
|
|
2784
|
+
# Add to synchronized dialog
|
|
2785
|
+
dlg.add_plot(row, col, plot, sync=True)
|
|
2786
|
+
|
|
2787
|
+
# Finalize and show dialog
|
|
2788
|
+
dlg.finalize_configuration()
|
|
2789
|
+
|
|
2790
|
+
# Set explicit size for proper display
|
|
2791
|
+
dlg.resize(20 + 440 * num_cols, 20 + 400 * num_rows)
|
|
2792
|
+
|
|
2793
|
+
exec_dialog(dlg)
|
|
2794
|
+
|
|
2795
|
+
def get_dialog_size(self) -> tuple[int, int]:
|
|
2796
|
+
"""Get dialog size (minimum and maximum)"""
|
|
2797
|
+
# Resize the dialog so that it's at least MINDIALOGSIZE (absolute values),
|
|
2798
|
+
# and at most MAXDIALOGSIZE (% of the main window size):
|
|
2799
|
+
minwidth, minheight = self.MINDIALOGSIZE
|
|
2800
|
+
maxwidth = int(self.mainwindow.width() * self.MAXDIALOGSIZE)
|
|
2801
|
+
maxheight = int(self.mainwindow.height() * self.MAXDIALOGSIZE)
|
|
2802
|
+
size = min(minwidth, maxwidth), min(minheight, maxheight)
|
|
2803
|
+
return size
|
|
2804
|
+
|
|
2805
|
+
def create_new_dialog(
|
|
2806
|
+
self,
|
|
2807
|
+
edit: bool = False,
|
|
2808
|
+
toolbar: bool = True,
|
|
2809
|
+
title: str | None = None,
|
|
2810
|
+
name: str | None = None,
|
|
2811
|
+
options: dict[str, Any] | None = None,
|
|
2812
|
+
) -> PlotDialog | None:
|
|
2813
|
+
"""Create new pop-up signal/image plot dialog.
|
|
2814
|
+
|
|
2815
|
+
Args:
|
|
2816
|
+
edit: Edit mode
|
|
2817
|
+
toolbar: Show toolbar
|
|
2818
|
+
title: Dialog title
|
|
2819
|
+
name: Dialog object name
|
|
2820
|
+
options: Plot options
|
|
2821
|
+
|
|
2822
|
+
Returns:
|
|
2823
|
+
Plot dialog instance
|
|
2824
|
+
"""
|
|
2825
|
+
plot_options = self.plothandler.get_plot_options()
|
|
2826
|
+
if options is not None:
|
|
2827
|
+
plot_options = plot_options.copy(options)
|
|
2828
|
+
|
|
2829
|
+
# pylint: disable=not-callable
|
|
2830
|
+
dlg = PlotDialog(
|
|
2831
|
+
parent=self.parentWidget(),
|
|
2832
|
+
title=APP_NAME if title is None else f"{title} - {APP_NAME}",
|
|
2833
|
+
options=plot_options,
|
|
2834
|
+
toolbar=toolbar,
|
|
2835
|
+
icon="DataLab.svg",
|
|
2836
|
+
edit=edit,
|
|
2837
|
+
size=self.get_dialog_size(),
|
|
2838
|
+
)
|
|
2839
|
+
dlg.setObjectName(name)
|
|
2840
|
+
return dlg
|
|
2841
|
+
|
|
2842
|
+
def get_roi_editor_output(
|
|
2843
|
+
self, mode: Literal["apply", "extract", "define"] = "apply"
|
|
2844
|
+
) -> tuple[TypeROI, bool] | None:
|
|
2845
|
+
"""Get ROI data (array) from specific dialog box.
|
|
2846
|
+
|
|
2847
|
+
Args:
|
|
2848
|
+
mode: Mode of operation, either "apply" (define ROI, then apply it to
|
|
2849
|
+
selected objects), "extract" (define ROI, then extract data from it),
|
|
2850
|
+
or "define" (define ROI without applying or extracting).
|
|
2851
|
+
|
|
2852
|
+
Returns:
|
|
2853
|
+
A tuple containing the ROI object and a boolean indicating whether the
|
|
2854
|
+
dialog was accepted or not.
|
|
2855
|
+
"""
|
|
2856
|
+
obj = self.objview.get_sel_objects(include_groups=True)[-1]
|
|
2857
|
+
item = create_adapter_from_object(obj).make_item(
|
|
2858
|
+
update_from=self.plothandler[get_uuid(obj)]
|
|
2859
|
+
)
|
|
2860
|
+
roi_editor_class = self.get_roieditor_class() # pylint: disable=not-callable
|
|
2861
|
+
roi_editor = roi_editor_class(
|
|
2862
|
+
parent=self.parentWidget(),
|
|
2863
|
+
obj=obj,
|
|
2864
|
+
mode=mode,
|
|
2865
|
+
item=item,
|
|
2866
|
+
options=self.plothandler.get_plot_options(),
|
|
2867
|
+
size=self.get_dialog_size(),
|
|
2868
|
+
)
|
|
2869
|
+
if exec_dialog(roi_editor):
|
|
2870
|
+
return roi_editor.get_roieditor_results()
|
|
2871
|
+
return None
|
|
2872
|
+
|
|
2873
|
+
def get_objects_with_dialog(
|
|
2874
|
+
self,
|
|
2875
|
+
title: str,
|
|
2876
|
+
comment: str = "",
|
|
2877
|
+
nb_objects: int = 1,
|
|
2878
|
+
parent: QW.QWidget | None = None,
|
|
2879
|
+
) -> TypeObj | None:
|
|
2880
|
+
"""Get object with dialog box.
|
|
2881
|
+
|
|
2882
|
+
Args:
|
|
2883
|
+
title: Dialog title
|
|
2884
|
+
comment: Optional dialog comment
|
|
2885
|
+
nb_objects: Number of objects to select
|
|
2886
|
+
parent: Parent widget
|
|
2887
|
+
Returns:
|
|
2888
|
+
Object(s) (signal(s) or image(s), or None if dialog was canceled)
|
|
2889
|
+
"""
|
|
2890
|
+
parent = self if parent is None else parent
|
|
2891
|
+
dlg = objectview.GetObjectsDialog(parent, self, title, comment, nb_objects)
|
|
2892
|
+
if exec_dialog(dlg):
|
|
2893
|
+
return dlg.get_selected_objects()
|
|
2894
|
+
return None
|
|
2895
|
+
|
|
2896
|
+
def __show_no_result_warning(self):
|
|
2897
|
+
"""Show no result warning"""
|
|
2898
|
+
msg = "<br>".join(
|
|
2899
|
+
[
|
|
2900
|
+
_("No result currently available for this object."),
|
|
2901
|
+
"",
|
|
2902
|
+
_(
|
|
2903
|
+
"This feature leverages the results of previous analysis "
|
|
2904
|
+
"performed on the selected object(s).<br><br>"
|
|
2905
|
+
"To compute results, select one or more objects and choose "
|
|
2906
|
+
"a feature in the <u>Analysis</u> menu."
|
|
2907
|
+
),
|
|
2908
|
+
]
|
|
2909
|
+
)
|
|
2910
|
+
if not execenv.unattended:
|
|
2911
|
+
QW.QMessageBox.information(self, APP_NAME, msg)
|
|
2912
|
+
|
|
2913
|
+
def show_results(self) -> None:
|
|
2914
|
+
"""Show results"""
|
|
2915
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
2916
|
+
rdatadict = create_resultdata_dict(objs)
|
|
2917
|
+
if rdatadict:
|
|
2918
|
+
for rdata in rdatadict.values():
|
|
2919
|
+
show_resultdata(self.parentWidget(), rdata, f"{objs[0].PREFIX}_results")
|
|
2920
|
+
else:
|
|
2921
|
+
self.__show_no_result_warning()
|
|
2922
|
+
|
|
2923
|
+
def toggle_result_label_visibility(self, state: bool) -> None:
|
|
2924
|
+
"""Toggle the visibility of the merged result label on the plot.
|
|
2925
|
+
|
|
2926
|
+
Args:
|
|
2927
|
+
state: True to show the label, False to hide it
|
|
2928
|
+
"""
|
|
2929
|
+
show_label = state
|
|
2930
|
+
# Update the configuration
|
|
2931
|
+
Conf.view.show_result_label.set(show_label)
|
|
2932
|
+
# Synchronize the other panel's action state
|
|
2933
|
+
for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel):
|
|
2934
|
+
if panel is not self and panel.acthandler.show_label_action is not None:
|
|
2935
|
+
panel.acthandler.show_label_action.blockSignals(True)
|
|
2936
|
+
panel.acthandler.show_label_action.setChecked(show_label)
|
|
2937
|
+
panel.acthandler.show_label_action.blockSignals(False)
|
|
2938
|
+
# Refresh the plot to show/hide the label
|
|
2939
|
+
self.plothandler.toggle_result_label_visibility(show_label)
|
|
2940
|
+
|
|
2941
|
+
def __add_result_signal(
|
|
2942
|
+
self,
|
|
2943
|
+
x: np.ndarray | list[float],
|
|
2944
|
+
y: np.ndarray | list[float],
|
|
2945
|
+
title: str,
|
|
2946
|
+
xaxis: str,
|
|
2947
|
+
yaxis: str,
|
|
2948
|
+
group_id: str | None = None,
|
|
2949
|
+
) -> None:
|
|
2950
|
+
"""Add result signal
|
|
2951
|
+
|
|
2952
|
+
Args:
|
|
2953
|
+
x: X data
|
|
2954
|
+
y: Y data
|
|
2955
|
+
title: Signal title
|
|
2956
|
+
xaxis: X axis label
|
|
2957
|
+
yaxis: Y axis label
|
|
2958
|
+
group_id: UUID of the group to add the signal to. If None, add to
|
|
2959
|
+
current group.
|
|
2960
|
+
"""
|
|
2961
|
+
xdata = np.array(x, dtype=float)
|
|
2962
|
+
ydata = np.array(y, dtype=float)
|
|
2963
|
+
|
|
2964
|
+
obj = create_signal(
|
|
2965
|
+
title=f"{title}: {yaxis} = f({xaxis})",
|
|
2966
|
+
x=xdata,
|
|
2967
|
+
y=ydata,
|
|
2968
|
+
labels=[xaxis, yaxis],
|
|
2969
|
+
)
|
|
2970
|
+
self.mainwindow.signalpanel.add_object(obj, group_id=group_id)
|
|
2971
|
+
|
|
2972
|
+
def __create_plot_result_param_class(self, rdata: ResultData) -> type[gds.DataSet]:
|
|
2973
|
+
"""Create PlotResultParam class dynamically based on result data.
|
|
2974
|
+
|
|
2975
|
+
Args:
|
|
2976
|
+
rdata: Result data
|
|
2977
|
+
|
|
2978
|
+
Returns:
|
|
2979
|
+
PlotResultParam class
|
|
2980
|
+
"""
|
|
2981
|
+
# Build X and Y choices from result data headers
|
|
2982
|
+
xchoices = (("indices", _("Indices")),)
|
|
2983
|
+
for xlabel in rdata.headers:
|
|
2984
|
+
# If this column data is not numeric, we skip it:
|
|
2985
|
+
if not isinstance(
|
|
2986
|
+
rdata.results[0].get_column_values(xlabel)[0], (int, float, np.number)
|
|
2987
|
+
):
|
|
2988
|
+
continue
|
|
2989
|
+
xchoices += ((xlabel, xlabel),)
|
|
2990
|
+
ychoices = xchoices[1:]
|
|
2991
|
+
|
|
2992
|
+
# Determine default plot kind based on result data
|
|
2993
|
+
default_kind = (
|
|
2994
|
+
"one_curve_per_object"
|
|
2995
|
+
if any(len(result.to_dataframe()) > 1 for result in rdata.results)
|
|
2996
|
+
else "one_curve_per_title"
|
|
2997
|
+
)
|
|
2998
|
+
|
|
2999
|
+
class PlotResultParam(gds.DataSet):
|
|
3000
|
+
"""Plot results parameters"""
|
|
3001
|
+
|
|
3002
|
+
kind = gds.ChoiceItem(
|
|
3003
|
+
_("Plot kind"),
|
|
3004
|
+
(
|
|
3005
|
+
(
|
|
3006
|
+
"one_curve_per_object",
|
|
3007
|
+
_("One curve per object (or ROI) and per result title"),
|
|
3008
|
+
),
|
|
3009
|
+
("one_curve_per_title", _("One curve per result title")),
|
|
3010
|
+
),
|
|
3011
|
+
default=default_kind,
|
|
3012
|
+
)
|
|
3013
|
+
xaxis = gds.ChoiceItem(_("X axis"), xchoices, default="indices")
|
|
3014
|
+
yaxis = gds.ChoiceItem(_("Y axis"), ychoices, default=ychoices[0][0])
|
|
3015
|
+
|
|
3016
|
+
return PlotResultParam
|
|
3017
|
+
|
|
3018
|
+
def __plot_result(
|
|
3019
|
+
self,
|
|
3020
|
+
category: str,
|
|
3021
|
+
rdata: ResultData,
|
|
3022
|
+
objs: list[SignalObj | ImageObj],
|
|
3023
|
+
param: gds.DataSet | None = None,
|
|
3024
|
+
result_group_id: str | None = None,
|
|
3025
|
+
) -> None:
|
|
3026
|
+
"""Plot results for a specific category
|
|
3027
|
+
|
|
3028
|
+
Args:
|
|
3029
|
+
category: Result category
|
|
3030
|
+
rdata: Result data
|
|
3031
|
+
objs: List of objects
|
|
3032
|
+
param: Plot result parameters. If None, show dialog to get parameters.
|
|
3033
|
+
result_group_id: UUID of the group to add result signals to. If None,
|
|
3034
|
+
add to current group.
|
|
3035
|
+
"""
|
|
3036
|
+
# Regrouping ResultShape results by their `title` attribute:
|
|
3037
|
+
grouped_results: dict[str, list[GeometryAdapter | TableAdapter]] = {}
|
|
3038
|
+
for result in rdata.results:
|
|
3039
|
+
grouped_results.setdefault(result.title, []).append(result)
|
|
3040
|
+
|
|
3041
|
+
# From here, results are already grouped by their `category` attribute,
|
|
3042
|
+
# and then by their `title` attribute. We can now plot them.
|
|
3043
|
+
#
|
|
3044
|
+
# Now, we have two common use cases:
|
|
3045
|
+
# 1. Plotting one curve per object (signal/image) and per `title`
|
|
3046
|
+
# attribute, if each selected object contains a result object
|
|
3047
|
+
# with multiple values (e.g. from a blob detection feature).
|
|
3048
|
+
# 2. Plotting one curve per `title` attribute, if each selected object
|
|
3049
|
+
# contains a result object with a single value (e.g. from a FHWM
|
|
3050
|
+
# feature) - in that case, we select only the first value of each
|
|
3051
|
+
# result object.
|
|
3052
|
+
|
|
3053
|
+
if param is None:
|
|
3054
|
+
# Create parameter class and show dialog
|
|
3055
|
+
PlotResultParam = self.__create_plot_result_param_class(rdata)
|
|
3056
|
+
comment = (
|
|
3057
|
+
_(
|
|
3058
|
+
"Plot results obtained from previous analyses.<br><br>"
|
|
3059
|
+
"This plot is based on results associated with '%s' prefix."
|
|
3060
|
+
)
|
|
3061
|
+
% category
|
|
3062
|
+
)
|
|
3063
|
+
param = PlotResultParam(_("Plot results"), comment=comment)
|
|
3064
|
+
if not param.edit(parent=self.parentWidget()):
|
|
3065
|
+
return
|
|
3066
|
+
|
|
3067
|
+
if param.kind == "one_curve_per_title":
|
|
3068
|
+
# One curve per ROI (if any) and per result title
|
|
3069
|
+
# ------------------------------------------------------------------
|
|
3070
|
+
# Begin by checking if all results have the same number of ROIs:
|
|
3071
|
+
# for simplicity, let's check the number of unique ROI indices.
|
|
3072
|
+
all_roi_indexes = [
|
|
3073
|
+
result.get_unique_roi_indices() for result in rdata.results
|
|
3074
|
+
]
|
|
3075
|
+
# Check if all roi_indexes are the same:
|
|
3076
|
+
if len(set(map(tuple, all_roi_indexes))) > 1:
|
|
3077
|
+
if not execenv.unattended:
|
|
3078
|
+
QW.QMessageBox.warning(
|
|
3079
|
+
self,
|
|
3080
|
+
_("Plot results"),
|
|
3081
|
+
_(
|
|
3082
|
+
"All objects associated with results must have the "
|
|
3083
|
+
"same regions of interest (ROIs) to plot results "
|
|
3084
|
+
"together."
|
|
3085
|
+
),
|
|
3086
|
+
)
|
|
3087
|
+
return
|
|
3088
|
+
obj = objs[0]
|
|
3089
|
+
# Build a string with source object short IDs (max 3, then use "...")
|
|
3090
|
+
max_ids_to_show = 3
|
|
3091
|
+
short_ids = [get_short_id(obj) for obj in objs]
|
|
3092
|
+
if len(short_ids) <= max_ids_to_show:
|
|
3093
|
+
source_ids = ", ".join(short_ids)
|
|
3094
|
+
else:
|
|
3095
|
+
# Show first 2, "...", then last one: "s001, s002, ..., s010"
|
|
3096
|
+
source_ids = (
|
|
3097
|
+
", ".join(short_ids[: max_ids_to_show - 1])
|
|
3098
|
+
+ ", ..., "
|
|
3099
|
+
+ short_ids[-1]
|
|
3100
|
+
)
|
|
3101
|
+
for i_roi in all_roi_indexes[0]:
|
|
3102
|
+
roi_suffix = f"|ROI{int(i_roi + 1)}" if i_roi >= 0 else ""
|
|
3103
|
+
for title, results in grouped_results.items(): # title
|
|
3104
|
+
x, y = [], []
|
|
3105
|
+
for index, result in enumerate(results):
|
|
3106
|
+
if param.xaxis == "indices":
|
|
3107
|
+
x.append(index)
|
|
3108
|
+
else:
|
|
3109
|
+
x.append(result.get_column_values(param.xaxis, i_roi)[0])
|
|
3110
|
+
y.append(result.get_column_values(param.yaxis, i_roi)[0])
|
|
3111
|
+
if i_roi >= 0:
|
|
3112
|
+
roi_suffix = f"|{obj.roi.get_single_roi_title(int(i_roi))}"
|
|
3113
|
+
self.__add_result_signal(
|
|
3114
|
+
x,
|
|
3115
|
+
y,
|
|
3116
|
+
f"{title} ({source_ids}){roi_suffix}",
|
|
3117
|
+
param.xaxis,
|
|
3118
|
+
param.yaxis,
|
|
3119
|
+
result_group_id,
|
|
3120
|
+
)
|
|
3121
|
+
else:
|
|
3122
|
+
# One curve per result title, per object and per ROI
|
|
3123
|
+
# ------------------------------------------------------------------
|
|
3124
|
+
for title, results in grouped_results.items(): # title
|
|
3125
|
+
for index, result in enumerate(results): # object
|
|
3126
|
+
obj = objs[index]
|
|
3127
|
+
roi_indices = result.get_unique_roi_indices()
|
|
3128
|
+
for i_roi in roi_indices: # ROI
|
|
3129
|
+
roi_suffix = ""
|
|
3130
|
+
if i_roi >= 0:
|
|
3131
|
+
roi_suffix = f"|{obj.roi.get_single_roi_title(int(i_roi))}"
|
|
3132
|
+
roi_data = result.get_roi_data(i_roi)
|
|
3133
|
+
if param.xaxis == "indices":
|
|
3134
|
+
x = list(range(len(roi_data)))
|
|
3135
|
+
else:
|
|
3136
|
+
x = roi_data[param.xaxis].values
|
|
3137
|
+
y = roi_data[param.yaxis].values
|
|
3138
|
+
shid = get_short_id(objs[index])
|
|
3139
|
+
stitle = f"{title} ({shid}){roi_suffix}"
|
|
3140
|
+
self.__add_result_signal(
|
|
3141
|
+
x, y, stitle, param.xaxis, param.yaxis, result_group_id
|
|
3142
|
+
)
|
|
3143
|
+
|
|
3144
|
+
def plot_results(
|
|
3145
|
+
self,
|
|
3146
|
+
kind: str | None = None,
|
|
3147
|
+
xaxis: str | None = None,
|
|
3148
|
+
yaxis: str | None = None,
|
|
3149
|
+
) -> None:
|
|
3150
|
+
"""Plot results
|
|
3151
|
+
|
|
3152
|
+
Args:
|
|
3153
|
+
kind: Plot kind. Either "one_curve_per_object" or "one_curve_per_title".
|
|
3154
|
+
If None, show dialog to get parameters.
|
|
3155
|
+
xaxis: X axis column name. If None, show dialog to get parameters.
|
|
3156
|
+
yaxis: Y axis column name. If None, show dialog to get parameters.
|
|
3157
|
+
"""
|
|
3158
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
3159
|
+
rdatadict = create_resultdata_dict(objs)
|
|
3160
|
+
if rdatadict:
|
|
3161
|
+
# Always use or create a "Results" group for all plot results
|
|
3162
|
+
rgroup_title = _("Results")
|
|
3163
|
+
target_panel = self.mainwindow.signalpanel
|
|
3164
|
+
try:
|
|
3165
|
+
# Check if a "Results" group already exists in the signal panel
|
|
3166
|
+
rgroup = target_panel.objmodel.get_group_from_title(rgroup_title)
|
|
3167
|
+
except KeyError:
|
|
3168
|
+
# Create the group if it doesn't exist
|
|
3169
|
+
rgroup = target_panel.add_group(rgroup_title)
|
|
3170
|
+
result_group_id = get_uuid(rgroup)
|
|
3171
|
+
|
|
3172
|
+
for category, rdata in rdatadict.items():
|
|
3173
|
+
param = None
|
|
3174
|
+
if kind is not None and xaxis is not None and yaxis is not None:
|
|
3175
|
+
# Create parameter object programmatically
|
|
3176
|
+
PlotResultParam = self.__create_plot_result_param_class(rdata)
|
|
3177
|
+
param = PlotResultParam()
|
|
3178
|
+
param.kind = kind
|
|
3179
|
+
param.xaxis = xaxis
|
|
3180
|
+
param.yaxis = yaxis
|
|
3181
|
+
|
|
3182
|
+
self.__plot_result(category, rdata, objs, param, result_group_id)
|
|
3183
|
+
else:
|
|
3184
|
+
self.__show_no_result_warning()
|
|
3185
|
+
|
|
3186
|
+
def delete_results(self) -> None:
|
|
3187
|
+
"""Delete results"""
|
|
3188
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
3189
|
+
rdatadict = create_resultdata_dict(objs)
|
|
3190
|
+
if rdatadict:
|
|
3191
|
+
if execenv.unattended:
|
|
3192
|
+
confirmed = True
|
|
3193
|
+
else:
|
|
3194
|
+
answer = QW.QMessageBox.warning(
|
|
3195
|
+
self,
|
|
3196
|
+
_("Delete results"),
|
|
3197
|
+
_(
|
|
3198
|
+
"Are you sure you want to delete all results "
|
|
3199
|
+
"of the selected object(s)?"
|
|
3200
|
+
),
|
|
3201
|
+
QW.QMessageBox.Yes | QW.QMessageBox.No,
|
|
3202
|
+
)
|
|
3203
|
+
confirmed = answer == QW.QMessageBox.Yes
|
|
3204
|
+
if confirmed:
|
|
3205
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
3206
|
+
for obj in objs:
|
|
3207
|
+
# Remove all table and geometry results using adapter methods
|
|
3208
|
+
TableAdapter.remove_all_from(obj)
|
|
3209
|
+
GeometryAdapter.remove_all_from(obj)
|
|
3210
|
+
if obj is self.objview.get_current_object():
|
|
3211
|
+
self.objprop.update_properties_from(obj)
|
|
3212
|
+
# Update action states to reflect the removal
|
|
3213
|
+
selected_groups = self.objview.get_sel_groups()
|
|
3214
|
+
self.acthandler.selected_objects_changed(selected_groups, objs)
|
|
3215
|
+
self.refresh_plot("selected", True, False)
|
|
3216
|
+
else:
|
|
3217
|
+
self.__show_no_result_warning()
|
|
3218
|
+
|
|
3219
|
+
def add_label_with_title(
|
|
3220
|
+
self, title: str | None = None, ignore_msg: bool = True
|
|
3221
|
+
) -> None:
|
|
3222
|
+
"""Add a label with object title on the associated plot
|
|
3223
|
+
|
|
3224
|
+
Args:
|
|
3225
|
+
title: Label title. Defaults to None.
|
|
3226
|
+
If None, the title is the object title.
|
|
3227
|
+
ignore_msg: If True, do not show the information message. Defaults to True.
|
|
3228
|
+
If False, show a message box to inform the user that the label has been
|
|
3229
|
+
added as an annotation, and that it can be edited or removed using the
|
|
3230
|
+
annotation editing window.
|
|
3231
|
+
"""
|
|
3232
|
+
objs = self.objview.get_sel_objects(include_groups=True)
|
|
3233
|
+
for obj in objs:
|
|
3234
|
+
create_adapter_from_object(obj).add_label_with_title(title=title)
|
|
3235
|
+
if (
|
|
3236
|
+
not Conf.view.ignore_title_insertion_msg.get(False)
|
|
3237
|
+
and not ignore_msg
|
|
3238
|
+
and not execenv.unattended
|
|
3239
|
+
):
|
|
3240
|
+
answer = QW.QMessageBox.information(
|
|
3241
|
+
self,
|
|
3242
|
+
_("Annotation added"),
|
|
3243
|
+
_(
|
|
3244
|
+
"The label has been added as an annotation. "
|
|
3245
|
+
"You can edit or remove it using the annotation editing window."
|
|
3246
|
+
"<br><br>"
|
|
3247
|
+
"Choosing to ignore this message will prevent it "
|
|
3248
|
+
"from being displayed again."
|
|
3249
|
+
),
|
|
3250
|
+
QW.QMessageBox.Ok | QW.QMessageBox.Ignore,
|
|
3251
|
+
)
|
|
3252
|
+
if answer == QW.QMessageBox.Ignore:
|
|
3253
|
+
Conf.view.ignore_title_insertion_msg.set(True)
|
|
3254
|
+
self.refresh_plot("selected", True, False)
|