setiastrosuitepro 1.6.2.post1__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1293 @@
1
+ # pro/multiscale_decomp.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import cv2
5
+
6
+ from dataclasses import dataclass
7
+ from PyQt6.QtCore import Qt, QTimer
8
+ from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon
9
+ from PyQt6.QtWidgets import (
10
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
11
+ QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox,
12
+ QTableWidget, QTableWidgetItem, QWidget, QLabel, QGraphicsView,
13
+ QGraphicsScene, QGraphicsPixmapItem, QMessageBox, QToolButton, QSlider, QSplitter,
14
+ QProgressDialog, QApplication
15
+ )
16
+
17
+
18
+
19
+ class _ZoomPanView(QGraphicsView):
20
+ def __init__(self, *args, **kwargs):
21
+ super().__init__(*args, **kwargs)
22
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
23
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
24
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
25
+
26
+ self._panning = False
27
+ self._pan_start = None
28
+
29
+ def wheelEvent(self, ev):
30
+ # Ctrl+wheel optional – but I’ll make plain wheel zoom since you asked
31
+ delta = ev.angleDelta().y()
32
+ if delta == 0:
33
+ return
34
+ factor = 1.25 if delta > 0 else 0.8
35
+ self.scale(factor, factor)
36
+ ev.accept()
37
+
38
+ def mousePressEvent(self, ev):
39
+ if ev.button() == Qt.MouseButton.LeftButton:
40
+ self._panning = True
41
+ self._pan_start = ev.pos()
42
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
43
+ ev.accept()
44
+ return
45
+ super().mousePressEvent(ev)
46
+
47
+ def mouseMoveEvent(self, ev):
48
+ if self._panning and self._pan_start is not None:
49
+ delta = ev.pos() - self._pan_start
50
+ self._pan_start = ev.pos()
51
+
52
+ h = self.horizontalScrollBar()
53
+ v = self.verticalScrollBar()
54
+ h.setValue(h.value() - delta.x())
55
+ v.setValue(v.value() - delta.y())
56
+ ev.accept()
57
+ return
58
+ super().mouseMoveEvent(ev)
59
+
60
+ def mouseReleaseEvent(self, ev):
61
+ if ev.button() == Qt.MouseButton.LeftButton and self._panning:
62
+ self._panning = False
63
+ self._pan_start = None
64
+ self.setCursor(Qt.CursorShape.ArrowCursor)
65
+ ev.accept()
66
+ return
67
+ super().mouseReleaseEvent(ev)
68
+
69
+
70
+ # ─────────────────────────────────────────────
71
+ # Core math (your backbone)
72
+ # ─────────────────────────────────────────────
73
+
74
+ def _blur_gaussian(img01: np.ndarray, sigma: float) -> np.ndarray:
75
+ k = int(max(3, 2 * round(3 * sigma) + 1)) # odd
76
+ return cv2.GaussianBlur(img01, (k, k), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
77
+
78
+ def multiscale_decompose(img01: np.ndarray, layers: int, base_sigma: float = 1.0):
79
+ c = img01.astype(np.float32, copy=False)
80
+ details = []
81
+ for k in range(layers):
82
+ sigma = base_sigma * (2 ** k)
83
+ c_next = _blur_gaussian(c, sigma)
84
+ w = c - c_next
85
+ details.append(w)
86
+ c = c_next
87
+ residual = c
88
+ return details, residual
89
+
90
+ def multiscale_reconstruct(details, residual):
91
+ out = residual.astype(np.float32, copy=True)
92
+ for w in details:
93
+ out += w
94
+ return out
95
+
96
+ def soft_threshold(x: np.ndarray, t: float):
97
+ a = np.abs(x)
98
+ return np.sign(x) * np.maximum(0.0, a - t)
99
+
100
+ def apply_layer_ops(
101
+ w: np.ndarray,
102
+ bias_gain: float,
103
+ thr_sigma: float, # threshold in units of σ
104
+ amount: float,
105
+ denoise_strength: float = 0.0,
106
+ sigma: float | np.ndarray | None = None,
107
+ *,
108
+ mode: str = "μ–σ Thresholding",
109
+ ):
110
+ w2 = w
111
+
112
+ # Normalize mode to something robust to label wording
113
+ m = (mode or "").strip().lower()
114
+ is_linear = m.startswith("linear")
115
+
116
+ # --- Linear mode: strictly linear multiscale transform ---
117
+ if is_linear:
118
+ # Ignore thresholding and denoise; just apply gain
119
+ if abs(bias_gain - 1.0) > 1e-6:
120
+ return w * bias_gain
121
+ return w
122
+
123
+ # --- μ–σ Thresholding mode (robust nonlinear) ---
124
+ # 1) Noise reduction step (MMT-style NR)
125
+ if denoise_strength > 0.0:
126
+ if sigma is None:
127
+ sigma = _robust_sigma(w2)
128
+ sigma_f = float(sigma)
129
+ # 3σ at denoise=1, scaled linearly
130
+ t_dn = max(0.0, denoise_strength * 3.0 * sigma_f)
131
+ if t_dn > 0.0:
132
+ w_dn = soft_threshold(w2, t_dn)
133
+ # Blend original vs denoised based on denoise_strength
134
+ w2 = (1.0 - denoise_strength) * w2 + denoise_strength * w_dn
135
+
136
+ # 2) Threshold in σ units + bias shaping
137
+ if thr_sigma > 0.0:
138
+ if sigma is None:
139
+ sigma = _robust_sigma(w2)
140
+ sigma_f = float(sigma)
141
+ t = thr_sigma * sigma_f # convert N·σ → absolute threshold
142
+ if t > 0.0:
143
+ wt = soft_threshold(w2, t)
144
+ w2 = (1.0 - amount) * w2 + amount * wt
145
+
146
+ if abs(bias_gain - 1.0) > 1e-6:
147
+ w2 = w2 * bias_gain
148
+ return w2
149
+
150
+
151
+ def _robust_sigma(arr: np.ndarray) -> float:
152
+ """
153
+ Robust per-layer sigma estimate using MAD, fallback to std if needed.
154
+ Ignores NaN/Inf and uses a subset if very large.
155
+ """
156
+ a = np.asarray(arr, dtype=np.float32)
157
+ a = a[np.isfinite(a)]
158
+ if a.size == 0:
159
+ return 1e-6
160
+
161
+ # Optional: subsample for speed on huge arrays
162
+ if a.size > 500_000:
163
+ idx = np.random.choice(a.size, 500_000, replace=False)
164
+ a = a[idx]
165
+
166
+ med = np.median(a)
167
+ mad = np.median(np.abs(a - med))
168
+ if mad <= 0:
169
+ # fallback to plain std if MAD degenerates
170
+ s = float(np.std(a))
171
+ return s if s > 0 else 1e-6
172
+
173
+ sigma = 1.4826 * mad # MAD → σ for Gaussian
174
+ return sigma if sigma > 0 else 1e-6
175
+
176
+
177
+ # ─────────────────────────────────────────────
178
+ # Layer config
179
+ # ─────────────────────────────────────────────
180
+
181
+
182
+ @dataclass
183
+ class LayerCfg:
184
+ enabled: bool = True
185
+ bias_gain: float = 1.0 # 1.0 = unchanged
186
+ thr: float = 0.0 # soft threshold in detail domain
187
+ amount: float = 0.0 # 0..1 blend toward thresholded
188
+ denoise: float = 0.0 # 0..1 additional noise reduction
189
+
190
+
191
+ # ─────────────────────────────────────────────
192
+ # Dialog
193
+ # ─────────────────────────────────────────────
194
+
195
+ class MultiscaleDecompDialog(QDialog):
196
+ def __init__(self, parent, doc):
197
+ super().__init__(parent)
198
+ self.setWindowTitle("Multiscale Decomposition")
199
+ self.setWindowFlag(Qt.WindowType.Window, True)
200
+ self.setWindowModality(Qt.WindowModality.NonModal)
201
+ self.setModal(False)
202
+ self.setMinimumSize(1050, 700)
203
+ self.residual_enabled = True
204
+ self._layer_noise = None # list[float] per detail layer
205
+
206
+ self._doc = doc
207
+ base = getattr(doc, "image", None)
208
+ if base is None:
209
+ raise RuntimeError("Document has no image.")
210
+
211
+ # normalize to float32 [0..1] ...
212
+ img = np.asarray(base)
213
+ img = img.astype(np.float32, copy=False)
214
+ if img.dtype.kind in "ui":
215
+ maxv = float(np.nanmax(img)) or 1.0
216
+ img = img / max(1.0, maxv)
217
+ img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
218
+
219
+ self._orig_shape = img.shape
220
+ self._orig_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
221
+
222
+ # force display buffer to 3ch ...
223
+ if img.ndim == 2:
224
+ img3 = np.repeat(img[:, :, None], 3, axis=2)
225
+ elif img.ndim == 3 and img.shape[2] == 1:
226
+ img3 = np.repeat(img, 3, axis=2)
227
+ else:
228
+ img3 = img[:, :, :3]
229
+
230
+ self._image = img3.copy() # working linear image (edited on Apply only)
231
+ self._preview_img = img3.copy()
232
+
233
+ # decomposition cache
234
+ self._cached_layers = None
235
+ self._cached_residual = None
236
+ self._cached_key = None
237
+
238
+ # per-layer configs
239
+ self.layers = 4
240
+ self.base_sigma = 1.0
241
+ self.cfgs: list[LayerCfg] = [LayerCfg() for _ in range(self.layers)]
242
+
243
+ # debounce preview updates
244
+ self._preview_timer = QTimer(self)
245
+ self._preview_timer.setSingleShot(True)
246
+ self._preview_timer.timeout.connect(self._rebuild_preview)
247
+
248
+ self._build_ui()
249
+
250
+ # ───── NEW: initialization busy dialog ─────
251
+ prog = QProgressDialog("Initializing multiscale decomposition…", "", 0, 0, self)
252
+ prog.setWindowTitle("Multiscale Decomposition")
253
+ prog.setWindowModality(Qt.WindowModality.ApplicationModal)
254
+ prog.setCancelButton(None) # no cancel button, just a busy indicator
255
+ prog.setMinimumDuration(0) # show immediately
256
+ prog.show()
257
+ QApplication.processEvents()
258
+
259
+ # heavy work (MADs, blurs, etc.)
260
+ self._recompute_decomp(force=True)
261
+ self._rebuild_preview()
262
+
263
+ prog.close()
264
+ # ─────────────── END NEW ───────────────
265
+
266
+ QTimer.singleShot(0, self._fit_view)
267
+
268
+
269
+ # ---------- UI ----------
270
+ def _build_ui(self):
271
+ root = QHBoxLayout(self)
272
+
273
+ # Splitter between preview (left) and controls (right)
274
+ splitter = QSplitter(Qt.Orientation.Horizontal)
275
+ root.addWidget(splitter)
276
+
277
+ # ----- LEFT: preview -----
278
+ left_widget = QWidget(self)
279
+ left = QVBoxLayout(left_widget)
280
+
281
+ self.scene = QGraphicsScene(self)
282
+ self.view = _ZoomPanView(self.scene)
283
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
284
+ self.pix = QGraphicsPixmapItem()
285
+ self.scene.addItem(self.pix)
286
+
287
+ left.addWidget(self.view)
288
+
289
+ zoom_row = QHBoxLayout()
290
+
291
+ self.zoom_out_btn = QToolButton()
292
+ self.zoom_out_btn.setIcon(QIcon.fromTheme("zoom-out"))
293
+ self.zoom_out_btn.setToolTip("Zoom Out")
294
+
295
+ self.zoom_in_btn = QToolButton()
296
+ self.zoom_in_btn.setIcon(QIcon.fromTheme("zoom-in"))
297
+ self.zoom_in_btn.setToolTip("Zoom In")
298
+
299
+ self.fit_btn = QToolButton()
300
+ self.fit_btn.setIcon(QIcon.fromTheme("zoom-fit-best"))
301
+ self.fit_btn.setToolTip("Fit to Preview")
302
+
303
+ self.one_to_one_btn = QToolButton()
304
+ self.one_to_one_btn.setIcon(QIcon.fromTheme("zoom-original"))
305
+ self.one_to_one_btn.setToolTip("1:1")
306
+
307
+ self.zoom_out_btn.clicked.connect(lambda: self.view.scale(0.8, 0.8))
308
+ self.zoom_in_btn.clicked.connect(lambda: self.view.scale(1.25, 1.25))
309
+ self.fit_btn.clicked.connect(self._fit_view)
310
+ self.one_to_one_btn.clicked.connect(self._one_to_one)
311
+
312
+ zoom_row.addStretch(1)
313
+ zoom_row.addWidget(self.zoom_out_btn)
314
+ zoom_row.addWidget(self.zoom_in_btn)
315
+ zoom_row.addSpacing(10)
316
+ zoom_row.addWidget(self.fit_btn)
317
+ zoom_row.addWidget(self.one_to_one_btn)
318
+ zoom_row.addStretch(1)
319
+
320
+ left.addLayout(zoom_row)
321
+
322
+ # ----- RIGHT: controls -----
323
+ right_widget = QWidget(self)
324
+ right = QVBoxLayout(right_widget)
325
+
326
+ gb_global = QGroupBox("Global")
327
+ form = QFormLayout(gb_global)
328
+
329
+ self.spin_layers = QSpinBox()
330
+ self.spin_layers.setRange(1, 10)
331
+ self.spin_layers.setValue(self.layers)
332
+
333
+ self.spin_sigma = QDoubleSpinBox()
334
+ self.spin_sigma.setRange(0.3, 5.0)
335
+ self.spin_sigma.setSingleStep(0.1)
336
+ self.spin_sigma.setValue(self.base_sigma)
337
+
338
+ self.cb_linked_rgb = QCheckBox("Linked RGB (apply same params to all channels)")
339
+ self.cb_linked_rgb.setChecked(True)
340
+
341
+ # New: Mode combo (Mean vs Linear)
342
+ self.combo_mode = QComboBox()
343
+ self.combo_mode.addItems(["μ–σ Thresholding", "Linear"])
344
+ self.combo_mode.setCurrentText("μ–σ Thresholding")
345
+ self.combo_mode.setToolTip(
346
+ "Multiscale mode:\n"
347
+ "• μ–σ Thresholding: σ-based thresholding + denoise and gain (nonlinear).\n"
348
+ "• Linear: strictly linear multiscale transform; only Gain is applied."
349
+ )
350
+
351
+ self.combo_preview = QComboBox()
352
+ self._refresh_preview_combo()
353
+
354
+ form.addRow("Layers:", self.spin_layers)
355
+ form.addRow("Base sigma:", self.spin_sigma)
356
+ form.addRow(self.cb_linked_rgb)
357
+ form.addRow("Mode:", self.combo_mode) # <── NEW ROW
358
+ form.addRow("Layer preview:", self.combo_preview)
359
+
360
+ right.addWidget(gb_global)
361
+
362
+ # Layers table
363
+ gb_layers = QGroupBox("Layers")
364
+ v = QVBoxLayout(gb_layers)
365
+ self.table = QTableWidget(0, 8)
366
+ self.table.setHorizontalHeaderLabels(
367
+ ["On", "Layer", "Scale", "Gain", "Thr (σ)", "Amt", "NR", "Type"]
368
+ )
369
+
370
+ self.table.verticalHeader().setVisible(False)
371
+ self.table.setSelectionBehavior(self.table.SelectionBehavior.SelectRows)
372
+ self.table.setSelectionMode(self.table.SelectionMode.SingleSelection)
373
+ v.addWidget(self.table)
374
+ right.addWidget(gb_layers, stretch=1)
375
+
376
+ # Per-layer editor (now with sliders)
377
+ gb_edit = QGroupBox("Selected Layer")
378
+ ef = QFormLayout(gb_edit)
379
+ self.lbl_sel = QLabel("Layer: —")
380
+
381
+ # --- Spin boxes ---
382
+ self.spin_gain = QDoubleSpinBox()
383
+ self.spin_gain.setRange(0.0, 3.0)
384
+ self.spin_gain.setSingleStep(0.05)
385
+ self.spin_gain.setValue(1.0)
386
+ self.spin_gain.setToolTip(
387
+ "Gain: multiplies the detail coefficients on this layer.\n"
388
+ "1.0 = unchanged, >1.0 boosts detail, <1.0 reduces it."
389
+ )
390
+
391
+ self.spin_thr = QDoubleSpinBox()
392
+ self.spin_thr.setRange(0.0, 10.0) # N·σ
393
+ self.spin_thr.setSingleStep(0.1)
394
+ self.spin_thr.setDecimals(2)
395
+ self.spin_thr.setToolTip(
396
+ "Threshold (σ): soft threshold level in units of this layer's noise σ.\n"
397
+ "0 = no thresholding; 1–3 ≈ mild to strong suppression of small coefficients."
398
+ )
399
+
400
+ self.spin_amt = QDoubleSpinBox()
401
+ self.spin_amt.setRange(0.0, 1.0)
402
+ self.spin_amt.setSingleStep(0.05)
403
+ self.spin_amt.setToolTip(
404
+ "Amount: blend factor toward the thresholded version of the layer.\n"
405
+ "0 = ignore thresholding, 1 = fully use the thresholded layer."
406
+ )
407
+
408
+ self.spin_denoise = QDoubleSpinBox()
409
+ self.spin_denoise.setRange(0.0, 1.0)
410
+ self.spin_denoise.setSingleStep(0.05)
411
+ self.spin_denoise.setValue(0.0)
412
+ self.spin_denoise.setToolTip(
413
+ "Denoise: extra multiscale noise reduction on this layer.\n"
414
+ "0 = off, 1 = strong NR (≈3σ soft threshold blended in)."
415
+ )
416
+
417
+ # --- Sliders (int ranges, mapped to spins) ---
418
+ self.slider_gain = QSlider(Qt.Orientation.Horizontal)
419
+ self.slider_gain.setRange(0, 300) # 0..3.00
420
+ self.slider_gain.setToolTip(self.spin_gain.toolTip())
421
+
422
+ self.slider_thr = QSlider(Qt.Orientation.Horizontal)
423
+ self.slider_thr.setRange(0, 1000) # 0..10.00 σ (×0.01)
424
+ self.slider_thr.setToolTip(self.spin_thr.toolTip())
425
+
426
+ self.slider_amt = QSlider(Qt.Orientation.Horizontal)
427
+ self.slider_amt.setRange(0, 100) # 0..1.00
428
+ self.slider_amt.setToolTip(self.spin_amt.toolTip())
429
+
430
+ self.slider_denoise = QSlider(Qt.Orientation.Horizontal)
431
+ self.slider_denoise.setRange(0, 100) # 0..1.00
432
+ self.slider_denoise.setToolTip(self.spin_denoise.toolTip())
433
+
434
+ # Layout rows: label -> [slider | spinbox]
435
+ ef.addRow(self.lbl_sel)
436
+
437
+ gain_row = QHBoxLayout()
438
+ gain_row.addWidget(self.slider_gain)
439
+ gain_row.addWidget(self.spin_gain)
440
+ ef.addRow("Gain:", gain_row)
441
+
442
+ thr_row = QHBoxLayout()
443
+ thr_row.addWidget(self.slider_thr)
444
+ thr_row.addWidget(self.spin_thr)
445
+ ef.addRow("Threshold (σ):", thr_row)
446
+
447
+ amt_row = QHBoxLayout()
448
+ amt_row.addWidget(self.slider_amt)
449
+ amt_row.addWidget(self.spin_amt)
450
+ ef.addRow("Amount:", amt_row)
451
+
452
+ dn_row = QHBoxLayout()
453
+ dn_row.addWidget(self.slider_denoise)
454
+ dn_row.addWidget(self.spin_denoise)
455
+ ef.addRow("Denoise:", dn_row)
456
+
457
+ right.addWidget(gb_edit)
458
+
459
+ # Buttons
460
+ btn_row = QHBoxLayout()
461
+ self.btn_apply = QPushButton("Apply to Document")
462
+ self.btn_detail_new = QPushButton("Send to New Document")
463
+ self.btn_split_layers = QPushButton("Split Layers to Documents")
464
+ self.btn_close = QPushButton("Close")
465
+
466
+ btn_row.addStretch(1)
467
+ btn_row.addWidget(self.btn_apply)
468
+ btn_row.addWidget(self.btn_detail_new)
469
+ btn_row.addWidget(self.btn_split_layers)
470
+ btn_row.addWidget(self.btn_close)
471
+ right.addLayout(btn_row)
472
+
473
+ # Add widgets to splitter
474
+ splitter.addWidget(left_widget)
475
+ splitter.addWidget(right_widget)
476
+ splitter.setStretchFactor(0, 2)
477
+ splitter.setStretchFactor(1, 1)
478
+
479
+ # ----- Signals -----
480
+ self.spin_layers.valueChanged.connect(self._on_layers_changed)
481
+ self.spin_sigma.valueChanged.connect(self._on_global_changed)
482
+ self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
483
+ self.combo_preview.currentIndexChanged.connect(self._schedule_preview)
484
+
485
+ self.table.itemSelectionChanged.connect(self._on_table_select)
486
+
487
+ # spinboxes -> layer cfg
488
+ self.spin_gain.valueChanged.connect(self._on_layer_editor_changed)
489
+ self.spin_thr.valueChanged.connect(self._on_layer_editor_changed)
490
+ self.spin_amt.valueChanged.connect(self._on_layer_editor_changed)
491
+ self.spin_denoise.valueChanged.connect(self._on_layer_editor_changed)
492
+
493
+ # sliders -> spinboxes
494
+ self.slider_gain.valueChanged.connect(self._on_gain_slider_changed)
495
+ self.slider_thr.valueChanged.connect(self._on_thr_slider_changed)
496
+ self.slider_amt.valueChanged.connect(self._on_amt_slider_changed)
497
+ self.slider_denoise.valueChanged.connect(self._on_dn_slider_changed)
498
+
499
+ self.btn_apply.clicked.connect(self._commit_to_doc)
500
+ self.btn_detail_new.clicked.connect(self._send_detail_to_new_doc)
501
+ self.btn_split_layers.clicked.connect(self._split_layers_to_docs)
502
+ self.btn_close.clicked.connect(self.reject)
503
+
504
+ # ---------- Preview plumbing ----------
505
+ def _on_mode_changed(self, idx: int):
506
+ # Re-enable/disable controls as needed
507
+ self._update_param_widgets_for_mode()
508
+ self._schedule_preview()
509
+
510
+ def _schedule_preview(self):
511
+ self._preview_timer.start(60)
512
+
513
+ def _recompute_decomp(self, force: bool = False):
514
+ layers = int(self.spin_layers.value())
515
+ base_sigma = float(self.spin_sigma.value())
516
+ key = (layers, base_sigma)
517
+
518
+ if (not force) and self._cached_key == key and self._cached_layers is not None:
519
+ return
520
+
521
+ self.layers = layers
522
+ self.base_sigma = base_sigma
523
+
524
+ self._cached_layers, self._cached_residual = multiscale_decompose(
525
+ self._image, layers=self.layers, base_sigma=self.base_sigma
526
+ )
527
+ self._cached_key = key
528
+
529
+ self._layer_noise = []
530
+ for w in self._cached_layers:
531
+ sigma = _robust_sigma(w) if w.size else 1e-6
532
+ self._layer_noise.append(sigma)
533
+
534
+ # ensure cfg list matches layer count
535
+ if len(self.cfgs) != self.layers:
536
+ old = self.cfgs[:]
537
+ self.cfgs = [LayerCfg() for _ in range(self.layers)]
538
+ for i in range(min(len(old), self.layers)):
539
+ self.cfgs[i] = old[i]
540
+
541
+ self._rebuild_table()
542
+ self._refresh_preview_combo()
543
+
544
+ def _build_tuned_layers(self):
545
+ """
546
+ Ensure decomposition is current and apply per-layer ops
547
+ using the current mode and layer configs.
548
+
549
+ Returns (tuned_layers, residual) or (None, None) on failure.
550
+ """
551
+ self._recompute_decomp(force=False)
552
+
553
+ details = self._cached_layers
554
+ residual = self._cached_residual
555
+ if details is None or residual is None:
556
+ return None, None
557
+
558
+ mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
559
+
560
+ tuned = []
561
+ for i, w in enumerate(details):
562
+ cfg = self.cfgs[i]
563
+ if not cfg.enabled:
564
+ tuned.append(np.zeros_like(w))
565
+ else:
566
+ sigma = None
567
+ if self._layer_noise is not None and i < len(self._layer_noise):
568
+ sigma = self._layer_noise[i]
569
+ tuned.append(
570
+ apply_layer_ops(
571
+ w,
572
+ cfg.bias_gain,
573
+ cfg.thr,
574
+ cfg.amount,
575
+ cfg.denoise,
576
+ sigma,
577
+ mode=mode,
578
+ )
579
+ )
580
+
581
+ return tuned, residual
582
+
583
+
584
+ def _rebuild_preview(self):
585
+ tuned, residual = self._build_tuned_layers()
586
+ if tuned is None or residual is None:
587
+ return
588
+
589
+ # reconstruction (keep raw version for visualization)
590
+ res = residual if self.residual_enabled else np.zeros_like(residual)
591
+ out_raw = multiscale_reconstruct(tuned, res)
592
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
593
+
594
+ sel = self.combo_preview.currentData()
595
+ if sel is None or sel == "final":
596
+ if not self.residual_enabled:
597
+ # Detail-only visualization: SAME style as detail-layer preview
598
+ d = out_raw.astype(np.float32, copy=False)
599
+ vis = 0.5 + d * 4.0 # same gain as single-layer view
600
+ self._preview_img = np.clip(vis, 0.0, 1.0).astype(np.float32, copy=False)
601
+ else:
602
+ self._preview_img = out
603
+
604
+ elif sel == "residual":
605
+ self._preview_img = np.clip(residual, 0, 1)
606
+
607
+ else:
608
+ # sel is int index of detail layer
609
+ w = tuned[int(sel)]
610
+ vis = np.clip(0.5 + (w * 4.0), 0.0, 1.0)
611
+ self._preview_img = vis.astype(np.float32, copy=False)
612
+
613
+ self._refresh_pix()
614
+
615
+ def _update_param_widgets_for_mode(self):
616
+ linear = (self.combo_mode.currentText() == "Linear")
617
+
618
+ # Always allow Gain in both modes
619
+ gain_widgets = (self.spin_gain, self.slider_gain)
620
+
621
+ # These are only meaningful in Mean mode
622
+ nonlin_widgets = (
623
+ self.spin_thr, self.slider_thr,
624
+ self.spin_amt, self.slider_amt,
625
+ self.spin_denoise, self.slider_denoise,
626
+ )
627
+
628
+ # For residual row we already disable everything in _load_layer_into_editor,
629
+ # so here we just respect the current selection.
630
+ idx = getattr(self, "_selected_layer", None)
631
+ if idx is None or idx == self.layers:
632
+ # Residual – handled in _load_layer_into_editor
633
+ return
634
+
635
+ for w in gain_widgets:
636
+ w.setEnabled(True)
637
+
638
+ for w in nonlin_widgets:
639
+ w.setEnabled(not linear)
640
+
641
+
642
+ def _np_to_qpix(self, img: np.ndarray) -> QPixmap:
643
+ arr = np.ascontiguousarray(np.clip(img * 255.0, 0, 255).astype(np.uint8))
644
+ h, w = arr.shape[:2]
645
+ if arr.ndim == 2:
646
+ arr = np.repeat(arr[:, :, None], 3, axis=2)
647
+ qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
648
+ return QPixmap.fromImage(qimg)
649
+
650
+ def _refresh_pix(self):
651
+ self.pix.setPixmap(self._np_to_qpix(self._preview_img))
652
+ self.scene.setSceneRect(self.pix.boundingRect())
653
+
654
+ def _fit_view(self):
655
+ if self.pix.pixmap().isNull():
656
+ return
657
+ self.view.resetTransform()
658
+ self.view.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
659
+
660
+ def _one_to_one(self):
661
+ self.view.resetTransform()
662
+
663
+ # ---------- Table / layer editing ----------
664
+ def _on_gain_slider_changed(self, v: int):
665
+ # 0..300 -> 0.00..3.00
666
+ val = v / 100.0
667
+ self.spin_gain.blockSignals(True)
668
+ self.spin_gain.setValue(val)
669
+ self.spin_gain.blockSignals(False)
670
+ self._on_layer_editor_changed()
671
+
672
+ def _on_thr_slider_changed(self, v: int):
673
+ # 0..1000 -> 0.00..10.00 σ
674
+ val = v / 100.0
675
+ self.spin_thr.blockSignals(True)
676
+ self.spin_thr.setValue(val)
677
+ self.spin_thr.blockSignals(False)
678
+ self._on_layer_editor_changed()
679
+
680
+
681
+ def _on_amt_slider_changed(self, v: int):
682
+ # 0..100 -> 0.00..1.00
683
+ val = v / 100.0
684
+ self.spin_amt.blockSignals(True)
685
+ self.spin_amt.setValue(val)
686
+ self.spin_amt.blockSignals(False)
687
+ self._on_layer_editor_changed()
688
+
689
+ def _on_dn_slider_changed(self, v: int):
690
+ # 0..100 -> 0.00..1.00
691
+ val = v / 100.0
692
+ self.spin_denoise.blockSignals(True)
693
+ self.spin_denoise.setValue(val)
694
+ self.spin_denoise.blockSignals(False)
695
+ self._on_layer_editor_changed()
696
+
697
+
698
+ def _rebuild_table(self):
699
+ self.table.blockSignals(True)
700
+ try:
701
+ # +1 row for residual ("R")
702
+ self.table.setRowCount(self.layers + 1)
703
+
704
+ # detail rows
705
+ for i in range(self.layers):
706
+ cfg = self.cfgs[i]
707
+
708
+ item_on = QTableWidgetItem("")
709
+ item_on.setFlags(item_on.flags() | Qt.ItemFlag.ItemIsUserCheckable)
710
+ item_on.setCheckState(Qt.CheckState.Checked if cfg.enabled else Qt.CheckState.Unchecked)
711
+ self.table.setItem(i, 0, item_on)
712
+
713
+ self.table.setItem(i, 1, QTableWidgetItem(str(i + 1)))
714
+ self.table.setItem(i, 2, QTableWidgetItem(f"{self.base_sigma * (2**i):.2f}"))
715
+ self.table.setItem(i, 3, QTableWidgetItem(f"{cfg.bias_gain:.2f}"))
716
+ self.table.setItem(i, 4, QTableWidgetItem(f"{cfg.thr:.2f}")) # N·σ
717
+ self.table.setItem(i, 5, QTableWidgetItem(f"{cfg.amount:.2f}"))
718
+ self.table.setItem(i, 6, QTableWidgetItem(f"{cfg.denoise:.2f}"))
719
+
720
+ self.table.setItem(i, 7, QTableWidgetItem("D"))
721
+
722
+ # residual row
723
+ r = self.layers
724
+ item_on = QTableWidgetItem("")
725
+ item_on.setFlags(item_on.flags() | Qt.ItemFlag.ItemIsUserCheckable)
726
+ item_on.setCheckState(
727
+ Qt.CheckState.Checked if self.residual_enabled else Qt.CheckState.Unchecked
728
+ )
729
+ self.table.setItem(r, 0, item_on)
730
+
731
+ self.table.setItem(r, 1, QTableWidgetItem("R"))
732
+ self.table.setItem(r, 2, QTableWidgetItem("—"))
733
+ self.table.setItem(r, 3, QTableWidgetItem("1.00"))
734
+ self.table.setItem(r, 4, QTableWidgetItem("0.0000"))
735
+ self.table.setItem(r, 5, QTableWidgetItem("0.00"))
736
+ self.table.setItem(r, 6, QTableWidgetItem("0.00"))
737
+ self.table.setItem(r, 7, QTableWidgetItem("R"))
738
+
739
+ finally:
740
+ self.table.blockSignals(False)
741
+
742
+ # connect once (avoid stacking connects)
743
+ try:
744
+ self.table.itemChanged.disconnect(self._on_table_item_changed)
745
+ except Exception:
746
+ pass
747
+ self.table.itemChanged.connect(self._on_table_item_changed)
748
+
749
+ if self.layers > 0 and not self.table.selectedItems():
750
+ self.table.selectRow(0)
751
+ self._load_layer_into_editor(0)
752
+
753
+ def _on_table_item_changed(self, item: QTableWidgetItem):
754
+ r, c = item.row(), item.column()
755
+
756
+ # Residual row
757
+ if r == self.layers:
758
+ if c == 0:
759
+ self.residual_enabled = (item.checkState() == Qt.CheckState.Checked)
760
+ self._schedule_preview()
761
+ # ignore other edits for residual
762
+ return
763
+
764
+ if not (0 <= r < len(self.cfgs)):
765
+ return
766
+
767
+ cfg = self.cfgs[r]
768
+
769
+ if c == 0:
770
+ # On/off
771
+ cfg.enabled = (item.checkState() == Qt.CheckState.Checked)
772
+ self._schedule_preview()
773
+ return
774
+
775
+ # numeric columns: Gain(3), Thr(4), Amt(5), NR(6)
776
+ try:
777
+ text = item.text().strip()
778
+ val = float(text) if text else 0.0
779
+ except Exception:
780
+ return
781
+
782
+ if c == 3:
783
+ cfg.bias_gain = val
784
+ elif c == 4:
785
+ cfg.thr = val
786
+ elif c == 5:
787
+ cfg.amount = val
788
+ elif c == 6:
789
+ cfg.denoise = val
790
+ else:
791
+ return
792
+
793
+ # If this row is currently selected, update editor widgets too
794
+ if getattr(self, "_selected_layer", None) == r:
795
+ self._load_layer_into_editor(r)
796
+
797
+ self._schedule_preview()
798
+
799
+
800
+
801
+ def _on_table_select(self):
802
+ rows = {it.row() for it in self.table.selectedItems()}
803
+ if not rows:
804
+ return
805
+ r = min(rows)
806
+ self._load_layer_into_editor(r)
807
+
808
+ def _load_layer_into_editor(self, idx: int):
809
+ self._selected_layer = idx
810
+
811
+ if idx == self.layers:
812
+ self.lbl_sel.setText("Layer: R (Residual)")
813
+ for w in (self.spin_gain, self.spin_thr, self.spin_amt, self.spin_denoise,
814
+ self.slider_gain, self.slider_thr, self.slider_amt, self.slider_denoise):
815
+ w.setEnabled(False)
816
+ return
817
+
818
+ for w in (self.spin_gain, self.spin_thr, self.spin_amt, self.spin_denoise,
819
+ self.slider_gain, self.slider_thr, self.slider_amt, self.slider_denoise):
820
+ w.setEnabled(True)
821
+
822
+ cfg = self.cfgs[idx]
823
+ self.lbl_sel.setText(f"Layer: {idx+1} / {self.layers}")
824
+
825
+ # spins + sliders in sync
826
+ self.spin_gain.blockSignals(True)
827
+ self.spin_thr.blockSignals(True)
828
+ self.spin_amt.blockSignals(True)
829
+ self.spin_denoise.blockSignals(True)
830
+
831
+ self.slider_gain.blockSignals(True)
832
+ self.slider_thr.blockSignals(True)
833
+ self.slider_amt.blockSignals(True)
834
+ self.slider_denoise.blockSignals(True)
835
+ try:
836
+ self.spin_gain.setValue(cfg.bias_gain)
837
+ self.spin_thr.setValue(cfg.thr) # thr is N·σ now
838
+ self.spin_amt.setValue(cfg.amount)
839
+ self.spin_denoise.setValue(cfg.denoise)
840
+
841
+ self.slider_gain.setValue(int(round(cfg.bias_gain * 100.0)))
842
+ self.slider_thr.setValue(int(round(cfg.thr * 100.0))) # N·σ → 0..1000
843
+ self.slider_amt.setValue(int(round(cfg.amount * 100.0)))
844
+ self.slider_denoise.setValue(int(round(cfg.denoise * 100.0)))
845
+ finally:
846
+ self.spin_gain.blockSignals(False)
847
+ self.spin_thr.blockSignals(False)
848
+ self.spin_amt.blockSignals(False)
849
+ self.spin_denoise.blockSignals(False)
850
+ self.slider_gain.blockSignals(False)
851
+ self.slider_thr.blockSignals(False)
852
+ self.slider_amt.blockSignals(False)
853
+ self.slider_denoise.blockSignals(False)
854
+ self._update_param_widgets_for_mode()
855
+
856
+
857
+
858
+ def _on_layer_editor_changed(self):
859
+ idx = getattr(self, "_selected_layer", None)
860
+ if idx is None or not (0 <= idx < len(self.cfgs)):
861
+ return
862
+ cfg = self.cfgs[idx]
863
+ cfg.bias_gain = float(self.spin_gain.value())
864
+ cfg.thr = float(self.spin_thr.value())
865
+ cfg.amount = float(self.spin_amt.value())
866
+ cfg.denoise = float(self.spin_denoise.value())
867
+
868
+ # keep table in sync
869
+ self.table.blockSignals(True)
870
+ try:
871
+ self.table.item(idx, 3).setText(f"{cfg.bias_gain:.2f}")
872
+ self.table.item(idx, 4).setText(f"{cfg.thr:.2f}") # N·σ
873
+ self.table.item(idx, 5).setText(f"{cfg.amount:.2f}")
874
+ self.table.item(idx, 6).setText(f"{cfg.denoise:.2f}")
875
+
876
+ finally:
877
+ self.table.blockSignals(False)
878
+
879
+ self._schedule_preview()
880
+
881
+ def _on_layers_changed(self):
882
+ self._recompute_decomp(force=True)
883
+ self._schedule_preview()
884
+
885
+ def _on_global_changed(self):
886
+ self._recompute_decomp(force=True)
887
+ self._schedule_preview()
888
+
889
+ def _refresh_preview_combo(self):
890
+ self.combo_preview.blockSignals(True)
891
+ try:
892
+ self.combo_preview.clear()
893
+ self.combo_preview.addItem("Final", userData="final")
894
+ self.combo_preview.addItem("R (Residual)", userData="residual")
895
+ for i in range(self.layers):
896
+ self.combo_preview.addItem(f"Detail Layer {i+1}", userData=i)
897
+ finally:
898
+ self.combo_preview.blockSignals(False)
899
+
900
+ # ---------- Apply to doc ----------
901
+ def _commit_to_doc(self):
902
+ tuned, residual = self._build_tuned_layers()
903
+ if tuned is None or residual is None:
904
+ return
905
+
906
+ # --- Reconstruction (match preview behavior) ---
907
+ res = residual if self.residual_enabled else np.zeros_like(residual)
908
+ out_raw = multiscale_reconstruct(tuned, res)
909
+
910
+ if not self.residual_enabled:
911
+ # Detail-only result: same “mid-gray + gain” hack as preview
912
+ d = out_raw.astype(np.float32, copy=False)
913
+ out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
914
+ else:
915
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
916
+
917
+ # convert back to mono if original was mono
918
+ if self._orig_mono:
919
+ mono = out[..., 0]
920
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
921
+ mono = mono[:, :, None]
922
+ out_final = mono.astype(np.float32, copy=False)
923
+ else:
924
+ out_final = out
925
+
926
+ try:
927
+ if hasattr(self._doc, "set_image"):
928
+ self._doc.set_image(out_final, step_name="Multiscale Decomposition")
929
+ elif hasattr(self._doc, "apply_numpy"):
930
+ self._doc.apply_numpy(out_final, step_name="Multiscale Decomposition")
931
+ else:
932
+ self._doc.image = out_final
933
+ except Exception as e:
934
+ QMessageBox.critical(self, "Multiscale Decomposition", f"Failed to write to document:\n{e}")
935
+ return
936
+
937
+ if hasattr(self.parent(), "_refresh_active_view"):
938
+ try:
939
+ self.parent()._refresh_active_view()
940
+ except Exception:
941
+ pass
942
+
943
+ self.accept()
944
+
945
+ def _send_detail_to_new_doc(self):
946
+ """
947
+ Send the *final* multiscale result (same as Apply to Document)
948
+ to a brand-new document via DocManager.
949
+
950
+ - If residual is enabled: standard 0..1 clipped composite.
951
+ - If residual is disabled: uses the mid-gray detail-only hack
952
+ (0.5 + d*4.0), just like the preview/commit path.
953
+ """
954
+ self._recompute_decomp(force=False)
955
+
956
+ details = self._cached_layers
957
+ residual = self._cached_residual
958
+ if details is None or residual is None:
959
+ return
960
+
961
+ dm = self._get_doc_manager()
962
+ if dm is None:
963
+ QMessageBox.warning(
964
+ self,
965
+ "Multiscale Decomposition",
966
+ "No DocManager available to create a new document."
967
+ )
968
+ return
969
+
970
+ # --- Same tuned-layer logic as _commit_to_doc -------------------
971
+ mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
972
+
973
+ tuned = []
974
+ for i, w in enumerate(details):
975
+ cfg = self.cfgs[i]
976
+ if not cfg.enabled:
977
+ tuned.append(np.zeros_like(w))
978
+ else:
979
+ sigma = None
980
+ if self._layer_noise is not None and i < len(self._layer_noise):
981
+ sigma = self._layer_noise[i]
982
+ tuned.append(
983
+ apply_layer_ops(
984
+ w,
985
+ cfg.bias_gain,
986
+ cfg.thr,
987
+ cfg.amount,
988
+ cfg.denoise,
989
+ sigma,
990
+ mode=mode,
991
+ )
992
+ )
993
+
994
+ # --- Reconstruction (match Apply-to-Document behavior) ----------
995
+ res = residual if self.residual_enabled else np.zeros_like(residual)
996
+ out_raw = multiscale_reconstruct(tuned, res)
997
+
998
+ if not self.residual_enabled:
999
+ # Detail-only flavor: mid-gray + gain hack
1000
+ d = out_raw.astype(np.float32, copy=False)
1001
+ out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1002
+ else:
1003
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1004
+
1005
+ # --- Back to original mono/color layout -------------------------
1006
+ if self._orig_mono:
1007
+ mono = out[..., 0]
1008
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1009
+ mono = mono[:, :, None]
1010
+ out_final = mono.astype(np.float32, copy=False)
1011
+ else:
1012
+ out_final = out
1013
+
1014
+ title = "Multiscale Result"
1015
+ meta = self._build_new_doc_metadata(title, out_final)
1016
+
1017
+ try:
1018
+ dm.create_document(out_final, metadata=meta, name=title)
1019
+ except Exception as e:
1020
+ QMessageBox.critical(
1021
+ self,
1022
+ "Multiscale Decomposition",
1023
+ f"Failed to create new document:\n{e}"
1024
+ )
1025
+
1026
+ def _split_layers_to_docs(self):
1027
+ """
1028
+ Create a new document for each tuned detail layer *and* the residual.
1029
+
1030
+ - Detail layers use the same mid-gray visualization as the per-layer preview:
1031
+ vis = 0.5 + layer*4.0
1032
+ - Residual layer is just the residual itself (0..1 clipped).
1033
+ """
1034
+ self._recompute_decomp(force=False)
1035
+
1036
+ details = self._cached_layers
1037
+ residual = self._cached_residual
1038
+ if details is None or residual is None:
1039
+ return
1040
+
1041
+ dm = self._get_doc_manager()
1042
+ if dm is None:
1043
+ QMessageBox.warning(
1044
+ self,
1045
+ "Multiscale Decomposition",
1046
+ "No DocManager available to create new documents."
1047
+ )
1048
+ return
1049
+
1050
+ mode = self.combo_mode.currentText()
1051
+ # Build tuned layers just like everywhere else
1052
+ tuned = []
1053
+ for i, w in enumerate(details):
1054
+ cfg = self.cfgs[i]
1055
+ if not cfg.enabled:
1056
+ tuned.append(np.zeros_like(w))
1057
+ else:
1058
+ sigma = None
1059
+ if self._layer_noise is not None and i < len(self._layer_noise):
1060
+ sigma = self._layer_noise[i]
1061
+ tuned.append(
1062
+ apply_layer_ops(
1063
+ w,
1064
+ cfg.bias_gain,
1065
+ cfg.thr,
1066
+ cfg.amount,
1067
+ cfg.denoise,
1068
+ sigma,
1069
+ mode=mode,
1070
+ )
1071
+ )
1072
+
1073
+ # ---- 1) Detail layers ------------------------------------------
1074
+ for i, layer in enumerate(tuned):
1075
+ d = layer.astype(np.float32, copy=False)
1076
+ vis = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1077
+
1078
+ if self._orig_mono:
1079
+ mono = vis[..., 0]
1080
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1081
+ mono = mono[:, :, None]
1082
+ out_final = mono.astype(np.float32, copy=False)
1083
+ else:
1084
+ out_final = vis
1085
+
1086
+ title = f"Multiscale Detail Layer {i+1}"
1087
+ meta = self._build_new_doc_metadata(title, out_final)
1088
+
1089
+ try:
1090
+ dm.create_document(out_final, metadata=meta, name=title)
1091
+ except Exception as e:
1092
+ QMessageBox.critical(
1093
+ self,
1094
+ "Multiscale Decomposition",
1095
+ f"Failed to create document for layer {i+1}:\n{e}"
1096
+ )
1097
+ # Don’t bail entirely on first error if you’d rather continue;
1098
+ # right now we stop on first hard failure.
1099
+ return
1100
+
1101
+ # ---- 2) Residual layer -----------------------------------------
1102
+ try:
1103
+ res = residual.astype(np.float32, copy=False)
1104
+ res_img = np.clip(res, 0.0, 1.0)
1105
+
1106
+ if self._orig_mono:
1107
+ mono = res_img[..., 0]
1108
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1109
+ mono = mono[:, :, None]
1110
+ res_final = mono.astype(np.float32, copy=False)
1111
+ else:
1112
+ res_final = res_img
1113
+
1114
+ r_title = "Multiscale Residual Layer"
1115
+ r_meta = self._build_new_doc_metadata(r_title, res_final)
1116
+
1117
+ dm.create_document(res_final, metadata=r_meta, name=r_title)
1118
+ except Exception as e:
1119
+ QMessageBox.critical(
1120
+ self,
1121
+ "Multiscale Decomposition",
1122
+ f"Failed to create residual-layer document:\n{e}"
1123
+ )
1124
+
1125
+
1126
+
1127
+ def _get_doc_manager(self):
1128
+ """
1129
+ Best-effort: find the DocManager that owns the source document.
1130
+ Prefer the doc's own _doc_manager; fall back to parent.doc_manager.
1131
+ """
1132
+ doc = getattr(self, "_doc", None)
1133
+ dm = getattr(doc, "_doc_manager", None) if doc is not None else None
1134
+
1135
+ if dm is None:
1136
+ parent = self.parent()
1137
+ dm = getattr(parent, "doc_manager", None) if parent is not None else None
1138
+
1139
+ return dm
1140
+
1141
+ def _build_new_doc_metadata(self, title: str, img: np.ndarray) -> dict:
1142
+ """
1143
+ Clone the source document's metadata and sanitize it for a brand-new doc.
1144
+ """
1145
+ base_doc = getattr(self, "_doc", None)
1146
+ base_meta = getattr(base_doc, "metadata", {}) or {}
1147
+ meta = dict(base_meta)
1148
+
1149
+ # New display name
1150
+ if title:
1151
+ meta["display_name"] = title
1152
+
1153
+ # Drop things that make it look linked/preview/ROI
1154
+ imi = dict(meta.get("image_meta") or {})
1155
+ for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
1156
+ imi.pop(k, None)
1157
+ meta["image_meta"] = imi
1158
+
1159
+ # Remove any ROI-ish keys
1160
+ for k in list(meta.keys()):
1161
+ if k.startswith("_roi_") or k.endswith("_roi") or k == "roi":
1162
+ meta.pop(k, None)
1163
+
1164
+ # For a brand-new doc, don't keep the original file_path
1165
+ meta.pop("file_path", None)
1166
+
1167
+ # Normalize mono flag
1168
+ if isinstance(img, np.ndarray):
1169
+ meta["is_mono"] = (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1))
1170
+
1171
+ # Keep bit depth / headers / WCS as-is; DocManager.open_array() will
1172
+ # ensure bit_depth etc. are sane.
1173
+ return meta
1174
+
1175
+
1176
+
1177
+ class _MultiScaleDecompPresetDialog(QDialog):
1178
+ """
1179
+ Preset editor for Multiscale Decomposition (headless + shortcuts).
1180
+ """
1181
+ def __init__(self, parent=None, initial: dict | None = None):
1182
+ super().__init__(parent)
1183
+ self.setWindowTitle("Multiscale Decomposition — Preset")
1184
+ init = dict(initial or {})
1185
+
1186
+ v = QVBoxLayout(self)
1187
+
1188
+ # ---- Global ----
1189
+ gb = QGroupBox("Global")
1190
+ form = QFormLayout(gb)
1191
+
1192
+ self.sp_layers = QSpinBox()
1193
+ self.sp_layers.setRange(1, 10)
1194
+ self.sp_layers.setValue(int(init.get("layers", 4)))
1195
+
1196
+ self.sp_sigma = QDoubleSpinBox()
1197
+ self.sp_sigma.setRange(0.3, 5.0)
1198
+ self.sp_sigma.setDecimals(2)
1199
+ self.sp_sigma.setSingleStep(0.1)
1200
+ self.sp_sigma.setValue(float(init.get("base_sigma", 1.0)))
1201
+
1202
+ self.cb_linked = QCheckBox("Linked RGB channels")
1203
+ self.cb_linked.setChecked(bool(init.get("linked_rgb", True)))
1204
+
1205
+ form.addRow("Layers:", self.sp_layers)
1206
+ form.addRow("Base sigma:", self.sp_sigma)
1207
+ form.addRow("", self.cb_linked)
1208
+
1209
+ v.addWidget(gb)
1210
+
1211
+ # ---- Layers ----
1212
+ gb_layers = QGroupBox("Per-Layer Settings")
1213
+ lv = QVBoxLayout(gb_layers)
1214
+
1215
+ self.table = QTableWidget(0, 6)
1216
+ self.table.setHorizontalHeaderLabels(
1217
+ ["On", "Layer", "Gain", "Thr (σ)", "Amount", "Denoise"]
1218
+ )
1219
+
1220
+ self.table.verticalHeader().setVisible(False)
1221
+ lv.addWidget(self.table)
1222
+
1223
+ v.addWidget(gb_layers)
1224
+
1225
+ # ---- Buttons ----
1226
+ btns = QHBoxLayout()
1227
+ ok = QPushButton("OK")
1228
+ cancel = QPushButton("Cancel")
1229
+ btns.addStretch(1)
1230
+ btns.addWidget(ok)
1231
+ btns.addWidget(cancel)
1232
+ v.addLayout(btns)
1233
+
1234
+ ok.clicked.connect(self.accept)
1235
+ cancel.clicked.connect(self.reject)
1236
+
1237
+ self._populate_table(init)
1238
+
1239
+ def _populate_table(self, init: dict):
1240
+ layers = int(self.sp_layers.value())
1241
+ cfgs = init.get("layers_cfg", [])
1242
+
1243
+ self.table.setRowCount(layers)
1244
+
1245
+ for i in range(layers):
1246
+ cfg = cfgs[i] if i < len(cfgs) else {}
1247
+
1248
+ # Enabled
1249
+ chk = QTableWidgetItem("")
1250
+ chk.setFlags(chk.flags() | Qt.ItemFlag.ItemIsUserCheckable)
1251
+ chk.setCheckState(
1252
+ Qt.CheckState.Checked if cfg.get("enabled", True)
1253
+ else Qt.CheckState.Unchecked
1254
+ )
1255
+ self.table.setItem(i, 0, chk)
1256
+
1257
+ self.table.setItem(i, 1, QTableWidgetItem(str(i + 1)))
1258
+ self.table.setItem(i, 2, QTableWidgetItem(f"{float(cfg.get('gain', 1.0)):.2f}"))
1259
+ self.table.setItem(i, 3, QTableWidgetItem(f"{float(cfg.get('thr', 0.0)):.2f}")) # N·σ
1260
+ self.table.setItem(i, 4, QTableWidgetItem(f"{float(cfg.get('amount', 0.0)):.2f}"))
1261
+ self.table.setItem(i, 5, QTableWidgetItem(f"{float(cfg.get('denoise',0.0)):.2f}"))
1262
+
1263
+
1264
+
1265
+ def result_dict(self) -> dict:
1266
+ layers = int(self.sp_layers.value())
1267
+ out_layers = []
1268
+
1269
+ for r in range(layers):
1270
+ enabled = self.table.item(r, 0).checkState() == Qt.CheckState.Checked
1271
+ gain = float(self.table.item(r, 2).text())
1272
+ thr = float(self.table.item(r, 3).text())
1273
+ amt = float(self.table.item(r, 4).text())
1274
+ try:
1275
+ dn = float(self.table.item(r, 5).text())
1276
+ except Exception:
1277
+ dn = 0.0
1278
+
1279
+ out_layers.append({
1280
+ "enabled": enabled,
1281
+ "gain": gain,
1282
+ "thr": thr,
1283
+ "amount": amt,
1284
+ "denoise": dn,
1285
+ })
1286
+
1287
+
1288
+ return {
1289
+ "layers": layers,
1290
+ "base_sigma": float(self.sp_sigma.value()),
1291
+ "linked_rgb": bool(self.cb_linked.isChecked()),
1292
+ "layers_cfg": out_layers,
1293
+ }