setiastrosuitepro 1.6.7__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 (394) 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/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1751 @@
1
+ # pro/multiscale_decomp.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import cv2
5
+ import os
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from PyQt6.QtCore import Qt, QTimer, QRect, QRectF
9
+ from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon, QMovie
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
12
+ QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox,
13
+ QTableWidget, QTableWidgetItem, QWidget, QLabel, QGraphicsView,
14
+ QGraphicsScene, QGraphicsPixmapItem, QMessageBox, QToolButton, QSlider, QSplitter,
15
+ QProgressDialog, QApplication
16
+ )
17
+ from contextlib import contextmanager
18
+ from setiastro.saspro.resources import get_resources
19
+ try:
20
+ cv2.setUseOptimized(True)
21
+ cv2.setNumThreads(0) # 0 = let OpenCV decide
22
+ except Exception:
23
+ pass
24
+
25
+ class _ZoomPanView(QGraphicsView):
26
+ """
27
+ QGraphicsView that supports wheel-zoom and click-drag panning.
28
+ Calls on_view_changed() whenever viewport position/scale changes.
29
+ """
30
+ def __init__(self, *args, on_view_changed=None, **kwargs):
31
+ super().__init__(*args, **kwargs)
32
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
33
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
34
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
35
+
36
+ self._panning = False
37
+ self._pan_start = None
38
+ self._on_view_changed = on_view_changed # callable or None
39
+
40
+ def _notify(self):
41
+ cb = self._on_view_changed
42
+ if callable(cb):
43
+ cb()
44
+
45
+ def wheelEvent(self, ev):
46
+ delta = ev.angleDelta().y()
47
+ if delta == 0:
48
+ return
49
+ factor = 1.25 if delta > 0 else 0.8
50
+ self.scale(factor, factor)
51
+ ev.accept()
52
+ self._notify()
53
+
54
+ def mousePressEvent(self, ev):
55
+ if ev.button() == Qt.MouseButton.LeftButton:
56
+ self._panning = True
57
+ self._pan_start = ev.pos()
58
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
59
+ ev.accept()
60
+ return
61
+ super().mousePressEvent(ev)
62
+
63
+ def mouseMoveEvent(self, ev):
64
+ if self._panning and self._pan_start is not None:
65
+ delta = ev.pos() - self._pan_start
66
+ self._pan_start = ev.pos()
67
+
68
+ h = self.horizontalScrollBar()
69
+ v = self.verticalScrollBar()
70
+ h.setValue(h.value() - delta.x())
71
+ v.setValue(v.value() - delta.y())
72
+ ev.accept()
73
+ # scrollbars will trigger _notify via their signals too, but harmless:
74
+ self._notify()
75
+ return
76
+
77
+ super().mouseMoveEvent(ev)
78
+
79
+ def mouseReleaseEvent(self, ev):
80
+ if ev.button() == Qt.MouseButton.LeftButton and self._panning:
81
+ self._panning = False
82
+ self._pan_start = None
83
+ self.setCursor(Qt.CursorShape.ArrowCursor)
84
+ ev.accept()
85
+ return
86
+ super().mouseReleaseEvent(ev)
87
+
88
+
89
+
90
+ # ─────────────────────────────────────────────
91
+ # Core math (your backbone)
92
+ # ─────────────────────────────────────────────
93
+
94
+ def _blur_gaussian(img01: np.ndarray, sigma: float) -> np.ndarray:
95
+ k = int(max(3, 2 * round(3 * sigma) + 1)) # odd
96
+ return cv2.GaussianBlur(img01, (k, k), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
97
+
98
+ def multiscale_decompose(img01: np.ndarray, layers: int, base_sigma: float = 1.0):
99
+ c = img01.astype(np.float32, copy=False)
100
+ details = []
101
+ for k in range(layers):
102
+ sigma = base_sigma * (2 ** k)
103
+ c_next = _blur_gaussian(c, sigma)
104
+ w = c - c_next
105
+ details.append(w)
106
+ c = c_next
107
+ residual = c
108
+ return details, residual
109
+
110
+ def multiscale_reconstruct(details, residual):
111
+ out = residual.astype(np.float32, copy=True)
112
+ for w in details:
113
+ out += w
114
+ return out
115
+
116
+ def soft_threshold(x: np.ndarray, t: float):
117
+ a = np.abs(x)
118
+ return np.sign(x) * np.maximum(0.0, a - t)
119
+
120
+ def apply_layer_ops(
121
+ w: np.ndarray,
122
+ bias_gain: float,
123
+ thr_sigma: float, # threshold in units of σ
124
+ amount: float,
125
+ denoise_strength: float = 0.0,
126
+ sigma: float | np.ndarray | None = None,
127
+ *,
128
+ mode: str = "μ–σ Thresholding",
129
+ ):
130
+ w2 = w
131
+
132
+ # Normalize mode to something robust to label wording
133
+ m = (mode or "").strip().lower()
134
+ is_linear = m.startswith("linear")
135
+
136
+ # --- Linear mode: strictly linear multiscale transform ---
137
+ if is_linear:
138
+ # Ignore thresholding and denoise; just apply gain
139
+ if abs(bias_gain - 1.0) > 1e-6:
140
+ return w * bias_gain
141
+ return w
142
+
143
+ # --- μ–σ Thresholding mode (robust nonlinear) ---
144
+ # 1) Noise reduction step (MMT-style NR)
145
+ if denoise_strength > 0.0:
146
+ if sigma is None:
147
+ sigma = _robust_sigma(w2)
148
+ sigma_f = float(sigma)
149
+ # 3σ at denoise=1, scaled linearly
150
+ t_dn = max(0.0, denoise_strength * 3.0 * sigma_f)
151
+ if t_dn > 0.0:
152
+ w_dn = soft_threshold(w2, t_dn)
153
+ # Blend original vs denoised based on denoise_strength
154
+ w2 = (1.0 - denoise_strength) * w2 + denoise_strength * w_dn
155
+
156
+ # 2) Threshold in σ units + bias shaping
157
+ if thr_sigma > 0.0:
158
+ if sigma is None:
159
+ sigma = _robust_sigma(w2)
160
+ sigma_f = float(sigma)
161
+ t = thr_sigma * sigma_f # convert N·σ → absolute threshold
162
+ if t > 0.0:
163
+ wt = soft_threshold(w2, t)
164
+ w2 = (1.0 - amount) * w2 + amount * wt
165
+
166
+ if abs(bias_gain - 1.0) > 1e-6:
167
+ w2 = w2 * bias_gain
168
+ return w2
169
+
170
+
171
+ def _robust_sigma(arr: np.ndarray) -> float:
172
+ """
173
+ Robust per-layer sigma estimate using MAD, fallback to std if needed.
174
+ Ignores NaN/Inf and uses a subset if very large.
175
+ """
176
+ a = np.asarray(arr, dtype=np.float32)
177
+ a = a[np.isfinite(a)]
178
+ if a.size == 0:
179
+ return 1e-6
180
+
181
+ # Optional: subsample for speed on huge arrays
182
+ if a.size > 500_000:
183
+ idx = np.random.choice(a.size, 500_000, replace=False)
184
+ a = a[idx]
185
+
186
+ med = np.median(a)
187
+ mad = np.median(np.abs(a - med))
188
+ if mad <= 0:
189
+ # fallback to plain std if MAD degenerates
190
+ s = float(np.std(a))
191
+ return s if s > 0 else 1e-6
192
+
193
+ sigma = 1.4826 * mad # MAD → σ for Gaussian
194
+ return sigma if sigma > 0 else 1e-6
195
+
196
+
197
+ # ─────────────────────────────────────────────
198
+ # Layer config
199
+ # ─────────────────────────────────────────────
200
+
201
+
202
+ @dataclass
203
+ class LayerCfg:
204
+ enabled: bool = True
205
+ bias_gain: float = 1.0 # 1.0 = unchanged
206
+ thr: float = 0.0 # soft threshold in detail domain
207
+ amount: float = 0.0 # 0..1 blend toward thresholded
208
+ denoise: float = 0.0 # 0..1 additional noise reduction
209
+
210
+
211
+ # ─────────────────────────────────────────────
212
+ # Dialog
213
+ # ─────────────────────────────────────────────
214
+
215
+ class MultiscaleDecompDialog(QDialog):
216
+ def __init__(self, parent, doc):
217
+ super().__init__(parent)
218
+ self.setWindowTitle("Multiscale Decomposition")
219
+ self.setWindowFlag(Qt.WindowType.Window, True)
220
+ self.setWindowModality(Qt.WindowModality.NonModal)
221
+ self.setModal(False)
222
+ try:
223
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
224
+ except Exception:
225
+ pass # older PyQt6 versions
226
+ self.setMinimumSize(1050, 700)
227
+ self.residual_enabled = True
228
+ self._layer_noise = None # list[float] per detail layer
229
+ self._cached_coarse = None
230
+ self._cached_img_id = None
231
+ self._doc = doc
232
+ base = getattr(doc, "image", None)
233
+ if base is None:
234
+ raise RuntimeError("Document has no image.")
235
+
236
+ # normalize to float32 [0..1] ...
237
+ img0 = np.asarray(base)
238
+ is_int = (img0.dtype.kind in "ui")
239
+
240
+ img = img0.astype(np.float32, copy=False)
241
+ if is_int:
242
+ maxv = float(np.nanmax(img0)) or 1.0
243
+ img = img / max(1.0, maxv)
244
+ img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
245
+
246
+ self._orig_shape = img.shape
247
+ self._orig_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
248
+
249
+ # force display buffer to 3ch ...
250
+ if img.ndim == 2:
251
+ img3 = np.repeat(img[:, :, None], 3, axis=2)
252
+ elif img.ndim == 3 and img.shape[2] == 1:
253
+ img3 = np.repeat(img, 3, axis=2)
254
+ else:
255
+ img3 = img[:, :, :3]
256
+
257
+ self._image = img3.copy() # working linear image (edited on Apply only)
258
+ self._preview_img = img3.copy()
259
+
260
+
261
+ # decomposition cache
262
+ self._cached_layers = None
263
+ self._cached_residual = None
264
+ self._cached_key = None
265
+
266
+ # per-layer configs
267
+ self.layers = 4
268
+ self.base_sigma = 1.0
269
+ self.cfgs: list[LayerCfg] = [LayerCfg() for _ in range(self.layers)]
270
+
271
+ # debounce preview updates
272
+ self._preview_timer = QTimer(self)
273
+ self._preview_timer.setSingleShot(True)
274
+ self._preview_timer.timeout.connect(self._rebuild_preview)
275
+
276
+ self._build_ui()
277
+ H, W = self._image.shape[:2]
278
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
279
+ # ───── NEW: initialization busy dialog ─────
280
+ prog = QProgressDialog("Initializing multiscale decomposition…", "", 0, 0, self)
281
+ prog.setWindowTitle("Multiscale Decomposition")
282
+ prog.setWindowModality(Qt.WindowModality.ApplicationModal)
283
+ prog.setCancelButton(None) # no cancel button, just a busy indicator
284
+ prog.setMinimumDuration(0) # show immediately
285
+ prog.show()
286
+ QApplication.processEvents()
287
+
288
+ # heavy work (MADs, blurs, etc.)
289
+ self._recompute_decomp(force=True)
290
+ self._rebuild_preview()
291
+
292
+ prog.close()
293
+ # ─────────────── END NEW ───────────────
294
+
295
+ QTimer.singleShot(0, self._fit_view)
296
+
297
+
298
+ # ---------- UI ----------
299
+ def _build_ui(self):
300
+ root = QHBoxLayout(self)
301
+
302
+ splitter = QSplitter(Qt.Orientation.Horizontal)
303
+ root.addWidget(splitter)
304
+
305
+ # ----- LEFT: preview -----
306
+ left_widget = QWidget(self)
307
+ left = QVBoxLayout(left_widget)
308
+
309
+ self.scene = QGraphicsScene(self)
310
+
311
+ self.view = _ZoomPanView(self.scene, on_view_changed=self._schedule_roi_preview)
312
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
313
+
314
+ # Base full-image item (keeps zoom/pan working)
315
+ self.pix_base = QGraphicsPixmapItem()
316
+ self.pix_base.setOffset(0, 0)
317
+ self.scene.addItem(self.pix_base)
318
+
319
+ # ROI overlay item (updates fast)
320
+ self.pix_roi = QGraphicsPixmapItem()
321
+ self.pix_roi.setZValue(10) # draw above base
322
+ self.scene.addItem(self.pix_roi)
323
+
324
+ left.addWidget(self.view)
325
+ # Busy overlay (shown during recompute)
326
+ self.busy_label = QLabel("Computing…", self.view.viewport())
327
+ self.busy_label.setStyleSheet("""
328
+ QLabel {
329
+ background: rgba(0,0,0,140);
330
+ color: white;
331
+ padding: 6px 10px;
332
+ border-radius: 8px;
333
+ font-weight: 600;
334
+ }
335
+ """)
336
+ self.busy_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
337
+ self.busy_label.hide()
338
+ # --- Spinner (animated) ---
339
+ self.busy_spinner = QLabel()
340
+ self.busy_spinner.setFixedSize(20, 20)
341
+ self.busy_spinner.setToolTip("Computing…")
342
+ self.busy_spinner.setVisible(False)
343
+
344
+ gif_path = get_resources().SPINNER_GIF # <- canonical, works frozen/dev
345
+ gif_path = os.path.normpath(gif_path)
346
+
347
+ self._busy_movie = QMovie(gif_path)
348
+ self._busy_movie.setScaledSize(self.busy_spinner.size())
349
+ self.busy_spinner.setMovie(self._busy_movie)
350
+
351
+ self._busy_show_timer = QTimer(self)
352
+ self._busy_show_timer.setSingleShot(True)
353
+ self._busy_show_timer.timeout.connect(self._show_busy_overlay)
354
+ self._busy_depth = 0
355
+ zoom_row = QHBoxLayout()
356
+
357
+ self.zoom_out_btn = QToolButton()
358
+ self.zoom_out_btn.setIcon(QIcon.fromTheme("zoom-out"))
359
+ self.zoom_out_btn.setToolTip("Zoom Out")
360
+
361
+ self.zoom_in_btn = QToolButton()
362
+ self.zoom_in_btn.setIcon(QIcon.fromTheme("zoom-in"))
363
+ self.zoom_in_btn.setToolTip("Zoom In")
364
+
365
+ self.fit_btn = QToolButton()
366
+ self.fit_btn.setIcon(QIcon.fromTheme("zoom-fit-best"))
367
+ self.fit_btn.setToolTip("Fit to Preview")
368
+
369
+ self.one_to_one_btn = QToolButton()
370
+ self.one_to_one_btn.setIcon(QIcon.fromTheme("zoom-original"))
371
+ self.one_to_one_btn.setToolTip("1:1")
372
+
373
+ self.zoom_out_btn.clicked.connect(lambda: (self.view.scale(0.8, 0.8), self._schedule_roi_preview()))
374
+ self.zoom_in_btn.clicked.connect(lambda: (self.view.scale(1.25, 1.25), self._schedule_roi_preview()))
375
+ self.fit_btn.clicked.connect(self._fit_view)
376
+ self.one_to_one_btn.clicked.connect(self._one_to_one)
377
+
378
+ zoom_row.addStretch(1)
379
+ zoom_row.addWidget(self.zoom_out_btn)
380
+ zoom_row.addWidget(self.zoom_in_btn)
381
+ zoom_row.addSpacing(10)
382
+ zoom_row.addWidget(self.fit_btn)
383
+ zoom_row.addWidget(self.one_to_one_btn)
384
+ zoom_row.addSpacing(10)
385
+ zoom_row.addWidget(self.busy_spinner) # <-- add here
386
+ zoom_row.addStretch(1)
387
+
388
+ left.addLayout(zoom_row)
389
+
390
+ # ----- RIGHT: controls -----
391
+ right_widget = QWidget(self)
392
+ right = QVBoxLayout(right_widget)
393
+
394
+ gb_global = QGroupBox("Global")
395
+ form = QFormLayout(gb_global)
396
+
397
+ self.spin_layers = QSpinBox()
398
+ self.spin_layers.setRange(1, 10)
399
+ self.spin_layers.setValue(self.layers)
400
+
401
+ self.spin_sigma = QDoubleSpinBox()
402
+ self.spin_sigma.setRange(0.3, 5.0)
403
+ self.spin_sigma.setSingleStep(0.1)
404
+ self.spin_sigma.setValue(self.base_sigma)
405
+
406
+ self.cb_linked_rgb = QCheckBox("Linked RGB (apply same params to all channels)")
407
+ self.cb_linked_rgb.setChecked(True)
408
+
409
+ # NEW: Fast ROI preview
410
+ self.cb_fast_roi_preview = QCheckBox("Fast ROI preview (compute visible area only)")
411
+ self.cb_fast_roi_preview.setChecked(True)
412
+ self.cb_fast_roi_preview.setToolTip(
413
+ "When enabled, preview only computes the currently visible region (with padding for blur).\n"
414
+ "Apply/Send-to-Doc always computes the full image."
415
+ )
416
+
417
+ self.combo_mode = QComboBox()
418
+ self.combo_mode.addItems(["μ–σ Thresholding", "Linear"])
419
+ self.combo_mode.setCurrentText("μ–σ Thresholding")
420
+ self.combo_mode.setToolTip(
421
+ "Multiscale mode:\n"
422
+ "• μ–σ Thresholding: σ-based thresholding + denoise and gain (nonlinear).\n"
423
+ "• Linear: strictly linear multiscale transform; only Gain is applied."
424
+ )
425
+
426
+ self.combo_preview = QComboBox()
427
+ self._refresh_preview_combo()
428
+
429
+ form.addRow("Layers:", self.spin_layers)
430
+ form.addRow("Base sigma:", self.spin_sigma)
431
+ form.addRow(self.cb_linked_rgb)
432
+ form.addRow(self.cb_fast_roi_preview)
433
+ form.addRow("Mode:", self.combo_mode)
434
+ form.addRow("Layer preview:", self.combo_preview)
435
+
436
+ right.addWidget(gb_global)
437
+
438
+ # Layers table
439
+ gb_layers = QGroupBox("Layers")
440
+ v = QVBoxLayout(gb_layers)
441
+ self.table = QTableWidget(0, 8)
442
+ self.table.setHorizontalHeaderLabels(
443
+ ["On", "Layer", "Scale", "Gain", "Thr (σ)", "Amt", "NR", "Type"]
444
+ )
445
+ self.table.verticalHeader().setVisible(False)
446
+ self.table.setSelectionBehavior(self.table.SelectionBehavior.SelectRows)
447
+ self.table.setSelectionMode(self.table.SelectionMode.SingleSelection)
448
+ v.addWidget(self.table)
449
+ right.addWidget(gb_layers, stretch=1)
450
+
451
+ # Per-layer editor...
452
+ gb_edit = QGroupBox("Selected Layer")
453
+ ef = QFormLayout(gb_edit)
454
+ self.lbl_sel = QLabel("Layer: —")
455
+
456
+ # --- Spin boxes ---
457
+ self.spin_gain = QDoubleSpinBox()
458
+ self.spin_gain.setRange(0.0, 3.0)
459
+ self.spin_gain.setSingleStep(0.05)
460
+ self.spin_gain.setValue(1.0)
461
+ self.spin_gain.setToolTip(
462
+ "Gain: multiplies the detail coefficients on this layer.\n"
463
+ "1.0 = unchanged, >1.0 boosts detail, <1.0 reduces it."
464
+ )
465
+
466
+ self.spin_thr = QDoubleSpinBox()
467
+ self.spin_thr.setRange(0.0, 10.0) # N·σ
468
+ self.spin_thr.setSingleStep(0.1)
469
+ self.spin_thr.setDecimals(2)
470
+ self.spin_thr.setToolTip(
471
+ "Threshold (σ): soft threshold level in units of this layer's noise σ.\n"
472
+ "0 = no thresholding; 1–3 ≈ mild to strong suppression of small coefficients."
473
+ )
474
+
475
+ self.spin_amt = QDoubleSpinBox()
476
+ self.spin_amt.setRange(0.0, 1.0)
477
+ self.spin_amt.setSingleStep(0.05)
478
+ self.spin_amt.setToolTip(
479
+ "Amount: blend factor toward the thresholded version of the layer.\n"
480
+ "0 = ignore thresholding, 1 = fully use the thresholded layer."
481
+ )
482
+
483
+ self.spin_denoise = QDoubleSpinBox()
484
+ self.spin_denoise.setRange(0.0, 1.0)
485
+ self.spin_denoise.setSingleStep(0.05)
486
+ self.spin_denoise.setValue(0.0)
487
+ self.spin_denoise.setToolTip(
488
+ "Denoise: extra multiscale noise reduction on this layer.\n"
489
+ "0 = off, 1 = strong NR (≈3σ soft threshold blended in)."
490
+ )
491
+
492
+ # --- Sliders (int ranges, mapped to spins) ---
493
+ self.slider_gain = QSlider(Qt.Orientation.Horizontal)
494
+ self.slider_gain.setRange(0, 300) # 0..3.00
495
+ self.slider_gain.setToolTip(self.spin_gain.toolTip())
496
+
497
+ self.slider_thr = QSlider(Qt.Orientation.Horizontal)
498
+ self.slider_thr.setRange(0, 1000) # 0..10.00 σ (×0.01)
499
+ self.slider_thr.setToolTip(self.spin_thr.toolTip())
500
+
501
+ self.slider_amt = QSlider(Qt.Orientation.Horizontal)
502
+ self.slider_amt.setRange(0, 100) # 0..1.00
503
+ self.slider_amt.setToolTip(self.spin_amt.toolTip())
504
+
505
+ self.slider_denoise = QSlider(Qt.Orientation.Horizontal)
506
+ self.slider_denoise.setRange(0, 100) # 0..1.00
507
+ self.slider_denoise.setToolTip(self.spin_denoise.toolTip())
508
+
509
+ # Layout rows: label -> [slider | spinbox]
510
+ ef.addRow(self.lbl_sel)
511
+
512
+ gain_row = QHBoxLayout()
513
+ gain_row.addWidget(self.slider_gain)
514
+ gain_row.addWidget(self.spin_gain)
515
+ ef.addRow("Gain:", gain_row)
516
+
517
+ thr_row = QHBoxLayout()
518
+ thr_row.addWidget(self.slider_thr)
519
+ thr_row.addWidget(self.spin_thr)
520
+ ef.addRow("Threshold (σ):", thr_row)
521
+
522
+ amt_row = QHBoxLayout()
523
+ amt_row.addWidget(self.slider_amt)
524
+ amt_row.addWidget(self.spin_amt)
525
+ ef.addRow("Amount:", amt_row)
526
+
527
+ dn_row = QHBoxLayout()
528
+ dn_row.addWidget(self.slider_denoise)
529
+ dn_row.addWidget(self.spin_denoise)
530
+ ef.addRow("Denoise:", dn_row)
531
+
532
+ right.addWidget(gb_edit)
533
+
534
+ # Buttons...
535
+ btn_row = QHBoxLayout()
536
+ self.btn_apply = QPushButton("Apply to Document")
537
+ self.btn_detail_new = QPushButton("Send to New Document")
538
+ self.btn_split_layers = QPushButton("Split Layers to Documents")
539
+ self.btn_close = QPushButton("Close")
540
+
541
+ btn_row.addStretch(1)
542
+ btn_row.addWidget(self.btn_apply)
543
+ btn_row.addWidget(self.btn_detail_new)
544
+ btn_row.addWidget(self.btn_split_layers)
545
+ btn_row.addWidget(self.btn_close)
546
+ right.addLayout(btn_row)
547
+
548
+ splitter.addWidget(left_widget)
549
+ splitter.addWidget(right_widget)
550
+ splitter.setStretchFactor(0, 2)
551
+ splitter.setStretchFactor(1, 1)
552
+
553
+ # ----- Signals -----
554
+ self.spin_layers.valueChanged.connect(self._on_layers_changed)
555
+ self.spin_sigma.valueChanged.connect(self._on_global_changed)
556
+ self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
557
+ self.combo_preview.currentIndexChanged.connect(self._schedule_preview)
558
+ self.cb_fast_roi_preview.toggled.connect(self._schedule_roi_preview)
559
+
560
+ self.table.itemSelectionChanged.connect(self._on_table_select)
561
+
562
+ self.spin_gain.valueChanged.connect(self._on_layer_editor_changed)
563
+ self.spin_thr.valueChanged.connect(self._on_layer_editor_changed)
564
+ self.spin_amt.valueChanged.connect(self._on_layer_editor_changed)
565
+ self.spin_denoise.valueChanged.connect(self._on_layer_editor_changed)
566
+
567
+ self.slider_gain.valueChanged.connect(self._on_gain_slider_changed)
568
+ self.slider_thr.valueChanged.connect(self._on_thr_slider_changed)
569
+ self.slider_amt.valueChanged.connect(self._on_amt_slider_changed)
570
+ self.slider_denoise.valueChanged.connect(self._on_dn_slider_changed)
571
+
572
+ self.btn_apply.clicked.connect(self._commit_to_doc)
573
+ self.btn_detail_new.clicked.connect(self._send_detail_to_new_doc)
574
+ self.btn_split_layers.clicked.connect(self._split_layers_to_docs)
575
+ self.btn_close.clicked.connect(self.reject)
576
+
577
+ # Connect viewport scroll changes
578
+ self._connect_viewport_signals()
579
+
580
+ # ---------- Preview plumbing ----------
581
+ def _spinner_on(self):
582
+ if getattr(self, "busy_spinner", None) is None:
583
+ return
584
+ self.busy_spinner.setVisible(True)
585
+ if getattr(self, "_busy_movie", None) is not None:
586
+ if self._busy_movie.state() != QMovie.MovieState.Running:
587
+ self._busy_movie.start()
588
+
589
+ def _spinner_off(self):
590
+ if getattr(self, "busy_spinner", None) is None:
591
+ return
592
+ if getattr(self, "_busy_movie", None) is not None:
593
+ self._busy_movie.stop()
594
+ self.busy_spinner.setVisible(False)
595
+
596
+
597
+ def _show_busy_overlay(self):
598
+ try:
599
+ self.busy_label.adjustSize()
600
+ self.busy_label.move(12, 12)
601
+ self.busy_label.show()
602
+ except Exception:
603
+ pass
604
+
605
+ def _begin_busy(self):
606
+ self._busy_depth += 1
607
+ if self._busy_depth == 1:
608
+ # show only if compute isn't instant
609
+ self._busy_show_timer.start(120)
610
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
611
+
612
+ def _end_busy(self):
613
+ self._busy_depth = max(0, self._busy_depth - 1)
614
+ if self._busy_depth == 0:
615
+ self._busy_show_timer.stop()
616
+ self.busy_label.hide()
617
+ QApplication.restoreOverrideCursor()
618
+
619
+
620
+ def _on_mode_changed(self, idx: int):
621
+ # Re-enable/disable controls as needed
622
+ self._update_param_widgets_for_mode()
623
+ self._schedule_preview()
624
+
625
+ def _schedule_preview(self):
626
+ # generic “something changed” entry point
627
+ self._preview_timer.start(60)
628
+
629
+ def _schedule_roi_preview(self):
630
+ # view changed (scroll/zoom/pan) — still debounced
631
+ self._preview_timer.start(60)
632
+
633
+ def _connect_viewport_signals(self):
634
+ """
635
+ Any pan/scroll should schedule ROI preview recompute.
636
+ """
637
+ try:
638
+ self.view.horizontalScrollBar().valueChanged.connect(self._schedule_roi_preview)
639
+ self.view.verticalScrollBar().valueChanged.connect(self._schedule_roi_preview)
640
+ except Exception:
641
+ pass
642
+
643
+ def _recompute_decomp(self, force: bool = False):
644
+ layers = int(self.spin_layers.value())
645
+ base_sigma = float(self.spin_sigma.value())
646
+
647
+ # cache identity: sigma + the actual ndarray buffer identity
648
+ img_id = id(self._image)
649
+ key = (base_sigma, img_id)
650
+
651
+ if force or self._cached_key != key or self._cached_layers is None or self._cached_coarse is None:
652
+ self.layers = layers
653
+ self.base_sigma = base_sigma
654
+
655
+ c = self._image.astype(np.float32, copy=False)
656
+ details = []
657
+ coarse = []
658
+
659
+ for k in range(layers):
660
+ sigma = base_sigma * (2 ** k)
661
+ c_next = _blur_gaussian(c, sigma)
662
+ details.append(c - c_next)
663
+ c = c_next
664
+ coarse.append(c)
665
+
666
+ self._cached_layers = details
667
+ self._cached_coarse = coarse
668
+ self._cached_residual = c
669
+ self._cached_key = key
670
+
671
+ self._layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in self._cached_layers]
672
+ self._sync_cfgs_and_ui()
673
+ return
674
+
675
+ # reuse existing pyramid, adjust layer count
676
+ old_layers = len(self._cached_layers)
677
+ self.layers = layers
678
+ self.base_sigma = base_sigma
679
+
680
+ if layers == old_layers:
681
+ self._sync_cfgs_and_ui()
682
+ return
683
+
684
+ if layers < old_layers:
685
+ self._cached_layers = self._cached_layers[:layers]
686
+ self._cached_coarse = self._cached_coarse[:layers]
687
+ self._layer_noise = self._layer_noise[:layers]
688
+
689
+ if layers > 0:
690
+ self._cached_residual = self._cached_coarse[layers - 1]
691
+ else:
692
+ self._cached_residual = self._image.astype(np.float32, copy=False)
693
+
694
+ self._sync_cfgs_and_ui()
695
+ return
696
+
697
+ # Grow: compute only missing layers from current residual
698
+ c = self._cached_residual
699
+ for k in range(old_layers, layers):
700
+ sigma = base_sigma * (2 ** k)
701
+ c_next = _blur_gaussian(c, sigma)
702
+ w = c - c_next
703
+
704
+ self._cached_layers.append(w)
705
+ self._cached_coarse.append(c_next)
706
+ self._layer_noise.append(_robust_sigma(w) if w.size else 1e-6)
707
+
708
+ c = c_next
709
+
710
+ self._cached_residual = c
711
+ self._sync_cfgs_and_ui()
712
+
713
+ def _sync_cfgs_and_ui(self):
714
+ # ensure cfg list matches layer count (your existing logic, just moved)
715
+ if len(self.cfgs) != self.layers:
716
+ old = self.cfgs[:]
717
+ self.cfgs = [LayerCfg() for _ in range(self.layers)]
718
+ for i in range(min(len(old), self.layers)):
719
+ self.cfgs[i] = old[i]
720
+
721
+ self._rebuild_table()
722
+ self._refresh_preview_combo()
723
+
724
+ def _build_tuned_layers(self):
725
+ self._recompute_decomp(force=False)
726
+
727
+ details = self._cached_layers
728
+ residual = self._cached_residual
729
+ if details is None or residual is None:
730
+ return None, None
731
+
732
+ mode = self.combo_mode.currentText()
733
+
734
+ def do_one(i_w):
735
+ i, w = i_w
736
+ cfg = self.cfgs[i]
737
+ if not cfg.enabled:
738
+ return i, np.zeros_like(w)
739
+ sigma = self._layer_noise[i] if self._layer_noise and i < len(self._layer_noise) else None
740
+ out = apply_layer_ops(
741
+ w,
742
+ cfg.bias_gain,
743
+ cfg.thr,
744
+ cfg.amount,
745
+ cfg.denoise,
746
+ sigma,
747
+ mode=mode,
748
+ )
749
+ return i, out
750
+
751
+ n = len(details)
752
+ if n == 0:
753
+ return [], residual
754
+
755
+ max_workers = min(os.cpu_count() or 4, n)
756
+
757
+ tuned = [None] * n
758
+ # ThreadPoolExecutor is fine here because apply_layer_ops is numpy-heavy
759
+ # (but real speed-up depends on GIL/OpenCV/BLAS behavior).
760
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
761
+ for i, out in ex.map(do_one, enumerate(details)):
762
+ tuned[i] = out
763
+
764
+ return tuned, residual
765
+
766
+ def _rebuild_preview(self):
767
+ self._spinner_on()
768
+ QApplication.processEvents()
769
+ #self._begin_busy()
770
+ try:
771
+ # ROI preview can't work until we have *some* pixmap in the scene to derive visible rects from.
772
+ roi_ok = (
773
+ getattr(self, "cb_fast_roi_preview", None) is not None
774
+ and self.cb_fast_roi_preview.isChecked()
775
+ and not self.pix_base.pixmap().isNull()
776
+ )
777
+
778
+ if roi_ok:
779
+ roi_img, roi_rect = self._compute_preview_roi()
780
+ if roi_img is None:
781
+ return
782
+ self._refresh_pix_roi(roi_img, roi_rect)
783
+ return
784
+
785
+ # ---- Full-frame preview (bootstrap path, and when ROI disabled) ----
786
+ tuned, residual = self._build_tuned_layers()
787
+ if tuned is None or residual is None:
788
+ return
789
+
790
+ res = residual if self.residual_enabled else np.zeros_like(residual)
791
+ out_raw = multiscale_reconstruct(tuned, res)
792
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
793
+
794
+ sel = self.combo_preview.currentData()
795
+ if sel is None or sel == "final":
796
+ if not self.residual_enabled:
797
+ d = out_raw.astype(np.float32, copy=False)
798
+ vis = 0.5 + d * 4.0
799
+ self._preview_img = np.clip(vis, 0.0, 1.0).astype(np.float32, copy=False)
800
+ else:
801
+ self._preview_img = out
802
+ elif sel == "residual":
803
+ self._preview_img = np.clip(residual, 0, 1)
804
+ else:
805
+ w = tuned[int(sel)]
806
+ vis = np.clip(0.5 + (w * 4.0), 0.0, 1.0)
807
+ self._preview_img = vis.astype(np.float32, copy=False)
808
+
809
+ self._refresh_pix()
810
+
811
+ finally:
812
+ #self._end_busy()
813
+ self._spinner_off()
814
+
815
+ def _update_param_widgets_for_mode(self):
816
+ linear = (self.combo_mode.currentText() == "Linear")
817
+
818
+ # Always allow Gain in both modes
819
+ gain_widgets = (self.spin_gain, self.slider_gain)
820
+
821
+ # These are only meaningful in Mean mode
822
+ nonlin_widgets = (
823
+ self.spin_thr, self.slider_thr,
824
+ self.spin_amt, self.slider_amt,
825
+ self.spin_denoise, self.slider_denoise,
826
+ )
827
+
828
+ # For residual row we already disable everything in _load_layer_into_editor,
829
+ # so here we just respect the current selection.
830
+ idx = getattr(self, "_selected_layer", None)
831
+ if idx is None or idx == self.layers:
832
+ # Residual – handled in _load_layer_into_editor
833
+ return
834
+
835
+ for w in gain_widgets:
836
+ w.setEnabled(True)
837
+
838
+ for w in nonlin_widgets:
839
+ w.setEnabled(not linear)
840
+
841
+
842
+ def _np_to_qpix(self, img: np.ndarray) -> QPixmap:
843
+ arr = np.ascontiguousarray(np.clip(img * 255.0, 0, 255).astype(np.uint8))
844
+ h, w = arr.shape[:2]
845
+ if arr.ndim == 2:
846
+ arr = np.repeat(arr[:, :, None], 3, axis=2)
847
+ qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
848
+ return QPixmap.fromImage(qimg)
849
+
850
+ def _refresh_pix(self):
851
+ pm = self._np_to_qpix(self._preview_img)
852
+ self.pix_base.setPixmap(pm)
853
+ self.pix_base.setOffset(0, 0)
854
+
855
+ # Optional: clear ROI overlay on full refresh
856
+ self.pix_roi.setPixmap(QPixmap())
857
+ self.pix_roi.setOffset(0, 0)
858
+
859
+ H, W = self._image.shape[:2]
860
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
861
+
862
+ def _fast_preview_enabled(self) -> bool:
863
+ return bool(getattr(self, "cb_fast_roi_preview", None)) and self.cb_fast_roi_preview.isChecked()
864
+
865
+ def _invalidate_full_decomp_cache(self):
866
+ self._cached_layers = None
867
+ self._cached_coarse = None
868
+ self._cached_residual = None
869
+ self._cached_key = None
870
+ self._layer_noise = None
871
+
872
+
873
+ def _fit_view(self):
874
+ if self.pix_base.pixmap().isNull():
875
+ return
876
+ self.view.resetTransform()
877
+ self.view.fitInView(self.pix_base, Qt.AspectRatioMode.KeepAspectRatio)
878
+ self._schedule_roi_preview()
879
+
880
+ def _one_to_one(self):
881
+ self.view.resetTransform()
882
+ self._schedule_roi_preview()
883
+
884
+ # ---------- Table / layer editing ----------
885
+ def _on_gain_slider_changed(self, v: int):
886
+ # 0..300 -> 0.00..3.00
887
+ val = v / 100.0
888
+ self.spin_gain.blockSignals(True)
889
+ self.spin_gain.setValue(val)
890
+ self.spin_gain.blockSignals(False)
891
+ self._on_layer_editor_changed()
892
+
893
+ def _on_thr_slider_changed(self, v: int):
894
+ # 0..1000 -> 0.00..10.00 σ
895
+ val = v / 100.0
896
+ self.spin_thr.blockSignals(True)
897
+ self.spin_thr.setValue(val)
898
+ self.spin_thr.blockSignals(False)
899
+ self._on_layer_editor_changed()
900
+
901
+
902
+ def _on_amt_slider_changed(self, v: int):
903
+ # 0..100 -> 0.00..1.00
904
+ val = v / 100.0
905
+ self.spin_amt.blockSignals(True)
906
+ self.spin_amt.setValue(val)
907
+ self.spin_amt.blockSignals(False)
908
+ self._on_layer_editor_changed()
909
+
910
+ def _on_dn_slider_changed(self, v: int):
911
+ # 0..100 -> 0.00..1.00
912
+ val = v / 100.0
913
+ self.spin_denoise.blockSignals(True)
914
+ self.spin_denoise.setValue(val)
915
+ self.spin_denoise.blockSignals(False)
916
+ self._on_layer_editor_changed()
917
+
918
+
919
+ def _rebuild_table(self):
920
+ self.table.blockSignals(True)
921
+ try:
922
+ # +1 row for residual ("R")
923
+ self.table.setRowCount(self.layers + 1)
924
+
925
+ # detail rows
926
+ for i in range(self.layers):
927
+ cfg = self.cfgs[i]
928
+
929
+ item_on = QTableWidgetItem("")
930
+ item_on.setFlags(item_on.flags() | Qt.ItemFlag.ItemIsUserCheckable)
931
+ item_on.setCheckState(Qt.CheckState.Checked if cfg.enabled else Qt.CheckState.Unchecked)
932
+ self.table.setItem(i, 0, item_on)
933
+
934
+ self.table.setItem(i, 1, QTableWidgetItem(str(i + 1)))
935
+ self.table.setItem(i, 2, QTableWidgetItem(f"{self.base_sigma * (2**i):.2f}"))
936
+ self.table.setItem(i, 3, QTableWidgetItem(f"{cfg.bias_gain:.2f}"))
937
+ self.table.setItem(i, 4, QTableWidgetItem(f"{cfg.thr:.2f}")) # N·σ
938
+ self.table.setItem(i, 5, QTableWidgetItem(f"{cfg.amount:.2f}"))
939
+ self.table.setItem(i, 6, QTableWidgetItem(f"{cfg.denoise:.2f}"))
940
+
941
+ self.table.setItem(i, 7, QTableWidgetItem("D"))
942
+
943
+ # residual row
944
+ r = self.layers
945
+ item_on = QTableWidgetItem("")
946
+ item_on.setFlags(item_on.flags() | Qt.ItemFlag.ItemIsUserCheckable)
947
+ item_on.setCheckState(
948
+ Qt.CheckState.Checked if self.residual_enabled else Qt.CheckState.Unchecked
949
+ )
950
+ self.table.setItem(r, 0, item_on)
951
+
952
+ self.table.setItem(r, 1, QTableWidgetItem("R"))
953
+ self.table.setItem(r, 2, QTableWidgetItem("—"))
954
+ self.table.setItem(r, 3, QTableWidgetItem("1.00"))
955
+ self.table.setItem(r, 4, QTableWidgetItem("0.0000"))
956
+ self.table.setItem(r, 5, QTableWidgetItem("0.00"))
957
+ self.table.setItem(r, 6, QTableWidgetItem("0.00"))
958
+ self.table.setItem(r, 7, QTableWidgetItem("R"))
959
+
960
+ finally:
961
+ self.table.blockSignals(False)
962
+
963
+ # connect once (avoid stacking connects)
964
+ try:
965
+ self.table.itemChanged.disconnect(self._on_table_item_changed)
966
+ except Exception:
967
+ pass
968
+ self.table.itemChanged.connect(self._on_table_item_changed)
969
+
970
+ if self.layers > 0 and not self.table.selectedItems():
971
+ self.table.selectRow(0)
972
+ self._load_layer_into_editor(0)
973
+
974
+ def _on_table_item_changed(self, item: QTableWidgetItem):
975
+ r, c = item.row(), item.column()
976
+
977
+ # Residual row
978
+ if r == self.layers:
979
+ if c == 0:
980
+ self.residual_enabled = (item.checkState() == Qt.CheckState.Checked)
981
+ self._schedule_preview()
982
+ # ignore other edits for residual
983
+ return
984
+
985
+ if not (0 <= r < len(self.cfgs)):
986
+ return
987
+
988
+ cfg = self.cfgs[r]
989
+
990
+ if c == 0:
991
+ # On/off
992
+ cfg.enabled = (item.checkState() == Qt.CheckState.Checked)
993
+ self._schedule_preview()
994
+ return
995
+
996
+ # numeric columns: Gain(3), Thr(4), Amt(5), NR(6)
997
+ try:
998
+ text = item.text().strip()
999
+ val = float(text) if text else 0.0
1000
+ except Exception:
1001
+ return
1002
+
1003
+ if c == 3:
1004
+ cfg.bias_gain = val
1005
+ elif c == 4:
1006
+ cfg.thr = val
1007
+ elif c == 5:
1008
+ cfg.amount = val
1009
+ elif c == 6:
1010
+ cfg.denoise = val
1011
+ else:
1012
+ return
1013
+
1014
+ # If this row is currently selected, update editor widgets too
1015
+ if getattr(self, "_selected_layer", None) == r:
1016
+ self._load_layer_into_editor(r)
1017
+
1018
+ self._schedule_preview()
1019
+
1020
+ @contextmanager
1021
+ def _busy_popup(self, text: str):
1022
+ dlg = QProgressDialog(text, "", 0, 0, self)
1023
+ dlg.setWindowTitle("Multiscale Decomposition")
1024
+ dlg.setWindowModality(Qt.WindowModality.ApplicationModal)
1025
+ dlg.setCancelButton(None)
1026
+ dlg.setMinimumDuration(0)
1027
+ dlg.show()
1028
+
1029
+ self._spinner_on()
1030
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
1031
+ QApplication.processEvents()
1032
+
1033
+ try:
1034
+ yield dlg
1035
+ finally:
1036
+ try:
1037
+ dlg.close()
1038
+ except Exception:
1039
+ pass
1040
+ QApplication.restoreOverrideCursor()
1041
+ self._spinner_off()
1042
+ QApplication.processEvents()
1043
+
1044
+ def _on_table_select(self):
1045
+ rows = {it.row() for it in self.table.selectedItems()}
1046
+ if not rows:
1047
+ return
1048
+ r = min(rows)
1049
+ self._load_layer_into_editor(r)
1050
+
1051
+ def _load_layer_into_editor(self, idx: int):
1052
+ self._selected_layer = idx
1053
+
1054
+ if idx == self.layers:
1055
+ self.lbl_sel.setText("Layer: R (Residual)")
1056
+ for w in (self.spin_gain, self.spin_thr, self.spin_amt, self.spin_denoise,
1057
+ self.slider_gain, self.slider_thr, self.slider_amt, self.slider_denoise):
1058
+ w.setEnabled(False)
1059
+ return
1060
+
1061
+ for w in (self.spin_gain, self.spin_thr, self.spin_amt, self.spin_denoise,
1062
+ self.slider_gain, self.slider_thr, self.slider_amt, self.slider_denoise):
1063
+ w.setEnabled(True)
1064
+
1065
+ cfg = self.cfgs[idx]
1066
+ self.lbl_sel.setText(f"Layer: {idx+1} / {self.layers}")
1067
+
1068
+ # spins + sliders in sync
1069
+ self.spin_gain.blockSignals(True)
1070
+ self.spin_thr.blockSignals(True)
1071
+ self.spin_amt.blockSignals(True)
1072
+ self.spin_denoise.blockSignals(True)
1073
+
1074
+ self.slider_gain.blockSignals(True)
1075
+ self.slider_thr.blockSignals(True)
1076
+ self.slider_amt.blockSignals(True)
1077
+ self.slider_denoise.blockSignals(True)
1078
+ try:
1079
+ self.spin_gain.setValue(cfg.bias_gain)
1080
+ self.spin_thr.setValue(cfg.thr) # thr is N·σ now
1081
+ self.spin_amt.setValue(cfg.amount)
1082
+ self.spin_denoise.setValue(cfg.denoise)
1083
+
1084
+ self.slider_gain.setValue(int(round(cfg.bias_gain * 100.0)))
1085
+ self.slider_thr.setValue(int(round(cfg.thr * 100.0))) # N·σ → 0..1000
1086
+ self.slider_amt.setValue(int(round(cfg.amount * 100.0)))
1087
+ self.slider_denoise.setValue(int(round(cfg.denoise * 100.0)))
1088
+ finally:
1089
+ self.spin_gain.blockSignals(False)
1090
+ self.spin_thr.blockSignals(False)
1091
+ self.spin_amt.blockSignals(False)
1092
+ self.spin_denoise.blockSignals(False)
1093
+ self.slider_gain.blockSignals(False)
1094
+ self.slider_thr.blockSignals(False)
1095
+ self.slider_amt.blockSignals(False)
1096
+ self.slider_denoise.blockSignals(False)
1097
+ self._update_param_widgets_for_mode()
1098
+
1099
+
1100
+
1101
+ def _on_layer_editor_changed(self):
1102
+ idx = getattr(self, "_selected_layer", None)
1103
+ if idx is None or not (0 <= idx < len(self.cfgs)):
1104
+ return
1105
+ cfg = self.cfgs[idx]
1106
+ cfg.bias_gain = float(self.spin_gain.value())
1107
+ cfg.thr = float(self.spin_thr.value())
1108
+ cfg.amount = float(self.spin_amt.value())
1109
+ cfg.denoise = float(self.spin_denoise.value())
1110
+
1111
+ # keep table in sync
1112
+ self.table.blockSignals(True)
1113
+ try:
1114
+ self.table.item(idx, 3).setText(f"{cfg.bias_gain:.2f}")
1115
+ self.table.item(idx, 4).setText(f"{cfg.thr:.2f}") # N·σ
1116
+ self.table.item(idx, 5).setText(f"{cfg.amount:.2f}")
1117
+ self.table.item(idx, 6).setText(f"{cfg.denoise:.2f}")
1118
+
1119
+ finally:
1120
+ self.table.blockSignals(False)
1121
+
1122
+ self._schedule_preview()
1123
+
1124
+ def _on_layers_changed(self):
1125
+ # Always update counts/UI
1126
+ self.layers = int(self.spin_layers.value())
1127
+
1128
+ # Ensure cfgs length matches new layer count and table/combos update
1129
+ self._sync_cfgs_and_ui()
1130
+
1131
+ if self._fast_preview_enabled():
1132
+ # Do NOT recompute full pyramid here; ROI preview will compute on-demand
1133
+ self._invalidate_full_decomp_cache()
1134
+ self._schedule_roi_preview()
1135
+ return
1136
+
1137
+ # Old behavior for non-ROI mode
1138
+ self._recompute_decomp(force=True)
1139
+ self._schedule_preview()
1140
+
1141
+
1142
+ def _on_global_changed(self):
1143
+ self.base_sigma = float(self.spin_sigma.value())
1144
+
1145
+ # Update table scale column text (it uses self.base_sigma)
1146
+ self._sync_cfgs_and_ui()
1147
+
1148
+ if self._fast_preview_enabled():
1149
+ self._invalidate_full_decomp_cache()
1150
+ self._schedule_roi_preview()
1151
+ return
1152
+
1153
+ self._recompute_decomp(force=True)
1154
+ self._schedule_preview()
1155
+
1156
+ def _refresh_preview_combo(self):
1157
+ self.combo_preview.blockSignals(True)
1158
+ try:
1159
+ self.combo_preview.clear()
1160
+ self.combo_preview.addItem("Final", userData="final")
1161
+ self.combo_preview.addItem("R (Residual)", userData="residual")
1162
+ for i in range(self.layers):
1163
+ self.combo_preview.addItem(f"Detail Layer {i+1}", userData=i)
1164
+ finally:
1165
+ self.combo_preview.blockSignals(False)
1166
+
1167
+ def _visible_image_rect(self) -> tuple[int, int, int, int] | None:
1168
+ # Use full image rect, NOT the pixmap bounds
1169
+ H, W = self._image.shape[:2]
1170
+ full_item_rect_scene = QRectF(0, 0, W, H)
1171
+
1172
+ vr = self.view.viewport().rect()
1173
+ tl = self.view.mapToScene(vr.topLeft())
1174
+ br = self.view.mapToScene(vr.bottomRight())
1175
+ scene_rect = QRectF(tl, br).normalized()
1176
+
1177
+ inter = scene_rect.intersected(full_item_rect_scene)
1178
+ if inter.isEmpty():
1179
+ return None
1180
+
1181
+ x0 = int(np.floor(inter.left()))
1182
+ y0 = int(np.floor(inter.top()))
1183
+ x1 = int(np.ceil(inter.right()))
1184
+ y1 = int(np.ceil(inter.bottom()))
1185
+
1186
+ x0 = max(0, min(W, x0))
1187
+ x1 = max(0, min(W, x1))
1188
+ y0 = max(0, min(H, y0))
1189
+ y1 = max(0, min(H, y1))
1190
+
1191
+ if x1 <= x0 or y1 <= y0:
1192
+ return None
1193
+ return (x0, y0, x1, y1)
1194
+
1195
+
1196
+ def _compute_preview_roi(self):
1197
+ """
1198
+ Computes preview only for visible ROI (plus padding), then returns:
1199
+ (roi_img_float01, (x0,y0,x1,y1)) or (None, None)
1200
+ roi_img is float32 RGB [0..1] and corresponds exactly to visible roi box.
1201
+ """
1202
+ vis = self._visible_image_rect()
1203
+ if vis is None:
1204
+ return None, None
1205
+
1206
+ x0, y0, x1, y1 = vis
1207
+
1208
+ # ROI cap to prevent enormous compute in fit-to-preview scenarios
1209
+ MAX = 1400
1210
+ w = x1 - x0
1211
+ h = y1 - y0
1212
+ if w > MAX:
1213
+ cx = (x0 + x1) // 2
1214
+ x0 = max(0, cx - MAX // 2)
1215
+ x1 = min(self._image.shape[1], x0 + MAX)
1216
+ if h > MAX:
1217
+ cy = (y0 + y1) // 2
1218
+ y0 = max(0, cy - MAX // 2)
1219
+ y1 = min(self._image.shape[0], y0 + MAX)
1220
+
1221
+ layers = int(self.spin_layers.value())
1222
+ base_sigma = float(self.spin_sigma.value())
1223
+ if layers <= 0:
1224
+ return None, None
1225
+
1226
+ sigma_max = base_sigma * (2 ** (layers - 1))
1227
+ pad = int(np.ceil(3.0 * sigma_max)) + 2
1228
+
1229
+ H, W = self._image.shape[:2]
1230
+ px0 = max(0, x0 - pad)
1231
+ py0 = max(0, y0 - pad)
1232
+ px1 = min(W, x1 + pad)
1233
+ py1 = min(H, y1 + pad)
1234
+
1235
+ crop = self._image[py0:py1, px0:px1].astype(np.float32, copy=False)
1236
+
1237
+ details, residual = multiscale_decompose(crop, layers=layers, base_sigma=base_sigma)
1238
+ layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in details]
1239
+
1240
+ mode = self.combo_mode.currentText()
1241
+
1242
+ # Apply per-layer ops (threaded)
1243
+ def do_one(i_w):
1244
+ i, w = i_w
1245
+ cfg = self.cfgs[i]
1246
+ if not cfg.enabled:
1247
+ return i, np.zeros_like(w)
1248
+ return i, apply_layer_ops(
1249
+ w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
1250
+ layer_noise[i], mode=mode
1251
+ )
1252
+
1253
+ tuned = [None] * len(details)
1254
+ max_workers = min(os.cpu_count() or 4, len(details) or 1)
1255
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
1256
+ for i, out in ex.map(do_one, enumerate(details)):
1257
+ tuned[i] = out
1258
+
1259
+ res = residual if self.residual_enabled else np.zeros_like(residual)
1260
+ out_raw = multiscale_reconstruct(tuned, res)
1261
+
1262
+ # Match preview rules
1263
+ if not self.residual_enabled:
1264
+ out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1265
+ else:
1266
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1267
+
1268
+ # Crop back to visible ROI coordinates
1269
+ cx0 = x0 - px0
1270
+ cy0 = y0 - py0
1271
+ cx1 = cx0 + (x1 - x0)
1272
+ cy1 = cy0 + (y1 - y0)
1273
+
1274
+ roi = out[cy0:cy1, cx0:cx1]
1275
+ return roi, (x0, y0, x1, y1)
1276
+
1277
+ def _np_to_qpix_roi_comp(self, img_rgb01: np.ndarray) -> QPixmap:
1278
+ """
1279
+ img_rgb01 is float32 RGB [0..1]
1280
+ """
1281
+ arr = np.ascontiguousarray(np.clip(img_rgb01 * 255.0, 0, 255).astype(np.uint8))
1282
+ h, w = arr.shape[:2]
1283
+ if arr.ndim == 2:
1284
+ arr = np.repeat(arr[:, :, None], 3, axis=2)
1285
+
1286
+ bytes_per_line = arr.strides[0]
1287
+ qimg = QImage(arr.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
1288
+ return QPixmap.fromImage(qimg.copy()) # copy to detach from numpy buffer
1289
+
1290
+ def _refresh_pix_roi(self, roi_img01: np.ndarray, roi_rect: tuple[int,int,int,int]):
1291
+ x0, y0, x1, y1 = roi_rect
1292
+ pm = self._np_to_qpix_roi_comp(roi_img01)
1293
+
1294
+ self.pix_roi.setPixmap(pm)
1295
+ self.pix_roi.setOffset(x0, y0)
1296
+
1297
+ # Keep scene bounds as full image, not ROI
1298
+ H, W = self._image.shape[:2]
1299
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
1300
+
1301
+
1302
+ def _build_preview_roi(self):
1303
+ vis = self._visible_image_rect()
1304
+ if vis is None:
1305
+ return None
1306
+
1307
+ x0,y0,x1,y1 = vis
1308
+ layers = int(self.spin_layers.value())
1309
+ base_sigma = float(self.spin_sigma.value())
1310
+
1311
+ if layers <= 0:
1312
+ return None
1313
+
1314
+ sigma_max = base_sigma * (2 ** (layers - 1))
1315
+ pad = int(np.ceil(3.0 * sigma_max)) + 2
1316
+
1317
+ H, W = self._image.shape[:2]
1318
+ px0 = max(0, x0 - pad); py0 = max(0, y0 - pad)
1319
+ px1 = min(W, x1 + pad); py1 = min(H, y1 + pad)
1320
+
1321
+ crop = self._image[py0:py1, px0:px1].astype(np.float32, copy=False)
1322
+
1323
+ # Decompose crop
1324
+ details, residual = multiscale_decompose(crop, layers=layers, base_sigma=base_sigma)
1325
+
1326
+ # noise per layer (crop-based) — good enough for preview
1327
+ layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in details]
1328
+
1329
+ # Apply tuning per layer (can thread this like we discussed)
1330
+ mode = self.combo_mode.currentText()
1331
+ tuned = []
1332
+ for i,w in enumerate(details):
1333
+ cfg = self.cfgs[i]
1334
+ if not cfg.enabled:
1335
+ tuned.append(np.zeros_like(w))
1336
+ else:
1337
+ tuned.append(apply_layer_ops(w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
1338
+ layer_noise[i], mode=mode))
1339
+
1340
+ res = residual if self.residual_enabled else np.zeros_like(residual)
1341
+ out_raw = multiscale_reconstruct(tuned, res)
1342
+
1343
+ # Match your preview rules
1344
+ if not self.residual_enabled:
1345
+ out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1346
+ else:
1347
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1348
+
1349
+ # Crop back from padded-crop coords to visible ROI coords
1350
+ cx0 = x0 - px0; cy0 = y0 - py0
1351
+ cx1 = cx0 + (x1 - x0); cy1 = cy0 + (y1 - y0)
1352
+ return out[cy0:cy1, cx0:cx1], (x0,y0,x1,y1)
1353
+
1354
+
1355
+ # ---------- Apply to doc ----------
1356
+ def _commit_to_doc(self):
1357
+ with self._busy_popup("Applying multiscale result to document…"):
1358
+ tuned, residual = self._build_tuned_layers()
1359
+ if tuned is None or residual is None:
1360
+ return
1361
+
1362
+ # --- Reconstruction (match preview behavior) ---
1363
+ res = residual if self.residual_enabled else np.zeros_like(residual)
1364
+ out_raw = multiscale_reconstruct(tuned, res)
1365
+
1366
+ if not self.residual_enabled:
1367
+ # Detail-only result: same “mid-gray + gain” hack as preview
1368
+ d = out_raw.astype(np.float32, copy=False)
1369
+ out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1370
+ else:
1371
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1372
+
1373
+ # convert back to mono if original was mono
1374
+ if self._orig_mono:
1375
+ mono = out[..., 0]
1376
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1377
+ mono = mono[:, :, None]
1378
+ out_final = mono.astype(np.float32, copy=False)
1379
+ else:
1380
+ out_final = out
1381
+
1382
+ try:
1383
+ if hasattr(self._doc, "set_image"):
1384
+ self._doc.set_image(out_final, step_name="Multiscale Decomposition")
1385
+ elif hasattr(self._doc, "apply_numpy"):
1386
+ self._doc.apply_numpy(out_final, step_name="Multiscale Decomposition")
1387
+ else:
1388
+ self._doc.image = out_final
1389
+ except Exception as e:
1390
+ QMessageBox.critical(self, "Multiscale Decomposition", f"Failed to write to document:\n{e}")
1391
+ return
1392
+
1393
+ if hasattr(self.parent(), "_refresh_active_view"):
1394
+ try:
1395
+ self.parent()._refresh_active_view()
1396
+ except Exception:
1397
+ pass
1398
+
1399
+ self.accept()
1400
+
1401
+ def _send_detail_to_new_doc(self):
1402
+ """
1403
+ Send the *final* multiscale result (same as Apply to Document)
1404
+ to a brand-new document via DocManager.
1405
+
1406
+ - If residual is enabled: standard 0..1 clipped composite.
1407
+ - If residual is disabled: uses the mid-gray detail-only hack
1408
+ (0.5 + d*4.0), just like the preview/commit path.
1409
+ """
1410
+ with self._busy_popup("Creating new document from multiscale result…"):
1411
+ self._recompute_decomp(force=False)
1412
+
1413
+ details = self._cached_layers
1414
+ residual = self._cached_residual
1415
+ if details is None or residual is None:
1416
+ return
1417
+
1418
+ dm = self._get_doc_manager()
1419
+ if dm is None:
1420
+ QMessageBox.warning(
1421
+ self,
1422
+ "Multiscale Decomposition",
1423
+ "No DocManager available to create a new document."
1424
+ )
1425
+ return
1426
+
1427
+ # --- Same tuned-layer logic as _commit_to_doc -------------------
1428
+ mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
1429
+
1430
+ tuned = []
1431
+ for i, w in enumerate(details):
1432
+ cfg = self.cfgs[i]
1433
+ if not cfg.enabled:
1434
+ tuned.append(np.zeros_like(w))
1435
+ else:
1436
+ sigma = None
1437
+ if self._layer_noise is not None and i < len(self._layer_noise):
1438
+ sigma = self._layer_noise[i]
1439
+ tuned.append(
1440
+ apply_layer_ops(
1441
+ w,
1442
+ cfg.bias_gain,
1443
+ cfg.thr,
1444
+ cfg.amount,
1445
+ cfg.denoise,
1446
+ sigma,
1447
+ mode=mode,
1448
+ )
1449
+ )
1450
+
1451
+ # --- Reconstruction (match Apply-to-Document behavior) ----------
1452
+ res = residual if self.residual_enabled else np.zeros_like(residual)
1453
+ out_raw = multiscale_reconstruct(tuned, res)
1454
+
1455
+ if not self.residual_enabled:
1456
+ # Detail-only flavor: mid-gray + gain hack
1457
+ d = out_raw.astype(np.float32, copy=False)
1458
+ out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1459
+ else:
1460
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1461
+
1462
+ # --- Back to original mono/color layout -------------------------
1463
+ if self._orig_mono:
1464
+ mono = out[..., 0]
1465
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1466
+ mono = mono[:, :, None]
1467
+ out_final = mono.astype(np.float32, copy=False)
1468
+ else:
1469
+ out_final = out
1470
+
1471
+ title = "Multiscale Result"
1472
+ meta = self._build_new_doc_metadata(title, out_final)
1473
+
1474
+ try:
1475
+ dm.create_document(out_final, metadata=meta, name=title)
1476
+ except Exception as e:
1477
+ QMessageBox.critical(
1478
+ self,
1479
+ "Multiscale Decomposition",
1480
+ f"Failed to create new document:\n{e}"
1481
+ )
1482
+
1483
+ def _split_layers_to_docs(self):
1484
+ """
1485
+ Create a new document for each tuned detail layer *and* the residual.
1486
+
1487
+ - Detail layers use the same mid-gray visualization as the per-layer preview:
1488
+ vis = 0.5 + layer*4.0
1489
+ - Residual layer is just the residual itself (0..1 clipped).
1490
+ """
1491
+ with self._busy_popup("Splitting layers into documents…") as prog:
1492
+ self._recompute_decomp(force=False)
1493
+
1494
+ details = self._cached_layers
1495
+ residual = self._cached_residual
1496
+ if details is None or residual is None:
1497
+ return
1498
+
1499
+ dm = self._get_doc_manager()
1500
+ if dm is None:
1501
+ QMessageBox.warning(
1502
+ self,
1503
+ "Multiscale Decomposition",
1504
+ "No DocManager available to create new documents."
1505
+ )
1506
+ return
1507
+
1508
+ mode = self.combo_mode.currentText()
1509
+ # Build tuned layers just like everywhere else
1510
+ tuned = []
1511
+ for i, w in enumerate(details):
1512
+ cfg = self.cfgs[i]
1513
+ if not cfg.enabled:
1514
+ tuned.append(np.zeros_like(w))
1515
+ else:
1516
+ sigma = None
1517
+ if self._layer_noise is not None and i < len(self._layer_noise):
1518
+ sigma = self._layer_noise[i]
1519
+ tuned.append(
1520
+ apply_layer_ops(
1521
+ w,
1522
+ cfg.bias_gain,
1523
+ cfg.thr,
1524
+ cfg.amount,
1525
+ cfg.denoise,
1526
+ sigma,
1527
+ mode=mode,
1528
+ )
1529
+ )
1530
+
1531
+ # ---- 1) Detail layers ------------------------------------------
1532
+ for i, layer in enumerate(tuned):
1533
+ d = layer.astype(np.float32, copy=False)
1534
+ vis = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1535
+
1536
+ if self._orig_mono:
1537
+ mono = vis[..., 0]
1538
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1539
+ mono = mono[:, :, None]
1540
+ out_final = mono.astype(np.float32, copy=False)
1541
+ else:
1542
+ out_final = vis
1543
+
1544
+ title = f"Multiscale Detail Layer {i+1}"
1545
+ meta = self._build_new_doc_metadata(title, out_final)
1546
+
1547
+ try:
1548
+ dm.create_document(out_final, metadata=meta, name=title)
1549
+ except Exception as e:
1550
+ QMessageBox.critical(
1551
+ self,
1552
+ "Multiscale Decomposition",
1553
+ f"Failed to create document for layer {i+1}:\n{e}"
1554
+ )
1555
+ # Don’t bail entirely on first error if you’d rather continue;
1556
+ # right now we stop on first hard failure.
1557
+ return
1558
+
1559
+ # ---- 2) Residual layer -----------------------------------------
1560
+ try:
1561
+ res = residual.astype(np.float32, copy=False)
1562
+ res_img = np.clip(res, 0.0, 1.0)
1563
+
1564
+ if self._orig_mono:
1565
+ mono = res_img[..., 0]
1566
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1567
+ mono = mono[:, :, None]
1568
+ res_final = mono.astype(np.float32, copy=False)
1569
+ else:
1570
+ res_final = res_img
1571
+
1572
+ r_title = "Multiscale Residual Layer"
1573
+ r_meta = self._build_new_doc_metadata(r_title, res_final)
1574
+
1575
+ dm.create_document(res_final, metadata=r_meta, name=r_title)
1576
+ except Exception as e:
1577
+ QMessageBox.critical(
1578
+ self,
1579
+ "Multiscale Decomposition",
1580
+ f"Failed to create residual-layer document:\n{e}"
1581
+ )
1582
+
1583
+
1584
+
1585
+ def _get_doc_manager(self):
1586
+ """
1587
+ Best-effort: find the DocManager that owns the source document.
1588
+ Prefer the doc's own _doc_manager; fall back to parent.doc_manager.
1589
+ """
1590
+ doc = getattr(self, "_doc", None)
1591
+ dm = getattr(doc, "_doc_manager", None) if doc is not None else None
1592
+
1593
+ if dm is None:
1594
+ parent = self.parent()
1595
+ dm = getattr(parent, "doc_manager", None) if parent is not None else None
1596
+
1597
+ return dm
1598
+
1599
+ def _build_new_doc_metadata(self, title: str, img: np.ndarray) -> dict:
1600
+ """
1601
+ Clone the source document's metadata and sanitize it for a brand-new doc.
1602
+ """
1603
+ base_doc = getattr(self, "_doc", None)
1604
+ base_meta = getattr(base_doc, "metadata", {}) or {}
1605
+ meta = dict(base_meta)
1606
+
1607
+ # New display name
1608
+ if title:
1609
+ meta["display_name"] = title
1610
+
1611
+ # Drop things that make it look linked/preview/ROI
1612
+ imi = dict(meta.get("image_meta") or {})
1613
+ for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
1614
+ imi.pop(k, None)
1615
+ meta["image_meta"] = imi
1616
+
1617
+ # Remove any ROI-ish keys
1618
+ for k in list(meta.keys()):
1619
+ if k.startswith("_roi_") or k.endswith("_roi") or k == "roi":
1620
+ meta.pop(k, None)
1621
+
1622
+ # For a brand-new doc, don't keep the original file_path
1623
+ meta.pop("file_path", None)
1624
+
1625
+ # Normalize mono flag
1626
+ if isinstance(img, np.ndarray):
1627
+ meta["is_mono"] = (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1))
1628
+
1629
+ # Keep bit depth / headers / WCS as-is; DocManager.open_array() will
1630
+ # ensure bit_depth etc. are sane.
1631
+ return meta
1632
+
1633
+
1634
+
1635
+ class _MultiScaleDecompPresetDialog(QDialog):
1636
+ """
1637
+ Preset editor for Multiscale Decomposition (headless + shortcuts).
1638
+ """
1639
+ def __init__(self, parent=None, initial: dict | None = None):
1640
+ super().__init__(parent)
1641
+ self.setWindowTitle("Multiscale Decomposition — Preset")
1642
+ init = dict(initial or {})
1643
+
1644
+ v = QVBoxLayout(self)
1645
+
1646
+ # ---- Global ----
1647
+ gb = QGroupBox("Global")
1648
+ form = QFormLayout(gb)
1649
+
1650
+ self.sp_layers = QSpinBox()
1651
+ self.sp_layers.setRange(1, 10)
1652
+ self.sp_layers.setValue(int(init.get("layers", 4)))
1653
+
1654
+ self.sp_sigma = QDoubleSpinBox()
1655
+ self.sp_sigma.setRange(0.3, 5.0)
1656
+ self.sp_sigma.setDecimals(2)
1657
+ self.sp_sigma.setSingleStep(0.1)
1658
+ self.sp_sigma.setValue(float(init.get("base_sigma", 1.0)))
1659
+
1660
+ self.cb_linked = QCheckBox("Linked RGB channels")
1661
+ self.cb_linked.setChecked(bool(init.get("linked_rgb", True)))
1662
+
1663
+ form.addRow("Layers:", self.sp_layers)
1664
+ form.addRow("Base sigma:", self.sp_sigma)
1665
+ form.addRow("", self.cb_linked)
1666
+
1667
+ v.addWidget(gb)
1668
+
1669
+ # ---- Layers ----
1670
+ gb_layers = QGroupBox("Per-Layer Settings")
1671
+ lv = QVBoxLayout(gb_layers)
1672
+
1673
+ self.table = QTableWidget(0, 6)
1674
+ self.table.setHorizontalHeaderLabels(
1675
+ ["On", "Layer", "Gain", "Thr (σ)", "Amount", "Denoise"]
1676
+ )
1677
+
1678
+ self.table.verticalHeader().setVisible(False)
1679
+ lv.addWidget(self.table)
1680
+
1681
+ v.addWidget(gb_layers)
1682
+
1683
+ # ---- Buttons ----
1684
+ btns = QHBoxLayout()
1685
+ ok = QPushButton("OK")
1686
+ cancel = QPushButton("Cancel")
1687
+ btns.addStretch(1)
1688
+ btns.addWidget(ok)
1689
+ btns.addWidget(cancel)
1690
+ v.addLayout(btns)
1691
+
1692
+ ok.clicked.connect(self.accept)
1693
+ cancel.clicked.connect(self.reject)
1694
+
1695
+ self._populate_table(init)
1696
+
1697
+ def _populate_table(self, init: dict):
1698
+ layers = int(self.sp_layers.value())
1699
+ cfgs = init.get("layers_cfg", [])
1700
+
1701
+ self.table.setRowCount(layers)
1702
+
1703
+ for i in range(layers):
1704
+ cfg = cfgs[i] if i < len(cfgs) else {}
1705
+
1706
+ # Enabled
1707
+ chk = QTableWidgetItem("")
1708
+ chk.setFlags(chk.flags() | Qt.ItemFlag.ItemIsUserCheckable)
1709
+ chk.setCheckState(
1710
+ Qt.CheckState.Checked if cfg.get("enabled", True)
1711
+ else Qt.CheckState.Unchecked
1712
+ )
1713
+ self.table.setItem(i, 0, chk)
1714
+
1715
+ self.table.setItem(i, 1, QTableWidgetItem(str(i + 1)))
1716
+ self.table.setItem(i, 2, QTableWidgetItem(f"{float(cfg.get('gain', 1.0)):.2f}"))
1717
+ self.table.setItem(i, 3, QTableWidgetItem(f"{float(cfg.get('thr', 0.0)):.2f}")) # N·σ
1718
+ self.table.setItem(i, 4, QTableWidgetItem(f"{float(cfg.get('amount', 0.0)):.2f}"))
1719
+ self.table.setItem(i, 5, QTableWidgetItem(f"{float(cfg.get('denoise',0.0)):.2f}"))
1720
+
1721
+
1722
+
1723
+ def result_dict(self) -> dict:
1724
+ layers = int(self.sp_layers.value())
1725
+ out_layers = []
1726
+
1727
+ for r in range(layers):
1728
+ enabled = self.table.item(r, 0).checkState() == Qt.CheckState.Checked
1729
+ gain = float(self.table.item(r, 2).text())
1730
+ thr = float(self.table.item(r, 3).text())
1731
+ amt = float(self.table.item(r, 4).text())
1732
+ try:
1733
+ dn = float(self.table.item(r, 5).text())
1734
+ except Exception:
1735
+ dn = 0.0
1736
+
1737
+ out_layers.append({
1738
+ "enabled": enabled,
1739
+ "gain": gain,
1740
+ "thr": thr,
1741
+ "amount": amt,
1742
+ "denoise": dn,
1743
+ })
1744
+
1745
+
1746
+ return {
1747
+ "layers": layers,
1748
+ "base_sigma": float(self.sp_sigma.value()),
1749
+ "linked_rgb": bool(self.cb_linked.isChecked()),
1750
+ "layers_cfg": out_layers,
1751
+ }