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.
Files changed (496) hide show
  1. datalab/__init__.py +35 -2
  2. datalab/adapters_metadata/__init__.py +31 -0
  3. datalab/adapters_metadata/base_adapter.py +316 -0
  4. datalab/adapters_metadata/common.py +422 -0
  5. datalab/adapters_metadata/geometry_adapter.py +98 -0
  6. datalab/adapters_metadata/table_adapter.py +84 -0
  7. datalab/adapters_plotpy/__init__.py +54 -0
  8. datalab/adapters_plotpy/annotations.py +124 -0
  9. datalab/adapters_plotpy/base.py +110 -0
  10. datalab/adapters_plotpy/converters.py +86 -0
  11. datalab/adapters_plotpy/factories.py +80 -0
  12. datalab/adapters_plotpy/objects/__init__.py +0 -0
  13. datalab/adapters_plotpy/objects/base.py +197 -0
  14. datalab/adapters_plotpy/objects/image.py +157 -0
  15. datalab/adapters_plotpy/objects/scalar.py +565 -0
  16. datalab/adapters_plotpy/objects/signal.py +264 -0
  17. datalab/adapters_plotpy/roi/__init__.py +0 -0
  18. datalab/adapters_plotpy/roi/base.py +146 -0
  19. datalab/adapters_plotpy/roi/factory.py +93 -0
  20. datalab/adapters_plotpy/roi/image.py +207 -0
  21. datalab/adapters_plotpy/roi/signal.py +72 -0
  22. datalab/app.py +98 -0
  23. datalab/config.py +817 -0
  24. datalab/control/__init__.py +0 -0
  25. datalab/control/baseproxy.py +776 -0
  26. datalab/control/proxy.py +343 -0
  27. datalab/control/remote.py +1005 -0
  28. datalab/data/doc/DataLab_en.pdf +0 -0
  29. datalab/data/doc/DataLab_fr.pdf +0 -0
  30. datalab/data/icons/analysis/delete_results.svg +109 -0
  31. datalab/data/icons/analysis/fw1e2.svg +156 -0
  32. datalab/data/icons/analysis/fwhm.svg +156 -0
  33. datalab/data/icons/analysis/histogram.svg +49 -0
  34. datalab/data/icons/analysis/peak_detect.svg +160 -0
  35. datalab/data/icons/analysis/plot_results.svg +151 -0
  36. datalab/data/icons/analysis/show_results.svg +83 -0
  37. datalab/data/icons/analysis/stats.svg +49 -0
  38. datalab/data/icons/analysis.svg +120 -0
  39. datalab/data/icons/apply.svg +3 -0
  40. datalab/data/icons/check_all.svg +15 -0
  41. datalab/data/icons/collapse.svg +44 -0
  42. datalab/data/icons/collapse_selection.svg +63 -0
  43. datalab/data/icons/console.svg +101 -0
  44. datalab/data/icons/create/1d-normal.svg +8 -0
  45. datalab/data/icons/create/1d-poisson.svg +9 -0
  46. datalab/data/icons/create/1d-uniform.svg +8 -0
  47. datalab/data/icons/create/1d-zero.svg +57 -0
  48. datalab/data/icons/create/2d-gaussian.svg +56 -0
  49. datalab/data/icons/create/2d-normal.svg +38 -0
  50. datalab/data/icons/create/2d-poisson.svg +38 -0
  51. datalab/data/icons/create/2d-ramp.svg +90 -0
  52. datalab/data/icons/create/2d-sinc.svg +62 -0
  53. datalab/data/icons/create/2d-uniform.svg +38 -0
  54. datalab/data/icons/create/2d-zero.svg +13 -0
  55. datalab/data/icons/create/checkerboard.svg +39 -0
  56. datalab/data/icons/create/cosine.svg +12 -0
  57. datalab/data/icons/create/exponential.svg +55 -0
  58. datalab/data/icons/create/gaussian.svg +12 -0
  59. datalab/data/icons/create/grating.svg +29 -0
  60. datalab/data/icons/create/linear_chirp.svg +7 -0
  61. datalab/data/icons/create/logistic.svg +7 -0
  62. datalab/data/icons/create/lorentzian.svg +12 -0
  63. datalab/data/icons/create/planck.svg +12 -0
  64. datalab/data/icons/create/polynomial.svg +7 -0
  65. datalab/data/icons/create/pulse.svg +12 -0
  66. datalab/data/icons/create/ring.svg +18 -0
  67. datalab/data/icons/create/sawtooth.svg +7 -0
  68. datalab/data/icons/create/siemens.svg +35 -0
  69. datalab/data/icons/create/sinc.svg +12 -0
  70. datalab/data/icons/create/sine.svg +7 -0
  71. datalab/data/icons/create/square.svg +7 -0
  72. datalab/data/icons/create/square_pulse.svg +7 -0
  73. datalab/data/icons/create/step.svg +7 -0
  74. datalab/data/icons/create/step_pulse.svg +12 -0
  75. datalab/data/icons/create/triangle.svg +7 -0
  76. datalab/data/icons/create/voigt.svg +12 -0
  77. datalab/data/icons/edit/annotations.svg +72 -0
  78. datalab/data/icons/edit/annotations_copy.svg +114 -0
  79. datalab/data/icons/edit/annotations_delete.svg +83 -0
  80. datalab/data/icons/edit/annotations_edit.svg +98 -0
  81. datalab/data/icons/edit/annotations_export.svg +85 -0
  82. datalab/data/icons/edit/annotations_import.svg +85 -0
  83. datalab/data/icons/edit/annotations_paste.svg +100 -0
  84. datalab/data/icons/edit/copy_titles.svg +109 -0
  85. datalab/data/icons/edit/delete.svg +84 -0
  86. datalab/data/icons/edit/delete_all.svg +214 -0
  87. datalab/data/icons/edit/duplicate.svg +64 -0
  88. datalab/data/icons/edit/goto_source.svg +60 -0
  89. datalab/data/icons/edit/metadata.svg +60 -0
  90. datalab/data/icons/edit/metadata_add.svg +80 -0
  91. datalab/data/icons/edit/metadata_copy.svg +96 -0
  92. datalab/data/icons/edit/metadata_delete.svg +62 -0
  93. datalab/data/icons/edit/metadata_export.svg +68 -0
  94. datalab/data/icons/edit/metadata_import.svg +68 -0
  95. datalab/data/icons/edit/metadata_paste.svg +79 -0
  96. datalab/data/icons/edit/move_down.svg +55 -0
  97. datalab/data/icons/edit/move_up.svg +54 -0
  98. datalab/data/icons/edit/new_group.svg +76 -0
  99. datalab/data/icons/edit/recompute.svg +60 -0
  100. datalab/data/icons/edit/rename.svg +49 -0
  101. datalab/data/icons/edit.svg +16 -0
  102. datalab/data/icons/expand.svg +44 -0
  103. datalab/data/icons/expand_selection.svg +63 -0
  104. datalab/data/icons/fit/cdf_fit.svg +56 -0
  105. datalab/data/icons/fit/exponential_fit.svg +55 -0
  106. datalab/data/icons/fit/gaussian_fit.svg +62 -0
  107. datalab/data/icons/fit/interactive_fit.svg +101 -0
  108. datalab/data/icons/fit/linear_fit.svg +57 -0
  109. datalab/data/icons/fit/lorentzian_fit.svg +209 -0
  110. datalab/data/icons/fit/multigaussian_fit.svg +85 -0
  111. datalab/data/icons/fit/multilorentzian_fit.svg +85 -0
  112. datalab/data/icons/fit/piecewiseexponential_fit.svg +209 -0
  113. datalab/data/icons/fit/planckian_fit.svg +62 -0
  114. datalab/data/icons/fit/polynomial_fit.svg +59 -0
  115. datalab/data/icons/fit/sigmoid_fit.svg +56 -0
  116. datalab/data/icons/fit/sinusoidal_fit.svg +72 -0
  117. datalab/data/icons/fit/twohalfgaussian_fit.svg +63 -0
  118. datalab/data/icons/fit/voigt_fit.svg +57 -0
  119. datalab/data/icons/group.svg +56 -0
  120. datalab/data/icons/h5/h5array.svg +59 -0
  121. datalab/data/icons/h5/h5attrs.svg +75 -0
  122. datalab/data/icons/h5/h5browser.svg +133 -0
  123. datalab/data/icons/h5/h5file.svg +69 -0
  124. datalab/data/icons/h5/h5group.svg +49 -0
  125. datalab/data/icons/h5/h5scalar.svg +1 -0
  126. datalab/data/icons/help_pdf.svg +46 -0
  127. datalab/data/icons/history.svg +7 -0
  128. datalab/data/icons/image.svg +135 -0
  129. datalab/data/icons/io/fileopen_directory.svg +60 -0
  130. datalab/data/icons/io/fileopen_h5.svg +84 -0
  131. datalab/data/icons/io/fileopen_ima.svg +187 -0
  132. datalab/data/icons/io/fileopen_py.svg +123 -0
  133. datalab/data/icons/io/fileopen_sig.svg +138 -0
  134. datalab/data/icons/io/filesave_h5.svg +97 -0
  135. datalab/data/icons/io/filesave_ima.svg +200 -0
  136. datalab/data/icons/io/filesave_py.svg +136 -0
  137. datalab/data/icons/io/filesave_sig.svg +151 -0
  138. datalab/data/icons/io/import_text.svg +144 -0
  139. datalab/data/icons/io/save_to_directory.svg +134 -0
  140. datalab/data/icons/io.svg +84 -0
  141. datalab/data/icons/libre-camera-flash-off.svg +1 -0
  142. datalab/data/icons/libre-camera-flash-on.svg +1 -0
  143. datalab/data/icons/libre-gui-about.svg +1 -0
  144. datalab/data/icons/libre-gui-action-delete.svg +1 -0
  145. datalab/data/icons/libre-gui-add.svg +1 -0
  146. datalab/data/icons/libre-gui-arrow-down.svg +1 -0
  147. datalab/data/icons/libre-gui-arrow-left.svg +1 -0
  148. datalab/data/icons/libre-gui-arrow-right.svg +1 -0
  149. datalab/data/icons/libre-gui-arrow-up.svg +1 -0
  150. datalab/data/icons/libre-gui-close.svg +40 -0
  151. datalab/data/icons/libre-gui-cogs.svg +1 -0
  152. datalab/data/icons/libre-gui-globe.svg +1 -0
  153. datalab/data/icons/libre-gui-help.svg +1 -0
  154. datalab/data/icons/libre-gui-link.svg +1 -0
  155. datalab/data/icons/libre-gui-menu.svg +1 -0
  156. datalab/data/icons/libre-gui-pencil.svg +1 -0
  157. datalab/data/icons/libre-gui-plugin.svg +1 -0
  158. datalab/data/icons/libre-gui-questions.svg +1 -0
  159. datalab/data/icons/libre-gui-settings.svg +1 -0
  160. datalab/data/icons/libre-gui-unlink.svg +1 -0
  161. datalab/data/icons/libre-tech-ram.svg +1 -0
  162. datalab/data/icons/libre-toolbox.svg +1 -0
  163. datalab/data/icons/logs.svg +1 -0
  164. datalab/data/icons/markers.svg +74 -0
  165. datalab/data/icons/menu.svg +13 -0
  166. datalab/data/icons/new_ima.svg +148 -0
  167. datalab/data/icons/new_sig.svg +123 -0
  168. datalab/data/icons/operations/abs.svg +116 -0
  169. datalab/data/icons/operations/arithmetic.svg +123 -0
  170. datalab/data/icons/operations/average.svg +124 -0
  171. datalab/data/icons/operations/complex_from_magnitude_phase.svg +116 -0
  172. datalab/data/icons/operations/complex_from_real_imag.svg +124 -0
  173. datalab/data/icons/operations/constant.svg +116 -0
  174. datalab/data/icons/operations/constant_add.svg +109 -0
  175. datalab/data/icons/operations/constant_divide.svg +109 -0
  176. datalab/data/icons/operations/constant_multiply.svg +109 -0
  177. datalab/data/icons/operations/constant_subtract.svg +109 -0
  178. datalab/data/icons/operations/convert_dtype.svg +117 -0
  179. datalab/data/icons/operations/convolution.svg +46 -0
  180. datalab/data/icons/operations/deconvolution.svg +57 -0
  181. datalab/data/icons/operations/derivative.svg +127 -0
  182. datalab/data/icons/operations/difference.svg +52 -0
  183. datalab/data/icons/operations/division.svg +139 -0
  184. datalab/data/icons/operations/exp.svg +116 -0
  185. datalab/data/icons/operations/flip_horizontally.svg +69 -0
  186. datalab/data/icons/operations/flip_vertically.svg +74 -0
  187. datalab/data/icons/operations/im.svg +124 -0
  188. datalab/data/icons/operations/integral.svg +50 -0
  189. datalab/data/icons/operations/inverse.svg +143 -0
  190. datalab/data/icons/operations/log10.svg +109 -0
  191. datalab/data/icons/operations/phase.svg +116 -0
  192. datalab/data/icons/operations/power.svg +118 -0
  193. datalab/data/icons/operations/product.svg +124 -0
  194. datalab/data/icons/operations/profile.svg +379 -0
  195. datalab/data/icons/operations/profile_average.svg +399 -0
  196. datalab/data/icons/operations/profile_radial.svg +261 -0
  197. datalab/data/icons/operations/profile_segment.svg +262 -0
  198. datalab/data/icons/operations/quadratic_difference.svg +84 -0
  199. datalab/data/icons/operations/re.svg +124 -0
  200. datalab/data/icons/operations/rotate_left.svg +72 -0
  201. datalab/data/icons/operations/rotate_right.svg +72 -0
  202. datalab/data/icons/operations/signals_to_image.svg +314 -0
  203. datalab/data/icons/operations/sqrt.svg +110 -0
  204. datalab/data/icons/operations/std.svg +124 -0
  205. datalab/data/icons/operations/sum.svg +102 -0
  206. datalab/data/icons/play_demo.svg +9 -0
  207. datalab/data/icons/processing/axis_transform.svg +62 -0
  208. datalab/data/icons/processing/bandpass.svg +79 -0
  209. datalab/data/icons/processing/bandstop.svg +71 -0
  210. datalab/data/icons/processing/binning.svg +126 -0
  211. datalab/data/icons/processing/clip.svg +119 -0
  212. datalab/data/icons/processing/detrending.svg +173 -0
  213. datalab/data/icons/processing/distribute_on_grid.svg +769 -0
  214. datalab/data/icons/processing/edge_detection.svg +46 -0
  215. datalab/data/icons/processing/erase.svg +1 -0
  216. datalab/data/icons/processing/exposure.svg +143 -0
  217. datalab/data/icons/processing/fourier.svg +104 -0
  218. datalab/data/icons/processing/highpass.svg +59 -0
  219. datalab/data/icons/processing/interpolation.svg +71 -0
  220. datalab/data/icons/processing/level_adjustment.svg +70 -0
  221. datalab/data/icons/processing/lowpass.svg +60 -0
  222. datalab/data/icons/processing/morphology.svg +49 -0
  223. datalab/data/icons/processing/noise_addition.svg +114 -0
  224. datalab/data/icons/processing/noise_reduction.svg +38 -0
  225. datalab/data/icons/processing/normalize.svg +84 -0
  226. datalab/data/icons/processing/offset_correction.svg +131 -0
  227. datalab/data/icons/processing/resampling1d.svg +101 -0
  228. datalab/data/icons/processing/resampling2d.svg +240 -0
  229. datalab/data/icons/processing/reset_positions.svg +185 -0
  230. datalab/data/icons/processing/resize.svg +9 -0
  231. datalab/data/icons/processing/reverse_signal_x.svg +171 -0
  232. datalab/data/icons/processing/stability.svg +11 -0
  233. datalab/data/icons/processing/swap_x_y.svg +65 -0
  234. datalab/data/icons/processing/thresholding.svg +63 -0
  235. datalab/data/icons/processing/windowing.svg +45 -0
  236. datalab/data/icons/properties.svg +26 -0
  237. datalab/data/icons/reset.svg +9 -0
  238. datalab/data/icons/restore.svg +40 -0
  239. datalab/data/icons/roi/roi.svg +76 -0
  240. datalab/data/icons/roi/roi_coordinate.svg +78 -0
  241. datalab/data/icons/roi/roi_copy.svg +112 -0
  242. datalab/data/icons/roi/roi_delete.svg +81 -0
  243. datalab/data/icons/roi/roi_export.svg +87 -0
  244. datalab/data/icons/roi/roi_graphical.svg +78 -0
  245. datalab/data/icons/roi/roi_grid.svg +67 -0
  246. datalab/data/icons/roi/roi_ima.svg +188 -0
  247. datalab/data/icons/roi/roi_import.svg +87 -0
  248. datalab/data/icons/roi/roi_new.svg +81 -0
  249. datalab/data/icons/roi/roi_new_circle.svg +95 -0
  250. datalab/data/icons/roi/roi_new_polygon.svg +110 -0
  251. datalab/data/icons/roi/roi_new_rectangle.svg +70 -0
  252. datalab/data/icons/roi/roi_paste.svg +98 -0
  253. datalab/data/icons/roi/roi_sig.svg +124 -0
  254. datalab/data/icons/shapes.svg +134 -0
  255. datalab/data/icons/signal.svg +103 -0
  256. datalab/data/icons/table.svg +85 -0
  257. datalab/data/icons/table_unavailable.svg +102 -0
  258. datalab/data/icons/to_signal.svg +124 -0
  259. datalab/data/icons/tour/next.svg +44 -0
  260. datalab/data/icons/tour/previous.svg +44 -0
  261. datalab/data/icons/tour/rewind.svg +51 -0
  262. datalab/data/icons/tour/stop.svg +47 -0
  263. datalab/data/icons/tour/tour.svg +16 -0
  264. datalab/data/icons/uncheck_all.svg +78 -0
  265. datalab/data/icons/view/curve_antialiasing.svg +50 -0
  266. datalab/data/icons/view/new_window.svg +98 -0
  267. datalab/data/icons/view/refresh-auto.svg +57 -0
  268. datalab/data/icons/view/refresh-manual.svg +51 -0
  269. datalab/data/icons/view/reset_curve_styles.svg +96 -0
  270. datalab/data/icons/view/show_first.svg +55 -0
  271. datalab/data/icons/view/show_titles.svg +46 -0
  272. datalab/data/icons/visualization.svg +51 -0
  273. datalab/data/logo/DataLab-Banner-150.png +0 -0
  274. datalab/data/logo/DataLab-Banner-200.png +0 -0
  275. datalab/data/logo/DataLab-Banner2-100.png +0 -0
  276. datalab/data/logo/DataLab-Splash.png +0 -0
  277. datalab/data/logo/DataLab-watermark.png +0 -0
  278. datalab/data/logo/DataLab.svg +83 -0
  279. datalab/data/tests/reordering_test.h5 +0 -0
  280. datalab/data/tutorials/fabry_perot/fabry-perot1.jpg +0 -0
  281. datalab/data/tutorials/fabry_perot/fabry-perot2.jpg +0 -0
  282. datalab/data/tutorials/laser_beam/TEM00_z_13.jpg +0 -0
  283. datalab/data/tutorials/laser_beam/TEM00_z_18.jpg +0 -0
  284. datalab/data/tutorials/laser_beam/TEM00_z_23.jpg +0 -0
  285. datalab/data/tutorials/laser_beam/TEM00_z_30.jpg +0 -0
  286. datalab/data/tutorials/laser_beam/TEM00_z_35.jpg +0 -0
  287. datalab/data/tutorials/laser_beam/TEM00_z_40.jpg +0 -0
  288. datalab/data/tutorials/laser_beam/TEM00_z_45.jpg +0 -0
  289. datalab/data/tutorials/laser_beam/TEM00_z_50.jpg +0 -0
  290. datalab/data/tutorials/laser_beam/TEM00_z_55.jpg +0 -0
  291. datalab/data/tutorials/laser_beam/TEM00_z_60.jpg +0 -0
  292. datalab/data/tutorials/laser_beam/TEM00_z_65.jpg +0 -0
  293. datalab/data/tutorials/laser_beam/TEM00_z_70.jpg +0 -0
  294. datalab/data/tutorials/laser_beam/TEM00_z_75.jpg +0 -0
  295. datalab/data/tutorials/laser_beam/TEM00_z_80.jpg +0 -0
  296. datalab/env.py +542 -0
  297. datalab/gui/__init__.py +89 -0
  298. datalab/gui/actionhandler.py +1701 -0
  299. datalab/gui/docks.py +473 -0
  300. datalab/gui/h5io.py +150 -0
  301. datalab/gui/macroeditor.py +310 -0
  302. datalab/gui/main.py +2081 -0
  303. datalab/gui/newobject.py +217 -0
  304. datalab/gui/objectview.py +766 -0
  305. datalab/gui/panel/__init__.py +48 -0
  306. datalab/gui/panel/base.py +3254 -0
  307. datalab/gui/panel/image.py +157 -0
  308. datalab/gui/panel/macro.py +607 -0
  309. datalab/gui/panel/signal.py +164 -0
  310. datalab/gui/plothandler.py +800 -0
  311. datalab/gui/processor/__init__.py +84 -0
  312. datalab/gui/processor/base.py +2456 -0
  313. datalab/gui/processor/catcher.py +75 -0
  314. datalab/gui/processor/image.py +1214 -0
  315. datalab/gui/processor/signal.py +755 -0
  316. datalab/gui/profiledialog.py +333 -0
  317. datalab/gui/roieditor.py +633 -0
  318. datalab/gui/roigrideditor.py +208 -0
  319. datalab/gui/settings.py +612 -0
  320. datalab/gui/tour.py +908 -0
  321. datalab/h5/__init__.py +12 -0
  322. datalab/h5/common.py +314 -0
  323. datalab/h5/generic.py +580 -0
  324. datalab/h5/native.py +39 -0
  325. datalab/h5/utils.py +95 -0
  326. datalab/objectmodel.py +640 -0
  327. datalab/plugins/_readme_.txt +9 -0
  328. datalab/plugins/datalab_imageformats.py +175 -0
  329. datalab/plugins/datalab_testdata.py +190 -0
  330. datalab/plugins.py +355 -0
  331. datalab/tests/__init__.py +199 -0
  332. datalab/tests/backbone/__init__.py +1 -0
  333. datalab/tests/backbone/config_unit_test.py +170 -0
  334. datalab/tests/backbone/config_versioning_unit_test.py +34 -0
  335. datalab/tests/backbone/dictlistserial_app_test.py +38 -0
  336. datalab/tests/backbone/errorcatcher_unit_test.py +69 -0
  337. datalab/tests/backbone/errormsgbox_unit_test.py +50 -0
  338. datalab/tests/backbone/execenv_unit.py +262 -0
  339. datalab/tests/backbone/loadtest_gdi.py +147 -0
  340. datalab/tests/backbone/long_callback.py +96 -0
  341. datalab/tests/backbone/main_app_test.py +137 -0
  342. datalab/tests/backbone/memory_leak.py +43 -0
  343. datalab/tests/backbone/procisolation1_unit.py +128 -0
  344. datalab/tests/backbone/procisolation2_unit.py +171 -0
  345. datalab/tests/backbone/procisolation_unit_test.py +22 -0
  346. datalab/tests/backbone/profiling_app.py +27 -0
  347. datalab/tests/backbone/strings_unit_test.py +65 -0
  348. datalab/tests/backbone/title_formatting_unit_test.py +82 -0
  349. datalab/tests/conftest.py +131 -0
  350. datalab/tests/features/__init__.py +1 -0
  351. datalab/tests/features/applauncher/__init__.py +1 -0
  352. datalab/tests/features/applauncher/launcher1_app_test.py +28 -0
  353. datalab/tests/features/applauncher/launcher2_app_test.py +30 -0
  354. datalab/tests/features/common/__init__.py +1 -0
  355. datalab/tests/features/common/add_metadata_app_test.py +134 -0
  356. datalab/tests/features/common/add_metadata_unit_test.py +267 -0
  357. datalab/tests/features/common/annotations_management_unit_test.py +152 -0
  358. datalab/tests/features/common/auto_analysis_recompute_unit_test.py +240 -0
  359. datalab/tests/features/common/createobject_unit_test.py +50 -0
  360. datalab/tests/features/common/geometry_results_app_test.py +135 -0
  361. datalab/tests/features/common/interactive_processing_test.py +1109 -0
  362. datalab/tests/features/common/io_app_test.py +75 -0
  363. datalab/tests/features/common/large_results_app_test.py +187 -0
  364. datalab/tests/features/common/metadata_all_patterns_test.py +103 -0
  365. datalab/tests/features/common/metadata_app_test.py +139 -0
  366. datalab/tests/features/common/metadata_io_unit_test.py +60 -0
  367. datalab/tests/features/common/misc_app_test.py +236 -0
  368. datalab/tests/features/common/multiple_geometry_results_unit_test.py +122 -0
  369. datalab/tests/features/common/multiple_table_results_unit_test.py +64 -0
  370. datalab/tests/features/common/operation_modes_app_test.py +392 -0
  371. datalab/tests/features/common/plot_results_app_test.py +278 -0
  372. datalab/tests/features/common/reorder_app_test.py +75 -0
  373. datalab/tests/features/common/result_deletion_unit_test.py +96 -0
  374. datalab/tests/features/common/result_merged_label_unit_test.py +154 -0
  375. datalab/tests/features/common/result_shape_settings_unit_test.py +223 -0
  376. datalab/tests/features/common/roi_plotitem_unit_test.py +64 -0
  377. datalab/tests/features/common/roieditor_unit_test.py +102 -0
  378. datalab/tests/features/common/save_to_dir_app_test.py +163 -0
  379. datalab/tests/features/common/save_to_dir_unit_test.py +474 -0
  380. datalab/tests/features/common/stat_app_test.py +40 -0
  381. datalab/tests/features/common/stats_tools_unit_test.py +77 -0
  382. datalab/tests/features/common/table_results_app_test.py +52 -0
  383. datalab/tests/features/common/textimport_unit_test.py +131 -0
  384. datalab/tests/features/common/uuid_preservation_test.py +281 -0
  385. datalab/tests/features/common/worker_unit_test.py +402 -0
  386. datalab/tests/features/control/__init__.py +1 -0
  387. datalab/tests/features/control/connect_dialog.py +28 -0
  388. datalab/tests/features/control/embedded1_unit_test.py +304 -0
  389. datalab/tests/features/control/embedded2_unit_test.py +52 -0
  390. datalab/tests/features/control/remoteclient_app_test.py +219 -0
  391. datalab/tests/features/control/remoteclient_unit.py +75 -0
  392. datalab/tests/features/control/simpleclient_unit_test.py +321 -0
  393. datalab/tests/features/hdf5/__init__.py +1 -0
  394. datalab/tests/features/hdf5/h5browser1_unit_test.py +31 -0
  395. datalab/tests/features/hdf5/h5browser2_unit.py +55 -0
  396. datalab/tests/features/hdf5/h5browser_app_test.py +77 -0
  397. datalab/tests/features/hdf5/h5import_app_test.py +25 -0
  398. datalab/tests/features/hdf5/h5importer_app_test.py +34 -0
  399. datalab/tests/features/image/__init__.py +1 -0
  400. datalab/tests/features/image/annotations_app_test.py +28 -0
  401. datalab/tests/features/image/annotations_unit_test.py +80 -0
  402. datalab/tests/features/image/average_app_test.py +46 -0
  403. datalab/tests/features/image/background_dialog_test.py +70 -0
  404. datalab/tests/features/image/blobs_app_test.py +50 -0
  405. datalab/tests/features/image/contour_app_test.py +42 -0
  406. datalab/tests/features/image/contour_fabryperot_app_test.py +51 -0
  407. datalab/tests/features/image/denoise_app_test.py +31 -0
  408. datalab/tests/features/image/distribute_on_grid_app_test.py +95 -0
  409. datalab/tests/features/image/edges_app_test.py +31 -0
  410. datalab/tests/features/image/erase_app_test.py +21 -0
  411. datalab/tests/features/image/fft2d_app_test.py +27 -0
  412. datalab/tests/features/image/flatfield_app_test.py +40 -0
  413. datalab/tests/features/image/geometry_transform_unit_test.py +396 -0
  414. datalab/tests/features/image/imagetools_app_test.py +51 -0
  415. datalab/tests/features/image/imagetools_unit_test.py +27 -0
  416. datalab/tests/features/image/load_app_test.py +73 -0
  417. datalab/tests/features/image/morph_app_test.py +32 -0
  418. datalab/tests/features/image/offsetcorrection_app_test.py +30 -0
  419. datalab/tests/features/image/peak2d_app_test.py +53 -0
  420. datalab/tests/features/image/profile_app_test.py +73 -0
  421. datalab/tests/features/image/profile_dialog_test.py +56 -0
  422. datalab/tests/features/image/roi_app_test.py +98 -0
  423. datalab/tests/features/image/roi_circ_app_test.py +62 -0
  424. datalab/tests/features/image/roi_manipulation_app_test.py +268 -0
  425. datalab/tests/features/image/roigrid_unit_test.py +60 -0
  426. datalab/tests/features/image/side_by_side_app_test.py +52 -0
  427. datalab/tests/features/macro/__init__.py +1 -0
  428. datalab/tests/features/macro/macro_app_test.py +28 -0
  429. datalab/tests/features/macro/macroeditor_unit_test.py +102 -0
  430. datalab/tests/features/signal/__init__.py +1 -0
  431. datalab/tests/features/signal/baseline_dialog_test.py +53 -0
  432. datalab/tests/features/signal/deltax_dialog_unit_test.py +34 -0
  433. datalab/tests/features/signal/fft1d_app_test.py +26 -0
  434. datalab/tests/features/signal/filter_app_test.py +44 -0
  435. datalab/tests/features/signal/fitdialog_unit_test.py +50 -0
  436. datalab/tests/features/signal/interpolation_app_test.py +110 -0
  437. datalab/tests/features/signal/loadbigsignal_app_test.py +80 -0
  438. datalab/tests/features/signal/multiple_rois_unit_test.py +132 -0
  439. datalab/tests/features/signal/pulse_features_app_test.py +118 -0
  440. datalab/tests/features/signal/pulse_features_roi_app_test.py +55 -0
  441. datalab/tests/features/signal/roi_app_test.py +78 -0
  442. datalab/tests/features/signal/roi_manipulation_app_test.py +261 -0
  443. datalab/tests/features/signal/select_xy_cursor_unit_test.py +46 -0
  444. datalab/tests/features/signal/signalpeakdetection_dialog_test.py +33 -0
  445. datalab/tests/features/signal/signals_to_image_app_test.py +98 -0
  446. datalab/tests/features/signal/xarray_compat_app_test.py +128 -0
  447. datalab/tests/features/tour_unit_test.py +22 -0
  448. datalab/tests/features/utilities/__init__.py +1 -0
  449. datalab/tests/features/utilities/installconf_unit_test.py +21 -0
  450. datalab/tests/features/utilities/logview_app_test.py +21 -0
  451. datalab/tests/features/utilities/logview_error.py +24 -0
  452. datalab/tests/features/utilities/logview_unit_test.py +21 -0
  453. datalab/tests/features/utilities/memstatus_app_test.py +42 -0
  454. datalab/tests/features/utilities/settings_unit_test.py +88 -0
  455. datalab/tests/scenarios/__init__.py +1 -0
  456. datalab/tests/scenarios/beautiful_app.py +121 -0
  457. datalab/tests/scenarios/common.py +463 -0
  458. datalab/tests/scenarios/demo.py +212 -0
  459. datalab/tests/scenarios/example_app_test.py +47 -0
  460. datalab/tests/scenarios/scenario_h5_app_test.py +75 -0
  461. datalab/tests/scenarios/scenario_ima1_app_test.py +34 -0
  462. datalab/tests/scenarios/scenario_ima2_app_test.py +34 -0
  463. datalab/tests/scenarios/scenario_mac_app_test.py +58 -0
  464. datalab/tests/scenarios/scenario_sig1_app_test.py +36 -0
  465. datalab/tests/scenarios/scenario_sig2_app_test.py +35 -0
  466. datalab/utils/__init__.py +1 -0
  467. datalab/utils/conf.py +304 -0
  468. datalab/utils/dephash.py +105 -0
  469. datalab/utils/qthelpers.py +633 -0
  470. datalab/utils/strings.py +34 -0
  471. datalab/utils/tests.py +0 -0
  472. datalab/widgets/__init__.py +1 -0
  473. datalab/widgets/connection.py +138 -0
  474. datalab/widgets/filedialog.py +91 -0
  475. datalab/widgets/fileviewer.py +84 -0
  476. datalab/widgets/fitdialog.py +788 -0
  477. datalab/widgets/h5browser.py +1048 -0
  478. datalab/widgets/imagebackground.py +111 -0
  479. datalab/widgets/instconfviewer.py +175 -0
  480. datalab/widgets/logviewer.py +80 -0
  481. datalab/widgets/signalbaseline.py +90 -0
  482. datalab/widgets/signalcursor.py +208 -0
  483. datalab/widgets/signaldeltax.py +151 -0
  484. datalab/widgets/signalpeak.py +199 -0
  485. datalab/widgets/status.py +249 -0
  486. datalab/widgets/textimport.py +786 -0
  487. datalab/widgets/warningerror.py +223 -0
  488. datalab/widgets/wizard.py +286 -0
  489. datalab_platform-1.0.0.dist-info/METADATA +121 -0
  490. datalab_platform-1.0.0.dist-info/RECORD +494 -0
  491. datalab_platform-0.0.1.dev0.dist-info/METADATA +0 -67
  492. datalab_platform-0.0.1.dev0.dist-info/RECORD +0 -7
  493. {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/WHEEL +0 -0
  494. {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/entry_points.txt +0 -0
  495. {datalab_platform-0.0.1.dev0.dist-info → datalab_platform-1.0.0.dist-info}/licenses/LICENSE +0 -0
  496. {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)