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.

Potentially problematic release.


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

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,1090 @@
1
+ # pro/mask_creation.py
2
+ from __future__ import annotations
3
+ import uuid
4
+ import numpy as np
5
+ import math
6
+
7
+ # Optional deps
8
+ try:
9
+ import cv2
10
+ except Exception:
11
+ cv2 = None
12
+ try:
13
+ import sep
14
+ except Exception:
15
+ sep = None
16
+
17
+ from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, QEvent
18
+ from PyQt6.QtGui import (
19
+ QImage, QPixmap, QPainter, QColor, QPen, QBrush,
20
+ QPainterPath, QWheelEvent, QPolygonF, QMouseEvent
21
+ )
22
+ from PyQt6.QtWidgets import (
23
+ QInputDialog, QMessageBox, QFileDialog, # QFileDialog only used if you later add “export”
24
+ QDialog, QDialogButtonBox,
25
+ QVBoxLayout, QHBoxLayout, QGridLayout,
26
+ QLabel, QPushButton, QComboBox, QSlider, QCheckBox, QButtonGroup, QGroupBox,
27
+ QScrollArea, QSizePolicy,
28
+ QGraphicsView, QGraphicsScene, QGraphicsItem,
29
+ QGraphicsPixmapItem, QGraphicsPathItem, QGraphicsPolygonItem,
30
+ QGraphicsEllipseItem, QGraphicsRectItem, QMdiSubWindow, QLabel
31
+ )
32
+
33
+ from .masks_core import MaskLayer
34
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
35
+ from setiastro.saspro.imageops.stretch import stretch_color_image
36
+
37
+ # ---------- small utils ----------
38
+
39
+ def _to_qpixmap01(img01: np.ndarray) -> QPixmap:
40
+ a = np.clip(img01, 0.0, 1.0)
41
+ if a.ndim == 2:
42
+ buf = (a * 255).astype(np.uint8)
43
+ h, w = buf.shape
44
+ qimg = QImage(buf.data, w, h, w, QImage.Format.Format_Grayscale8)
45
+ else:
46
+ buf = (a * 255).astype(np.uint8)
47
+ h, w, _ = buf.shape
48
+ qimg = QImage(buf.data, w, h, buf.strides[0], QImage.Format.Format_RGB888)
49
+ return QPixmap.fromImage(qimg)
50
+
51
+ def _display_stretch(img01: np.ndarray) -> np.ndarray:
52
+ """
53
+ Display-only stretch. Does NOT modify underlying data used for mask creation.
54
+ Returns float32 in [0,1].
55
+ """
56
+ a = np.asarray(img01, dtype=np.float32)
57
+ a = np.clip(a, 0.0, 1.0)
58
+
59
+ # Color: use your existing stretch if available
60
+ if a.ndim == 3 and a.shape[2] == 3 and stretch_color_image is not None:
61
+ try:
62
+ return np.clip(stretch_color_image(a, 0.25, linked=False, normalize=False), 0.0, 1.0).astype(np.float32)
63
+ except Exception:
64
+ pass
65
+
66
+ # Mono (or fallback): simple robust stretch around median
67
+ # (keeps it predictable and fast; display-only)
68
+ m = float(np.nanmedian(a))
69
+ if not np.isfinite(m):
70
+ return a.astype(np.float32, copy=False)
71
+
72
+ # Simple gamma-like lift using median anchor
73
+ # If median is tiny, boost; if already bright, minimal change.
74
+ target = 0.25
75
+ eps = 1e-8
76
+ scale = target / max(m, eps)
77
+ out = np.clip(a * scale, 0.0, 1.0)
78
+
79
+ # Gentle midtone curve
80
+ out = np.sqrt(out)
81
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
82
+
83
+
84
+ def _find_main_window(w):
85
+ p = w
86
+ from PyQt6.QtWidgets import QMainWindow
87
+ while p is not None and not isinstance(p, QMainWindow):
88
+ p = p.parent()
89
+ return p
90
+
91
+ def _push_numpy_as_new_document(owner_widget, arr01: np.ndarray, default_name: str = "Mask") -> bool:
92
+ mw = _find_main_window(owner_widget)
93
+ if mw is None or not hasattr(mw, "docman"):
94
+ QMessageBox.warning(owner_widget, "Cannot Create Document", "Main window / DocManager not found.")
95
+ return False
96
+
97
+ # Ask for the document name
98
+ name, ok = QInputDialog.getText(owner_widget, "New Document Name", "Name:", text=default_name)
99
+ if not ok:
100
+ return False
101
+
102
+ # Ensure float32 in [0..1]
103
+ img = np.clip(arr01.astype(np.float32, copy=False), 0.0, 1.0)
104
+
105
+ # This sets metadata['display_name'] via DocManager and emits documentAdded
106
+ doc = mw.docman.open_array(img, title=name)
107
+
108
+ # Nothing else required: AstroSuiteProMainWindow._open_subwindow_for_added_doc
109
+ # will create/show the subwindow.
110
+ if hasattr(mw, "_log"):
111
+ mw._log(f"Created new document from mask: {doc.display_name()}")
112
+ return True
113
+
114
+
115
+ # ---------- Interactive ellipse handles ----------
116
+ class HandleItem(QGraphicsRectItem):
117
+ SIZE = 8
118
+ def __init__(self, role: str, parent_ellipse: QGraphicsEllipseItem):
119
+ super().__init__(-self.SIZE/2, -self.SIZE/2, self.SIZE, self.SIZE, parent_ellipse)
120
+ self.role = role
121
+ self.parent_ellipse = parent_ellipse
122
+ self.setBrush(QColor(255, 0, 0))
123
+ self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
124
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
125
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, False)
126
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations, True)
127
+
128
+ cursors = {
129
+ 'top': Qt.CursorShape.SizeVerCursor,
130
+ 'bottom': Qt.CursorShape.SizeVerCursor,
131
+ 'left': Qt.CursorShape.SizeHorCursor,
132
+ 'right': Qt.CursorShape.SizeHorCursor,
133
+ 'rotate': Qt.CursorShape.OpenHandCursor,
134
+ }
135
+ self.setCursor(cursors[role])
136
+
137
+ self._lastScenePos = None
138
+ # extra state for rotation
139
+ self._centerScene = None
140
+ self._startAngle = None
141
+ self._startRotation = None
142
+
143
+ def mousePressEvent(self, ev):
144
+ if self.role == 'rotate':
145
+ # Store center of ellipse in scene coords
146
+ rect = self.parent_ellipse.rect()
147
+ center_item = rect.center()
148
+ self._centerScene = self.parent_ellipse.mapToScene(center_item)
149
+
150
+ # Starting angle from center → mouse
151
+ p = ev.scenePos()
152
+ dx = p.x() - self._centerScene.x()
153
+ dy = p.y() - self._centerScene.y()
154
+ self._startAngle = math.degrees(math.atan2(dy, dx))
155
+
156
+ # Store current item rotation
157
+ self._startRotation = self.parent_ellipse.rotation()
158
+
159
+ # Optional: change cursor to "grabbing"
160
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
161
+ ev.accept()
162
+ return
163
+
164
+ # Non-rotate handles use the old dx/dy code path
165
+ self._lastScenePos = ev.scenePos()
166
+ ev.accept()
167
+
168
+ def mouseMoveEvent(self, ev):
169
+ if self.role == 'rotate':
170
+ if self._centerScene is None or self._startAngle is None or self._startRotation is None:
171
+ ev.accept()
172
+ return
173
+
174
+ p = ev.scenePos()
175
+ dx = p.x() - self._centerScene.x()
176
+ dy = p.y() - self._centerScene.y()
177
+
178
+ # Current angle from center → mouse
179
+ current_angle = math.degrees(math.atan2(dy, dx))
180
+
181
+ # Delta relative to the original grab angle
182
+ delta = current_angle - self._startAngle
183
+
184
+ # Set absolute rotation: starting rotation + delta
185
+ self.parent_ellipse.setRotation(self._startRotation + delta)
186
+ ev.accept()
187
+ return
188
+
189
+ # Resize handles: same as before
190
+ if self._lastScenePos is None:
191
+ self._lastScenePos = ev.scenePos()
192
+ dx = ev.scenePos().x() - self._lastScenePos.x()
193
+ dy = ev.scenePos().y() - self._lastScenePos.y()
194
+ self.parent_ellipse.interactiveResize(self.role, dx, dy)
195
+ self._lastScenePos = ev.scenePos()
196
+ ev.accept()
197
+
198
+ def mouseReleaseEvent(self, ev):
199
+ # Reset rotation state and cursor
200
+ if self.role == 'rotate':
201
+ self._centerScene = None
202
+ self._startAngle = None
203
+ self._startRotation = None
204
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
205
+
206
+ self._lastScenePos = None
207
+ ev.accept()
208
+
209
+
210
+
211
+ class InteractiveEllipseItem(QGraphicsEllipseItem):
212
+ def __init__(self, rect: QRectF):
213
+ super().__init__(rect)
214
+ self._resizing = False
215
+ self.setTransformOriginPoint(self.rect().center())
216
+ self.setFlags(
217
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
218
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
219
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
220
+ )
221
+
222
+ # Cosmetic pen: stays the same thickness on screen regardless of zoom
223
+ pen = QPen(QColor(0, 255, 0), 2)
224
+ pen.setCosmetic(True)
225
+ self.setPen(pen)
226
+
227
+ self.handles = {r: HandleItem(r, self) for r in ('top','bottom','left','right','rotate')}
228
+ self.updateHandles()
229
+
230
+ def updateHandles(self):
231
+ r = self.rect()
232
+ cx, cy = r.center().x(), r.center().y()
233
+ for h in self.handles.values():
234
+ h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, False)
235
+ positions = {
236
+ 'top': QPointF(cx, r.top()),
237
+ 'bottom': QPointF(cx, r.bottom()),
238
+ 'left': QPointF(r.left(), cy),
239
+ 'right': QPointF(r.right(), cy),
240
+ 'rotate': QPointF(cx, r.top()-20)
241
+ }
242
+ for role, h in self.handles.items():
243
+ h.setPos(self.mapFromScene(self.mapToScene(positions[role])))
244
+ for h in self.handles.values():
245
+ h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
246
+
247
+ def itemChange(self, change, value):
248
+ if change in (QGraphicsItem.GraphicsItemChange.ItemPositionChange,
249
+ QGraphicsItem.GraphicsItemChange.ItemTransformChange,
250
+ QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged):
251
+ QTimer.singleShot(0, self.updateHandles)
252
+ return super().itemChange(change, value)
253
+
254
+ def interactiveResize(self, role: str, dx: float, dy: float):
255
+ if self._resizing:
256
+ return
257
+ r = QRectF(self.rect())
258
+ if role == 'top':
259
+ r.setTop(r.top() + dy)
260
+ elif role == 'bottom':
261
+ r.setBottom(r.bottom() + dy)
262
+ elif role == 'left':
263
+ r.setLeft(r.left() + dx)
264
+ elif role == 'right':
265
+ r.setRight(r.right() + dx)
266
+ elif role == 'rotate':
267
+ # rotation is handled in HandleItem.mouseMoveEvent now
268
+ return
269
+ self._resizing = True
270
+ self.prepareGeometryChange()
271
+ self.setRect(r)
272
+ self.updateHandles()
273
+ self._resizing = False
274
+
275
+
276
+ # ---------- Canvas ----------
277
+
278
+ class MaskCanvas(QGraphicsView):
279
+ def __init__(self, image01: np.ndarray, parent=None):
280
+ super().__init__(parent)
281
+ self.setRenderHint(QPainter.RenderHint.Antialiasing)
282
+
283
+ self._base_image01 = np.asarray(image01, dtype=np.float32)
284
+ self._display_stretch_enabled = False
285
+
286
+ # scene + background image
287
+ self.scene = QGraphicsScene(self)
288
+ self.setScene(self.scene)
289
+
290
+ self.bg_item = QGraphicsPixmapItem(_to_qpixmap01(self._base_image01))
291
+ self.scene.addItem(self.bg_item)
292
+
293
+ # --- NEW: basic zoom state ---
294
+ self._zoom = 1.0
295
+ self._min_zoom = 0.05
296
+ self._max_zoom = 8.0
297
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
298
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
299
+ # Make sure the scene rect matches the image so fit works perfectly
300
+ self.setSceneRect(self.bg_item.boundingRect())
301
+
302
+ self.mode = 'polygon'
303
+ self.temp_path: QGraphicsPathItem | None = None
304
+ self.poly_points: list[QPointF] = []
305
+ self.temp_ellipse: QGraphicsEllipseItem | None = None
306
+ self.ellipse_origin: QPointF | None = None
307
+ self.shapes: list[QGraphicsItem] = []
308
+
309
+ # ------------------- NEW: Zoom API -------------------
310
+ def set_zoom(self, z: float):
311
+ """Absolute zoom setter (resets transform, then scales)."""
312
+ self._zoom = max(self._min_zoom, min(float(z), self._max_zoom))
313
+ self.resetTransform()
314
+ self.scale(self._zoom, self._zoom)
315
+
316
+ def zoom_in(self):
317
+ self.set_zoom(self._zoom * 1.25)
318
+
319
+ def zoom_out(self):
320
+ self.set_zoom(self._zoom / 1.25)
321
+
322
+ def fit_to_view(self):
323
+ """Fit the background image into the viewport (Keeps aspect)."""
324
+ pm = self.bg_item.pixmap()
325
+ if pm.isNull():
326
+ return
327
+ vw = max(1, self.viewport().width())
328
+ vh = max(1, self.viewport().height())
329
+ iw = pm.width()
330
+ ih = pm.height()
331
+ if iw == 0 or ih == 0:
332
+ return
333
+ s = min(vw / iw, vh / ih)
334
+ self.set_zoom(s)
335
+
336
+ def wheelEvent(self, ev):
337
+ """Ctrl + wheel → zoom; otherwise default scroll behavior."""
338
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
339
+ self.set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
340
+ ev.accept()
341
+ return
342
+ super().wheelEvent(ev)
343
+ # ----------------- END: Zoom API ---------------------
344
+
345
+ def set_display_stretch_enabled(self, enabled: bool):
346
+ enabled = bool(enabled)
347
+ if enabled == self._display_stretch_enabled:
348
+ return
349
+ self._display_stretch_enabled = enabled
350
+ self._refresh_background_pixmap(keep_view=True)
351
+
352
+ def display_stretch_enabled(self) -> bool:
353
+ return bool(self._display_stretch_enabled)
354
+
355
+ def current_display_image01(self) -> np.ndarray:
356
+ """Returns the image currently used for *display* (not for mask math)."""
357
+ if self._display_stretch_enabled:
358
+ return _display_stretch(self._base_image01)
359
+ return self._base_image01
360
+
361
+ def _refresh_background_pixmap(self, keep_view: bool = True):
362
+ # Preserve current view transform/center so toggling doesn't “jump”
363
+ old_transform = self.transform()
364
+ old_center = self.mapToScene(self.viewport().rect().center())
365
+
366
+ disp = self.current_display_image01()
367
+ self.bg_item.setPixmap(_to_qpixmap01(disp))
368
+
369
+ # Ensure scene rect still matches image pixels
370
+ self.setSceneRect(self.bg_item.boundingRect())
371
+
372
+ if keep_view:
373
+ self.setTransform(old_transform)
374
+ self.centerOn(old_center)
375
+
376
+
377
+ def set_mode(self, mode: str):
378
+ assert mode in ('polygon', 'ellipse', 'select')
379
+ self.mode = mode
380
+
381
+ def clear_shapes(self):
382
+ for it in list(self.shapes):
383
+ self.scene.removeItem(it)
384
+ self.shapes.clear()
385
+
386
+ def select_entire_image(self):
387
+ self.clear_shapes()
388
+ rect = self.bg_item.boundingRect()
389
+ poly = QGraphicsPolygonItem(QPolygonF([rect.topLeft(), rect.topRight(),
390
+ rect.bottomRight(), rect.bottomLeft()]))
391
+ poly.setBrush(QColor(0, 255, 0, 50))
392
+ pen = QPen(QColor(0, 255, 0), 2)
393
+ pen.setCosmetic(True)
394
+ poly.setPen(pen)
395
+ poly.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
396
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
397
+ self.scene.addItem(poly); self.shapes.append(poly)
398
+
399
+
400
+ def mousePressEvent(self, ev):
401
+ pt = self.mapToScene(ev.pos())
402
+ if self.mode == 'ellipse' and ev.button() == Qt.MouseButton.LeftButton:
403
+ for it in self.items(ev.pos()):
404
+ if isinstance(it, (InteractiveEllipseItem, HandleItem)):
405
+ return super().mousePressEvent(ev)
406
+
407
+ if self.mode == 'polygon' and ev.button() == Qt.MouseButton.LeftButton:
408
+ self.poly_points = [pt]
409
+ path = QPainterPath(pt)
410
+ self.temp_path = QGraphicsPathItem(path)
411
+ pen = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
412
+ pen.setCosmetic(True)
413
+ self.temp_path.setPen(pen)
414
+ self.scene.addItem(self.temp_path)
415
+ return
416
+
417
+ if self.mode == 'ellipse' and ev.button() == Qt.MouseButton.LeftButton:
418
+ self.ellipse_origin = pt
419
+ self.temp_ellipse = QGraphicsEllipseItem(QRectF(pt, pt))
420
+ pen = QPen(QColor(0, 255, 0), 2, Qt.PenStyle.DashLine)
421
+ pen.setCosmetic(True)
422
+ self.temp_ellipse.setPen(pen)
423
+ self.scene.addItem(self.temp_ellipse)
424
+ return
425
+
426
+ super().mousePressEvent(ev)
427
+
428
+ def mouseMoveEvent(self, ev):
429
+ pt = self.mapToScene(ev.pos())
430
+ if self.mode == 'ellipse' and self.temp_ellipse is not None:
431
+ self.temp_ellipse.setRect(QRectF(self.ellipse_origin, pt).normalized())
432
+ elif self.mode == 'polygon' and self.temp_path:
433
+ self.poly_points.append(pt)
434
+ p = QPainterPath(self.poly_points[0])
435
+ for q in self.poly_points[1:]:
436
+ p.lineTo(q)
437
+ self.temp_path.setPath(p)
438
+ else:
439
+ super().mouseMoveEvent(ev)
440
+
441
+ def mouseReleaseEvent(self, ev):
442
+ if self.mode == 'ellipse' and self.temp_ellipse is not None:
443
+ final_rect = self.temp_ellipse.rect().normalized()
444
+ self.scene.removeItem(self.temp_ellipse); self.temp_ellipse = None
445
+ if final_rect.width() > 4 and final_rect.height() > 4:
446
+ local_rect = QRectF(0, 0, final_rect.width(), final_rect.height())
447
+ ell = InteractiveEllipseItem(local_rect)
448
+ ell.setBrush(QBrush(Qt.BrushStyle.NoBrush))
449
+ ell.setZValue(1)
450
+ ell.setPos(final_rect.topLeft())
451
+ self.scene.addItem(ell); self.shapes.append(ell)
452
+ return
453
+
454
+ if self.mode == 'polygon' and self.temp_path:
455
+ poly = QGraphicsPolygonItem(QPolygonF(self.poly_points))
456
+ poly.setBrush(QColor(0, 255, 0, 50))
457
+ pen = QPen(QColor(0, 255, 0), 2)
458
+ pen.setCosmetic(True)
459
+ poly.setPen(pen)
460
+ poly.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
461
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
462
+ self.scene.removeItem(self.temp_path); self.temp_path = None
463
+ self.scene.addItem(poly); self.shapes.append(poly)
464
+ return
465
+
466
+ super().mouseReleaseEvent(ev)
467
+
468
+ def create_mask(self) -> np.ndarray:
469
+ if cv2 is None:
470
+ raise RuntimeError("OpenCV (cv2) is required for mask creation.")
471
+ h = self.bg_item.pixmap().height()
472
+ w = self.bg_item.pixmap().width()
473
+ mask = np.zeros((h, w), dtype=np.uint8)
474
+
475
+ for s in self.shapes:
476
+ if isinstance(s, QGraphicsPolygonItem):
477
+ pts = s.polygon()
478
+ arr = np.array([[p.x(), p.y()] for p in pts], np.int32)
479
+ cv2.fillPoly(mask, [arr], 1)
480
+ elif isinstance(s, InteractiveEllipseItem):
481
+ r = s.rect()
482
+ scenep = s.mapToScene(r.center())
483
+ cx, cy = int(scenep.x()), int(scenep.y())
484
+ rx = int(max(1, r.width() / 2))
485
+ ry = int(max(1, r.height() / 2))
486
+ angle = float(s.rotation())
487
+ cv2.ellipse(mask, (cx, cy), (rx, ry), angle, 0, 360, 1, -1)
488
+
489
+ return (mask > 0).astype(np.float32)
490
+
491
+ # Fit once on first show (nice UX)
492
+ def showEvent(self, ev):
493
+ super().showEvent(ev)
494
+ QTimer.singleShot(0, self.fit_to_view)
495
+
496
+
497
+
498
+ # ---------- Live preview ----------
499
+
500
+ class LivePreviewDialog(QDialog):
501
+ def __init__(self, original_image01: np.ndarray, parent=None):
502
+ super().__init__(parent)
503
+ self.setWindowTitle(self.tr("Live Mask Preview"))
504
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
505
+ lay = QVBoxLayout(self); lay.addWidget(self.label)
506
+ self.resize(300, 300)
507
+ self.base_pixmap = _to_qpixmap01(original_image01)
508
+ self.max_alpha = 150
509
+
510
+ def update_mask(self, mask01: np.ndarray):
511
+ h, w = mask01.shape
512
+ alpha = (np.clip(mask01, 0, 1) * self.max_alpha).astype(np.uint8)
513
+ rgba = np.zeros((h, w, 4), dtype=np.uint8)
514
+ rgba[..., 0] = 255 # red
515
+ rgba[..., 3] = alpha
516
+ overlay_qimg = QImage(rgba.data, w, h, 4*w, QImage.Format.Format_RGBA8888)
517
+ overlay = QPixmap.fromImage(overlay_qimg)
518
+ canvas = QPixmap(self.base_pixmap)
519
+ p = QPainter(canvas); p.drawPixmap(0, 0, overlay); p.end()
520
+ self.label.setPixmap(canvas.scaled(self.label.size(),
521
+ Qt.AspectRatioMode.KeepAspectRatio,
522
+ Qt.TransformationMode.SmoothTransformation))
523
+
524
+ def set_base_image(self, image01: np.ndarray):
525
+ self.base_pixmap = _to_qpixmap01(image01)
526
+
527
+ # ---------- Preview (push-as-doc) ----------
528
+ class MaskPreviewDialog(QDialog):
529
+ """Scrollable preview + 'Push as New Document…'."""
530
+ def __init__(self, mask01: np.ndarray, parent=None):
531
+ super().__init__(parent)
532
+ self.setWindowTitle(self.tr("Mask Preview"))
533
+ self.mask = np.clip(mask01, 0, 1).astype(np.float32)
534
+
535
+ # --- drag-pan state ---
536
+ self._dragging = False
537
+ self._drag_start = None
538
+ self._h_start = 0
539
+ self._v_start = 0
540
+
541
+ # Build UI first
542
+ self.scroll = QScrollArea(self)
543
+ self.scroll.setWidgetResizable(False)
544
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
545
+
546
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
547
+ self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
548
+
549
+ self.pixmap = self._to_pixmap(self.mask)
550
+ self.label.setPixmap(self.pixmap)
551
+ self.label.resize(self.pixmap.size())
552
+
553
+ self.scroll.setWidget(self.label)
554
+
555
+ # Enable mouse drag panning on the label (NOW label exists)
556
+ self.label.setMouseTracking(True)
557
+ self.label.installEventFilter(self)
558
+
559
+ btns = QHBoxLayout()
560
+ b_in = themed_toolbtn("zoom-in", "Zoom In")
561
+ b_out = themed_toolbtn("zoom-out", "Zoom Out")
562
+ b_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
563
+ b_push = QPushButton(self.tr("Push as New Document…"))
564
+
565
+ b_in.clicked.connect(lambda: self._zoom(1.2))
566
+ b_out.clicked.connect(lambda: self._zoom(1/1.2))
567
+ b_fit.clicked.connect(self._fit)
568
+ b_push.clicked.connect(self.push_as_new_document)
569
+
570
+ for b in (b_in, b_out, b_fit, b_push):
571
+ btns.addWidget(b)
572
+
573
+ lay = QVBoxLayout(self)
574
+ lay.addWidget(self.scroll)
575
+ lay.addLayout(btns)
576
+
577
+ self.scale = 1.0
578
+ self.setMinimumSize(600, 400)
579
+
580
+ def _to_pixmap(self, mask01: np.ndarray) -> QPixmap:
581
+ m8 = (np.clip(mask01, 0, 1) * 255).astype(np.uint8)
582
+ h, w = m8.shape
583
+ qimg = QImage(m8.data, w, h, w, QImage.Format.Format_Grayscale8)
584
+ return QPixmap.fromImage(qimg)
585
+
586
+ def _zoom(self, factor: float):
587
+ self.scale *= factor
588
+ scaled = self.pixmap.scaled(
589
+ self.pixmap.size() * self.scale,
590
+ Qt.AspectRatioMode.KeepAspectRatio,
591
+ Qt.TransformationMode.SmoothTransformation
592
+ )
593
+ self.label.setPixmap(scaled)
594
+ self.label.resize(scaled.size())
595
+
596
+ def _fit(self):
597
+ vp = self.scroll.viewport().size()
598
+ if self.pixmap.width() and self.pixmap.height():
599
+ s = min(vp.width()/self.pixmap.width(), vp.height()/self.pixmap.height())
600
+ self.scale = max(0.05, s)
601
+ # re-render at the new scale (don’t multiply again)
602
+ scaled = self.pixmap.scaled(
603
+ self.pixmap.size() * self.scale,
604
+ Qt.AspectRatioMode.KeepAspectRatio,
605
+ Qt.TransformationMode.SmoothTransformation
606
+ )
607
+ self.label.setPixmap(scaled)
608
+ self.label.resize(scaled.size())
609
+
610
+ def eventFilter(self, obj, ev):
611
+ if obj is self.label:
612
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
613
+ self._dragging = True
614
+ self._drag_start = ev.globalPosition().toPoint()
615
+ self._h_start = self.scroll.horizontalScrollBar().value()
616
+ self._v_start = self.scroll.verticalScrollBar().value()
617
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
618
+ return True
619
+
620
+ if ev.type() == QEvent.Type.MouseMove and self._dragging:
621
+ p = ev.globalPosition().toPoint()
622
+ d = p - self._drag_start
623
+ self.scroll.horizontalScrollBar().setValue(self._h_start - d.x())
624
+ self.scroll.verticalScrollBar().setValue(self._v_start - d.y())
625
+ return True
626
+
627
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
628
+ self._dragging = False
629
+ self._drag_start = None
630
+ self.unsetCursor()
631
+ return True
632
+
633
+ return super().eventFilter(obj, ev)
634
+
635
+ def push_as_new_document(self):
636
+ if self.mask is None:
637
+ QMessageBox.warning(self, "No Mask", "No mask to push.")
638
+ return
639
+
640
+ # Walk up to the main window to reach DocManager
641
+ host = self.parent()
642
+ while host is not None and not hasattr(host, "docman"):
643
+ host = host.parent()
644
+ if host is None or not hasattr(host, "docman"):
645
+ QMessageBox.warning(self, "No DocManager", "Could not find the document manager.")
646
+ return
647
+
648
+ # Ask for a friendly name
649
+ name, ok = QInputDialog.getText(self, "New Document Name", "Name:", text="Mask")
650
+ if not ok:
651
+ return
652
+
653
+ # Ensure float32; a mask is mono by definition
654
+ img = self.mask.astype(np.float32, copy=False)
655
+ meta = {
656
+ "bit_depth": "32-bit floating point",
657
+ "is_mono": True,
658
+ "original_format": "fits",
659
+ }
660
+
661
+ # Create the doc → this emits documentAdded, which the main window now handles via _spawn_subwindow_for
662
+ new_doc = host.docman.create_document(img, metadata=meta, name=(name or "Mask"))
663
+
664
+ # Focus it
665
+ try:
666
+ sw = host._find_subwindow_for_doc(new_doc)
667
+ if sw:
668
+ host.mdi.setActiveSubWindow(sw)
669
+ except Exception:
670
+ pass
671
+
672
+ self.accept()
673
+
674
+
675
+
676
+ # ---------- Mask dialog ----------
677
+
678
+ class MaskCreationDialog(QDialog):
679
+ """Mask creation UI for SASpro documents (returns a np mask on OK)."""
680
+ def __init__(self, image01: np.ndarray, parent=None, auto_push_on_ok: bool = True):
681
+ super().__init__(parent)
682
+ self.setWindowTitle(self.tr("Mask Creation"))
683
+ self.setWindowFlag(Qt.WindowType.Window, True)
684
+ self.setWindowModality(Qt.WindowModality.NonModal)
685
+ self.setModal(False)
686
+ try:
687
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
688
+ except Exception:
689
+ pass # older PyQt6 versions
690
+ self.image = np.asarray(image01, dtype=np.float32).copy()
691
+ self.mask: np.ndarray | None = None
692
+ self.live_preview = LivePreviewDialog(self.image, parent=self)
693
+
694
+ self.mask_type = "Binary"
695
+ self.blur_amount = 0
696
+
697
+ # <- this was missing
698
+ self.auto_push_on_ok = auto_push_on_ok
699
+
700
+ self._build_ui()
701
+
702
+ def _build_ui(self):
703
+ layout = QVBoxLayout(self)
704
+
705
+ # Mode toolbar
706
+ mode_bar = QHBoxLayout()
707
+ self.free_btn = QPushButton(self.tr("Freehand")); self.free_btn.setCheckable(True)
708
+ self.ellipse_btn = QPushButton(self.tr("Ellipse")); self.ellipse_btn.setCheckable(True)
709
+ self.select_btn = QPushButton(self.tr("Select Entire Image")); self.select_btn.setCheckable(True)
710
+ group = QButtonGroup(self); group.setExclusive(True)
711
+ for b in (self.free_btn, self.ellipse_btn, self.select_btn):
712
+ b.setAutoExclusive(True); group.addButton(b)
713
+ b.setStyleSheet("""
714
+ QPushButton { padding:6px; border:1px solid #888; border-radius:4px; background:transparent; }
715
+ QPushButton:checked { background-color:#0078d4; color:white; border-color:#005a9e; }
716
+ """)
717
+ for btn, mode in ((self.free_btn,'polygon'), (self.ellipse_btn,'ellipse'), (self.select_btn,'select')):
718
+ btn.clicked.connect(lambda _=False, m=mode: self._set_mode(m))
719
+ mode_bar.addWidget(btn)
720
+ self.free_btn.setChecked(True)
721
+ layout.addLayout(mode_bar)
722
+
723
+ zoom_bar = QHBoxLayout()
724
+ z_out = themed_toolbtn("zoom-out", "Zoom Out")
725
+ z_in = themed_toolbtn("zoom-in", "Zoom In")
726
+ z_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
727
+ z_out.clicked.connect(lambda: self._zoom_canvas(1/1.25))
728
+ z_in.clicked.connect(lambda: self._zoom_canvas(1.25))
729
+ z_fit.clicked.connect(self._fit_canvas)
730
+ zoom_bar.addWidget(z_out); zoom_bar.addWidget(z_in); zoom_bar.addWidget(z_fit)
731
+ layout.addLayout(zoom_bar)
732
+
733
+ # Display stretch toggle (display-only; never modifies image data)
734
+ self.btn_disp_stretch = QPushButton(self.tr("Toggle Display Stretch"))
735
+ self.btn_disp_stretch.setCheckable(True)
736
+ self.btn_disp_stretch.setToolTip(
737
+ "Display-only stretch for easier masking on linear images.\n"
738
+ "This does NOT change the image data or the generated mask."
739
+ )
740
+ self.btn_disp_stretch.toggled.connect(self._toggle_display_stretch)
741
+ self.btn_disp_stretch.setChecked(False)
742
+ self.btn_disp_stretch.setText("Enable Display Stretch")
743
+ zoom_bar.addWidget(self.btn_disp_stretch)
744
+
745
+ # Canvas
746
+ self.canvas = MaskCanvas(self.image)
747
+ layout.addWidget(self.canvas, 1)
748
+
749
+ # Mask type & blur
750
+ controls = QHBoxLayout()
751
+ controls.addWidget(QLabel("Mask Type:"))
752
+ self.type_dd = QComboBox()
753
+ self.type_dd.addItems([
754
+ "Binary","Range Selection","Lightness","Chrominance","Star Mask",
755
+ "Color: Red","Color: Orange","Color: Yellow",
756
+ "Color: Green","Color: Cyan","Color: Blue","Color: Magenta"
757
+ ])
758
+ self.type_dd.currentTextChanged.connect(lambda t: setattr(self, 'mask_type', t))
759
+ controls.addWidget(self.type_dd)
760
+
761
+ controls.addWidget(QLabel(self.tr("Edge Blur (px):")))
762
+ self.blur_slider = QSlider(Qt.Orientation.Horizontal); self.blur_slider.setRange(0, 300)
763
+ self.blur_slider.valueChanged.connect(lambda v: setattr(self, 'blur_amount', int(v)))
764
+ controls.addWidget(self.blur_slider)
765
+ self.blur_lbl = QLabel("0")
766
+ self.blur_slider.valueChanged.connect(lambda v: self.blur_lbl.setText(str(v)))
767
+ controls.addWidget(self.blur_lbl)
768
+ layout.addLayout(controls)
769
+
770
+ # Range Selection
771
+ self.range_box = QGroupBox("Range Selection"); g = QGridLayout(self.range_box)
772
+ def add_slider(row: int, name: str, maxv: int):
773
+ g.addWidget(QLabel(name + ":"), row, 0)
774
+ s = QSlider(Qt.Orientation.Horizontal); s.setRange(0, maxv)
775
+ s.setValue(maxv if name == "Upper" else 0)
776
+ lbl = QLabel(f"{(s.value()/maxv):.2f}")
777
+ s.valueChanged.connect(lambda v, l=lbl, s=s: l.setText(f"{v/s.maximum():.2f}"))
778
+ s.valueChanged.connect(self._update_live_preview)
779
+ g.addWidget(s, row, 1); g.addWidget(lbl, row, 2)
780
+ return s, lbl
781
+ self.lower_sl, _ = add_slider(0, "Lower", 100)
782
+ self.upper_sl, _ = add_slider(1, "Upper", 100)
783
+ self.fuzz_sl, _ = add_slider(2, "Transition", 100)
784
+ g.addWidget(QLabel("Blur:"), 3, 0)
785
+ self.smooth_sl = QSlider(Qt.Orientation.Horizontal)
786
+ self.smooth_sl.setRange(1, 200) # σ in pixels
787
+ self.smooth_sl.setValue(3) # a sensible default
788
+ g.addWidget(self.smooth_sl, 3, 1)
789
+
790
+ self.smooth_lbl = QLabel("σ = 3 px")
791
+ g.addWidget(self.smooth_lbl, 3, 2)
792
+
793
+ # live label + live preview
794
+ def _upd_smooth(v):
795
+ self.smooth_lbl.setText(f"σ = {int(v)} px")
796
+ self._update_live_preview()
797
+ self.smooth_sl.valueChanged.connect(_upd_smooth)
798
+ self.link_cb = QCheckBox("Link limits"); g.addWidget(self.link_cb, 0, 3, 2, 1)
799
+ self.screen_cb = QCheckBox("Screening"); g.addWidget(self.screen_cb, 4, 0, 1, 4)
800
+ self.light_cb = QCheckBox("Lightness"); g.addWidget(self.light_cb, 5, 0, 1, 4)
801
+ self.invert_cb = QCheckBox("Invert"); g.addWidget(self.invert_cb, 6, 0, 1, 4)
802
+ self.lower_sl.valueChanged.connect(self._on_linked)
803
+ self.link_cb.toggled.connect(self._on_link_switch)
804
+ layout.addWidget(self.range_box); self.range_box.hide()
805
+ self.type_dd.currentTextChanged.connect(self._on_type_changed)
806
+
807
+ # Preview & Clear
808
+ rowb = QHBoxLayout()
809
+ b_preview = QPushButton("Preview Mask"); b_preview.clicked.connect(self._preview_mask)
810
+ b_clear = QPushButton("Clear Shapes"); b_clear.clicked.connect(self._clear_shapes)
811
+ rowb.addWidget(b_preview); rowb.addWidget(b_clear)
812
+ layout.addLayout(rowb)
813
+
814
+ # OK / Cancel
815
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
816
+ btns.accepted.connect(self._accept_apply); btns.rejected.connect(self.reject)
817
+ layout.addWidget(btns)
818
+
819
+ self.canvas.installEventFilter(self)
820
+
821
+ self.resize(980, 640)
822
+
823
+ # ---- callbacks
824
+ def _set_mode(self, mode: str):
825
+ self.canvas.set_mode(mode)
826
+ if mode == 'select':
827
+ self.canvas.select_entire_image()
828
+
829
+ def _clear_shapes(self):
830
+ self.canvas.clear_shapes()
831
+
832
+ def _on_type_changed(self, txt: str):
833
+ show = (txt == "Range Selection")
834
+ self.range_box.setVisible(show)
835
+ if show:
836
+ if not self.live_preview.isVisible():
837
+ self.live_preview.show()
838
+ self._update_live_preview()
839
+ else:
840
+ if self.live_preview.isVisible():
841
+ self.live_preview.close()
842
+
843
+ def _on_link_switch(self, checked: bool):
844
+ if checked:
845
+ self.upper_sl.setValue(self.lower_sl.value())
846
+
847
+ def _on_linked(self, v: int):
848
+ if self.link_cb.isChecked():
849
+ self.upper_sl.setValue(v)
850
+
851
+ def _toggle_display_stretch(self, enabled: bool):
852
+ try:
853
+ self.canvas.set_display_stretch_enabled(bool(enabled))
854
+
855
+ # keep button label in sync
856
+ self.btn_disp_stretch.setText(
857
+ self.tr("Disable Display Stretch") if enabled else self.tr("Enable Display Stretch")
858
+ )
859
+
860
+ # Keep the live preview background in sync (Range Selection uses it)
861
+ if hasattr(self, "live_preview") and self.live_preview is not None:
862
+ self.live_preview.set_base_image(self.canvas.current_display_image01())
863
+ if self.live_preview.isVisible():
864
+ self._update_live_preview()
865
+ except Exception:
866
+ pass
867
+
868
+
869
+ # ---- generators
870
+ def _component_lightness(self) -> np.ndarray:
871
+ if self.image.ndim == 3:
872
+ return (self.image[..., 0]*0.2989 + self.image[..., 1]*0.5870 + self.image[..., 2]*0.1140).astype(np.float32)
873
+ return self.image.astype(np.float32)
874
+
875
+ def _range_selection_mask(self, comp01: np.ndarray, L, U, fuzz, smooth, screening, invert):
876
+ m = np.zeros_like(comp01, dtype=np.float32)
877
+ inside = (comp01 >= L) & (comp01 <= U); m[inside] = 1.0
878
+ if fuzz > 0:
879
+ ramp = (comp01 - (L - fuzz)) / max(fuzz, 1e-12); m += np.clip(ramp, 0, 1)
880
+ ramp2 = ((U + fuzz) - comp01) / max(fuzz, 1e-12); m *= np.clip(ramp2, 0, 1)
881
+ if screening: m *= comp01
882
+ if smooth > 0 and cv2 is not None: m = cv2.GaussianBlur(m, (0, 0), float(smooth))
883
+ if invert: m = 1.0 - m
884
+ return np.clip(m, 0, 1)
885
+
886
+ def _generate_color_mask(self, color: str) -> np.ndarray:
887
+ if cv2 is None:
888
+ QMessageBox.warning(self, "Missing OpenCV", "Color masks require OpenCV (cv2).")
889
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
890
+ ranges = {
891
+ "Red": [(0, 10), (350, 360)], "Orange": [(10, 40)], "Yellow": [(40, 70)],
892
+ "Green": [(70, 170)], "Cyan": [(170, 200)], "Blue": [(200, 270)], "Magenta": [(270, 350)],
893
+ }
894
+ if color not in ranges or self.image.ndim != 3:
895
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
896
+ rgb8 = (np.clip(self.image, 0, 1) * 255).astype(np.uint8)
897
+ hls = cv2.cvtColor(rgb8, cv2.COLOR_RGB2HLS)
898
+ hue = (hls[..., 0].astype(np.float32) / 180.0) * 360.0
899
+ mask = np.zeros(hue.shape, dtype=np.float32)
900
+ for lo, hi in ranges[color]:
901
+ if lo < hi:
902
+ mask = np.maximum(mask, ((hue >= lo) & (hue <= hi)).astype(np.float32))
903
+ else:
904
+ mask = np.maximum(mask, ((hue >= lo) | (hue <= hi)).astype(np.float32))
905
+ return mask
906
+
907
+ def _generate_chrominance(self) -> np.ndarray:
908
+ if cv2 is None or self.image.ndim != 3:
909
+ QMessageBox.warning(self, "Needs RGB + OpenCV", "Chrominance mask requires an RGB image and OpenCV.")
910
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
911
+ rgb8 = (np.clip(self.image, 0, 1) * 255).astype(np.uint8)
912
+ ycrcb = cv2.cvtColor(rgb8, cv2.COLOR_RGB2YCrCb)
913
+ cb = ycrcb[..., 1].astype(np.float32) / 255.0
914
+ cr = ycrcb[..., 2].astype(np.float32) / 255.0
915
+ out = np.sqrt((cb - cb.mean())**2 + (cr - cr.mean())**2)
916
+ return (out - out.min()) / (out.max() - out.min() + 1e-12)
917
+
918
+ def _generate_star_mask(self) -> np.ndarray:
919
+ if sep is None:
920
+ QMessageBox.warning(self, "Missing SEP", "Star mask requires the 'sep' package.")
921
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
922
+ data = self._component_lightness().astype(np.float32)
923
+ bkg = sep.Background(data); data_sub = data - bkg.back()
924
+ thresh = float(self.blur_amount) if self.blur_amount > 0 else 3.0
925
+ objs = sep.extract(data_sub, thresh=thresh, err=bkg.globalrms)
926
+ h, w = data.shape; out = np.zeros((h, w), dtype=np.float32)
927
+ if cv2 is None: return out
928
+ MAX_RADIUS = 10
929
+ for o in objs:
930
+ x, y = int(o['x']), int(o['y'])
931
+ r = int(max(o['a'], o['b']) * 1.5)
932
+ if r <= MAX_RADIUS:
933
+ cv2.circle(out, (x, y), max(1, r), 1.0, -1)
934
+ return np.clip(out, 0, 1)
935
+
936
+ def generate_mask(self) -> np.ndarray | None:
937
+ try:
938
+ base = self.canvas.create_mask()
939
+ except RuntimeError as e:
940
+ QMessageBox.warning(self, "Mask creation failed", str(e)); return None
941
+
942
+ t = self.mask_type
943
+ if t == "Binary":
944
+ m = base
945
+ elif t == "Range Selection":
946
+ comp = self._component_lightness() if self.light_cb.isChecked() else self._component_lightness()
947
+ L = self.lower_sl.value() / self.lower_sl.maximum()
948
+ U = self.upper_sl.value() / self.upper_sl.maximum()
949
+ fuzz = self.fuzz_sl.value() / self.fuzz_sl.maximum()
950
+ smooth = float(self.smooth_sl.value())
951
+ rs = self._range_selection_mask(comp, L, U, fuzz, smooth,
952
+ self.screen_cb.isChecked(), self.invert_cb.isChecked())
953
+ m = base * rs
954
+ elif t == "Lightness":
955
+ m = np.where(base > 0, self._component_lightness(), 0.0)
956
+ elif t == "Chrominance":
957
+ m = np.where(base > 0, self._generate_chrominance(), 0.0)
958
+ elif t == "Star Mask":
959
+ m = np.where(base > 0, self._generate_star_mask(), 0.0)
960
+ elif t.startswith("Color:"):
961
+ color = t.split(":", 1)[1].strip()
962
+ m = np.where(base > 0, self._generate_color_mask(color), 0.0)
963
+ else:
964
+ m = base
965
+
966
+ if self.blur_amount > 0 and cv2 is not None:
967
+ k = max(1, int(self.blur_amount) * 2 + 1)
968
+ m = cv2.GaussianBlur(m, (k, k), 0.0)
969
+ return np.clip(m, 0.0, 1.0)
970
+
971
+ def _update_live_preview(self, *_):
972
+ m = self.generate_mask()
973
+ if m is None: return
974
+ if not self.live_preview.isVisible(): self.live_preview.show()
975
+ self.live_preview.update_mask(m)
976
+
977
+ def _preview_mask(self):
978
+ m = self.generate_mask()
979
+ if m is None: return
980
+ MaskPreviewDialog(m, self).exec()
981
+
982
+ def _accept_apply(self):
983
+ m = self.generate_mask()
984
+ if m is None:
985
+ return
986
+
987
+ # always store it on the dialog for callers
988
+ self.mask = m
989
+
990
+ # if this dialog was opened in "tool" mode, push it as a new doc
991
+ if self.auto_push_on_ok:
992
+ _push_numpy_as_new_document(self, m, default_name="Mask")
993
+
994
+ self.accept()
995
+
996
+ def closeEvent(self, ev):
997
+ if self.live_preview and self.live_preview.isVisible():
998
+ self.live_preview.close()
999
+ super().closeEvent(ev)
1000
+
1001
+ # --- NEW: generic zoom helpers -----------------------------------------
1002
+ def _zoom_canvas(self, factor: float):
1003
+ """
1004
+ Try several zoom APIs so we work with different MaskCanvas versions.
1005
+ """
1006
+ c = self.canvas
1007
+ try:
1008
+ if hasattr(c, "zoom_in") and factor > 1.0:
1009
+ c.zoom_in()
1010
+ return
1011
+ if hasattr(c, "zoom_out") and factor < 1.0:
1012
+ c.zoom_out()
1013
+ return
1014
+ if hasattr(c, "set_zoom"):
1015
+ z = getattr(c, "_zoom", 1.0)
1016
+ c.set_zoom(max(0.05, min(z * float(factor), 8.0)))
1017
+ return
1018
+ # If it's a QGraphicsView or similar, scale its view transform
1019
+ if isinstance(c, QGraphicsView):
1020
+ c.scale(float(factor), float(factor))
1021
+ return
1022
+ except Exception:
1023
+ pass # fall through to friendly message
1024
+ QMessageBox.information(self, "Zoom", "Zoom is not supported by this canvas build.")
1025
+
1026
+ def _fit_canvas(self):
1027
+ c = self.canvas
1028
+ try:
1029
+ if hasattr(c, "fit_to_view"):
1030
+ c.fit_to_view()
1031
+ return
1032
+ if isinstance(c, QGraphicsView):
1033
+ # Fit the full scene rect (keep aspect)
1034
+ r = c.sceneRect() if hasattr(c, "sceneRect") else None
1035
+ if r and r.isValid():
1036
+ c.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
1037
+ return
1038
+ except Exception:
1039
+ pass
1040
+ QMessageBox.information(self, "Fit", "Fit-to-preview is not supported by this canvas build.")
1041
+
1042
+ # --- NEW: Ctrl+Wheel zoom passthrough -----------------------------------
1043
+ def eventFilter(self, obj, ev):
1044
+ # Let the canvas keep its own interactions;
1045
+ # only intercept Ctrl+Wheel to trigger our zoom.
1046
+ if obj is self.canvas and ev.type() == QEvent.Type.Wheel:
1047
+ if isinstance(ev, QWheelEvent) and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
1048
+ self._zoom_canvas(1.25 if ev.angleDelta().y() > 0 else 1/1.25)
1049
+ return True
1050
+ return super().eventFilter(obj, ev)
1051
+
1052
+
1053
+ # ---------- Integration helper ----------
1054
+
1055
+ def create_mask_and_attach(parent, document) -> bool:
1056
+ if document is None or getattr(document, "image", None) is None:
1057
+ QMessageBox.information(parent, "No image", "Open an image first.")
1058
+ return False
1059
+
1060
+ # NOW we let the dialog auto-push when user hits OK
1061
+ dlg = MaskCreationDialog(document.image, parent=parent, auto_push_on_ok=True)
1062
+ if dlg.exec() != QDialog.DialogCode.Accepted:
1063
+ return False
1064
+
1065
+ mask = getattr(dlg, "mask", None)
1066
+ if mask is None:
1067
+ QMessageBox.information(parent, "No mask", "No mask was generated.")
1068
+ return False
1069
+
1070
+ # since we already pushed a mask doc, just attach it quietly
1071
+ layer = MaskLayer(
1072
+ id=uuid.uuid4().hex,
1073
+ name="Mask", # keep it simple; matches preview default
1074
+ data=np.clip(mask.astype(np.float32, copy=False), 0.0, 1.0),
1075
+ invert=False,
1076
+ opacity=1.0,
1077
+ mode="affect",
1078
+ visible=True,
1079
+ )
1080
+ document.add_mask(layer, make_active=True)
1081
+
1082
+ try:
1083
+ if hasattr(parent, "_log"):
1084
+ parent._log(f"Added mask '{layer.name}' and set active (and pushed as document).")
1085
+ except Exception:
1086
+ pass
1087
+
1088
+ return True
1089
+
1090
+