setiastrosuitepro 1.6.2.post1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,931 @@
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
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
+
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
+
52
+ def _find_main_window(w):
53
+ p = w
54
+ from PyQt6.QtWidgets import QMainWindow
55
+ while p is not None and not isinstance(p, QMainWindow):
56
+ p = p.parent()
57
+ return p
58
+
59
+ def _push_numpy_as_new_document(owner_widget, arr01: np.ndarray, default_name: str = "Mask") -> bool:
60
+ mw = _find_main_window(owner_widget)
61
+ if mw is None or not hasattr(mw, "docman"):
62
+ QMessageBox.warning(owner_widget, "Cannot Create Document", "Main window / DocManager not found.")
63
+ return False
64
+
65
+ # Ask for the document name
66
+ name, ok = QInputDialog.getText(owner_widget, "New Document Name", "Name:", text=default_name)
67
+ if not ok:
68
+ return False
69
+
70
+ # Ensure float32 in [0..1]
71
+ img = np.clip(arr01.astype(np.float32, copy=False), 0.0, 1.0)
72
+
73
+ # This sets metadata['display_name'] via DocManager and emits documentAdded
74
+ doc = mw.docman.open_array(img, title=name)
75
+
76
+ # Nothing else required: AstroSuiteProMainWindow._open_subwindow_for_added_doc
77
+ # will create/show the subwindow.
78
+ if hasattr(mw, "_log"):
79
+ mw._log(f"Created new document from mask: {doc.display_name()}")
80
+ return True
81
+
82
+
83
+ # ---------- Interactive ellipse handles ----------
84
+ class HandleItem(QGraphicsRectItem):
85
+ SIZE = 8
86
+ def __init__(self, role: str, parent_ellipse: QGraphicsEllipseItem):
87
+ super().__init__(-self.SIZE/2, -self.SIZE/2, self.SIZE, self.SIZE, parent_ellipse)
88
+ self.role = role
89
+ self.parent_ellipse = parent_ellipse
90
+ self.setBrush(QColor(255, 0, 0))
91
+ self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
92
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
93
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, False)
94
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations, True)
95
+
96
+ cursors = {
97
+ 'top': Qt.CursorShape.SizeVerCursor,
98
+ 'bottom': Qt.CursorShape.SizeVerCursor,
99
+ 'left': Qt.CursorShape.SizeHorCursor,
100
+ 'right': Qt.CursorShape.SizeHorCursor,
101
+ 'rotate': Qt.CursorShape.OpenHandCursor,
102
+ }
103
+ self.setCursor(cursors[role])
104
+
105
+ self._lastScenePos = None
106
+ # extra state for rotation
107
+ self._centerScene = None
108
+ self._startAngle = None
109
+ self._startRotation = None
110
+
111
+ def mousePressEvent(self, ev):
112
+ if self.role == 'rotate':
113
+ # Store center of ellipse in scene coords
114
+ rect = self.parent_ellipse.rect()
115
+ center_item = rect.center()
116
+ self._centerScene = self.parent_ellipse.mapToScene(center_item)
117
+
118
+ # Starting angle from center → mouse
119
+ p = ev.scenePos()
120
+ dx = p.x() - self._centerScene.x()
121
+ dy = p.y() - self._centerScene.y()
122
+ self._startAngle = math.degrees(math.atan2(dy, dx))
123
+
124
+ # Store current item rotation
125
+ self._startRotation = self.parent_ellipse.rotation()
126
+
127
+ # Optional: change cursor to "grabbing"
128
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
129
+ ev.accept()
130
+ return
131
+
132
+ # Non-rotate handles use the old dx/dy code path
133
+ self._lastScenePos = ev.scenePos()
134
+ ev.accept()
135
+
136
+ def mouseMoveEvent(self, ev):
137
+ if self.role == 'rotate':
138
+ if self._centerScene is None or self._startAngle is None or self._startRotation is None:
139
+ ev.accept()
140
+ return
141
+
142
+ p = ev.scenePos()
143
+ dx = p.x() - self._centerScene.x()
144
+ dy = p.y() - self._centerScene.y()
145
+
146
+ # Current angle from center → mouse
147
+ current_angle = math.degrees(math.atan2(dy, dx))
148
+
149
+ # Delta relative to the original grab angle
150
+ delta = current_angle - self._startAngle
151
+
152
+ # Set absolute rotation: starting rotation + delta
153
+ self.parent_ellipse.setRotation(self._startRotation + delta)
154
+ ev.accept()
155
+ return
156
+
157
+ # Resize handles: same as before
158
+ if self._lastScenePos is None:
159
+ self._lastScenePos = ev.scenePos()
160
+ dx = ev.scenePos().x() - self._lastScenePos.x()
161
+ dy = ev.scenePos().y() - self._lastScenePos.y()
162
+ self.parent_ellipse.interactiveResize(self.role, dx, dy)
163
+ self._lastScenePos = ev.scenePos()
164
+ ev.accept()
165
+
166
+ def mouseReleaseEvent(self, ev):
167
+ # Reset rotation state and cursor
168
+ if self.role == 'rotate':
169
+ self._centerScene = None
170
+ self._startAngle = None
171
+ self._startRotation = None
172
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
173
+
174
+ self._lastScenePos = None
175
+ ev.accept()
176
+
177
+
178
+
179
+ class InteractiveEllipseItem(QGraphicsEllipseItem):
180
+ def __init__(self, rect: QRectF):
181
+ super().__init__(rect)
182
+ self._resizing = False
183
+ self.setTransformOriginPoint(self.rect().center())
184
+ self.setFlags(
185
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
186
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
187
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
188
+ )
189
+
190
+ # Cosmetic pen: stays the same thickness on screen regardless of zoom
191
+ pen = QPen(QColor(0, 255, 0), 2)
192
+ pen.setCosmetic(True)
193
+ self.setPen(pen)
194
+
195
+ self.handles = {r: HandleItem(r, self) for r in ('top','bottom','left','right','rotate')}
196
+ self.updateHandles()
197
+
198
+ def updateHandles(self):
199
+ r = self.rect()
200
+ cx, cy = r.center().x(), r.center().y()
201
+ for h in self.handles.values():
202
+ h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, False)
203
+ positions = {
204
+ 'top': QPointF(cx, r.top()),
205
+ 'bottom': QPointF(cx, r.bottom()),
206
+ 'left': QPointF(r.left(), cy),
207
+ 'right': QPointF(r.right(), cy),
208
+ 'rotate': QPointF(cx, r.top()-20)
209
+ }
210
+ for role, h in self.handles.items():
211
+ h.setPos(self.mapFromScene(self.mapToScene(positions[role])))
212
+ for h in self.handles.values():
213
+ h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
214
+
215
+ def itemChange(self, change, value):
216
+ if change in (QGraphicsItem.GraphicsItemChange.ItemPositionChange,
217
+ QGraphicsItem.GraphicsItemChange.ItemTransformChange,
218
+ QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged):
219
+ QTimer.singleShot(0, self.updateHandles)
220
+ return super().itemChange(change, value)
221
+
222
+ def interactiveResize(self, role: str, dx: float, dy: float):
223
+ if self._resizing:
224
+ return
225
+ r = QRectF(self.rect())
226
+ if role == 'top':
227
+ r.setTop(r.top() + dy)
228
+ elif role == 'bottom':
229
+ r.setBottom(r.bottom() + dy)
230
+ elif role == 'left':
231
+ r.setLeft(r.left() + dx)
232
+ elif role == 'right':
233
+ r.setRight(r.right() + dx)
234
+ elif role == 'rotate':
235
+ # rotation is handled in HandleItem.mouseMoveEvent now
236
+ return
237
+ self._resizing = True
238
+ self.prepareGeometryChange()
239
+ self.setRect(r)
240
+ self.updateHandles()
241
+ self._resizing = False
242
+
243
+
244
+ # ---------- Canvas ----------
245
+
246
+ class MaskCanvas(QGraphicsView):
247
+ def __init__(self, image01: np.ndarray, parent=None):
248
+ super().__init__(parent)
249
+ self.setRenderHint(QPainter.RenderHint.Antialiasing)
250
+
251
+ # scene + background image
252
+ self.scene = QGraphicsScene(self)
253
+ self.setScene(self.scene)
254
+ self.bg_item = QGraphicsPixmapItem(_to_qpixmap01(image01))
255
+ self.scene.addItem(self.bg_item)
256
+
257
+ # --- NEW: basic zoom state ---
258
+ self._zoom = 1.0
259
+ self._min_zoom = 0.05
260
+ self._max_zoom = 8.0
261
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
262
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
263
+ # Make sure the scene rect matches the image so fit works perfectly
264
+ self.setSceneRect(self.bg_item.boundingRect())
265
+
266
+ self.mode = 'polygon'
267
+ self.temp_path: QGraphicsPathItem | None = None
268
+ self.poly_points: list[QPointF] = []
269
+ self.temp_ellipse: QGraphicsEllipseItem | None = None
270
+ self.ellipse_origin: QPointF | None = None
271
+ self.shapes: list[QGraphicsItem] = []
272
+
273
+ # ------------------- NEW: Zoom API -------------------
274
+ def set_zoom(self, z: float):
275
+ """Absolute zoom setter (resets transform, then scales)."""
276
+ self._zoom = max(self._min_zoom, min(float(z), self._max_zoom))
277
+ self.resetTransform()
278
+ self.scale(self._zoom, self._zoom)
279
+
280
+ def zoom_in(self):
281
+ self.set_zoom(self._zoom * 1.25)
282
+
283
+ def zoom_out(self):
284
+ self.set_zoom(self._zoom / 1.25)
285
+
286
+ def fit_to_view(self):
287
+ """Fit the background image into the viewport (Keeps aspect)."""
288
+ pm = self.bg_item.pixmap()
289
+ if pm.isNull():
290
+ return
291
+ vw = max(1, self.viewport().width())
292
+ vh = max(1, self.viewport().height())
293
+ iw = pm.width()
294
+ ih = pm.height()
295
+ if iw == 0 or ih == 0:
296
+ return
297
+ s = min(vw / iw, vh / ih)
298
+ self.set_zoom(s)
299
+
300
+ def wheelEvent(self, ev):
301
+ """Ctrl + wheel → zoom; otherwise default scroll behavior."""
302
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
303
+ self.set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
304
+ ev.accept()
305
+ return
306
+ super().wheelEvent(ev)
307
+ # ----------------- END: Zoom API ---------------------
308
+
309
+ def set_mode(self, mode: str):
310
+ assert mode in ('polygon', 'ellipse', 'select')
311
+ self.mode = mode
312
+
313
+ def clear_shapes(self):
314
+ for it in list(self.shapes):
315
+ self.scene.removeItem(it)
316
+ self.shapes.clear()
317
+
318
+ def select_entire_image(self):
319
+ self.clear_shapes()
320
+ rect = self.bg_item.boundingRect()
321
+ poly = QGraphicsPolygonItem(QPolygonF([rect.topLeft(), rect.topRight(),
322
+ rect.bottomRight(), rect.bottomLeft()]))
323
+ poly.setBrush(QColor(0, 255, 0, 50))
324
+ pen = QPen(QColor(0, 255, 0), 2)
325
+ pen.setCosmetic(True)
326
+ poly.setPen(pen)
327
+ poly.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
328
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
329
+ self.scene.addItem(poly); self.shapes.append(poly)
330
+
331
+
332
+ def mousePressEvent(self, ev):
333
+ pt = self.mapToScene(ev.pos())
334
+ if self.mode == 'ellipse' and ev.button() == Qt.MouseButton.LeftButton:
335
+ for it in self.items(ev.pos()):
336
+ if isinstance(it, (InteractiveEllipseItem, HandleItem)):
337
+ return super().mousePressEvent(ev)
338
+
339
+ if self.mode == 'polygon' and ev.button() == Qt.MouseButton.LeftButton:
340
+ self.poly_points = [pt]
341
+ path = QPainterPath(pt)
342
+ self.temp_path = QGraphicsPathItem(path)
343
+ pen = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
344
+ pen.setCosmetic(True)
345
+ self.temp_path.setPen(pen)
346
+ self.scene.addItem(self.temp_path)
347
+ return
348
+
349
+ if self.mode == 'ellipse' and ev.button() == Qt.MouseButton.LeftButton:
350
+ self.ellipse_origin = pt
351
+ self.temp_ellipse = QGraphicsEllipseItem(QRectF(pt, pt))
352
+ pen = QPen(QColor(0, 255, 0), 2, Qt.PenStyle.DashLine)
353
+ pen.setCosmetic(True)
354
+ self.temp_ellipse.setPen(pen)
355
+ self.scene.addItem(self.temp_ellipse)
356
+ return
357
+
358
+ super().mousePressEvent(ev)
359
+
360
+ def mouseMoveEvent(self, ev):
361
+ pt = self.mapToScene(ev.pos())
362
+ if self.mode == 'ellipse' and self.temp_ellipse is not None:
363
+ self.temp_ellipse.setRect(QRectF(self.ellipse_origin, pt).normalized())
364
+ elif self.mode == 'polygon' and self.temp_path:
365
+ self.poly_points.append(pt)
366
+ p = QPainterPath(self.poly_points[0])
367
+ for q in self.poly_points[1:]:
368
+ p.lineTo(q)
369
+ self.temp_path.setPath(p)
370
+ else:
371
+ super().mouseMoveEvent(ev)
372
+
373
+ def mouseReleaseEvent(self, ev):
374
+ if self.mode == 'ellipse' and self.temp_ellipse is not None:
375
+ final_rect = self.temp_ellipse.rect().normalized()
376
+ self.scene.removeItem(self.temp_ellipse); self.temp_ellipse = None
377
+ if final_rect.width() > 4 and final_rect.height() > 4:
378
+ local_rect = QRectF(0, 0, final_rect.width(), final_rect.height())
379
+ ell = InteractiveEllipseItem(local_rect)
380
+ ell.setBrush(QBrush(Qt.BrushStyle.NoBrush))
381
+ ell.setZValue(1)
382
+ ell.setPos(final_rect.topLeft())
383
+ self.scene.addItem(ell); self.shapes.append(ell)
384
+ return
385
+
386
+ if self.mode == 'polygon' and self.temp_path:
387
+ poly = QGraphicsPolygonItem(QPolygonF(self.poly_points))
388
+ poly.setBrush(QColor(0, 255, 0, 50))
389
+ pen = QPen(QColor(0, 255, 0), 2)
390
+ pen.setCosmetic(True)
391
+ poly.setPen(pen)
392
+ poly.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
393
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
394
+ self.scene.removeItem(self.temp_path); self.temp_path = None
395
+ self.scene.addItem(poly); self.shapes.append(poly)
396
+ return
397
+
398
+ super().mouseReleaseEvent(ev)
399
+
400
+ def create_mask(self) -> np.ndarray:
401
+ if cv2 is None:
402
+ raise RuntimeError("OpenCV (cv2) is required for mask creation.")
403
+ h = self.bg_item.pixmap().height()
404
+ w = self.bg_item.pixmap().width()
405
+ mask = np.zeros((h, w), dtype=np.uint8)
406
+
407
+ for s in self.shapes:
408
+ if isinstance(s, QGraphicsPolygonItem):
409
+ pts = s.polygon()
410
+ arr = np.array([[p.x(), p.y()] for p in pts], np.int32)
411
+ cv2.fillPoly(mask, [arr], 1)
412
+ elif isinstance(s, InteractiveEllipseItem):
413
+ r = s.rect()
414
+ scenep = s.mapToScene(r.center())
415
+ cx, cy = int(scenep.x()), int(scenep.y())
416
+ rx = int(max(1, r.width() / 2))
417
+ ry = int(max(1, r.height() / 2))
418
+ angle = float(s.rotation())
419
+ cv2.ellipse(mask, (cx, cy), (rx, ry), angle, 0, 360, 1, -1)
420
+
421
+ return (mask > 0).astype(np.float32)
422
+
423
+ # Fit once on first show (nice UX)
424
+ def showEvent(self, ev):
425
+ super().showEvent(ev)
426
+ QTimer.singleShot(0, self.fit_to_view)
427
+
428
+
429
+
430
+ # ---------- Live preview ----------
431
+
432
+ class LivePreviewDialog(QDialog):
433
+ def __init__(self, original_image01: np.ndarray, parent=None):
434
+ super().__init__(parent)
435
+ self.setWindowTitle(self.tr("Live Mask Preview"))
436
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
437
+ lay = QVBoxLayout(self); lay.addWidget(self.label)
438
+ self.resize(300, 300)
439
+ self.base_pixmap = _to_qpixmap01(original_image01)
440
+ self.max_alpha = 150
441
+
442
+ def update_mask(self, mask01: np.ndarray):
443
+ h, w = mask01.shape
444
+ alpha = (np.clip(mask01, 0, 1) * self.max_alpha).astype(np.uint8)
445
+ rgba = np.zeros((h, w, 4), dtype=np.uint8)
446
+ rgba[..., 0] = 255 # red
447
+ rgba[..., 3] = alpha
448
+ overlay_qimg = QImage(rgba.data, w, h, 4*w, QImage.Format.Format_RGBA8888)
449
+ overlay = QPixmap.fromImage(overlay_qimg)
450
+ canvas = QPixmap(self.base_pixmap)
451
+ p = QPainter(canvas); p.drawPixmap(0, 0, overlay); p.end()
452
+ self.label.setPixmap(canvas.scaled(self.label.size(),
453
+ Qt.AspectRatioMode.KeepAspectRatio,
454
+ Qt.TransformationMode.SmoothTransformation))
455
+
456
+
457
+ # ---------- Preview (push-as-doc) ----------
458
+
459
+ class MaskPreviewDialog(QDialog):
460
+ """Scrollable preview + 'Push as New Document…'."""
461
+ def __init__(self, mask01: np.ndarray, parent=None):
462
+ super().__init__(parent)
463
+ self.setWindowTitle(self.tr("Mask Preview"))
464
+ self.mask = np.clip(mask01, 0, 1).astype(np.float32)
465
+
466
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(False)
467
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
468
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
469
+ self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
470
+ self.pixmap = self._to_pixmap(self.mask); self.label.setPixmap(self.pixmap)
471
+ self.scroll.setWidget(self.label)
472
+
473
+ btns = QHBoxLayout()
474
+ b_in = themed_toolbtn("zoom-in", "Zoom In")
475
+ b_out = themed_toolbtn("zoom-out", "Zoom Out")
476
+ b_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
477
+
478
+
479
+ b_push = QPushButton(self.tr("Push as New Document…"))
480
+ b_in.clicked.connect(lambda: self._zoom(1.2))
481
+ b_out.clicked.connect(lambda: self._zoom(1/1.2))
482
+ b_fit.clicked.connect(self._fit)
483
+ b_push.clicked.connect(self.push_as_new_document)
484
+ for b in (b_in, b_out, b_fit, b_push):
485
+ btns.addWidget(b)
486
+
487
+ lay = QVBoxLayout(self); lay.addWidget(self.scroll); lay.addLayout(btns)
488
+ self.scale = 1.0; self.setMinimumSize(600, 400)
489
+
490
+ def _to_pixmap(self, mask01: np.ndarray) -> QPixmap:
491
+ m8 = (np.clip(mask01, 0, 1) * 255).astype(np.uint8)
492
+ h, w = m8.shape
493
+ qimg = QImage(m8.data, w, h, w, QImage.Format.Format_Grayscale8)
494
+ return QPixmap.fromImage(qimg)
495
+
496
+ def _zoom(self, factor: float):
497
+ self.scale *= factor
498
+ scaled = self.pixmap.scaled(self.pixmap.size() * self.scale,
499
+ Qt.AspectRatioMode.KeepAspectRatio,
500
+ Qt.TransformationMode.SmoothTransformation)
501
+ self.label.setPixmap(scaled); self.label.resize(scaled.size())
502
+
503
+ def _fit(self):
504
+ vp = self.scroll.viewport().size()
505
+ if self.pixmap.width() and self.pixmap.height():
506
+ s = min(vp.width()/self.pixmap.width(), vp.height()/self.pixmap.height())
507
+ self.scale = max(0.05, s)
508
+ self._zoom(1.0)
509
+
510
+ def push_as_new_document(self):
511
+ if self.mask is None:
512
+ QMessageBox.warning(self, "No Mask", "No mask to push.")
513
+ return
514
+
515
+ # Walk up to the main window to reach DocManager
516
+ host = self.parent()
517
+ while host is not None and not hasattr(host, "docman"):
518
+ host = host.parent()
519
+ if host is None or not hasattr(host, "docman"):
520
+ QMessageBox.warning(self, "No DocManager", "Could not find the document manager.")
521
+ return
522
+
523
+ # Ask for a friendly name
524
+ name, ok = QInputDialog.getText(self, "New Document Name", "Name:", text="Mask")
525
+ if not ok:
526
+ return
527
+
528
+ # Ensure float32; a mask is mono by definition
529
+ img = self.mask.astype(np.float32, copy=False)
530
+ meta = {
531
+ "bit_depth": "32-bit floating point",
532
+ "is_mono": True,
533
+ "original_format": "fits",
534
+ }
535
+
536
+ # Create the doc → this emits documentAdded, which the main window now handles via _spawn_subwindow_for
537
+ new_doc = host.docman.create_document(img, metadata=meta, name=(name or "Mask"))
538
+
539
+ # Focus it
540
+ try:
541
+ sw = host._find_subwindow_for_doc(new_doc)
542
+ if sw:
543
+ host.mdi.setActiveSubWindow(sw)
544
+ except Exception:
545
+ pass
546
+
547
+ self.accept()
548
+
549
+
550
+
551
+ # ---------- Mask dialog ----------
552
+
553
+ class MaskCreationDialog(QDialog):
554
+ """Mask creation UI for SASpro documents (returns a np mask on OK)."""
555
+ def __init__(self, image01: np.ndarray, parent=None, auto_push_on_ok: bool = True):
556
+ super().__init__(parent)
557
+ self.setWindowTitle(self.tr("Mask Creation"))
558
+ self.setWindowFlag(Qt.WindowType.Window, True)
559
+ self.setWindowModality(Qt.WindowModality.NonModal)
560
+ self.setModal(False)
561
+ self.image = np.asarray(image01, dtype=np.float32).copy()
562
+ self.mask: np.ndarray | None = None
563
+ self.live_preview = LivePreviewDialog(self.image, parent=self)
564
+
565
+ self.mask_type = "Binary"
566
+ self.blur_amount = 0
567
+
568
+ # <- this was missing
569
+ self.auto_push_on_ok = auto_push_on_ok
570
+
571
+ self._build_ui()
572
+
573
+ def _build_ui(self):
574
+ layout = QVBoxLayout(self)
575
+
576
+ # Mode toolbar
577
+ mode_bar = QHBoxLayout()
578
+ self.free_btn = QPushButton(self.tr("Freehand")); self.free_btn.setCheckable(True)
579
+ self.ellipse_btn = QPushButton(self.tr("Ellipse")); self.ellipse_btn.setCheckable(True)
580
+ self.select_btn = QPushButton(self.tr("Select Entire Image")); self.select_btn.setCheckable(True)
581
+ group = QButtonGroup(self); group.setExclusive(True)
582
+ for b in (self.free_btn, self.ellipse_btn, self.select_btn):
583
+ b.setAutoExclusive(True); group.addButton(b)
584
+ b.setStyleSheet("""
585
+ QPushButton { padding:6px; border:1px solid #888; border-radius:4px; background:transparent; }
586
+ QPushButton:checked { background-color:#0078d4; color:white; border-color:#005a9e; }
587
+ """)
588
+ for btn, mode in ((self.free_btn,'polygon'), (self.ellipse_btn,'ellipse'), (self.select_btn,'select')):
589
+ btn.clicked.connect(lambda _=False, m=mode: self._set_mode(m))
590
+ mode_bar.addWidget(btn)
591
+ self.free_btn.setChecked(True)
592
+ layout.addLayout(mode_bar)
593
+
594
+ zoom_bar = QHBoxLayout()
595
+ z_out = themed_toolbtn("zoom-out", "Zoom Out")
596
+ z_in = themed_toolbtn("zoom-in", "Zoom In")
597
+ z_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
598
+ z_out.clicked.connect(lambda: self._zoom_canvas(1/1.25))
599
+ z_in.clicked.connect(lambda: self._zoom_canvas(1.25))
600
+ z_fit.clicked.connect(self._fit_canvas)
601
+ zoom_bar.addWidget(z_out); zoom_bar.addWidget(z_in); zoom_bar.addWidget(z_fit)
602
+ layout.addLayout(zoom_bar)
603
+
604
+ # Canvas
605
+ self.canvas = MaskCanvas(self.image)
606
+ layout.addWidget(self.canvas, 1)
607
+
608
+ # Mask type & blur
609
+ controls = QHBoxLayout()
610
+ controls.addWidget(QLabel("Mask Type:"))
611
+ self.type_dd = QComboBox()
612
+ self.type_dd.addItems([
613
+ "Binary","Range Selection","Lightness","Chrominance","Star Mask",
614
+ "Color: Red","Color: Orange","Color: Yellow",
615
+ "Color: Green","Color: Cyan","Color: Blue","Color: Magenta"
616
+ ])
617
+ self.type_dd.currentTextChanged.connect(lambda t: setattr(self, 'mask_type', t))
618
+ controls.addWidget(self.type_dd)
619
+
620
+ controls.addWidget(QLabel(self.tr("Edge Blur (px):")))
621
+ self.blur_slider = QSlider(Qt.Orientation.Horizontal); self.blur_slider.setRange(0, 300)
622
+ self.blur_slider.valueChanged.connect(lambda v: setattr(self, 'blur_amount', int(v)))
623
+ controls.addWidget(self.blur_slider)
624
+ self.blur_lbl = QLabel("0")
625
+ self.blur_slider.valueChanged.connect(lambda v: self.blur_lbl.setText(str(v)))
626
+ controls.addWidget(self.blur_lbl)
627
+ layout.addLayout(controls)
628
+
629
+ # Range Selection
630
+ self.range_box = QGroupBox("Range Selection"); g = QGridLayout(self.range_box)
631
+ def add_slider(row: int, name: str, maxv: int):
632
+ g.addWidget(QLabel(name + ":"), row, 0)
633
+ s = QSlider(Qt.Orientation.Horizontal); s.setRange(0, maxv)
634
+ s.setValue(maxv if name == "Upper" else 0)
635
+ lbl = QLabel(f"{(s.value()/maxv):.2f}")
636
+ s.valueChanged.connect(lambda v, l=lbl, s=s: l.setText(f"{v/s.maximum():.2f}"))
637
+ s.valueChanged.connect(self._update_live_preview)
638
+ g.addWidget(s, row, 1); g.addWidget(lbl, row, 2)
639
+ return s, lbl
640
+ self.lower_sl, _ = add_slider(0, "Lower", 100)
641
+ self.upper_sl, _ = add_slider(1, "Upper", 100)
642
+ self.fuzz_sl, _ = add_slider(2, "Transition", 100)
643
+ g.addWidget(QLabel("Blur:"), 3, 0)
644
+ self.smooth_sl = QSlider(Qt.Orientation.Horizontal)
645
+ self.smooth_sl.setRange(1, 200) # σ in pixels
646
+ self.smooth_sl.setValue(3) # a sensible default
647
+ g.addWidget(self.smooth_sl, 3, 1)
648
+
649
+ self.smooth_lbl = QLabel("σ = 3 px")
650
+ g.addWidget(self.smooth_lbl, 3, 2)
651
+
652
+ # live label + live preview
653
+ def _upd_smooth(v):
654
+ self.smooth_lbl.setText(f"σ = {int(v)} px")
655
+ self._update_live_preview()
656
+ self.smooth_sl.valueChanged.connect(_upd_smooth)
657
+ self.link_cb = QCheckBox("Link limits"); g.addWidget(self.link_cb, 0, 3, 2, 1)
658
+ self.screen_cb = QCheckBox("Screening"); g.addWidget(self.screen_cb, 4, 0, 1, 4)
659
+ self.light_cb = QCheckBox("Lightness"); g.addWidget(self.light_cb, 5, 0, 1, 4)
660
+ self.invert_cb = QCheckBox("Invert"); g.addWidget(self.invert_cb, 6, 0, 1, 4)
661
+ self.lower_sl.valueChanged.connect(self._on_linked)
662
+ self.link_cb.toggled.connect(self._on_link_switch)
663
+ layout.addWidget(self.range_box); self.range_box.hide()
664
+ self.type_dd.currentTextChanged.connect(self._on_type_changed)
665
+
666
+ # Preview & Clear
667
+ rowb = QHBoxLayout()
668
+ b_preview = QPushButton("Preview Mask"); b_preview.clicked.connect(self._preview_mask)
669
+ b_clear = QPushButton("Clear Shapes"); b_clear.clicked.connect(self._clear_shapes)
670
+ rowb.addWidget(b_preview); rowb.addWidget(b_clear)
671
+ layout.addLayout(rowb)
672
+
673
+ # OK / Cancel
674
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
675
+ btns.accepted.connect(self._accept_apply); btns.rejected.connect(self.reject)
676
+ layout.addWidget(btns)
677
+
678
+ self.canvas.installEventFilter(self)
679
+
680
+ self.resize(980, 640)
681
+
682
+ # ---- callbacks
683
+ def _set_mode(self, mode: str):
684
+ self.canvas.set_mode(mode)
685
+ if mode == 'select':
686
+ self.canvas.select_entire_image()
687
+
688
+ def _clear_shapes(self):
689
+ self.canvas.clear_shapes()
690
+
691
+ def _on_type_changed(self, txt: str):
692
+ show = (txt == "Range Selection")
693
+ self.range_box.setVisible(show)
694
+ if show:
695
+ if not self.live_preview.isVisible():
696
+ self.live_preview.show()
697
+ self._update_live_preview()
698
+ else:
699
+ if self.live_preview.isVisible():
700
+ self.live_preview.close()
701
+
702
+ def _on_link_switch(self, checked: bool):
703
+ if checked:
704
+ self.upper_sl.setValue(self.lower_sl.value())
705
+
706
+ def _on_linked(self, v: int):
707
+ if self.link_cb.isChecked():
708
+ self.upper_sl.setValue(v)
709
+
710
+ # ---- generators
711
+ def _component_lightness(self) -> np.ndarray:
712
+ if self.image.ndim == 3:
713
+ return (self.image[..., 0]*0.2989 + self.image[..., 1]*0.5870 + self.image[..., 2]*0.1140).astype(np.float32)
714
+ return self.image.astype(np.float32)
715
+
716
+ def _range_selection_mask(self, comp01: np.ndarray, L, U, fuzz, smooth, screening, invert):
717
+ m = np.zeros_like(comp01, dtype=np.float32)
718
+ inside = (comp01 >= L) & (comp01 <= U); m[inside] = 1.0
719
+ if fuzz > 0:
720
+ ramp = (comp01 - (L - fuzz)) / max(fuzz, 1e-12); m += np.clip(ramp, 0, 1)
721
+ ramp2 = ((U + fuzz) - comp01) / max(fuzz, 1e-12); m *= np.clip(ramp2, 0, 1)
722
+ if screening: m *= comp01
723
+ if smooth > 0 and cv2 is not None: m = cv2.GaussianBlur(m, (0, 0), float(smooth))
724
+ if invert: m = 1.0 - m
725
+ return np.clip(m, 0, 1)
726
+
727
+ def _generate_color_mask(self, color: str) -> np.ndarray:
728
+ if cv2 is None:
729
+ QMessageBox.warning(self, "Missing OpenCV", "Color masks require OpenCV (cv2).")
730
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
731
+ ranges = {
732
+ "Red": [(0, 10), (350, 360)], "Orange": [(10, 40)], "Yellow": [(40, 70)],
733
+ "Green": [(70, 170)], "Cyan": [(170, 200)], "Blue": [(200, 270)], "Magenta": [(270, 350)],
734
+ }
735
+ if color not in ranges or self.image.ndim != 3:
736
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
737
+ rgb8 = (np.clip(self.image, 0, 1) * 255).astype(np.uint8)
738
+ hls = cv2.cvtColor(rgb8, cv2.COLOR_RGB2HLS)
739
+ hue = (hls[..., 0].astype(np.float32) / 180.0) * 360.0
740
+ mask = np.zeros(hue.shape, dtype=np.float32)
741
+ for lo, hi in ranges[color]:
742
+ if lo < hi:
743
+ mask = np.maximum(mask, ((hue >= lo) & (hue <= hi)).astype(np.float32))
744
+ else:
745
+ mask = np.maximum(mask, ((hue >= lo) | (hue <= hi)).astype(np.float32))
746
+ return mask
747
+
748
+ def _generate_chrominance(self) -> np.ndarray:
749
+ if cv2 is None or self.image.ndim != 3:
750
+ QMessageBox.warning(self, "Needs RGB + OpenCV", "Chrominance mask requires an RGB image and OpenCV.")
751
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
752
+ rgb8 = (np.clip(self.image, 0, 1) * 255).astype(np.uint8)
753
+ ycrcb = cv2.cvtColor(rgb8, cv2.COLOR_RGB2YCrCb)
754
+ cb = ycrcb[..., 1].astype(np.float32) / 255.0
755
+ cr = ycrcb[..., 2].astype(np.float32) / 255.0
756
+ out = np.sqrt((cb - cb.mean())**2 + (cr - cr.mean())**2)
757
+ return (out - out.min()) / (out.max() - out.min() + 1e-12)
758
+
759
+ def _generate_star_mask(self) -> np.ndarray:
760
+ if sep is None:
761
+ QMessageBox.warning(self, "Missing SEP", "Star mask requires the 'sep' package.")
762
+ return np.zeros(self.image.shape[:2], dtype=np.float32)
763
+ data = self._component_lightness().astype(np.float32)
764
+ bkg = sep.Background(data); data_sub = data - bkg.back()
765
+ thresh = float(self.blur_amount) if self.blur_amount > 0 else 3.0
766
+ objs = sep.extract(data_sub, thresh=thresh, err=bkg.globalrms)
767
+ h, w = data.shape; out = np.zeros((h, w), dtype=np.float32)
768
+ if cv2 is None: return out
769
+ MAX_RADIUS = 10
770
+ for o in objs:
771
+ x, y = int(o['x']), int(o['y'])
772
+ r = int(max(o['a'], o['b']) * 1.5)
773
+ if r <= MAX_RADIUS:
774
+ cv2.circle(out, (x, y), max(1, r), 1.0, -1)
775
+ return np.clip(out, 0, 1)
776
+
777
+ def generate_mask(self) -> np.ndarray | None:
778
+ try:
779
+ base = self.canvas.create_mask()
780
+ except RuntimeError as e:
781
+ QMessageBox.warning(self, "Mask creation failed", str(e)); return None
782
+
783
+ t = self.mask_type
784
+ if t == "Binary":
785
+ m = base
786
+ elif t == "Range Selection":
787
+ comp = self._component_lightness() if self.light_cb.isChecked() else self._component_lightness()
788
+ L = self.lower_sl.value() / self.lower_sl.maximum()
789
+ U = self.upper_sl.value() / self.upper_sl.maximum()
790
+ fuzz = self.fuzz_sl.value() / self.fuzz_sl.maximum()
791
+ smooth = float(self.smooth_sl.value())
792
+ rs = self._range_selection_mask(comp, L, U, fuzz, smooth,
793
+ self.screen_cb.isChecked(), self.invert_cb.isChecked())
794
+ m = base * rs
795
+ elif t == "Lightness":
796
+ m = np.where(base > 0, self._component_lightness(), 0.0)
797
+ elif t == "Chrominance":
798
+ m = np.where(base > 0, self._generate_chrominance(), 0.0)
799
+ elif t == "Star Mask":
800
+ m = np.where(base > 0, self._generate_star_mask(), 0.0)
801
+ elif t.startswith("Color:"):
802
+ color = t.split(":", 1)[1].strip()
803
+ m = np.where(base > 0, self._generate_color_mask(color), 0.0)
804
+ else:
805
+ m = base
806
+
807
+ if self.blur_amount > 0 and cv2 is not None:
808
+ k = max(1, int(self.blur_amount) * 2 + 1)
809
+ m = cv2.GaussianBlur(m, (k, k), 0.0)
810
+ return np.clip(m, 0.0, 1.0)
811
+
812
+ def _update_live_preview(self, *_):
813
+ m = self.generate_mask()
814
+ if m is None: return
815
+ if not self.live_preview.isVisible(): self.live_preview.show()
816
+ self.live_preview.update_mask(m)
817
+
818
+ def _preview_mask(self):
819
+ m = self.generate_mask()
820
+ if m is None: return
821
+ MaskPreviewDialog(m, self).exec()
822
+
823
+ def _accept_apply(self):
824
+ m = self.generate_mask()
825
+ if m is None:
826
+ return
827
+
828
+ # always store it on the dialog for callers
829
+ self.mask = m
830
+
831
+ # if this dialog was opened in "tool" mode, push it as a new doc
832
+ if self.auto_push_on_ok:
833
+ _push_numpy_as_new_document(self, m, default_name="Mask")
834
+
835
+ self.accept()
836
+
837
+ def closeEvent(self, ev):
838
+ if self.live_preview and self.live_preview.isVisible():
839
+ self.live_preview.close()
840
+ super().closeEvent(ev)
841
+
842
+ # --- NEW: generic zoom helpers -----------------------------------------
843
+ def _zoom_canvas(self, factor: float):
844
+ """
845
+ Try several zoom APIs so we work with different MaskCanvas versions.
846
+ """
847
+ c = self.canvas
848
+ try:
849
+ if hasattr(c, "zoom_in") and factor > 1.0:
850
+ c.zoom_in()
851
+ return
852
+ if hasattr(c, "zoom_out") and factor < 1.0:
853
+ c.zoom_out()
854
+ return
855
+ if hasattr(c, "set_zoom"):
856
+ z = getattr(c, "_zoom", 1.0)
857
+ c.set_zoom(max(0.05, min(z * float(factor), 8.0)))
858
+ return
859
+ # If it's a QGraphicsView or similar, scale its view transform
860
+ if isinstance(c, QGraphicsView):
861
+ c.scale(float(factor), float(factor))
862
+ return
863
+ except Exception:
864
+ pass # fall through to friendly message
865
+ QMessageBox.information(self, "Zoom", "Zoom is not supported by this canvas build.")
866
+
867
+ def _fit_canvas(self):
868
+ c = self.canvas
869
+ try:
870
+ if hasattr(c, "fit_to_view"):
871
+ c.fit_to_view()
872
+ return
873
+ if isinstance(c, QGraphicsView):
874
+ # Fit the full scene rect (keep aspect)
875
+ r = c.sceneRect() if hasattr(c, "sceneRect") else None
876
+ if r and r.isValid():
877
+ c.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
878
+ return
879
+ except Exception:
880
+ pass
881
+ QMessageBox.information(self, "Fit", "Fit-to-preview is not supported by this canvas build.")
882
+
883
+ # --- NEW: Ctrl+Wheel zoom passthrough -----------------------------------
884
+ def eventFilter(self, obj, ev):
885
+ # Let the canvas keep its own interactions;
886
+ # only intercept Ctrl+Wheel to trigger our zoom.
887
+ if obj is self.canvas and ev.type() == QEvent.Type.Wheel:
888
+ if isinstance(ev, QWheelEvent) and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
889
+ self._zoom_canvas(1.25 if ev.angleDelta().y() > 0 else 1/1.25)
890
+ return True
891
+ return super().eventFilter(obj, ev)
892
+
893
+
894
+ # ---------- Integration helper ----------
895
+
896
+ def create_mask_and_attach(parent, document) -> bool:
897
+ if document is None or getattr(document, "image", None) is None:
898
+ QMessageBox.information(parent, "No image", "Open an image first.")
899
+ return False
900
+
901
+ # NOW we let the dialog auto-push when user hits OK
902
+ dlg = MaskCreationDialog(document.image, parent=parent, auto_push_on_ok=True)
903
+ if dlg.exec() != QDialog.DialogCode.Accepted:
904
+ return False
905
+
906
+ mask = getattr(dlg, "mask", None)
907
+ if mask is None:
908
+ QMessageBox.information(parent, "No mask", "No mask was generated.")
909
+ return False
910
+
911
+ # since we already pushed a mask doc, just attach it quietly
912
+ layer = MaskLayer(
913
+ id=uuid.uuid4().hex,
914
+ name="Mask", # keep it simple; matches preview default
915
+ data=np.clip(mask.astype(np.float32, copy=False), 0.0, 1.0),
916
+ invert=False,
917
+ opacity=1.0,
918
+ mode="affect",
919
+ visible=True,
920
+ )
921
+ document.add_mask(layer, make_active=True)
922
+
923
+ try:
924
+ if hasattr(parent, "_log"):
925
+ parent._log(f"Added mask '{layer.name}' and set active (and pushed as document).")
926
+ except Exception:
927
+ pass
928
+
929
+ return True
930
+
931
+