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,1071 @@
1
+ #legacy.xisf.py
2
+ # coding: utf-8
3
+
4
+ """
5
+ XISF Encoder/Decoder (see https://pixinsight.com/xisf/).
6
+
7
+ This implementation is not endorsed nor related with PixInsight development team.
8
+
9
+ Copyright (C) 2021-2023 Sergio Díaz, sergiodiaz.eu
10
+
11
+ This program is free software: you can redistribute it and/or modify it
12
+ under the terms of the GNU General Public License as published by the
13
+ Free Software Foundation, version 3 of the License.
14
+
15
+ This program is distributed in the hope that it will be useful, but WITHOUT
16
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
17
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
18
+ more details.
19
+
20
+ You should have received a copy of the GNU General Public License along with
21
+ this program. If not, see <http://www.gnu.org/licenses/>.
22
+ """
23
+
24
+ from importlib.metadata import version
25
+
26
+
27
+ import platform
28
+ import xml.etree.ElementTree as ET
29
+ import numpy as np
30
+ import lz4.block # https://python-lz4.readthedocs.io/en/stable/lz4.block.html
31
+ import zlib # https://docs.python.org/3/library/zlib.html
32
+ import zstandard # https://python-zstandard.readthedocs.io/en/stable/
33
+ import base64
34
+ import sys
35
+ from datetime import datetime
36
+ import ast
37
+
38
+ __version__ = "1.0.0"
39
+
40
+ class XISF:
41
+ """Implements an baseline XISF Decoder and a simple baseline Encoder.
42
+ It parses metadata from Image and Metadata XISF core elements. Image data is returned as a numpy ndarray
43
+ (using the "channels-last" convention by default).
44
+
45
+ What's supported:
46
+ - Monolithic XISF files only
47
+ - XISF data blocks with attachment, inline or embedded block locations
48
+ - Planar pixel storage models, *however it assumes 2D images only* (with multiple channels)
49
+ - UInt8/16/32 and Float32/64 pixel sample formats
50
+ - Grayscale and RGB color spaces
51
+ - Decoding:
52
+ - multiple Image core elements from a monolithic XISF file
53
+ - Support all standard compression codecs defined in this specification for decompression
54
+ (zlib/lz4[hc]/zstd + byte shuffling)
55
+ - Encoding:
56
+ - Single image core element with an attached data block
57
+ - Support all standard compression codecs defined in this specification for decompression
58
+ (zlib/lz4[hc]/zstd + byte shuffling)
59
+ - "Atomic" properties (scalar types, String, TimePoint), Vector and Matrix (e.g. astrometric
60
+ solutions)
61
+ - Metadata and FITSKeyword core elements
62
+
63
+ What's not supported (at least by now):
64
+ - Read pixel data in the normal pixel storage models
65
+ - Read pixel data in the planar pixel storage models other than 2D images
66
+ - Complex and Table properties
67
+ - Any other not explicitly supported core elements (Resolution, Thumbnail, ICCProfile, etc.)
68
+
69
+ Usage example:
70
+ ```
71
+ from xisf import XISF
72
+ import matplotlib.pyplot as plt
73
+ xisf = XISF("file.xisf")
74
+ file_meta = xisf.get_file_metadata()
75
+ file_meta
76
+ ims_meta = xisf.get_images_metadata()
77
+ ims_meta
78
+ im_data = xisf.read_image(0)
79
+ plt.imshow(im_data)
80
+ plt.show()
81
+ XISF.write(
82
+ "output.xisf", im_data,
83
+ creator_app="My script v1.0", image_metadata=ims_meta[0], xisf_metadata=file_meta,
84
+ codec='lz4hc', shuffle=True
85
+ )
86
+ ```
87
+
88
+ If the file is not huge and it contains only an image (or you're interested just in one of the
89
+ images inside the file), there is a convenience method for reading the data and the metadata:
90
+ ```
91
+ from xisf import XISF
92
+ import matplotlib.pyplot as plt
93
+ im_data = XISF.read("file.xisf")
94
+ plt.imshow(im_data)
95
+ plt.show()
96
+ ```
97
+
98
+ The XISF format specification is available at https://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html
99
+ """
100
+
101
+ # Static attributes
102
+ _creator_app = f"Python {platform.python_version()}"
103
+ _creator_module = f"XISF Python Module v{__version__} github.com/sergio-dr/xisf"
104
+ _signature = b"XISF0100" # Monolithic
105
+ _headerlength_len = 4
106
+ _reserved_len = 4
107
+ _xml_ns = {"xisf": "http://www.pixinsight.com/xisf"}
108
+ _xisf_attrs = {
109
+ "xmlns": "http://www.pixinsight.com/xisf",
110
+ "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
111
+ "version": "1.0",
112
+ "xsi:schemaLocation": "http://www.pixinsight.com/xisf http://pixinsight.com/xisf/xisf-1.0.xsd",
113
+ }
114
+ _compression_def_level = {
115
+ "zlib": 6, # 1..9, default: 6 as indicated in https://docs.python.org/3/library/zlib.html
116
+ "lz4": 0, # no other values, as indicated in https://python-lz4.readthedocs.io/en/stable/lz4.block.html
117
+ "lz4hc": 9, # 1..12, (4-9 recommended), default: 9 as indicated in https://python-lz4.readthedocs.io/en/stable/lz4.block.html
118
+ "zstd": 3, # 1..22, (3-9 recommended), default: 3 as indicated in https://facebook.github.io/zstd/zstd_manual.html
119
+ }
120
+ _block_alignment_size = 4096
121
+ _max_inline_block_size = 3072
122
+
123
+ def __init__(self, fname):
124
+ """Opens a XISF file and extract its metadata. To get the metadata and the images, see get_file_metadata(),
125
+ get_images_metadata() and read_image().
126
+ Args:
127
+ fname: filename
128
+
129
+ Returns:
130
+ XISF object.
131
+ """
132
+ self._fname = fname
133
+ self._headerlength = None
134
+ self._xisf_header = None
135
+ self._xisf_header_xml = None
136
+ self._images_meta = None
137
+ self._file_meta = None
138
+ ET.register_namespace("", self._xml_ns["xisf"])
139
+
140
+ self._read()
141
+
142
+ def _read(self):
143
+ with open(self._fname, "rb") as f:
144
+ # Check XISF signature
145
+ signature = f.read(len(self._signature))
146
+ if signature != self._signature:
147
+ raise ValueError("File doesn't have XISF signature")
148
+
149
+ # Get header length
150
+ self._headerlength = int.from_bytes(f.read(self._headerlength_len), byteorder="little")
151
+ # Equivalent:
152
+ # self._headerlength = np.fromfile(f, dtype=np.uint32, count=1)[0]
153
+
154
+ # Skip reserved field
155
+ _ = f.read(self._reserved_len)
156
+
157
+ # Get XISF (XML) Header
158
+ self._xisf_header = f.read(self._headerlength)
159
+ self._xisf_header_xml = ET.fromstring(self._xisf_header)
160
+ self._analyze_header()
161
+
162
+ def _analyze_header(self):
163
+ # Analyze header to get Data Blocks position and length
164
+ self._images_meta = []
165
+ for image in self._xisf_header_xml.findall("xisf:Image", self._xml_ns):
166
+ image_basic_meta = image.attrib
167
+
168
+ # Parse and replace geometry and location with tuples,
169
+ # parses and translates sampleFormat to numpy dtypes,
170
+ # and extend with metadata from children entities (FITSKeywords, XISFProperties)
171
+
172
+ # The same FITS keyword can appear multiple times, so we have to
173
+ # prepare a dict of lists. Each element in the list is a dict
174
+ # that hold the value and the comment associated with the keyword.
175
+ # Not as clear as I would like.
176
+ fits_keywords = {}
177
+ for a in image.findall("xisf:FITSKeyword", self._xml_ns):
178
+ fits_keywords.setdefault(a.attrib["name"], []).append(
179
+ {
180
+ "value": a.attrib["value"].strip("'").strip(" "),
181
+ "comment": a.attrib["comment"],
182
+ }
183
+ )
184
+
185
+ image_extended_meta = {
186
+ "geometry": self._parse_geometry(image.attrib["geometry"]),
187
+ "location": self._parse_location(image.attrib["location"]),
188
+ "dtype": self._parse_sampleFormat(image.attrib["sampleFormat"]),
189
+ "FITSKeywords": fits_keywords,
190
+ "XISFProperties": {
191
+ p.attrib["id"]: prop
192
+ for p in image.findall("xisf:Property", self._xml_ns)
193
+ if (prop := self._process_property(p))
194
+ },
195
+ }
196
+ # Also parses compression attribute if present, converting it to a tuple
197
+ if "compression" in image.attrib:
198
+ image_extended_meta["compression"] = self._parse_compression(
199
+ image.attrib["compression"]
200
+ )
201
+
202
+ # Merge basic and extended metadata in a dict
203
+ image_meta = {**image_basic_meta, **image_extended_meta}
204
+
205
+ # Append the image metadata to the list
206
+ self._images_meta.append(image_meta)
207
+
208
+ # Analyze header for file metadata
209
+ self._file_meta = {}
210
+ for p in self._xisf_header_xml.find("xisf:Metadata", self._xml_ns):
211
+ self._file_meta[p.attrib["id"]] = self._process_property(p)
212
+
213
+ # Parse additional XISF core elements: Resolution, ICCProfile, Thumbnail
214
+ self._parse_resolution_elements()
215
+ self._parse_icc_profiles()
216
+ self._parse_thumbnails()
217
+
218
+ def _parse_resolution_elements(self):
219
+ """Parse Resolution core elements and attach to image metadata."""
220
+ for i, image in enumerate(self._xisf_header_xml.findall("xisf:Image", self._xml_ns)):
221
+ res_elem = image.find("xisf:Resolution", self._xml_ns)
222
+ if res_elem is not None:
223
+ try:
224
+ res_data = {
225
+ "horizontal": float(res_elem.attrib.get("horizontal", 72.0)),
226
+ "vertical": float(res_elem.attrib.get("vertical", 72.0)),
227
+ "unit": res_elem.attrib.get("unit", "inch"), # "inch" or "cm"
228
+ }
229
+ if i < len(self._images_meta):
230
+ self._images_meta[i]["Resolution"] = res_data
231
+ except (ValueError, KeyError):
232
+ pass
233
+
234
+ def _parse_icc_profiles(self):
235
+ """Parse ICCProfile core elements and attach to image metadata."""
236
+ for i, image in enumerate(self._xisf_header_xml.findall("xisf:Image", self._xml_ns)):
237
+ icc_elem = image.find("xisf:ICCProfile", self._xml_ns)
238
+ if icc_elem is not None:
239
+ try:
240
+ icc_data = {"present": True}
241
+ if "location" in icc_elem.attrib:
242
+ loc = self._parse_location(icc_elem.attrib["location"])
243
+ icc_data["location"] = loc
244
+ # Read ICC profile binary data
245
+ if loc[0] == "attachment" and len(loc) >= 3:
246
+ icc_data["size"] = loc[2]
247
+ if i < len(self._images_meta):
248
+ self._images_meta[i]["ICCProfile"] = icc_data
249
+ except (ValueError, KeyError):
250
+ pass
251
+
252
+ def _parse_thumbnails(self):
253
+ """Parse Thumbnail core elements and attach to image metadata."""
254
+ for i, image in enumerate(self._xisf_header_xml.findall("xisf:Image", self._xml_ns)):
255
+ thumb_elem = image.find("xisf:Thumbnail", self._xml_ns)
256
+ if thumb_elem is not None:
257
+ try:
258
+ thumb_data = {
259
+ "present": True,
260
+ "geometry": self._parse_geometry(thumb_elem.attrib.get("geometry", "0:0:0")),
261
+ }
262
+ if "location" in thumb_elem.attrib:
263
+ thumb_data["location"] = self._parse_location(thumb_elem.attrib["location"])
264
+ if "sampleFormat" in thumb_elem.attrib:
265
+ thumb_data["dtype"] = self._parse_sampleFormat(thumb_elem.attrib["sampleFormat"])
266
+ if "colorSpace" in thumb_elem.attrib:
267
+ thumb_data["colorSpace"] = thumb_elem.attrib["colorSpace"]
268
+ if i < len(self._images_meta):
269
+ self._images_meta[i]["Thumbnail"] = thumb_data
270
+ except (ValueError, KeyError):
271
+ pass
272
+
273
+ def get_images_metadata(self):
274
+ """Provides the metadata of all image blocks contained in the XISF File, extracted from
275
+ the header (<Image> core elements). To get the actual image data, see read_image().
276
+
277
+ It outputs a dictionary m_i for each image, with the following structure:
278
+ ```
279
+ m_i = {
280
+ 'geometry': (width, height, channels), # only 2D images (with multiple channels) are supported
281
+ 'location': (pos, size), # used internally in read_image()
282
+ 'dtype': np.dtype('...'), # derived from sampleFormat argument
283
+ 'compression': (codec, uncompressed_size, item_size), # optional
284
+ 'key': 'value', # other <Image> attributes are simply copied
285
+ ...,
286
+ 'FITSKeywords': { <fits_keyword>: fits_keyword_values_list, ... },
287
+ 'XISFProperties': { <xisf_property_name>: property_dict, ... }
288
+ }
289
+
290
+ where:
291
+
292
+ fits_keyword_values_list = [ {'value': <value>, 'comment': <comment> }, ...]
293
+ property_dict = {'id': <xisf_property_name>, 'type': <xisf_type>, 'value': property_value, ...}
294
+ ```
295
+
296
+ Returns:
297
+ list [ m_0, m_1, ..., m_{n-1} ] where m_i is a dict as described above.
298
+
299
+ """
300
+ return self._images_meta
301
+
302
+ def get_file_metadata(self):
303
+ """Provides the metadata from the header of the XISF File (<Metadata> core elements).
304
+
305
+ Returns:
306
+ dictionary with one entry per property: { <xisf_property_name>: property_dict, ... }
307
+ where:
308
+ ```
309
+ property_dict = {'id': <xisf_property_name>, 'type': <xisf_type>, 'value': property_value, ...}
310
+ ```
311
+
312
+ """
313
+ return self._file_meta
314
+
315
+ def get_metadata_xml(self):
316
+ """Returns the complete XML header as a xml.etree.ElementTree.Element object.
317
+
318
+ Returns:
319
+ xml.etree.ElementTree.Element: complete XML XISF header
320
+ """
321
+ return self._xisf_header_xml
322
+
323
+ def _read_data_block(self, elem):
324
+ method = elem["location"][0]
325
+ if method == "inline":
326
+ return self._read_inline_data_block(elem)
327
+ elif method == "embedded":
328
+ return self._read_embedded_data_block(elem)
329
+ elif method == "attachment":
330
+ return self._read_attached_data_block(elem)
331
+ else:
332
+ raise NotImplementedError(f"Data block location type '{method}' not implemented: {elem}")
333
+
334
+ @staticmethod
335
+ def _read_inline_data_block(elem):
336
+ method, encoding = elem["location"]
337
+ assert method == "inline"
338
+ return XISF._decode_inline_or_embedded_data(encoding, elem["value"], elem)
339
+
340
+ @staticmethod
341
+ def _read_embedded_data_block(elem):
342
+ assert elem["location"][0] == "embedded"
343
+ data_elem = ET.fromstring(elem["value"])
344
+ encoding, data = data.attrib["encoding"], data_elem.text
345
+ return XISF._decode_inline_or_embedded_data(encoding, data, elem)
346
+
347
+ @staticmethod
348
+ def _decode_inline_or_embedded_data(encoding, data, elem):
349
+ encodings = {"base64": base64.b64decode, "hex": base64.b16decode}
350
+ if encoding not in encodings:
351
+ raise NotImplementedError(
352
+ f"Data block encoding type '{encoding}' not implemented: {elem}"
353
+ )
354
+
355
+ data = encodings[encoding](data)
356
+ if "compression" in elem:
357
+ data = XISF._decompress(data, elem)
358
+
359
+ return data
360
+
361
+ def _read_attached_data_block(self, elem):
362
+ # Position and size of the Data Block containing the image data
363
+ method, pos, size = elem["location"]
364
+
365
+ assert method == "attachment"
366
+
367
+ with open(self._fname, "rb") as f:
368
+ f.seek(pos)
369
+ data = f.read(size)
370
+
371
+ if "compression" in elem:
372
+ data = XISF._decompress(data, elem)
373
+
374
+ return data
375
+
376
+ def read_image(self, n=0, data_format="channels_last"):
377
+ """Extracts an image from a XISF object.
378
+
379
+ Args:
380
+ n: index of the image to extract in the list returned by get_images_metadata()
381
+ data_format: channels axis can be 'channels_first' or 'channels_last' (as used in
382
+ keras/tensorflow, pyplot's imshow, etc.), 0 by default.
383
+
384
+ Returns:
385
+ Numpy ndarray with the image data, in the requested format (channels_first or channels_last).
386
+
387
+ """
388
+ try:
389
+ meta = self._images_meta[n]
390
+ except IndexError as e:
391
+ if self._xisf_header is None:
392
+ raise RuntimeError("No file loaded") from e
393
+ elif not self._images_meta:
394
+ raise ValueError("File does not contain image data") from e
395
+ else:
396
+ raise ValueError(
397
+ f"Requested image #{n}, valid range is [0..{len(self._images_meta) - 1}]"
398
+ ) from e
399
+
400
+ try:
401
+ # Assumes *two*-dimensional images (chc=channel count)
402
+ w, h, chc = meta["geometry"]
403
+ except ValueError as e:
404
+ raise NotImplementedError(
405
+ f"Assumed 2D channels (width, height, channels), found {meta['geometry']} geometry"
406
+ )
407
+
408
+ data = self._read_data_block(meta)
409
+ im_data = np.frombuffer(data, dtype=meta["dtype"])
410
+ im_data = im_data.reshape((chc, h, w))
411
+ return np.transpose(im_data, (1, 2, 0)) if data_format == "channels_last" else im_data
412
+
413
+ @staticmethod
414
+ def read(fname, n=0, image_metadata={}, xisf_metadata={}):
415
+ """Convenience method for reading a file containing a single image.
416
+
417
+ Args:
418
+ fname (string): filename
419
+ n (int, optional): index of the image to extract (in the list returned by get_images_metadata()). Defaults to 0.
420
+ image_metadata (dict, optional): dictionary that will be updated with the metadata of the image.
421
+ xisf_metadata (dict, optional): dictionary that will be updated with the metadata of the file.
422
+
423
+ Returns:
424
+ [np.ndarray]: Numpy ndarray with the image data, in the requested format (channels_first or channels_last).
425
+ """
426
+ xisf = XISF(fname)
427
+ xisf_metadata.update(xisf.get_file_metadata())
428
+ image_metadata.update(xisf.get_images_metadata()[n])
429
+ return xisf.read_image(n)
430
+
431
+ # if 'colorSpace' is not specified, im_data.shape[2] dictates if colorSpace is 'Gray' or 'RGB'
432
+ # For float sample formats, bounds="0:1" is assumed
433
+ @staticmethod
434
+ def write(
435
+ fname,
436
+ im_data,
437
+ creator_app=None,
438
+ image_metadata=None,
439
+ xisf_metadata=None,
440
+ codec=None,
441
+ shuffle=False,
442
+ level=None,
443
+ ):
444
+ """Writes an image (numpy array) to a XISF file. Compression may be requested but it only
445
+ will be used if it actually reduces the data size.
446
+
447
+ Args:
448
+ fname: filename (will overwrite if existing)
449
+ im_data: numpy ndarray with the image data
450
+ creator_app: string for XISF:CreatorApplication file property (defaults to python version in None provided)
451
+ image_metadata: dict with the same structure described for m_i in get_images_metadata().
452
+ Only 'FITSKeywords' and 'XISFProperties' keys are actually written, the rest are derived from im_data.
453
+ xisf_metadata: file metadata, dict with the same structure returned by get_file_metadata()
454
+ codec: compression codec ('zlib', 'lz4', 'lz4hc' or 'zstd'), or None to disable compression
455
+ shuffle: whether to apply byte-shuffling before compression (ignored if codec is None). Recommended
456
+ for 'lz4' ,'lz4hc' and 'zstd' compression algorithms.
457
+ level: for zlib, 1..9 (default: 6); for lz4hc, 1..12 (default: 9); for zstd, 1..22 (default: 3).
458
+ Higher means more compression.
459
+ Returns:
460
+ bytes_written: the total number of bytes written into the output file.
461
+ codec: The codec actually used, i.e., None if compression did not reduce the data block size so
462
+ compression was not finally used.
463
+
464
+ """
465
+ if image_metadata is None:
466
+ image_metadata = {}
467
+
468
+ if xisf_metadata is None:
469
+ xisf_metadata = {}
470
+
471
+ # Data block alignment
472
+ blk_sz = xisf_metadata.get("XISF:BlockAlignmentSize", {"value": XISF._block_alignment_size})[
473
+ "value"
474
+ ]
475
+ # Maximum inline block size (larger will be attached instead)
476
+ max_inline_blk_sz = xisf_metadata.get(
477
+ "XISF:MaxInlineBlockSize", {"value": XISF._max_inline_block_size}
478
+ )["value"]
479
+
480
+ # Prepare basic image metadata
481
+ def _create_image_metadata(im_data, id):
482
+ image_attrs = {"id": id}
483
+ if im_data.shape[2] == 3 or im_data.shape[2] == 1:
484
+ data_format = "channels_last"
485
+ geometry = (im_data.shape[1], im_data.shape[0], im_data.shape[2])
486
+ channels = im_data.shape[2]
487
+ else:
488
+ data_format = "channels_first"
489
+ geometry = im_data.shape
490
+ channels = im_data.shape[0]
491
+ image_attrs["geometry"] = "%d:%d:%d" % geometry
492
+ image_attrs["colorSpace"] = "Gray" if channels == 1 else "RGB"
493
+ image_attrs["sampleFormat"] = XISF._get_sampleFormat(im_data.dtype)
494
+ if image_attrs["sampleFormat"].startswith("Float"):
495
+ image_attrs["bounds"] = "0:1" # Assumed
496
+ if sys.byteorder == "big" and image_attrs["sampleFormat"] != "UInt8":
497
+ image_attrs["byteOrder"] = "big"
498
+ return image_attrs, data_format
499
+
500
+ # Rearrange ndarray for data_format and serialize to bytes
501
+ def _prepare_image_data_block(im_data, data_format):
502
+ return np.transpose(im_data, (2, 0, 1)) if data_format == "channels_last" else im_data
503
+
504
+ # Serialize a data block, with optional compression (i.e., when codec is not None)
505
+ # Compression will be only applied if effectively reduces size
506
+ def _serialize_data_block(data, attr_dict, codec, level, shuffle):
507
+ data_block = data.tobytes()
508
+ uncompressed_size = data.nbytes
509
+ codec_str = codec
510
+
511
+ if codec is None:
512
+ data_size = uncompressed_size
513
+ else:
514
+ compressed_block = XISF._compress(data_block, codec, level, shuffle, data.itemsize)
515
+ compressed_size = len(compressed_block)
516
+
517
+ if compressed_size < uncompressed_size:
518
+ # The ideal situation, compressing actually reduces size
519
+ data_block, data_size = compressed_block, compressed_size
520
+
521
+ # Add 'compression' image attribute: (codec:uncompressed-size[:item-size])
522
+ if shuffle:
523
+ codec_str += "+sh"
524
+ attr_dict["compression"] = f"{codec_str}:{uncompressed_size}:{data.itemsize}"
525
+ else:
526
+ attr_dict["compression"] = f"{codec}:{uncompressed_size}"
527
+ else:
528
+ # If there's no gain in compressing, just discard the compressed block
529
+ # See https://pixinsight.com/forum.old/index.php?topic=10942.msg68043#msg68043
530
+ # (In fact, PixInsight will show garbage image data if the data block is
531
+ # compressed but the uncompressed size is smaller)
532
+ data_size = uncompressed_size
533
+ codec_str = None
534
+
535
+ return data_block, data_size, codec_str
536
+
537
+ # Overwrites/creates XISF metadata
538
+ def _update_xisf_metadata(creator_app, blk_sz, max_inline_blk_sz, codec, level):
539
+ # Create file metadata
540
+ xisf_metadata["XISF:CreationTime"] = {
541
+ "id": "XISF:CreationTime",
542
+ "type": "String",
543
+ "value": datetime.utcnow().isoformat(),
544
+ }
545
+ xisf_metadata["XISF:CreatorApplication"] = {
546
+ "id": "XISF:CreatorApplication",
547
+ "type": "String",
548
+ "value": creator_app if creator_app else XISF._creator_app,
549
+ }
550
+ xisf_metadata["XISF:CreatorModule"] = {
551
+ "id": "XISF:CreatorModule",
552
+ "type": "String",
553
+ "value": XISF._creator_module,
554
+ }
555
+ _OSes = {
556
+ "linux": "Linux",
557
+ "win32": "Windows",
558
+ "cygwin": "Windows",
559
+ "darwin": "macOS",
560
+ }
561
+ xisf_metadata["XISF:CreatorOS"] = {
562
+ "id": "XISF:CreatorOS",
563
+ "type": "String",
564
+ "value": _OSes[sys.platform],
565
+ }
566
+ xisf_metadata["XISF:BlockAlignmentSize"] = {
567
+ "id": "XISF:BlockAlignmentSize",
568
+ "type": "UInt16",
569
+ "value": blk_sz,
570
+ }
571
+ xisf_metadata["XISF:MaxInlineBlockSize"] = {
572
+ "id": "XISF:MaxInlineBlockSize",
573
+ "type": "UInt16",
574
+ "value": max_inline_blk_sz,
575
+ }
576
+ if codec is not None:
577
+ # Add XISF:CompressionCodecs and XISF:CompressionLevel to file metadata
578
+ xisf_metadata["XISF:CompressionCodecs"] = {
579
+ "id": "XISF:CompressionCodecs",
580
+ "type": "String",
581
+ "value": codec,
582
+ }
583
+ xisf_metadata["XISF:CompressionLevel"] = {
584
+ "id": "XISF:CompressionLevel",
585
+ "type": "Int",
586
+ "value": level if level else XISF._compression_def_level[codec],
587
+ }
588
+ else:
589
+ # Remove compression metadata if exists
590
+ try:
591
+ del xisf_metadata["XISF:CompressionCodecs"]
592
+ del xisf_metadata["XISF:CompressionLevel"]
593
+ except KeyError:
594
+ pass # Ignore if keys don't exist
595
+
596
+ def _compute_attached_positions(hdr_prov_sz, attached_blocks_locations):
597
+ # Computes aligned position nearest to the given one
598
+ _aligned_position = lambda pos: ((pos + blk_sz - 1) // blk_sz) * blk_sz
599
+
600
+ # Iterates data block positions until header size stabilizes
601
+ # (positions are represented as strings in the header so their
602
+ # values may impact header size, therefore changing data block
603
+ # positions in the file)
604
+ hdr_sz = hdr_prov_sz
605
+ prev_sum_len_positions = 0
606
+ while True:
607
+ # account for the size of the (provisional) header
608
+ pos = _aligned_position(hdr_sz)
609
+
610
+ # positions for data blocks of properties with attachment location
611
+ sum_len_positions = 0
612
+ for loc in attached_blocks_locations:
613
+ # Save the (possibly provisional) position
614
+ loc['position'] = pos
615
+ # Accumulate the size of the position string
616
+ sum_len_positions += len(str(pos))
617
+ # Fast forward position adding the size, honoring alignment
618
+ pos = _aligned_position(pos + loc['size'])
619
+
620
+ if sum_len_positions == prev_sum_len_positions:
621
+ break
622
+
623
+ prev_sum_len_positions = sum_len_positions
624
+ hdr_sz = hdr_prov_sz + sum_len_positions
625
+
626
+ # Update data blocks positions in XML Header
627
+ for b in attached_blocks_locations:
628
+ xml_elem, pos, sz = b["xml"], b["position"], b["size"]
629
+ xml_elem.attrib["location"] = XISF._to_location(("attachment", pos, sz))
630
+
631
+ # Zero padding (used for reserved fields and data block alignment)
632
+ def _zero_pad(length):
633
+ assert length >= 0
634
+ return (0).to_bytes(length, byteorder="little")
635
+
636
+ # __/ Prepare image and its metadata \__________
637
+ im_id = image_metadata.get("id", "image")
638
+ im_attrs, data_format = _create_image_metadata(im_data, im_id)
639
+ im_data = _prepare_image_data_block(im_data, data_format)
640
+ im_data_block, data_size, codec_str = _serialize_data_block(
641
+ im_data, im_attrs, codec, level, shuffle
642
+ )
643
+
644
+ # Assemble location attribute, *provisional* until we can compute the data block position
645
+ im_attrs["location"] = XISF._to_location(("attachment", "", data_size))
646
+
647
+ # __/ Build (provisional) XML Header \__________
648
+ # (for attached data blocks, the location is provisional)
649
+ # Convert metadata (dict) to XML Header
650
+ xisf_header_xml = ET.Element("xisf", XISF._xisf_attrs)
651
+
652
+ # Image
653
+ image_xml = ET.SubElement(xisf_header_xml, "Image", im_attrs)
654
+
655
+ # Image FITSKeywords
656
+ for kw_name, kw_values in image_metadata.get("FITSKeywords", {}).items():
657
+ XISF._insert_fitskeyword(image_xml, kw_name, kw_values)
658
+
659
+ # attached_blocks_locations will reference every element whose data block is to be attached
660
+ # = [{"xml": ElementTree, "position": int, "size": int, "data": ndarray or str}]
661
+ # (position key is actually a placeholder, it will be overwritten by
662
+ # _compute_attached_positions)
663
+ # The first element is the image (*provisional* location):
664
+ attached_blocks_locations = [
665
+ {
666
+ "xml": image_xml,
667
+ "position": 0,
668
+ "size": data_size,
669
+ "data": im_data_block,
670
+ }
671
+ ]
672
+
673
+ # Image XISFProperties
674
+ for p_dict in image_metadata.get("XISFProperties", {}).values():
675
+ if attached_block := XISF._insert_property(image_xml, p_dict, max_inline_blk_sz):
676
+ attached_blocks_locations.append(attached_block)
677
+
678
+ # File Metadata
679
+ metadata_xml = ET.SubElement(xisf_header_xml, "Metadata")
680
+ _update_xisf_metadata(creator_app, blk_sz, max_inline_blk_sz, codec, level)
681
+ for property_dict in xisf_metadata.values():
682
+ if attached_block := XISF._insert_property(
683
+ metadata_xml, property_dict, max_inline_blk_sz
684
+ ):
685
+ attached_blocks_locations.append(attached_block)
686
+
687
+ # Header provisional size (without attachment positions)
688
+ xisf_header = ET.tostring(xisf_header_xml, encoding="utf8")
689
+ header_provisional_sz = (
690
+ len(XISF._signature) + XISF._headerlength_len + len(xisf_header) + XISF._reserved_len
691
+ )
692
+
693
+ # Update location for every block in attached_blocks_locations
694
+ _compute_attached_positions(header_provisional_sz, attached_blocks_locations)
695
+
696
+ with open(fname, "wb") as f:
697
+ # Write XISF signature
698
+ f.write(XISF._signature)
699
+
700
+ xisf_header = ET.tostring(xisf_header_xml, encoding="utf8")
701
+ headerlength = len(xisf_header)
702
+ # Write header length
703
+ f.write(headerlength.to_bytes(XISF._headerlength_len, byteorder="little"))
704
+
705
+ # Write reserved field
706
+ reserved_field = _zero_pad(XISF._reserved_len)
707
+ f.write(reserved_field)
708
+
709
+ # Write header
710
+ f.write(xisf_header)
711
+
712
+ # Write data blocks
713
+ for b in attached_blocks_locations:
714
+ pos, data_block = b["position"], b["data"]
715
+ f.write(_zero_pad(pos - f.tell()))
716
+ assert f.tell() == pos
717
+ f.write(data_block)
718
+ bytes_written = f.tell()
719
+
720
+ return bytes_written, codec_str
721
+
722
+ # __/ Auxiliary functions to handle XISF attributes \________
723
+
724
+ # Process property attributes and convert to dict
725
+ def _process_property(self, p_et):
726
+ p_dict = p_et.attrib.copy()
727
+
728
+ if p_dict["type"] == "TimePoint":
729
+ # Timepoint 'value' attribute already set (as str)
730
+ # Convert ISO 8601 string to datetime object
731
+ try:
732
+ tp_str = p_dict.get("value", "")
733
+ if tp_str:
734
+ # Handle XISF TimePoint format: ISO 8601 with optional timezone
735
+ tp_str = tp_str.replace("Z", "+00:00")
736
+ if "." in tp_str and "+" not in tp_str.split(".")[-1] and "-" not in tp_str.split(".")[-1]:
737
+ tp_str += "+00:00"
738
+ p_dict["datetime"] = datetime.fromisoformat(tp_str)
739
+ except (ValueError, TypeError):
740
+ p_dict["datetime"] = None
741
+ elif p_dict["type"] == "String":
742
+ p_dict["value"] = p_et.text
743
+ if "location" in p_dict:
744
+ # Process location and compression attributes to find data block
745
+ self._process_location_compression(p_dict)
746
+ p_dict["value"] = self._read_data_block(p_dict).decode("utf-8")
747
+ elif p_dict["type"] == "Boolean":
748
+ # Boolean valid values are "true" and "false"
749
+ p_dict["value"] = p_dict["value"] == "true"
750
+ elif "value" in p_et.attrib:
751
+ # Scalars (Float64, UInt32, etc.) and Complex*
752
+ p_dict["value"] = ast.literal_eval(p_dict["value"])
753
+ elif "Vector" in p_dict["type"]:
754
+ p_dict["value"] = p_et.text
755
+ p_dict["length"] = int(p_dict["length"])
756
+ p_dict["dtype"] = self._parse_vector_dtype(p_dict["type"])
757
+ self._process_location_compression(p_dict)
758
+ raw_data = self._read_data_block(p_dict)
759
+ p_dict["value"] = np.frombuffer(raw_data, dtype=p_dict["dtype"], count=p_dict["length"])
760
+ elif "Matrix" in p_dict["type"]:
761
+ p_dict["value"] = p_et.text
762
+ p_dict["rows"] = int(p_dict["rows"])
763
+ p_dict["columns"] = int(p_dict["columns"])
764
+ length = p_dict["rows"] * p_dict["columns"]
765
+ p_dict["dtype"] = self._parse_vector_dtype(p_dict["type"])
766
+ self._process_location_compression(p_dict)
767
+ raw_data = self._read_data_block(p_dict)
768
+ p_dict["value"] = np.frombuffer(raw_data, dtype=p_dict["dtype"], count=length)
769
+ p_dict["value"] = p_dict["value"].reshape((p_dict["rows"], p_dict["columns"]))
770
+ else:
771
+ print(f"Unsupported Property type {p_dict['type']}: {p_et}")
772
+ p_dict = False
773
+
774
+ return p_dict
775
+
776
+ @staticmethod
777
+ def _process_location_compression(p_dict):
778
+ p_dict["location"] = XISF._parse_location(p_dict["location"])
779
+ if "compression" in p_dict:
780
+ p_dict["compression"] = XISF._parse_compression(p_dict["compression"])
781
+
782
+ # Insert XISF properties in the XML tree
783
+ @staticmethod
784
+ def _insert_property(parent, p_dict, max_inline_block_size):
785
+ # TODO ignores optional attributes (format, comment)
786
+ scalars = ["Int", "Byte", "Short", "Float", "Boolean", "TimePoint"]
787
+
788
+ if any(t in p_dict["type"] for t in scalars):
789
+ # scalars and TimePoint
790
+ # TODO add check for scalar or TimePoint
791
+ # TODO Boolean requires lowercase
792
+ ET.SubElement(
793
+ parent,
794
+ "Property",
795
+ {
796
+ "id": p_dict["id"],
797
+ "type": p_dict["type"],
798
+ "value": str(p_dict["value"]),
799
+ },
800
+ )
801
+ elif p_dict["type"] == "String":
802
+ text = str(p_dict["value"])
803
+ sz = len(text.encode("utf-8"))
804
+ if sz > max_inline_block_size:
805
+ # Attach string as data block (position pending)
806
+ # TODO ignores compression
807
+ xml = ET.SubElement(
808
+ parent,
809
+ "Property",
810
+ {
811
+ "id": p_dict["id"],
812
+ "type": p_dict["type"],
813
+ "location": XISF._to_location(("attachment", "", sz)),
814
+ },
815
+ )
816
+ return {"xml": xml, "location": 0, "size": sz, "data": text.encode()}
817
+ else:
818
+ # string directly as child (no 'location' attribute)
819
+ ET.SubElement(
820
+ parent,
821
+ "Property",
822
+ {
823
+ "id": p_dict["id"],
824
+ "type": p_dict["type"],
825
+ },
826
+ ).text = text
827
+ elif "Vector" in p_dict["type"]:
828
+ # TODO ignores compression
829
+ data = p_dict["value"]
830
+ sz = data.nbytes
831
+ if sz > max_inline_block_size:
832
+ # Attach vector as data block (position pending)
833
+ xml = ET.SubElement(
834
+ parent,
835
+ "Property",
836
+ {
837
+ "id": p_dict["id"],
838
+ "type": p_dict["type"],
839
+ "length": str(data.size),
840
+ "location": XISF._to_location(("attachment", "", sz)),
841
+ },
842
+ )
843
+ return {"xml": xml, "location": 0, "size": sz, "data": data}
844
+ else:
845
+ # Inline data block (assuming base64)
846
+ ET.SubElement(
847
+ parent,
848
+ "Property",
849
+ {
850
+ "id": p_dict["id"],
851
+ "type": p_dict["type"],
852
+ "length": str(data.size),
853
+ "location": XISF._to_location(("inline", "base64")),
854
+ },
855
+ ).text = str(base64.b64encode(data.tobytes()), "ascii")
856
+ elif "Matrix" in p_dict["type"]:
857
+ # TODO ignores compression
858
+ data = p_dict["value"]
859
+ sz = data.nbytes
860
+ if sz > max_inline_block_size:
861
+ # Attach vector as data block (position pending)
862
+ xml = ET.SubElement(
863
+ parent,
864
+ "Property",
865
+ {
866
+ "id": p_dict["id"],
867
+ "type": p_dict["type"],
868
+ "rows": str(data.shape[0]),
869
+ "columns": str(data.shape[1]),
870
+ "location": XISF._to_location(("attachment", "", sz)),
871
+ },
872
+ )
873
+ return {"xml": xml, "location": 0, "size": sz, "data": data}
874
+ else:
875
+ # Inline data block (assuming base64)
876
+ ET.SubElement(
877
+ parent,
878
+ "Property",
879
+ {
880
+ "id": p_dict["id"],
881
+ "type": p_dict["type"],
882
+ "rows": str(data.shape[0]),
883
+ "columns": str(data.shape[1]),
884
+ "location": XISF._to_location(("inline", "base64")),
885
+ },
886
+ ).text = str(base64.b64encode(data.tobytes()), "ascii")
887
+ else:
888
+ print(f"Warning: skipping unsupported property {p_dict}")
889
+
890
+ return False
891
+
892
+ # Insert FITS Keywords in the XML tree
893
+ @staticmethod
894
+ def _insert_fitskeyword(image_xml, keyword_name, keyword_values):
895
+ for entry in keyword_values:
896
+ ET.SubElement(
897
+ image_xml,
898
+ "FITSKeyword",
899
+ {
900
+ "name": keyword_name,
901
+ "value": entry["value"],
902
+ "comment": entry["comment"],
903
+ },
904
+ )
905
+
906
+ # Returns image shape, e.g. (x, y, channels)
907
+ @staticmethod
908
+ def _parse_geometry(g):
909
+ return tuple(map(int, g.split(":")))
910
+
911
+ # Returns ("attachment", position, size), ("inline", encoding) or ("embedded")
912
+ @staticmethod
913
+ def _parse_location(l):
914
+ ll = l.split(":")
915
+ if ll[0] not in ["inline", "embedded", "attachment"]:
916
+ raise NotImplementedError(f"Data block location type '{ll[0]}' not implemented")
917
+ return (ll[0], int(ll[1]), int(ll[2])) if ll[0] == "attachment" else ll
918
+
919
+ # Serialize location tuple to string, as value for location attribute
920
+ @staticmethod
921
+ def _to_location(location_tuple):
922
+ return ":".join([str(e) for e in location_tuple])
923
+
924
+ # Returns (codec, uncompressed_size, item_size); item_size is None if not using byte shuffling
925
+ @staticmethod
926
+ def _parse_compression(c):
927
+ cl = c.split(":")
928
+ if len(cl) == 3:
929
+ # (codec+byteshuffling, uncompressed_size, shuffling_item_size)
930
+ return (cl[0], int(cl[1]), int(cl[2]))
931
+ else:
932
+ # (codec, uncompressed_size, None)
933
+ return (cl[0], int(cl[1]), None)
934
+
935
+ # Return equivalent numpy dtype
936
+ @staticmethod
937
+ def _parse_sampleFormat(s):
938
+ # Translate alternate names to "canonical" type names
939
+ alternate_names = {
940
+ 'Byte': 'UInt8',
941
+ 'Short': 'Int16',
942
+ 'UShort': 'UInt16',
943
+ 'Int': 'Int32',
944
+ 'UInt': 'UInt32',
945
+ 'Float': 'Float32',
946
+ 'Double': 'Float64',
947
+ }
948
+ try:
949
+ s = alternate_names[s]
950
+ except KeyError:
951
+ pass
952
+
953
+ _dtypes = {
954
+ "UInt8": np.dtype("uint8"),
955
+ "UInt16": np.dtype("uint16"),
956
+ "UInt32": np.dtype("uint32"),
957
+ "Float32": np.dtype("float32"),
958
+ "Float64": np.dtype("float64"),
959
+ }
960
+ try:
961
+ return _dtypes[s]
962
+ except:
963
+ raise NotImplementedError(f"sampleFormat {s} not implemented")
964
+
965
+ # Return XISF data type from numpy dtype
966
+ @staticmethod
967
+ def _get_sampleFormat(dtype):
968
+ _sampleFormats = {
969
+ "uint8": "UInt8",
970
+ "uint16": "UInt16",
971
+ "uint32": "UInt32",
972
+ "float32": "Float32",
973
+ "float64": "Float64",
974
+ }
975
+ try:
976
+ return _sampleFormats[str(dtype)]
977
+ except:
978
+ raise NotImplementedError(f"sampleFormat for {dtype} not implemented")
979
+
980
+ @staticmethod
981
+ def _parse_vector_dtype(type_name):
982
+ # Translate alternate names to "canonical" type names
983
+ alternate_names = {
984
+ 'ByteArray': 'UI8Vector',
985
+ 'IVector': 'I32Vector',
986
+ 'UIVector': 'UI32Vector',
987
+ 'Vector': 'F64Vector',
988
+ }
989
+ try:
990
+ type_name = alternate_names[type_name]
991
+ except KeyError:
992
+ pass
993
+
994
+ type_prefix = type_name[:-6] # removes "Vector" and "Matrix" suffixes
995
+ _dtypes = {
996
+ "I8": np.dtype("int8"),
997
+ "UI8": np.dtype("uint8"),
998
+ "I16": np.dtype("int16"),
999
+ "UI16": np.dtype("uint16"),
1000
+ "I32": np.dtype("int32"),
1001
+ "UI32": np.dtype("uint32"),
1002
+ "I64": np.dtype("int64"),
1003
+ "UI64": np.dtype("uint64"),
1004
+ "F32": np.dtype("float32"),
1005
+ "F64": np.dtype("float64"),
1006
+ "C32": np.dtype("csingle"),
1007
+ "C64": np.dtype("cdouble"),
1008
+ }
1009
+ try:
1010
+ return _dtypes[type_prefix]
1011
+ except:
1012
+ raise NotImplementedError(f"data type {type_name} not implemented")
1013
+
1014
+ # __/ Auxiliary functions for compression/shuffling \________
1015
+
1016
+ # Un-byteshuffling implementation based on numpy
1017
+ @staticmethod
1018
+ def _unshuffle(d, item_size):
1019
+ a = np.frombuffer(d, dtype=np.dtype("uint8"))
1020
+ a = a.reshape((item_size, -1))
1021
+ return np.transpose(a).tobytes()
1022
+
1023
+ # Byteshuffling implementation based on numpy
1024
+ @staticmethod
1025
+ def _shuffle(d, item_size):
1026
+ a = np.frombuffer(d, dtype=np.dtype("uint8"))
1027
+ a = a.reshape((-1, item_size))
1028
+ return np.transpose(a).tobytes()
1029
+
1030
+ # LZ4/zlib/zstd decompression
1031
+ @staticmethod
1032
+ def _decompress(data, elem):
1033
+ # (codec, uncompressed-size, item-size); item-size is None if not using byte shuffling
1034
+ codec, uncompressed_size, item_size = elem["compression"]
1035
+
1036
+ if codec.startswith("lz4"):
1037
+ data = lz4.block.decompress(data, uncompressed_size=uncompressed_size)
1038
+ elif codec.startswith("zstd"):
1039
+ data = zstandard.decompress(data, max_output_size=uncompressed_size)
1040
+ elif codec.startswith("zlib"):
1041
+ data = zlib.decompress(data)
1042
+ else:
1043
+ raise NotImplementedError(f"Unimplemented compression codec {codec}")
1044
+
1045
+ if item_size: # using byte-shuffling
1046
+ data = XISF._unshuffle(data, item_size)
1047
+
1048
+ return data
1049
+
1050
+ # LZ4/zlib/zstd compression
1051
+ @staticmethod
1052
+ def _compress(data, codec, level=None, shuffle=False, itemsize=None):
1053
+ compressed = XISF._shuffle(data, itemsize) if shuffle else data
1054
+
1055
+ if codec == "lz4hc":
1056
+ level = level if level else XISF._compression_def_level["lz4hc"]
1057
+ compressed = lz4.block.compress(
1058
+ compressed, mode="high_compression", compression=level, store_size=False
1059
+ )
1060
+ elif codec == "lz4":
1061
+ compressed = lz4.block.compress(compressed, store_size=False)
1062
+ elif codec == "zstd":
1063
+ level = level if level else XISF._compression_def_level["zstd"]
1064
+ compressed = zstandard.compress(compressed, level=level)
1065
+ elif codec == "zlib":
1066
+ level = level if level else XISF._compression_def_level["zlib"]
1067
+ compressed = zlib.compress(compressed, level=level)
1068
+ else:
1069
+ raise NotImplementedError(f"Unimplemented compression codec {codec}")
1070
+
1071
+ return compressed