setiastrosuitepro 1.6.1__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 (342) 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/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotateclockwise.png +0 -0
  106. setiastro/images/rotatecounterclockwise.png +0 -0
  107. setiastro/images/satellite.png +0 -0
  108. setiastro/images/script.png +0 -0
  109. setiastro/images/selectivecolor.png +0 -0
  110. setiastro/images/simbad.png +0 -0
  111. setiastro/images/slot0.png +0 -0
  112. setiastro/images/slot1.png +0 -0
  113. setiastro/images/slot2.png +0 -0
  114. setiastro/images/slot3.png +0 -0
  115. setiastro/images/slot4.png +0 -0
  116. setiastro/images/slot5.png +0 -0
  117. setiastro/images/slot6.png +0 -0
  118. setiastro/images/slot7.png +0 -0
  119. setiastro/images/slot8.png +0 -0
  120. setiastro/images/slot9.png +0 -0
  121. setiastro/images/spcc.png +0 -0
  122. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  123. setiastro/images/spinner.gif +0 -0
  124. setiastro/images/stacking.png +0 -0
  125. setiastro/images/staradd.png +0 -0
  126. setiastro/images/staralign.png +0 -0
  127. setiastro/images/starnet.png +0 -0
  128. setiastro/images/starregistration.png +0 -0
  129. setiastro/images/starspike.png +0 -0
  130. setiastro/images/starstretch.png +0 -0
  131. setiastro/images/statstretch.png +0 -0
  132. setiastro/images/supernova.png +0 -0
  133. setiastro/images/uhs.png +0 -0
  134. setiastro/images/undoicon.png +0 -0
  135. setiastro/images/upscale.png +0 -0
  136. setiastro/images/viewbundle.png +0 -0
  137. setiastro/images/whitebalance.png +0 -0
  138. setiastro/images/wimi_icon_256x256.png +0 -0
  139. setiastro/images/wimilogo.png +0 -0
  140. setiastro/images/wims.png +0 -0
  141. setiastro/images/wrench_icon.png +0 -0
  142. setiastro/images/xisfliberator.png +0 -0
  143. setiastro/saspro/__init__.py +20 -0
  144. setiastro/saspro/__main__.py +809 -0
  145. setiastro/saspro/_generated/__init__.py +7 -0
  146. setiastro/saspro/_generated/build_info.py +2 -0
  147. setiastro/saspro/abe.py +1295 -0
  148. setiastro/saspro/abe_preset.py +196 -0
  149. setiastro/saspro/aberration_ai.py +694 -0
  150. setiastro/saspro/aberration_ai_preset.py +224 -0
  151. setiastro/saspro/accel_installer.py +218 -0
  152. setiastro/saspro/accel_workers.py +30 -0
  153. setiastro/saspro/add_stars.py +621 -0
  154. setiastro/saspro/astrobin_exporter.py +1007 -0
  155. setiastro/saspro/astrospike.py +153 -0
  156. setiastro/saspro/astrospike_python.py +1839 -0
  157. setiastro/saspro/autostretch.py +196 -0
  158. setiastro/saspro/backgroundneutral.py +560 -0
  159. setiastro/saspro/batch_convert.py +325 -0
  160. setiastro/saspro/batch_renamer.py +519 -0
  161. setiastro/saspro/blemish_blaster.py +488 -0
  162. setiastro/saspro/blink_comparator_pro.py +2926 -0
  163. setiastro/saspro/bundles.py +61 -0
  164. setiastro/saspro/bundles_dock.py +114 -0
  165. setiastro/saspro/cheat_sheet.py +178 -0
  166. setiastro/saspro/clahe.py +342 -0
  167. setiastro/saspro/comet_stacking.py +1377 -0
  168. setiastro/saspro/common_tr.py +107 -0
  169. setiastro/saspro/config.py +38 -0
  170. setiastro/saspro/config_bootstrap.py +40 -0
  171. setiastro/saspro/config_manager.py +316 -0
  172. setiastro/saspro/continuum_subtract.py +1617 -0
  173. setiastro/saspro/convo.py +1397 -0
  174. setiastro/saspro/convo_preset.py +414 -0
  175. setiastro/saspro/copyastro.py +187 -0
  176. setiastro/saspro/cosmicclarity.py +1564 -0
  177. setiastro/saspro/cosmicclarity_preset.py +407 -0
  178. setiastro/saspro/crop_dialog_pro.py +956 -0
  179. setiastro/saspro/crop_preset.py +189 -0
  180. setiastro/saspro/curve_editor_pro.py +2544 -0
  181. setiastro/saspro/curves_preset.py +375 -0
  182. setiastro/saspro/debayer.py +670 -0
  183. setiastro/saspro/debug_utils.py +29 -0
  184. setiastro/saspro/dnd_mime.py +35 -0
  185. setiastro/saspro/doc_manager.py +2641 -0
  186. setiastro/saspro/exoplanet_detector.py +2166 -0
  187. setiastro/saspro/file_utils.py +284 -0
  188. setiastro/saspro/fitsmodifier.py +745 -0
  189. setiastro/saspro/fix_bom.py +32 -0
  190. setiastro/saspro/free_torch_memory.py +48 -0
  191. setiastro/saspro/frequency_separation.py +1343 -0
  192. setiastro/saspro/function_bundle.py +1594 -0
  193. setiastro/saspro/generate_translations.py +2378 -0
  194. setiastro/saspro/ghs_dialog_pro.py +660 -0
  195. setiastro/saspro/ghs_preset.py +284 -0
  196. setiastro/saspro/graxpert.py +634 -0
  197. setiastro/saspro/graxpert_preset.py +287 -0
  198. setiastro/saspro/gui/__init__.py +0 -0
  199. setiastro/saspro/gui/main_window.py +8567 -0
  200. setiastro/saspro/gui/mixins/__init__.py +33 -0
  201. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  202. setiastro/saspro/gui/mixins/file_mixin.py +443 -0
  203. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  204. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  205. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  206. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  207. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  208. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  209. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  210. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  211. setiastro/saspro/halobgon.py +462 -0
  212. setiastro/saspro/header_viewer.py +448 -0
  213. setiastro/saspro/headless_utils.py +88 -0
  214. setiastro/saspro/histogram.py +753 -0
  215. setiastro/saspro/history_explorer.py +939 -0
  216. setiastro/saspro/i18n.py +156 -0
  217. setiastro/saspro/image_combine.py +414 -0
  218. setiastro/saspro/image_peeker_pro.py +1601 -0
  219. setiastro/saspro/imageops/__init__.py +37 -0
  220. setiastro/saspro/imageops/mdi_snap.py +292 -0
  221. setiastro/saspro/imageops/scnr.py +36 -0
  222. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  223. setiastro/saspro/imageops/stretch.py +244 -0
  224. setiastro/saspro/isophote.py +1179 -0
  225. setiastro/saspro/layers.py +208 -0
  226. setiastro/saspro/layers_dock.py +714 -0
  227. setiastro/saspro/lazy_imports.py +193 -0
  228. setiastro/saspro/legacy/__init__.py +2 -0
  229. setiastro/saspro/legacy/image_manager.py +2226 -0
  230. setiastro/saspro/legacy/numba_utils.py +3659 -0
  231. setiastro/saspro/legacy/xisf.py +1071 -0
  232. setiastro/saspro/linear_fit.py +534 -0
  233. setiastro/saspro/live_stacking.py +1830 -0
  234. setiastro/saspro/log_bus.py +5 -0
  235. setiastro/saspro/logging_config.py +460 -0
  236. setiastro/saspro/luminancerecombine.py +309 -0
  237. setiastro/saspro/main_helpers.py +201 -0
  238. setiastro/saspro/mask_creation.py +928 -0
  239. setiastro/saspro/masks_core.py +56 -0
  240. setiastro/saspro/mdi_widgets.py +353 -0
  241. setiastro/saspro/memory_utils.py +666 -0
  242. setiastro/saspro/metadata_patcher.py +75 -0
  243. setiastro/saspro/mfdeconv.py +3826 -0
  244. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  245. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  246. setiastro/saspro/mfdeconvsport.py +2382 -0
  247. setiastro/saspro/minorbodycatalog.py +567 -0
  248. setiastro/saspro/morphology.py +382 -0
  249. setiastro/saspro/multiscale_decomp.py +1290 -0
  250. setiastro/saspro/nbtorgb_stars.py +531 -0
  251. setiastro/saspro/numba_utils.py +3044 -0
  252. setiastro/saspro/numba_warmup.py +141 -0
  253. setiastro/saspro/ops/__init__.py +9 -0
  254. setiastro/saspro/ops/command_help_dialog.py +623 -0
  255. setiastro/saspro/ops/command_runner.py +217 -0
  256. setiastro/saspro/ops/commands.py +1594 -0
  257. setiastro/saspro/ops/script_editor.py +1102 -0
  258. setiastro/saspro/ops/scripts.py +1413 -0
  259. setiastro/saspro/ops/settings.py +679 -0
  260. setiastro/saspro/parallel_utils.py +554 -0
  261. setiastro/saspro/pedestal.py +121 -0
  262. setiastro/saspro/perfect_palette_picker.py +1070 -0
  263. setiastro/saspro/pipeline.py +110 -0
  264. setiastro/saspro/pixelmath.py +1600 -0
  265. setiastro/saspro/plate_solver.py +2444 -0
  266. setiastro/saspro/project_io.py +797 -0
  267. setiastro/saspro/psf_utils.py +136 -0
  268. setiastro/saspro/psf_viewer.py +549 -0
  269. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  270. setiastro/saspro/remove_green.py +314 -0
  271. setiastro/saspro/remove_stars.py +1625 -0
  272. setiastro/saspro/remove_stars_preset.py +404 -0
  273. setiastro/saspro/resources.py +477 -0
  274. setiastro/saspro/rgb_combination.py +207 -0
  275. setiastro/saspro/rgb_extract.py +19 -0
  276. setiastro/saspro/rgbalign.py +723 -0
  277. setiastro/saspro/runtime_imports.py +7 -0
  278. setiastro/saspro/runtime_torch.py +754 -0
  279. setiastro/saspro/save_options.py +72 -0
  280. setiastro/saspro/selective_color.py +1552 -0
  281. setiastro/saspro/sfcc.py +1430 -0
  282. setiastro/saspro/shortcuts.py +3043 -0
  283. setiastro/saspro/signature_insert.py +1099 -0
  284. setiastro/saspro/stacking_suite.py +18181 -0
  285. setiastro/saspro/star_alignment.py +7420 -0
  286. setiastro/saspro/star_alignment_preset.py +329 -0
  287. setiastro/saspro/star_metrics.py +49 -0
  288. setiastro/saspro/star_spikes.py +681 -0
  289. setiastro/saspro/star_stretch.py +470 -0
  290. setiastro/saspro/stat_stretch.py +506 -0
  291. setiastro/saspro/status_log_dock.py +78 -0
  292. setiastro/saspro/subwindow.py +3267 -0
  293. setiastro/saspro/supernovaasteroidhunter.py +1716 -0
  294. setiastro/saspro/swap_manager.py +99 -0
  295. setiastro/saspro/torch_backend.py +89 -0
  296. setiastro/saspro/torch_rejection.py +434 -0
  297. setiastro/saspro/translations/de_translations.py +3733 -0
  298. setiastro/saspro/translations/es_translations.py +3923 -0
  299. setiastro/saspro/translations/fr_translations.py +3842 -0
  300. setiastro/saspro/translations/integrate_translations.py +234 -0
  301. setiastro/saspro/translations/it_translations.py +3662 -0
  302. setiastro/saspro/translations/ja_translations.py +3585 -0
  303. setiastro/saspro/translations/pt_translations.py +3853 -0
  304. setiastro/saspro/translations/saspro_de.qm +0 -0
  305. setiastro/saspro/translations/saspro_de.ts +253 -0
  306. setiastro/saspro/translations/saspro_es.qm +0 -0
  307. setiastro/saspro/translations/saspro_es.ts +12520 -0
  308. setiastro/saspro/translations/saspro_fr.qm +0 -0
  309. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  310. setiastro/saspro/translations/saspro_it.qm +0 -0
  311. setiastro/saspro/translations/saspro_it.ts +12520 -0
  312. setiastro/saspro/translations/saspro_ja.qm +0 -0
  313. setiastro/saspro/translations/saspro_ja.ts +257 -0
  314. setiastro/saspro/translations/saspro_pt.qm +0 -0
  315. setiastro/saspro/translations/saspro_pt.ts +257 -0
  316. setiastro/saspro/translations/saspro_zh.qm +0 -0
  317. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  318. setiastro/saspro/translations/zh_translations.py +3659 -0
  319. setiastro/saspro/versioning.py +71 -0
  320. setiastro/saspro/view_bundle.py +1555 -0
  321. setiastro/saspro/wavescale_hdr.py +624 -0
  322. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  323. setiastro/saspro/wavescalede.py +658 -0
  324. setiastro/saspro/wavescalede_preset.py +230 -0
  325. setiastro/saspro/wcs_update.py +374 -0
  326. setiastro/saspro/whitebalance.py +456 -0
  327. setiastro/saspro/widgets/__init__.py +48 -0
  328. setiastro/saspro/widgets/common_utilities.py +306 -0
  329. setiastro/saspro/widgets/graphics_views.py +122 -0
  330. setiastro/saspro/widgets/image_utils.py +518 -0
  331. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  332. setiastro/saspro/widgets/spinboxes.py +275 -0
  333. setiastro/saspro/widgets/themed_buttons.py +13 -0
  334. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  335. setiastro/saspro/window_shelf.py +185 -0
  336. setiastro/saspro/xisf.py +1123 -0
  337. setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
  338. setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
  339. setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
  340. setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
  341. setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
  342. setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,745 @@
1
+ #pro.fitsmodifier.py
2
+ from __future__ import annotations
3
+ import os
4
+ from typing import Optional, Tuple
5
+
6
+ import numpy as np
7
+ from astropy.io import fits
8
+ try:
9
+ from astropy.io.fits.verify import VerifyError
10
+ except Exception:
11
+ # Fallback for older Astropy – same pattern as in legacy.image_manager
12
+ class VerifyError(Exception):
13
+ pass
14
+ from PyQt6.QtCore import Qt
15
+ from PyQt6.QtWidgets import (
16
+ QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QHBoxLayout,
17
+ QLabel, QLineEdit, QMessageBox, QPushButton, QTreeWidget, QTreeWidgetItem,
18
+ QVBoxLayout
19
+ )
20
+ from PyQt6.QtCore import QSettings
21
+ from setiastro.saspro.legacy.image_manager import (
22
+ load_image as legacy_load_image,
23
+ save_image as legacy_save_image,
24
+ _drop_invalid_cards, # ← new
25
+ )
26
+
27
+ class FITSModifier(QDialog):
28
+ def __init__(self, file_path: Optional[str], header,
29
+ image_manager=None,
30
+ doc_manager=None, active_document=None, # <— rename param
31
+ parent=None):
32
+ super().__init__(parent)
33
+ self.setWindowTitle(self.tr("FITS Header Editor"))
34
+ self.resize(800, 600)
35
+
36
+ self._doc_manager = doc_manager
37
+ self._active_document = active_document
38
+ self.image_manager = None # stop using old ImageManager path
39
+
40
+ self.file_path = file_path if (file_path and os.path.isfile(file_path)) else None
41
+
42
+ self.hdul = None
43
+ self.current_hdu_index = 0
44
+ self._fallback_header = header
45
+
46
+ self._populating = False
47
+ self._dirty = False
48
+
49
+ # UI
50
+ top = QHBoxLayout()
51
+ self.path_label = QLabel(self.file_path or self.tr("(no file)"))
52
+ self.open_btn = QPushButton(self.tr("Open FITS…"))
53
+ self.reload_btn = QPushButton(self.tr("Reload"))
54
+ self.hdu_combo = QComboBox()
55
+ self.save_btn = QPushButton(self.tr("Save"))
56
+ self.saveas_btn = QPushButton(self.tr("Save a Copy As…"))
57
+ # self.apply_to_slot_btn = QPushButton("Apply to Slot Metadata") # optional
58
+
59
+ top.addWidget(QLabel(self.tr("File:")))
60
+ top.addWidget(self.path_label, 1)
61
+ top.addWidget(QLabel(self.tr("HDU:")))
62
+ top.addWidget(self.hdu_combo)
63
+ top.addWidget(self.open_btn)
64
+ top.addWidget(self.reload_btn)
65
+ #top.addWidget(self.save_btn)
66
+ top.addWidget(self.saveas_btn)
67
+ # top.addWidget(self.apply_to_slot_btn)
68
+
69
+ batch = QHBoxLayout()
70
+ self.batch_btn = QPushButton(self.tr("Batch Modify..."))
71
+ batch.addStretch()
72
+ batch.addWidget(self.batch_btn)
73
+ batch.addStretch()
74
+
75
+ self.tree = QTreeWidget()
76
+ self.tree.setColumnCount(3)
77
+ self.tree.setHeaderLabels([self.tr("Keyword"), self.tr("Value"), self.tr("Comment")])
78
+ self.tree.setAlternatingRowColors(True)
79
+ self.tree.setRootIsDecorated(False)
80
+ self.tree.setEditTriggers(QTreeWidget.EditTrigger.DoubleClicked | QTreeWidget.EditTrigger.SelectedClicked)
81
+ self.tree.setUniformRowHeights(True)
82
+ self.tree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
83
+ self.tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
84
+ self.tree.setStyleSheet("""
85
+ QTreeWidget::item:selected:active { background-color: #1E90FF; color: white; }
86
+ QTreeWidget::item:selected:!active { background-color: #5AA7FF; color: white; }
87
+ QTreeWidget::item:hover { background-color: rgba(30,144,255,0.18); }
88
+ QTreeWidget::item { padding: 2px 6px; }
89
+ """)
90
+
91
+ bottom = QHBoxLayout()
92
+ self.add_key_edit = QLineEdit(); self.add_key_edit.setPlaceholderText(self.tr("KEYWORD"))
93
+ self.add_val_edit = QLineEdit(); self.add_val_edit.setPlaceholderText(self.tr("Value"))
94
+ self.add_com_edit = QLineEdit(); self.add_com_edit.setPlaceholderText(self.tr("Comment (optional)"))
95
+ self.add_btn = QPushButton(self.tr("Add/Update"))
96
+ self.del_btn = QPushButton(self.tr("Delete Selected"))
97
+ self.all_hdus_chk = QCheckBox(self.tr("Apply add/update/delete to all HDUs"))
98
+ bottom.addWidget(self.add_key_edit)
99
+ bottom.addWidget(self.add_val_edit)
100
+ bottom.addWidget(self.add_com_edit)
101
+ bottom.addWidget(self.all_hdus_chk)
102
+ bottom.addWidget(self.add_btn)
103
+ bottom.addWidget(self.del_btn)
104
+
105
+ layout = QVBoxLayout(self)
106
+ layout.addLayout(top)
107
+ layout.addLayout(batch)
108
+ layout.addWidget(self.tree, 1)
109
+ layout.addLayout(bottom)
110
+
111
+ # Signals
112
+ self.open_btn.clicked.connect(self._choose_file)
113
+ self.reload_btn.clicked.connect(self._reload)
114
+ self.hdu_combo.currentIndexChanged.connect(self._on_hdu_changed)
115
+ self.save_btn.clicked.connect(self._save_in_place)
116
+ self.saveas_btn.clicked.connect(self._save_as_copy)
117
+ # self.apply_to_slot_btn.clicked.connect(self._apply_to_slot_metadata)
118
+ self.add_btn.clicked.connect(self._add_or_update_keyword)
119
+ self.del_btn.clicked.connect(self._delete_selected)
120
+
121
+ # Initial content
122
+ if self.file_path:
123
+ ok = self._load_file(self.file_path)
124
+ if not ok and header is not None:
125
+ self._init_from_header(header)
126
+ elif header is not None:
127
+ self._init_from_header(header)
128
+ else:
129
+ self._init_from_header(fits.Header())
130
+
131
+ self.tree.itemChanged.connect(self._on_item_changed)
132
+ self.tree.currentItemChanged.connect(self._on_row_selected)
133
+ self.batch_btn.clicked.connect(self._open_batch_modifier)
134
+
135
+ # ---- helpers ----
136
+ def _get_active_doc(self):
137
+ """
138
+ Prefer the explicitly passed active_document; fall back to doc_manager.
139
+ This avoids accidentally selecting the last opened document when MDI focus
140
+ detection is flaky.
141
+ """
142
+ # 1) if caller passed a doc, use it
143
+ if getattr(self, "_active_document", None) is not None:
144
+ return self._active_document
145
+
146
+ # 2) otherwise ask the doc manager
147
+ try:
148
+ if self._doc_manager and hasattr(self._doc_manager, "get_active_document"):
149
+ d = self._doc_manager.get_active_document()
150
+ if d is not None:
151
+ return d
152
+ except Exception:
153
+ pass
154
+
155
+ return None
156
+
157
+ def _update_multi_hdu_ui(self):
158
+ n = len(self.hdul) if self.hdul else 0
159
+ self.all_hdus_chk.setVisible(n > 1)
160
+
161
+ def _on_row_selected(self, curr, prev):
162
+ if not curr:
163
+ return
164
+ self.add_key_edit.setText(curr.text(0))
165
+ self.add_val_edit.setText(curr.text(1))
166
+ self.add_com_edit.setText(curr.text(2))
167
+
168
+ def _selected_row_triplet(self) -> Tuple[str, str, str]:
169
+ it = self.tree.currentItem()
170
+ if not it:
171
+ return "", "", ""
172
+ return (it.text(0).strip(), it.text(1), it.text(2))
173
+
174
+ def _open_batch_modifier(self):
175
+ key, val, com = self._selected_row_triplet()
176
+ dlg = BatchFITSHeaderDialog(parent=self,
177
+ preset_keyword=key,
178
+ preset_value=val,
179
+ preset_comment=com)
180
+ dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
181
+ dlg.show()
182
+
183
+ def _init_from_header(self, header):
184
+ phdu = fits.PrimaryHDU()
185
+ if isinstance(header, fits.Header):
186
+ clean = _drop_invalid_cards(header)
187
+ phdu.header = clean.copy()
188
+ elif isinstance(header, dict):
189
+ for k, v in header.items():
190
+ try:
191
+ phdu.header[k] = v
192
+ except Exception:
193
+ pass
194
+ self.hdul = fits.HDUList([phdu])
195
+ self._refresh_hdu_combo()
196
+ self._populate_tree_from_header(phdu.header)
197
+
198
+ def _apply_to_slot_metadata(self):
199
+ try:
200
+ hdr = self.hdul[self.current_hdu_index].header.copy()
201
+ hdr = _drop_invalid_cards(hdr)
202
+ doc = self._get_active_doc()
203
+ if doc is not None and hasattr(doc, "metadata"):
204
+ doc.metadata["original_header"] = hdr
205
+ if hasattr(doc, "changed"):
206
+ doc.changed.emit()
207
+ except Exception as e:
208
+ print(f"[FITSModifier] _apply_to_slot_metadata error: {e}")
209
+
210
+
211
+ def _set_dirty(self, dirty=True):
212
+ self._dirty = dirty
213
+ self.setWindowTitle(self.tr("FITS Header Editor") + (" *" if dirty else ""))
214
+
215
+ def _sync_tree_to_header(self):
216
+ if not self.hdul:
217
+ return
218
+ hdr = self.hdul[self.current_hdu_index].header
219
+ self._collect_tree_into_header(hdr)
220
+
221
+ def _choose_file(self):
222
+ fn, _ = QFileDialog.getOpenFileName(self, self.tr("Open FITS"), self._last_dir(), self.tr("FITS files (*.fits *.fit *.fts *.fz)"))
223
+ if not fn:
224
+ return
225
+ self._load_file(fn)
226
+
227
+ def _load_file(self, path) -> bool:
228
+ try:
229
+ if self.hdul is not None:
230
+ self.hdul.close()
231
+ except Exception:
232
+ pass
233
+ try:
234
+ self.hdul = fits.open(path, mode='update', memmap=False)
235
+ except Exception as e:
236
+ QMessageBox.warning(self, self.tr("Invalid FITS"),
237
+ self.tr("This file does not appear to be a valid FITS:\n\n{0}\n\n{1}\n\n"
238
+ "Tip: Choose a FITS file via 'Open FITS…' or edit an in-memory header.").format(path, e))
239
+ self.hdul = None
240
+ self.file_path = None
241
+ self.path_label.setText(self.tr("(no file)"))
242
+ self.hdu_combo.clear()
243
+ self._update_multi_hdu_ui()
244
+ return False
245
+
246
+ # Sanitize all HDU headers to drop invalid cards (e.g. bad TELESCOP)
247
+ for hdu in self.hdul:
248
+ try:
249
+ if isinstance(hdu.header, fits.Header):
250
+ hdu.header = _drop_invalid_cards(hdu.header)
251
+ except Exception as e:
252
+ print(f"[FITSModifier] Header sanitize failed for HDU: {e}")
253
+
254
+ self.file_path = path
255
+ self.path_label.setText(path)
256
+ self._save_last_dir(os.path.dirname(path))
257
+ self._refresh_hdu_combo()
258
+
259
+ # Use the sanitized header for the current HDU
260
+ hdr = self.hdul[self.current_hdu_index].header
261
+ self._populate_tree_from_header(hdr)
262
+ return True
263
+
264
+
265
+ def _reload(self):
266
+ if not self.hdul and not self.file_path:
267
+ return
268
+ if self.file_path:
269
+ self._load_file(self.file_path)
270
+ else:
271
+ self._populate_tree_from_header(self.hdul[0].header)
272
+
273
+ def _refresh_hdu_combo(self):
274
+ self.hdu_combo.blockSignals(True)
275
+ self.hdu_combo.clear()
276
+ for i, hdu in enumerate(self.hdul):
277
+ name = getattr(hdu, 'name', 'UNKNOWN')
278
+ self.hdu_combo.addItem(f"{i}: {name}")
279
+ self.hdu_combo.setCurrentIndex(0)
280
+ self.current_hdu_index = 0
281
+ self.hdu_combo.blockSignals(False)
282
+ self._update_multi_hdu_ui()
283
+
284
+ def _on_hdu_changed(self, idx):
285
+ self.current_hdu_index = int(idx)
286
+ hdr = self.hdul[self.current_hdu_index].header
287
+ hdr = _drop_invalid_cards(hdr)
288
+ self._populate_tree_from_header(hdr)
289
+
290
+ def _populate_tree_from_header(self, header: fits.Header):
291
+ self._populating = True
292
+ try:
293
+ self.tree.blockSignals(True)
294
+ self.tree.clear()
295
+ for card in header.cards:
296
+ key = card.keyword
297
+ if key in ("HISTORY", "COMMENT"):
298
+ val = ""
299
+ com = ""
300
+ else:
301
+ try:
302
+ val = self._val_to_str(card.value)
303
+ com = card.comment or ""
304
+ except VerifyError as e:
305
+ print(f"[FITSModifier] Skipping invalid card {key!r}: {e}")
306
+ continue # Don't add this card to the tree
307
+ it = QTreeWidgetItem([key, val, com])
308
+ it.setFlags(it.flags() | Qt.ItemFlag.ItemIsEditable)
309
+ self.tree.addTopLevelItem(it)
310
+ self.tree.resizeColumnToContents(0)
311
+ finally:
312
+ self.tree.blockSignals(False)
313
+ self._populating = False
314
+ self._set_dirty(False)
315
+
316
+
317
+
318
+ def _collect_tree_into_header(self, header: fits.Header):
319
+ new_header = fits.Header()
320
+ for i in range(self.tree.topLevelItemCount()):
321
+ it = self.tree.topLevelItem(i)
322
+ key = (it.text(0) or "").strip()
323
+ val_txt = it.text(1)
324
+ com = it.text(2)
325
+ if not key:
326
+ continue
327
+ if key in ("HISTORY", "COMMENT"):
328
+ if key == "HISTORY" and val_txt:
329
+ new_header.add_history(val_txt)
330
+ elif key == "COMMENT" and val_txt:
331
+ new_header.add_comment(val_txt)
332
+ else:
333
+ if key == "COMMENT" and not val_txt and com:
334
+ new_header.add_comment(com)
335
+ continue
336
+ try:
337
+ val = self._parse_val(val_txt)
338
+ new_header[key] = (val, com if com else None)
339
+ except Exception:
340
+ new_header[key] = (val_txt, com if com else None)
341
+
342
+ header.clear()
343
+ header.update(new_header)
344
+
345
+
346
+
347
+
348
+ def _on_item_changed(self, item, column):
349
+ if self._populating:
350
+ return
351
+ self._sync_tree_to_header()
352
+ self._set_dirty(True)
353
+
354
+ def _edited_primary_header(self) -> fits.Header:
355
+ """Header we’ll write back out (primary HDU for single-image saves)."""
356
+ if self.hdul is not None and len(self.hdul) > 0:
357
+ return self.hdul[0].header.copy()
358
+ if isinstance(self._fallback_header, fits.Header):
359
+ return self._fallback_header.copy()
360
+ return fits.Header()
361
+
362
+ def _active_doc(self):
363
+ return self._doc_manager.get_active_document() if self._doc_manager else None
364
+
365
+ # 1) Change _write_to_path to optionally NOT touch the active doc
366
+ def _write_to_path(self, out_path: str, *, update_doc_metadata: bool = True) -> bool:
367
+ """
368
+ Save the active document’s pixels with the edited header to `out_path`
369
+ using legacy.save_image.
370
+
371
+ If update_doc_metadata=False, this is a pure 'Save Copy' and we leave the
372
+ active document + dialog state untouched (no open, no rename).
373
+ """
374
+ doc = self._active_doc()
375
+ if doc is None or doc.image is None:
376
+ QMessageBox.warning(self, self.tr("No Image"), self.tr("No active image/document to save."))
377
+ return False
378
+
379
+ edited_hdr = self._edited_primary_header()
380
+
381
+ # pick format from the extension (fallback to doc’s current)
382
+ ext = os.path.splitext(out_path)[1].lower().lstrip(".")
383
+ if not ext:
384
+ ext = doc.metadata.get("original_format", "fits")
385
+ out_path = out_path + f".{ext}"
386
+
387
+ bit_depth = doc.metadata.get("bit_depth")
388
+ is_mono = doc.metadata.get("is_mono", (doc.image.ndim == 2))
389
+ image_meta = doc.metadata.get("image_meta")
390
+ file_meta = doc.metadata.get("file_meta")
391
+
392
+ try:
393
+ legacy_save_image(
394
+ img_array=doc.image,
395
+ filename=out_path,
396
+ original_format=ext,
397
+ bit_depth=bit_depth,
398
+ original_header=edited_hdr,
399
+ is_mono=is_mono,
400
+ image_meta=image_meta,
401
+ file_meta=file_meta,
402
+ )
403
+ except Exception as e:
404
+ QMessageBox.critical(self, self.tr("Save Error"), self.tr("Could not save:\n{0}").format(e))
405
+ return False
406
+
407
+ if update_doc_metadata:
408
+ # Only in true “Save” (not Save As copy)
409
+ self.file_path = out_path
410
+ self.path_label.setText(out_path)
411
+ doc.metadata["file_path"] = out_path
412
+ doc.metadata["original_format"] = ext
413
+ doc.metadata["original_header"] = edited_hdr
414
+ doc.changed.emit()
415
+ self._set_dirty(False)
416
+
417
+ return True
418
+
419
+
420
+ def _save_in_place(self):
421
+ # If the editor was opened on a file, use that.
422
+ # Else use the active document’s file_path if present,
423
+ # otherwise fall back to Save As…
424
+ target = self.file_path
425
+ if not target:
426
+ doc = self._get_active_doc()
427
+ target = (doc.metadata.get("file_path") if doc else None)
428
+ if not target:
429
+ return self._save_as_copy()
430
+ # apply any in-tree edits first
431
+ self._sync_tree_to_header()
432
+ self._write_to_path(target)
433
+
434
+
435
+ def _save_as_copy(self):
436
+ self._sync_tree_to_header()
437
+ last = self._settings().value("fits_modifier/last_dir", "", type=str) or ""
438
+ path, _ = QFileDialog.getSaveFileName(
439
+ self, self.tr("Save Image As"),
440
+ last,
441
+ self.tr("FITS (*.fits *.fit);;TIFF (*.tif *.tiff);;PNG (*.png);;JPEG (*.jpg *.jpeg);;XISF (*.xisf)")
442
+ )
443
+ if not path:
444
+ return
445
+ ok = self._write_to_path(path, update_doc_metadata=False) # <<— key change
446
+ if ok:
447
+ self._save_last_dir(os.path.dirname(path))
448
+ # Optional: toast confirmation only
449
+ QMessageBox.information(self, self.tr("Saved Copy"), self.tr("Saved a copy to:\n{0}").format(path))
450
+
451
+
452
+ def _save_to_path(self, path: str) -> bool:
453
+ """Write image+updated header to 'path' using legacy save if available; else fall back to astropy."""
454
+ try:
455
+ hdr = self.hdul[self.current_hdu_index].header.copy()
456
+ except Exception:
457
+ hdr = fits.Header()
458
+
459
+ img, bit_depth, is_mono, src = self._grab_image_for_save(self.file_path)
460
+
461
+ # Prefer legacy save_image if available and we have image data
462
+ if legacy_save_image and img is not None:
463
+ try:
464
+ if bit_depth is not None and is_mono is not None:
465
+ legacy_save_image(path, img, hdr, bit_depth=bit_depth, is_mono=is_mono)
466
+ else:
467
+ # permissive fallback if legacy signature is simpler
468
+ legacy_save_image(path, img, hdr)
469
+ return True
470
+ except TypeError:
471
+ # Try positional signature: (path, img, hdr, bit_depth, is_mono)
472
+ try:
473
+ legacy_save_image(path, img, hdr, bit_depth, is_mono)
474
+ return True
475
+ except Exception:
476
+ pass
477
+ except Exception:
478
+ pass
479
+
480
+ # Fall back: write a FITS with astropy
481
+ try:
482
+ phdu = fits.PrimaryHDU(data=img, header=hdr)
483
+ hdul = fits.HDUList([phdu])
484
+ hdul.writeto(path, overwrite=True)
485
+ return True
486
+ except Exception:
487
+ return False
488
+
489
+ def _grab_image_for_save(self, fallback_path: Optional[str]):
490
+ doc = self._get_active_doc() # <— new
491
+ img = getattr(doc, "image", None) if doc is not None else None
492
+ bit_depth = (doc.metadata.get("bit_depth") if (doc and hasattr(doc, "metadata")) else None)
493
+ is_mono = (doc.metadata.get("is_mono") if (doc and hasattr(doc, "metadata")) else None)
494
+ src = ((doc.metadata.get("file_path") if (doc and hasattr(doc, "metadata")) else None)
495
+ or fallback_path)
496
+
497
+ if img is None and src and legacy_load_image:
498
+ try:
499
+ # common signature from your WIMI usage
500
+ img, orig_hdr, bit_depth, is_mono = legacy_load_image(src)
501
+ except TypeError:
502
+ # try alternate signatures
503
+ try:
504
+ img, orig_hdr = legacy_load_image(src)
505
+ except Exception:
506
+ pass
507
+ except Exception:
508
+ pass
509
+
510
+ # As a last resort, use data from opened HDUList if present
511
+ if img is None and self.hdul is not None:
512
+ try:
513
+ img = self.hdul[0].data
514
+ except Exception:
515
+ pass
516
+
517
+ return img, bit_depth, is_mono, src
518
+
519
+ def _add_or_update_keyword(self):
520
+ key = self.add_key_edit.text().strip()
521
+ if not key:
522
+ return
523
+ val = self.add_val_edit.text()
524
+ com = self.add_com_edit.text()
525
+ try:
526
+ parsed_val = self._parse_val(val)
527
+ except Exception:
528
+ parsed_val = val # leave as string if parsing fails
529
+
530
+ targets = range(len(self.hdul)) if self.all_hdus_chk.isChecked() and self.hdul else [self.current_hdu_index]
531
+ for idx in targets:
532
+ hdr = self.hdul[idx].header
533
+ hdr[key] = (parsed_val, com if com else None)
534
+
535
+ if not self.all_hdus_chk.isChecked():
536
+ self._populate_tree_from_header(self.hdul[self.current_hdu_index].header)
537
+
538
+ def _delete_selected(self):
539
+ items = self.tree.selectedItems()
540
+ if not items:
541
+ return
542
+ targets = range(len(self.hdul)) if self.all_hdus_chk.isChecked() and self.hdul else [self.current_hdu_index]
543
+ for it in items:
544
+ key = it.text(0).strip()
545
+ for idx in targets:
546
+ hdr = self.hdul[idx].header
547
+ if key in ("HISTORY", "COMMENT"):
548
+ rebuilt = fits.Header()
549
+ for c in hdr.cards:
550
+ if c.keyword == key:
551
+ if (key == "HISTORY" and c.value == it.text(1)) or \
552
+ (key == "COMMENT" and (c.value == it.text(1) or c.comment == it.text(2))):
553
+ continue
554
+ rebuilt.append(c)
555
+ hdr.clear(); hdr.update(rebuilt)
556
+ else:
557
+ if key in hdr:
558
+ del hdr[key]
559
+ self._populate_tree_from_header(self.hdul[self.current_hdu_index].header)
560
+
561
+ # ---- value parsing helpers ----
562
+ def _parse_val(self, s: str):
563
+ if s is None:
564
+ return ""
565
+ t = s.strip()
566
+ if t.lower() in ("true", "t"): return True
567
+ if t.lower() in ("false", "f"): return False
568
+ if t.lower() in ("nan",): return np.nan
569
+ try:
570
+ if t.startswith("0x"):
571
+ return int(t, 16)
572
+ return int(t)
573
+ except ValueError:
574
+ pass
575
+ try:
576
+ return float(t)
577
+ except ValueError:
578
+ pass
579
+ if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
580
+ return t[1:-1]
581
+ return t
582
+
583
+ def _val_to_str(self, v):
584
+ if isinstance(v, (float, np.floating)) and np.isnan(v):
585
+ return "nan"
586
+ return str(v)
587
+
588
+ def closeEvent(self, e):
589
+ try:
590
+ if self.hdul is not None:
591
+ self.hdul.close()
592
+ except Exception:
593
+ pass
594
+ super().closeEvent(e)
595
+
596
+ # ---- QSettings helpers ----
597
+ def _settings(self):
598
+ return self.parent().settings if (self.parent() and hasattr(self.parent(), "settings")) else QSettings()
599
+ def _last_dir(self):
600
+ return self._settings().value("fits_modifier/last_dir", "", type=str) or ""
601
+ def _save_last_dir(self, d):
602
+ self._settings().setValue("fits_modifier/last_dir", d)
603
+
604
+
605
+ class BatchFITSHeaderDialog(QDialog):
606
+ def __init__(self, parent=None, preset_keyword: str = "", preset_value: str = "", preset_comment: str = ""):
607
+ super().__init__(parent)
608
+ self.setWindowTitle(self.tr("Batch Modify FITS Headers"))
609
+ self.resize(520, 220)
610
+
611
+ v = QVBoxLayout(self)
612
+
613
+ row1 = QHBoxLayout()
614
+ self.files_edit = QLineEdit(); self.files_edit.setPlaceholderText(self.tr("No files selected"))
615
+ self.pick_btn = QPushButton(self.tr("Choose FITS Files…"))
616
+ row1.addWidget(self.files_edit, 1); row1.addWidget(self.pick_btn)
617
+
618
+ row2 = QHBoxLayout()
619
+ self.key_edit = QLineEdit(); self.key_edit.setPlaceholderText(self.tr("KEYWORD"))
620
+ self.val_edit = QLineEdit(); self.val_edit.setPlaceholderText(self.tr("Value (leave blank for delete)"))
621
+ self.com_edit = QLineEdit(); self.com_edit.setPlaceholderText(self.tr("Comment (optional)"))
622
+ row2.addWidget(self.key_edit); row2.addWidget(self.val_edit); row2.addWidget(self.com_edit)
623
+
624
+ row3 = QHBoxLayout()
625
+ self.mode_combo = QComboBox()
626
+ self.mode_combo.addItem(self.tr("Add/Update"), "Add/Update")
627
+ self.mode_combo.addItem(self.tr("Delete"), "Delete")
628
+ self.all_hdus_chk = QCheckBox(self.tr("Apply to all HDUs"))
629
+ self.add_if_missing_chk = QCheckBox(self.tr("Add if missing (for Add/Update)"))
630
+ self.add_if_missing_chk.setChecked(True)
631
+ row3.addWidget(self.mode_combo)
632
+ row3.addWidget(self.all_hdus_chk)
633
+ row3.addWidget(self.add_if_missing_chk)
634
+ row3.addStretch()
635
+
636
+ row4 = QHBoxLayout()
637
+ self.run_btn = QPushButton(self.tr("Run"))
638
+ self.close_btn = QPushButton(self.tr("Close"))
639
+ row4.addStretch(); row4.addWidget(self.run_btn); row4.addWidget(self.close_btn)
640
+
641
+ v.addLayout(row1)
642
+ v.addLayout(row2)
643
+ v.addLayout(row3)
644
+ v.addLayout(row4)
645
+
646
+ if preset_keyword:
647
+ self.key_edit.setText(preset_keyword)
648
+ if preset_value:
649
+ self.val_edit.setText(preset_value)
650
+ if preset_comment:
651
+ self.com_edit.setText(preset_comment)
652
+
653
+ self.pick_btn.clicked.connect(self._pick_files)
654
+ self.run_btn.clicked.connect(self._run)
655
+ self.close_btn.clicked.connect(self.close)
656
+
657
+ self.files = []
658
+
659
+ def _settings(self):
660
+ return self.parent().settings if (self.parent() and hasattr(self.parent(), "settings")) else QSettings()
661
+
662
+ def _pick_files(self):
663
+ last = self._settings().value("fits_modifier/batch_dir", "", type=str) or ""
664
+ files, _ = QFileDialog.getOpenFileNames(self, self.tr("Select FITS files"), last, self.tr("FITS files (*.fits *.fit *.fts *.fz)"))
665
+ if not files:
666
+ return
667
+ self.files = files
668
+ self.files_edit.setText(self.tr("{0} files selected").format(len(files)))
669
+ self._settings().setValue("fits_modifier/batch_dir", os.path.dirname(files[0]))
670
+
671
+ def _parse_val(self, s: str):
672
+ t = (s or "").strip()
673
+ if t == "": return ""
674
+ if t.lower() in ("true", "t"): return True
675
+ if t.lower() in ("false", "f"): return False
676
+ if t.lower() in ("nan",): return np.nan
677
+ try:
678
+ if t.startswith("0x"):
679
+ return int(t, 16)
680
+ return int(t)
681
+ except ValueError:
682
+ pass
683
+ try:
684
+ return float(t)
685
+ except ValueError:
686
+ pass
687
+ if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
688
+ return t[1:-1]
689
+ return t
690
+
691
+ def _run(self):
692
+ if not self.files:
693
+ QMessageBox.warning(self, self.tr("No files"), self.tr("Please choose one or more FITS files."))
694
+ return
695
+ key = self.key_edit.text().strip()
696
+ if not key:
697
+ QMessageBox.warning(self, self.tr("Missing keyword"), self.tr("Please enter a FITS keyword."))
698
+ return
699
+
700
+ mode = self.mode_combo.currentData()
701
+ apply_all_hdus = self.all_hdus_chk.isChecked()
702
+ add_if_missing = self.add_if_missing_chk.isChecked()
703
+ com = self.com_edit.text().strip()
704
+ value_txt = self.val_edit.text()
705
+
706
+ n_ok, n_err = 0, 0
707
+ for fp in self.files:
708
+ try:
709
+ with fits.open(fp, mode='update', memmap=False) as hdul:
710
+ targets = range(len(hdul)) if apply_all_hdus else [0]
711
+ if mode == "Delete":
712
+ for i in targets:
713
+ hdr = hdul[i].header
714
+ if key in ("HISTORY", "COMMENT"):
715
+ rebuilt = fits.Header()
716
+ for c in hdr.cards:
717
+ if c.keyword == key:
718
+ if value_txt and str(c.value) == value_txt:
719
+ continue
720
+ if (not value_txt) and (not com):
721
+ continue
722
+ if com and (c.comment == com):
723
+ continue
724
+ rebuilt.append(c)
725
+ hdr.clear(); hdr.update(rebuilt)
726
+ else:
727
+ if key in hdr:
728
+ del hdr[key]
729
+ hdul.flush()
730
+ else:
731
+ try:
732
+ val = self._parse_val(value_txt)
733
+ except Exception:
734
+ val = value_txt
735
+ for i in targets:
736
+ hdr = hdul[i].header
737
+ if key in hdr or add_if_missing:
738
+ hdr[key] = (val, com if com else None)
739
+ hdul.flush()
740
+ n_ok += 1
741
+ except Exception as e:
742
+ print(f"[Batch FITS] Error on {fp}: {e}")
743
+ n_err += 1
744
+
745
+ QMessageBox.information(self, self.tr("Batch Complete"), self.tr("Updated {0} file(s); {1} error(s).").format(n_ok, n_err))