setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1213 @@
1
+ # coding: utf-8
2
+
3
+ """
4
+ XISF Encoder/Decoder (see https://pixinsight.com/xisf/).
5
+
6
+ This implementation is not endorsed nor related with PixInsight development team.
7
+
8
+ Copyright (C) 2021-2023 Sergio Díaz, sergiodiaz.eu
9
+
10
+ This program is free software: you can redistribute it and/or modify it
11
+ under the terms of the GNU General Public License as published by the
12
+ Free Software Foundation, version 3 of the License.
13
+
14
+ This program is distributed in the hope that it will be useful, but WITHOUT
15
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17
+ more details.
18
+
19
+ You should have received a copy of the GNU General Public License along with
20
+ this program. If not, see <http://www.gnu.org/licenses/>.
21
+ """
22
+
23
+ from importlib.metadata import version
24
+
25
+
26
+ import platform
27
+ import xml.etree.ElementTree as ET
28
+ import numpy as np
29
+ import lz4.block # https://python-lz4.readthedocs.io/en/stable/lz4.block.html
30
+ import zlib # https://docs.python.org/3/library/zlib.html
31
+ import zstandard # https://python-zstandard.readthedocs.io/en/stable/
32
+ import base64
33
+ import sys
34
+ from datetime import datetime
35
+ import ast
36
+
37
+ __version__ = "1.0.1"
38
+
39
+ def _is_attached_or_inline_property(p_dict):
40
+ return "location" in p_dict # location implies inline/embedded/attachment
41
+
42
+ def _make_lazy(p_dict):
43
+ p_dict["_lazy"] = True
44
+ return p_dict
45
+
46
+ class XISF:
47
+ """Implements an baseline XISF Decoder and a simple baseline Encoder.
48
+ It parses metadata from Image and Metadata XISF core elements. Image data is returned as a numpy ndarray
49
+ (using the "channels-last" convention by default).
50
+
51
+ What's supported:
52
+ - Monolithic XISF files only
53
+ - XISF data blocks with attachment, inline or embedded block locations
54
+ - Planar pixel storage models, *however it assumes 2D images only* (with multiple channels)
55
+ - UInt8/16/32 and Float32/64 pixel sample formats
56
+ - Grayscale and RGB color spaces
57
+ - Decoding:
58
+ - multiple Image core elements from a monolithic XISF file
59
+ - Support all standard compression codecs defined in this specification for decompression
60
+ (zlib/lz4[hc]/zstd + byte shuffling)
61
+ - Encoding:
62
+ - Single image core element with an attached data block
63
+ - Support all standard compression codecs defined in this specification for decompression
64
+ (zlib/lz4[hc]/zstd + byte shuffling)
65
+ - "Atomic" properties (scalar types, String, TimePoint), Vector and Matrix (e.g. astrometric
66
+ solutions)
67
+ - Metadata and FITSKeyword core elements
68
+
69
+ What's not supported (at least by now):
70
+ - Read pixel data in the normal pixel storage models
71
+ - Read pixel data in the planar pixel storage models other than 2D images
72
+ - Complex and Table properties
73
+ - Any other not explicitly supported core elements (Resolution, Thumbnail, ICCProfile, etc.)
74
+
75
+ Usage example:
76
+ ```
77
+ from setiastro.saspro.xisf import XISF
78
+ import matplotlib.pyplot as plt
79
+ xisf = XISF("file.xisf")
80
+ file_meta = xisf.get_file_metadata()
81
+ file_meta
82
+ ims_meta = xisf.get_images_metadata()
83
+ ims_meta
84
+ im_data = xisf.read_image(0)
85
+ plt.imshow(im_data)
86
+ plt.show()
87
+ XISF.write(
88
+ "output.xisf", im_data,
89
+ creator_app="My script v1.0", image_metadata=ims_meta[0], xisf_metadata=file_meta,
90
+ codec='lz4hc', shuffle=True
91
+ )
92
+ ```
93
+
94
+ If the file is not huge and it contains only an image (or you're interested just in one of the
95
+ images inside the file), there is a convenience method for reading the data and the metadata:
96
+ ```
97
+ from setiastro.saspro.xisf import XISF
98
+ import matplotlib.pyplot as plt
99
+ im_data = XISF.read("file.xisf")
100
+ plt.imshow(im_data)
101
+ plt.show()
102
+ ```
103
+
104
+ The XISF format specification is available at https://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html
105
+ """
106
+
107
+ # Static attributes
108
+ _creator_app = f"Python {platform.python_version()}"
109
+ _creator_module = f"XISF Python Module v{__version__} github.com/sergio-dr/xisf"
110
+ _signature = b"XISF0100" # Monolithic
111
+ _headerlength_len = 4
112
+ _reserved_len = 4
113
+ _xml_ns = {"xisf": "http://www.pixinsight.com/xisf"}
114
+ _xisf_attrs = {
115
+ "xmlns": "http://www.pixinsight.com/xisf",
116
+ "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
117
+ "version": "1.0",
118
+ "xsi:schemaLocation": "http://www.pixinsight.com/xisf http://pixinsight.com/xisf/xisf-1.0.xsd",
119
+ }
120
+ _compression_def_level = {
121
+ "zlib": 6, # 1..9, default: 6 as indicated in https://docs.python.org/3/library/zlib.html
122
+ "lz4": 0, # no other values, as indicated in https://python-lz4.readthedocs.io/en/stable/lz4.block.html
123
+ "lz4hc": 9, # 1..12, (4-9 recommended), default: 9 as indicated in https://python-lz4.readthedocs.io/en/stable/lz4.block.html
124
+ "zstd": 3, # 1..22, (3-9 recommended), default: 3 as indicated in https://facebook.github.io/zstd/zstd_manual.html
125
+ }
126
+ _block_alignment_size = 4096
127
+ _max_inline_block_size = 3072
128
+
129
+ def __init__(self, fname):
130
+ """Opens a XISF file and extract its metadata. To get the metadata and the images, see get_file_metadata(),
131
+ get_images_metadata() and read_image().
132
+ Args:
133
+ fname: filename
134
+
135
+ Returns:
136
+ XISF object.
137
+ """
138
+ self._fname = fname
139
+ self._headerlength = None
140
+ self._xisf_header = None
141
+ self._xisf_header_xml = None
142
+ self._images_meta = None
143
+ self._file_meta = None
144
+ ET.register_namespace("", self._xml_ns["xisf"])
145
+
146
+ self._read()
147
+
148
+ def _read(self):
149
+ with open(self._fname, "rb") as f:
150
+ # Check XISF signature
151
+ signature = f.read(len(self._signature))
152
+ if signature != self._signature:
153
+ raise ValueError("File doesn't have XISF signature")
154
+
155
+ # Get header length
156
+ self._headerlength = int.from_bytes(f.read(self._headerlength_len), byteorder="little")
157
+ # Equivalent:
158
+ # self._headerlength = np.fromfile(f, dtype=np.uint32, count=1)[0]
159
+
160
+ # Skip reserved field
161
+ _ = f.read(self._reserved_len)
162
+
163
+ # Get XISF (XML) Header
164
+ self._xisf_header = f.read(self._headerlength)
165
+ self._xisf_header_xml = ET.fromstring(self._xisf_header)
166
+ self._analyze_header()
167
+
168
+ def _analyze_header(self):
169
+ # Analyze header to get Data Blocks position and length
170
+ self._images_meta = []
171
+ for image in self._xisf_header_xml.findall("xisf:Image", self._xml_ns):
172
+ image_basic_meta = image.attrib
173
+
174
+ # Parse and replace geometry and location with tuples,
175
+ # parses and translates sampleFormat to numpy dtypes,
176
+ # and extend with metadata from children entities (FITSKeywords, XISFProperties)
177
+
178
+ # The same FITS keyword can appear multiple times, so we have to
179
+ # prepare a dict of lists. Each element in the list is a dict
180
+ # that hold the value and the comment associated with the keyword.
181
+ # Not as clear as I would like.
182
+ fits_keywords = {}
183
+ for a in image.findall("xisf:FITSKeyword", self._xml_ns):
184
+ fits_keywords.setdefault(a.attrib["name"], []).append(
185
+ {
186
+ "value": a.attrib["value"].strip("'").strip(" "),
187
+ "comment": a.attrib["comment"],
188
+ }
189
+ )
190
+
191
+ image_extended_meta = {
192
+ "geometry": self._parse_geometry(image.attrib["geometry"]),
193
+ "location": self._parse_location(image.attrib["location"]),
194
+ "dtype": self._parse_sampleFormat(image.attrib["sampleFormat"]),
195
+ "FITSKeywords": fits_keywords,
196
+ "XISFProperties": {
197
+ p.attrib["id"]: prop
198
+ for p in image.findall("xisf:Property", self._xml_ns)
199
+ if (prop := self._process_property(p))
200
+ },
201
+ }
202
+ # Also parses compression attribute if present, converting it to a tuple
203
+ if "compression" in image.attrib:
204
+ image_extended_meta["compression"] = self._parse_compression(
205
+ image.attrib["compression"]
206
+ )
207
+
208
+ # Merge basic and extended metadata in a dict
209
+ image_meta = {**image_basic_meta, **image_extended_meta}
210
+
211
+ # Append the image metadata to the list
212
+ self._images_meta.append(image_meta)
213
+
214
+ # Analyze header for file metadata
215
+ self._file_meta = {}
216
+ for p in self._xisf_header_xml.find("xisf:Metadata", self._xml_ns):
217
+ self._file_meta[p.attrib["id"]] = self._process_property(p)
218
+
219
+ # Parse additional XISF core elements: Resolution, ICCProfile, Thumbnail
220
+ self._parse_resolution_elements()
221
+ self._parse_icc_profiles()
222
+ self._parse_thumbnails()
223
+
224
+ def _parse_resolution_elements(self):
225
+ """Parse Resolution core elements and attach to image metadata."""
226
+ for i, image in enumerate(self._xisf_header_xml.findall("xisf:Image", self._xml_ns)):
227
+ res_elem = image.find("xisf:Resolution", self._xml_ns)
228
+ if res_elem is not None:
229
+ try:
230
+ res_data = {
231
+ "horizontal": float(res_elem.attrib.get("horizontal", 72.0)),
232
+ "vertical": float(res_elem.attrib.get("vertical", 72.0)),
233
+ "unit": res_elem.attrib.get("unit", "inch"), # "inch" or "cm"
234
+ }
235
+ if i < len(self._images_meta):
236
+ self._images_meta[i]["Resolution"] = res_data
237
+ except (ValueError, KeyError):
238
+ pass
239
+
240
+ def _parse_icc_profiles(self):
241
+ """Parse ICCProfile core elements and attach to image metadata."""
242
+ for i, image in enumerate(self._xisf_header_xml.findall("xisf:Image", self._xml_ns)):
243
+ icc_elem = image.find("xisf:ICCProfile", self._xml_ns)
244
+ if icc_elem is not None:
245
+ try:
246
+ icc_data = {"present": True}
247
+ if "location" in icc_elem.attrib:
248
+ loc = self._parse_location(icc_elem.attrib["location"])
249
+ icc_data["location"] = loc
250
+ # Read ICC profile binary data
251
+ if loc[0] == "attachment" and len(loc) >= 3:
252
+ icc_data["size"] = loc[2]
253
+ if i < len(self._images_meta):
254
+ self._images_meta[i]["ICCProfile"] = icc_data
255
+ except (ValueError, KeyError):
256
+ pass
257
+
258
+ def _parse_thumbnails(self):
259
+ """Parse Thumbnail core elements and attach to image metadata."""
260
+ for i, image in enumerate(self._xisf_header_xml.findall("xisf:Image", self._xml_ns)):
261
+ thumb_elem = image.find("xisf:Thumbnail", self._xml_ns)
262
+ if thumb_elem is not None:
263
+ try:
264
+ thumb_data = {
265
+ "present": True,
266
+ "geometry": self._parse_geometry(thumb_elem.attrib.get("geometry", "0:0:0")),
267
+ }
268
+ if "location" in thumb_elem.attrib:
269
+ thumb_data["location"] = self._parse_location(thumb_elem.attrib["location"])
270
+ if "sampleFormat" in thumb_elem.attrib:
271
+ thumb_data["dtype"] = self._parse_sampleFormat(thumb_elem.attrib["sampleFormat"])
272
+ if "colorSpace" in thumb_elem.attrib:
273
+ thumb_data["colorSpace"] = thumb_elem.attrib["colorSpace"]
274
+ if i < len(self._images_meta):
275
+ self._images_meta[i]["Thumbnail"] = thumb_data
276
+ except (ValueError, KeyError):
277
+ pass
278
+
279
+ def get_images_metadata(self):
280
+ """Provides the metadata of all image blocks contained in the XISF File, extracted from
281
+ the header (<Image> core elements). To get the actual image data, see read_image().
282
+
283
+ It outputs a dictionary m_i for each image, with the following structure:
284
+ ```
285
+ m_i = {
286
+ 'geometry': (width, height, channels), # only 2D images (with multiple channels) are supported
287
+ 'location': (pos, size), # used internally in read_image()
288
+ 'dtype': np.dtype('...'), # derived from sampleFormat argument
289
+ 'compression': (codec, uncompressed_size, item_size), # optional
290
+ 'key': 'value', # other <Image> attributes are simply copied
291
+ ...,
292
+ 'FITSKeywords': { <fits_keyword>: fits_keyword_values_list, ... },
293
+ 'XISFProperties': { <xisf_property_name>: property_dict, ... }
294
+ }
295
+
296
+ where:
297
+
298
+ fits_keyword_values_list = [ {'value': <value>, 'comment': <comment> }, ...]
299
+ property_dict = {'id': <xisf_property_name>, 'type': <xisf_type>, 'value': property_value, ...}
300
+ ```
301
+
302
+ Returns:
303
+ list [ m_0, m_1, ..., m_{n-1} ] where m_i is a dict as described above.
304
+
305
+ """
306
+ return self._images_meta
307
+
308
+ def get_file_metadata(self):
309
+ """Provides the metadata from the header of the XISF File (<Metadata> core elements).
310
+
311
+ Returns:
312
+ dictionary with one entry per property: { <xisf_property_name>: property_dict, ... }
313
+ where:
314
+ ```
315
+ property_dict = {'id': <xisf_property_name>, 'type': <xisf_type>, 'value': property_value, ...}
316
+ ```
317
+
318
+ """
319
+ return self._file_meta
320
+
321
+ def get_metadata_xml(self):
322
+ """Returns the complete XML header as a xml.etree.ElementTree.Element object.
323
+
324
+ Returns:
325
+ xml.etree.ElementTree.Element: complete XML XISF header
326
+ """
327
+ return self._xisf_header_xml
328
+
329
+ def _read_data_block(self, elem):
330
+ method = elem["location"][0]
331
+ if method == "inline":
332
+ return self._read_inline_data_block(elem)
333
+ elif method == "embedded":
334
+ return self._read_embedded_data_block(elem)
335
+ elif method == "attachment":
336
+ return self._read_attached_data_block(elem)
337
+ else:
338
+ raise NotImplementedError(f"Data block location type '{method}' not implemented: {elem}")
339
+
340
+ @staticmethod
341
+ def _read_inline_data_block(elem):
342
+ method, encoding = elem["location"]
343
+ assert method == "inline"
344
+ return XISF._decode_inline_or_embedded_data(encoding, elem["value"], elem)
345
+
346
+ @staticmethod
347
+ def _read_embedded_data_block(elem):
348
+ assert elem["location"][0] == "embedded"
349
+ data_elem = ET.fromstring(elem["value"])
350
+ encoding, data = data_elem.attrib["encoding"], data_elem.text
351
+ return XISF._decode_inline_or_embedded_data(encoding, data, elem)
352
+
353
+ @staticmethod
354
+ def _decode_inline_or_embedded_data(encoding, data, elem):
355
+ encodings = {"base64": base64.b64decode, "hex": base64.b16decode}
356
+ if encoding not in encodings:
357
+ raise NotImplementedError(
358
+ f"Data block encoding type '{encoding}' not implemented: {elem}"
359
+ )
360
+
361
+ data = encodings[encoding](data)
362
+ if "compression" in elem:
363
+ data = XISF._decompress(data, elem)
364
+
365
+ return data
366
+
367
+ def _read_attached_data_block(self, elem):
368
+ # Position and size of the Data Block containing the image data
369
+ method, pos, size = elem["location"]
370
+
371
+ assert method == "attachment"
372
+
373
+ with open(self._fname, "rb") as f:
374
+ f.seek(pos)
375
+ data = f.read(size)
376
+
377
+ if "compression" in elem:
378
+ data = XISF._decompress(data, elem)
379
+
380
+ return data
381
+
382
+ def read_image(self, n=0, data_format="channels_last"):
383
+ """Extracts an image from a XISF object.
384
+
385
+ Args:
386
+ n: index of the image to extract in the list returned by get_images_metadata()
387
+ data_format: channels axis can be 'channels_first' or 'channels_last' (as used in
388
+ keras/tensorflow, pyplot's imshow, etc.), 0 by default.
389
+
390
+ Returns:
391
+ Numpy ndarray with the image data, in the requested format (channels_first or channels_last).
392
+
393
+ """
394
+ try:
395
+ meta = self._images_meta[n]
396
+ except IndexError as e:
397
+ if self._xisf_header is None:
398
+ raise RuntimeError("No file loaded") from e
399
+ elif not self._images_meta:
400
+ raise ValueError("File does not contain image data") from e
401
+ else:
402
+ raise ValueError(
403
+ f"Requested image #{n}, valid range is [0..{len(self._images_meta) - 1}]"
404
+ ) from e
405
+
406
+ try:
407
+ # Assumes *two*-dimensional images (chc=channel count)
408
+ w, h, chc = meta["geometry"]
409
+ except ValueError as e:
410
+ raise NotImplementedError(
411
+ f"Assumed 2D channels (width, height, channels), found {meta['geometry']} geometry"
412
+ )
413
+
414
+ data = self._read_data_block(meta)
415
+ im_data = np.frombuffer(data, dtype=meta["dtype"])
416
+ im_data = im_data.reshape((chc, h, w))
417
+ return np.transpose(im_data, (1, 2, 0)) if data_format == "channels_last" else im_data
418
+
419
+ @staticmethod
420
+ def read(fname, n=0, image_metadata={}, xisf_metadata={}):
421
+ """Convenience method for reading a file containing a single image.
422
+
423
+ Args:
424
+ fname (string): filename
425
+ n (int, optional): index of the image to extract (in the list returned by get_images_metadata()). Defaults to 0.
426
+ image_metadata (dict, optional): dictionary that will be updated with the metadata of the image.
427
+ xisf_metadata (dict, optional): dictionary that will be updated with the metadata of the file.
428
+
429
+ Returns:
430
+ [np.ndarray]: Numpy ndarray with the image data, in the requested format (channels_first or channels_last).
431
+ """
432
+ xisf = XISF(fname)
433
+ xisf_metadata.update(xisf.get_file_metadata())
434
+ image_metadata.update(xisf.get_images_metadata()[n])
435
+ return xisf.read_image(n)
436
+
437
+ # if 'colorSpace' is not specified, im_data.shape[2] dictates if colorSpace is 'Gray' or 'RGB'
438
+ # For float sample formats, bounds="0:1" is assumed
439
+ @staticmethod
440
+ def write(
441
+ fname,
442
+ im_data,
443
+ creator_app=None,
444
+ image_metadata=None,
445
+ xisf_metadata=None,
446
+ codec=None,
447
+ shuffle=False,
448
+ level=None,
449
+ ):
450
+ """Writes an image (numpy array) to a XISF file. Compression may be requested but it only
451
+ will be used if it actually reduces the data size.
452
+
453
+ Args:
454
+ fname: filename (will overwrite if existing)
455
+ im_data: numpy ndarray with the image data
456
+ creator_app: string for XISF:CreatorApplication file property (defaults to python version in None provided)
457
+ image_metadata: dict with the same structure described for m_i in get_images_metadata().
458
+ Only 'FITSKeywords' and 'XISFProperties' keys are actually written, the rest are derived from im_data.
459
+ xisf_metadata: file metadata, dict with the same structure returned by get_file_metadata()
460
+ codec: compression codec ('zlib', 'lz4', 'lz4hc' or 'zstd'), or None to disable compression
461
+ shuffle: whether to apply byte-shuffling before compression (ignored if codec is None). Recommended
462
+ for 'lz4' ,'lz4hc' and 'zstd' compression algorithms.
463
+ level: for zlib, 1..9 (default: 6); for lz4hc, 1..12 (default: 9); for zstd, 1..22 (default: 3).
464
+ Higher means more compression.
465
+ Returns:
466
+ bytes_written: the total number of bytes written into the output file.
467
+ codec: The codec actually used, i.e., None if compression did not reduce the data block size so
468
+ compression was not finally used.
469
+
470
+ """
471
+ if image_metadata is None:
472
+ image_metadata = {}
473
+
474
+ if xisf_metadata is None:
475
+ xisf_metadata = {}
476
+
477
+ # Data block alignment
478
+ blk_sz = xisf_metadata.get("XISF:BlockAlignmentSize", {"value": XISF._block_alignment_size})[
479
+ "value"
480
+ ]
481
+ # Maximum inline block size (larger will be attached instead)
482
+ max_inline_blk_sz = xisf_metadata.get(
483
+ "XISF:MaxInlineBlockSize", {"value": XISF._max_inline_block_size}
484
+ )["value"]
485
+
486
+ # Prepare basic image metadata
487
+ def _create_image_metadata(im_data, id):
488
+ image_attrs = {"id": id}
489
+ if im_data.shape[2] == 3 or im_data.shape[2] == 1:
490
+ data_format = "channels_last"
491
+ geometry = (im_data.shape[1], im_data.shape[0], im_data.shape[2])
492
+ channels = im_data.shape[2]
493
+ else:
494
+ data_format = "channels_first"
495
+ geometry = im_data.shape
496
+ channels = im_data.shape[0]
497
+ image_attrs["geometry"] = "%d:%d:%d" % geometry
498
+ image_attrs["colorSpace"] = "Gray" if channels == 1 else "RGB"
499
+ image_attrs["sampleFormat"] = XISF._get_sampleFormat(im_data.dtype)
500
+ if image_attrs["sampleFormat"].startswith("Float"):
501
+ image_attrs["bounds"] = "0:1" # Assumed
502
+ if sys.byteorder == "big" and image_attrs["sampleFormat"] != "UInt8":
503
+ image_attrs["byteOrder"] = "big"
504
+ return image_attrs, data_format
505
+
506
+ # Rearrange ndarray for data_format and serialize to bytes
507
+ def _prepare_image_data_block(im_data, data_format):
508
+ return np.transpose(im_data, (2, 0, 1)) if data_format == "channels_last" else im_data
509
+
510
+ # Serialize a data block, with optional compression (i.e., when codec is not None)
511
+ # Compression will be only applied if effectively reduces size
512
+ def _serialize_data_block(data, attr_dict, codec, level, shuffle):
513
+ data_block = data.tobytes()
514
+ uncompressed_size = data.nbytes
515
+ codec_str = codec
516
+
517
+ if codec is None:
518
+ data_size = uncompressed_size
519
+ else:
520
+ compressed_block = XISF._compress(data_block, codec, level, shuffle, data.itemsize)
521
+ compressed_size = len(compressed_block)
522
+
523
+ if compressed_size < uncompressed_size:
524
+ # The ideal situation, compressing actually reduces size
525
+ data_block, data_size = compressed_block, compressed_size
526
+
527
+ # Add 'compression' image attribute: (codec:uncompressed-size[:item-size])
528
+ if shuffle:
529
+ codec_str += "+sh"
530
+ attr_dict["compression"] = f"{codec_str}:{uncompressed_size}:{data.itemsize}"
531
+ else:
532
+ attr_dict["compression"] = f"{codec}:{uncompressed_size}"
533
+ else:
534
+ # If there's no gain in compressing, just discard the compressed block
535
+ # See https://pixinsight.com/forum.old/index.php?topic=10942.msg68043#msg68043
536
+ # (In fact, PixInsight will show garbage image data if the data block is
537
+ # compressed but the uncompressed size is smaller)
538
+ data_size = uncompressed_size
539
+ codec_str = None
540
+
541
+ return data_block, data_size, codec_str
542
+
543
+ # Overwrites/creates XISF metadata
544
+ def _update_xisf_metadata(creator_app, blk_sz, max_inline_blk_sz, codec, level):
545
+ # Create file metadata
546
+ xisf_metadata["XISF:CreationTime"] = {
547
+ "id": "XISF:CreationTime",
548
+ "type": "String",
549
+ "value": datetime.utcnow().isoformat(),
550
+ }
551
+ xisf_metadata["XISF:CreatorApplication"] = {
552
+ "id": "XISF:CreatorApplication",
553
+ "type": "String",
554
+ "value": creator_app if creator_app else XISF._creator_app,
555
+ }
556
+ xisf_metadata["XISF:CreatorModule"] = {
557
+ "id": "XISF:CreatorModule",
558
+ "type": "String",
559
+ "value": XISF._creator_module,
560
+ }
561
+ _OSes = {
562
+ "linux": "Linux",
563
+ "win32": "Windows",
564
+ "cygwin": "Windows",
565
+ "darwin": "macOS",
566
+ }
567
+ xisf_metadata["XISF:CreatorOS"] = {
568
+ "id": "XISF:CreatorOS",
569
+ "type": "String",
570
+ "value": _OSes[sys.platform],
571
+ }
572
+ xisf_metadata["XISF:BlockAlignmentSize"] = {
573
+ "id": "XISF:BlockAlignmentSize",
574
+ "type": "UInt16",
575
+ "value": blk_sz,
576
+ }
577
+ xisf_metadata["XISF:MaxInlineBlockSize"] = {
578
+ "id": "XISF:MaxInlineBlockSize",
579
+ "type": "UInt16",
580
+ "value": max_inline_blk_sz,
581
+ }
582
+ if codec is not None:
583
+ # Add XISF:CompressionCodecs and XISF:CompressionLevel to file metadata
584
+ xisf_metadata["XISF:CompressionCodecs"] = {
585
+ "id": "XISF:CompressionCodecs",
586
+ "type": "String",
587
+ "value": codec,
588
+ }
589
+ xisf_metadata["XISF:CompressionLevel"] = {
590
+ "id": "XISF:CompressionLevel",
591
+ "type": "Int",
592
+ "value": level if level else XISF._compression_def_level[codec],
593
+ }
594
+ else:
595
+ # Remove compression metadata if exists
596
+ try:
597
+ del xisf_metadata["XISF:CompressionCodecs"]
598
+ del xisf_metadata["XISF:CompressionLevel"]
599
+ except KeyError:
600
+ pass # Ignore if keys don't exist
601
+
602
+ def _compute_attached_positions(hdr_prov_sz, attached_blocks_locations):
603
+ # Computes aligned position nearest to the given one
604
+ _aligned_position = lambda pos: ((pos + blk_sz - 1) // blk_sz) * blk_sz
605
+
606
+ # Iterates data block positions until header size stabilizes
607
+ # (positions are represented as strings in the header so their
608
+ # values may impact header size, therefore changing data block
609
+ # positions in the file)
610
+ hdr_sz = hdr_prov_sz
611
+ prev_sum_len_positions = 0
612
+ while True:
613
+ # account for the size of the (provisional) header
614
+ pos = _aligned_position(hdr_sz)
615
+
616
+ # positions for data blocks of properties with attachment location
617
+ sum_len_positions = 0
618
+ for loc in attached_blocks_locations:
619
+ # Save the (possibly provisional) position
620
+ loc['position'] = pos
621
+ # Accumulate the size of the position string
622
+ sum_len_positions += len(str(pos))
623
+ # Fast forward position adding the size, honoring alignment
624
+ pos = _aligned_position(pos + loc['size'])
625
+
626
+ if sum_len_positions == prev_sum_len_positions:
627
+ break
628
+
629
+ prev_sum_len_positions = sum_len_positions
630
+ hdr_sz = hdr_prov_sz + sum_len_positions
631
+
632
+ # Update data blocks positions in XML Header
633
+ for b in attached_blocks_locations:
634
+ xml_elem, pos, sz = b["xml"], b["position"], b["size"]
635
+ xml_elem.attrib["location"] = XISF._to_location(("attachment", pos, sz))
636
+
637
+ # Zero padding (used for reserved fields and data block alignment)
638
+ def _zero_pad(length):
639
+ assert length >= 0
640
+ return (0).to_bytes(length, byteorder="little")
641
+
642
+ # __/ Prepare image and its metadata \__________
643
+ im_id = image_metadata.get("id", "image")
644
+ im_attrs, data_format = _create_image_metadata(im_data, im_id)
645
+ im_data = _prepare_image_data_block(im_data, data_format)
646
+ im_data_block, data_size, codec_str = _serialize_data_block(
647
+ im_data, im_attrs, codec, level, shuffle
648
+ )
649
+
650
+ # Assemble location attribute, *provisional* until we can compute the data block position
651
+ im_attrs["location"] = XISF._to_location(("attachment", "", data_size))
652
+
653
+ # __/ Build (provisional) XML Header \__________
654
+ # (for attached data blocks, the location is provisional)
655
+ # Convert metadata (dict) to XML Header
656
+ xisf_header_xml = ET.Element("xisf", XISF._xisf_attrs)
657
+
658
+ # Image
659
+ image_xml = ET.SubElement(xisf_header_xml, "Image", im_attrs)
660
+
661
+ # Image FITSKeywords
662
+ for kw_name, kw_values in image_metadata.get("FITSKeywords", {}).items():
663
+ XISF._insert_fitskeyword(image_xml, kw_name, kw_values)
664
+
665
+ # attached_blocks_locations will reference every element whose data block is to be attached
666
+ # = [{"xml": ElementTree, "position": int, "size": int, "data": ndarray or str}]
667
+ # (position key is actually a placeholder, it will be overwritten by
668
+ # _compute_attached_positions)
669
+ # The first element is the image (*provisional* location):
670
+ attached_blocks_locations = [
671
+ {
672
+ "xml": image_xml,
673
+ "position": 0,
674
+ "size": data_size,
675
+ "data": im_data_block,
676
+ }
677
+ ]
678
+
679
+ # Image XISFProperties
680
+ for p_dict in image_metadata.get("XISFProperties", {}).values():
681
+ if attached_block := XISF._insert_property(image_xml, p_dict, max_inline_blk_sz):
682
+ attached_blocks_locations.append(attached_block)
683
+
684
+ # File Metadata
685
+ metadata_xml = ET.SubElement(xisf_header_xml, "Metadata")
686
+ _update_xisf_metadata(creator_app, blk_sz, max_inline_blk_sz, codec, level)
687
+ for property_dict in xisf_metadata.values():
688
+ if attached_block := XISF._insert_property(
689
+ metadata_xml, property_dict, max_inline_blk_sz
690
+ ):
691
+ attached_blocks_locations.append(attached_block)
692
+
693
+ # Header provisional size (without attachment positions)
694
+ xisf_header = ET.tostring(xisf_header_xml, encoding="utf8")
695
+ header_provisional_sz = (
696
+ len(XISF._signature) + XISF._headerlength_len + len(xisf_header) + XISF._reserved_len
697
+ )
698
+
699
+ # Update location for every block in attached_blocks_locations
700
+ _compute_attached_positions(header_provisional_sz, attached_blocks_locations)
701
+
702
+ with open(fname, "wb") as f:
703
+ # Write XISF signature
704
+ f.write(XISF._signature)
705
+
706
+ xisf_header = ET.tostring(xisf_header_xml, encoding="utf8")
707
+ headerlength = len(xisf_header)
708
+ # Write header length
709
+ f.write(headerlength.to_bytes(XISF._headerlength_len, byteorder="little"))
710
+
711
+ # Write reserved field
712
+ reserved_field = _zero_pad(XISF._reserved_len)
713
+ f.write(reserved_field)
714
+
715
+ # Write header
716
+ f.write(xisf_header)
717
+
718
+ # Write data blocks
719
+ for b in attached_blocks_locations:
720
+ pos, data_block = b["position"], b["data"]
721
+ f.write(_zero_pad(pos - f.tell()))
722
+ assert f.tell() == pos
723
+ f.write(data_block)
724
+ bytes_written = f.tell()
725
+
726
+ return bytes_written, codec_str
727
+
728
+ # __/ Auxiliary functions to handle XISF attributes \________
729
+
730
+ # Process property attributes and convert to dict
731
+ def _process_property(self, p_et):
732
+ p_dict = p_et.attrib.copy()
733
+
734
+ if p_dict["type"] == "TimePoint":
735
+ # Timepoint 'value' attribute already set (as str)
736
+ # Convert ISO 8601 string to datetime object
737
+ try:
738
+ tp_str = p_dict.get("value", "")
739
+ if tp_str:
740
+ # Handle XISF TimePoint format: ISO 8601 with optional timezone
741
+ # Examples: "2023-01-15T10:30:00Z", "2023-01-15T10:30:00.123456"
742
+ tp_str = tp_str.replace("Z", "+00:00")
743
+ if "." in tp_str and "+" not in tp_str.split(".")[-1] and "-" not in tp_str.split(".")[-1]:
744
+ # Add UTC timezone if missing after fractional seconds
745
+ tp_str += "+00:00"
746
+ p_dict["datetime"] = datetime.fromisoformat(tp_str)
747
+ except (ValueError, TypeError):
748
+ # Keep original string value if parsing fails
749
+ p_dict["datetime"] = None
750
+ elif p_dict["type"] == "String":
751
+ # NOTE: currently does: p_dict["value"] = p_et.text; then if location -> read block now
752
+ p_dict["value"] = p_et.text # may be None
753
+ if "location" in p_dict:
754
+ self._process_location_compression(p_dict)
755
+ # LAZY: do NOT read block here
756
+ return _make_lazy(p_dict)
757
+ return p_dict
758
+ elif p_dict["type"] == "Boolean":
759
+ # Boolean valid values are "true" and "false"
760
+ p_dict["value"] = p_dict["value"] == "true"
761
+ elif "value" in p_et.attrib:
762
+ # Scalars (Float64, UInt32, etc.) and Complex*
763
+ p_dict["value"] = ast.literal_eval(p_dict["value"])
764
+ elif "Vector" in p_dict["type"]:
765
+ p_dict["value"] = p_et.text
766
+ p_dict["length"] = int(p_dict["length"])
767
+ p_dict["dtype"] = self._parse_vector_dtype(p_dict["type"])
768
+ self._process_location_compression(p_dict)
769
+ # LAZY: do NOT read block here
770
+ return _make_lazy(p_dict)
771
+
772
+ elif "Matrix" in p_dict["type"]:
773
+ p_dict["value"] = p_et.text
774
+ p_dict["rows"] = int(p_dict["rows"])
775
+ p_dict["columns"] = int(p_dict["columns"])
776
+ p_dict["dtype"] = self._parse_vector_dtype(p_dict["type"])
777
+ self._process_location_compression(p_dict)
778
+ # LAZY: do NOT read block here
779
+ return _make_lazy(p_dict)
780
+ else:
781
+ print(f"Unsupported Property type {p_dict['type']}: {p_et}")
782
+ p_dict = False
783
+
784
+ return p_dict
785
+
786
+ def resolve_property(self, p_dict):
787
+ """
788
+ Resolve a lazy property (String/Vector/Matrix with a data block).
789
+ Mutates p_dict in place and returns decoded 'value'.
790
+ """
791
+ if not p_dict.get("_lazy"):
792
+ return p_dict.get("value")
793
+
794
+ raw = self._read_data_block(p_dict)
795
+
796
+ t = p_dict["type"]
797
+ if t == "String":
798
+ val = raw.decode("utf-8")
799
+ elif "Vector" in t:
800
+ val = np.frombuffer(raw, dtype=p_dict["dtype"], count=p_dict["length"])
801
+ elif "Matrix" in t:
802
+ length = p_dict["rows"] * p_dict["columns"]
803
+ val = np.frombuffer(raw, dtype=p_dict["dtype"], count=length).reshape((p_dict["rows"], p_dict["columns"]))
804
+ else:
805
+ # if something else ever gets marked lazy
806
+ val = raw
807
+
808
+ p_dict["value"] = val
809
+ p_dict["_lazy"] = False
810
+ return val
811
+
812
+ def can_partial_read_image(self, n=0):
813
+ meta = self._images_meta[n]
814
+ if meta["location"][0] != "attachment":
815
+ return False
816
+ if "compression" in meta:
817
+ return False
818
+ return True
819
+
820
+ def read_image_roi(self, n=0, x0=0, y0=0, x1=None, y1=None, channels=None, data_format="channels_last"):
821
+ meta = self._images_meta[n]
822
+ if meta["location"][0] != "attachment":
823
+ raise NotImplementedError("ROI read only supported for attachment blocks")
824
+ if "compression" in meta:
825
+ raise NotImplementedError("ROI read not supported for compressed image blocks")
826
+
827
+ w, h, chc = meta["geometry"]
828
+ dtype = meta["dtype"]
829
+ itemsize = dtype.itemsize
830
+
831
+ if x1 is None: x1 = w
832
+ if y1 is None: y1 = h
833
+ x0 = max(0, min(w, x0)); x1 = max(0, min(w, x1))
834
+ y0 = max(0, min(h, y0)); y1 = max(0, min(h, y1))
835
+ if x1 <= x0 or y1 <= y0:
836
+ raise ValueError("Empty ROI")
837
+
838
+ if channels is None:
839
+ channels = list(range(chc))
840
+ else:
841
+ channels = list(channels)
842
+
843
+ _, pos, _size = meta["location"]
844
+ roi_w = x1 - x0
845
+ roi_h = y1 - y0
846
+
847
+ out = np.empty((len(channels), roi_h, roi_w), dtype=dtype)
848
+
849
+ row_bytes = w * itemsize
850
+ roi_bytes = roi_w * itemsize
851
+ plane_bytes = h * row_bytes
852
+
853
+ with open(self._fname, "rb") as f:
854
+ for ci, c in enumerate(channels):
855
+ if c < 0 or c >= chc:
856
+ raise IndexError(f"channel {c} out of range")
857
+ plane_base = pos + c * plane_bytes
858
+ for r, y in enumerate(range(y0, y1)):
859
+ offset = plane_base + y * row_bytes + x0 * itemsize
860
+ f.seek(offset)
861
+ out[ci, r, :] = np.frombuffer(f.read(roi_bytes), dtype=dtype, count=roi_w)
862
+
863
+ if data_format == "channels_last":
864
+ return np.transpose(out, (1, 2, 0))
865
+ return out
866
+
867
+
868
+ @staticmethod
869
+ def _process_location_compression(p_dict):
870
+ p_dict["location"] = XISF._parse_location(p_dict["location"])
871
+ if "compression" in p_dict:
872
+ p_dict["compression"] = XISF._parse_compression(p_dict["compression"])
873
+
874
+ # Insert XISF properties in the XML tree
875
+ @staticmethod
876
+ def _insert_property(parent, p_dict, max_inline_block_size, codec=None, shuffle=False):
877
+ """Insert a property into the XML tree.
878
+
879
+ Args:
880
+ parent: Parent XML element
881
+ p_dict: Property dictionary with 'id', 'type', 'value', and optional 'format', 'comment'
882
+ max_inline_block_size: Maximum size for inline data blocks
883
+ codec: Compression codec (None, 'zlib', 'lz4', 'lz4hc', 'zstd')
884
+ shuffle: Enable byte shuffling for compression
885
+ """
886
+ scalars = ["Int", "Byte", "Short", "Float", "Boolean", "TimePoint"]
887
+
888
+ # Build base attributes including optional format and comment
889
+ def _build_attrs(base_attrs):
890
+ attrs = dict(base_attrs)
891
+ if "format" in p_dict and p_dict["format"]:
892
+ attrs["format"] = str(p_dict["format"])
893
+ if "comment" in p_dict and p_dict["comment"]:
894
+ attrs["comment"] = str(p_dict["comment"])
895
+ return attrs
896
+
897
+ if any(t in p_dict["type"] for t in scalars):
898
+ # scalars and TimePoint
899
+ value_str = str(p_dict["value"])
900
+ # Boolean requires lowercase per XISF spec
901
+ if p_dict["type"] == "Boolean":
902
+ value_str = "true" if p_dict["value"] else "false"
903
+ attrs = _build_attrs({
904
+ "id": p_dict["id"],
905
+ "type": p_dict["type"],
906
+ "value": value_str,
907
+ })
908
+ ET.SubElement(parent, "Property", attrs)
909
+ elif p_dict["type"] == "String":
910
+ text = str(p_dict["value"])
911
+ data_bytes = text.encode("utf-8")
912
+ sz = len(data_bytes)
913
+ if sz > max_inline_block_size:
914
+ # Attach string as data block with optional compression
915
+ attrs = _build_attrs({
916
+ "id": p_dict["id"],
917
+ "type": p_dict["type"],
918
+ })
919
+ if codec:
920
+ compressed, comp_str = XISF._compress_data_block(data_bytes, codec, shuffle, 1)
921
+ attrs["location"] = XISF._to_location(("attachment", "", len(compressed)))
922
+ attrs["compression"] = comp_str
923
+ xml = ET.SubElement(parent, "Property", attrs)
924
+ return {"xml": xml, "location": 0, "size": len(compressed), "data": compressed}
925
+ else:
926
+ attrs["location"] = XISF._to_location(("attachment", "", sz))
927
+ xml = ET.SubElement(parent, "Property", attrs)
928
+ return {"xml": xml, "location": 0, "size": sz, "data": data_bytes}
929
+ else:
930
+ # string directly as child (no 'location' attribute)
931
+ attrs = _build_attrs({
932
+ "id": p_dict["id"],
933
+ "type": p_dict["type"],
934
+ })
935
+ ET.SubElement(parent, "Property", attrs).text = text
936
+ elif "Vector" in p_dict["type"]:
937
+ data = p_dict["value"]
938
+ raw_bytes = data.tobytes()
939
+ sz = len(raw_bytes)
940
+ item_size = data.itemsize
941
+ if sz > max_inline_block_size:
942
+ # Attach vector as data block with optional compression
943
+ attrs = _build_attrs({
944
+ "id": p_dict["id"],
945
+ "type": p_dict["type"],
946
+ "length": str(data.size),
947
+ })
948
+ if codec:
949
+ compressed, comp_str = XISF._compress_data_block(raw_bytes, codec, shuffle, item_size)
950
+ attrs["location"] = XISF._to_location(("attachment", "", len(compressed)))
951
+ attrs["compression"] = comp_str
952
+ xml = ET.SubElement(parent, "Property", attrs)
953
+ return {"xml": xml, "location": 0, "size": len(compressed), "data": compressed}
954
+ else:
955
+ attrs["location"] = XISF._to_location(("attachment", "", sz))
956
+ xml = ET.SubElement(parent, "Property", attrs)
957
+ return {"xml": xml, "location": 0, "size": sz, "data": data}
958
+ else:
959
+ # Inline data block (assuming base64)
960
+ attrs = _build_attrs({
961
+ "id": p_dict["id"],
962
+ "type": p_dict["type"],
963
+ "length": str(data.size),
964
+ "location": XISF._to_location(("inline", "base64")),
965
+ })
966
+ ET.SubElement(parent, "Property", attrs).text = str(base64.b64encode(data.tobytes()), "ascii")
967
+ elif "Matrix" in p_dict["type"]:
968
+ data = p_dict["value"]
969
+ raw_bytes = data.tobytes()
970
+ sz = len(raw_bytes)
971
+ item_size = data.itemsize
972
+ if sz > max_inline_block_size:
973
+ # Attach matrix as data block with optional compression
974
+ attrs = _build_attrs({
975
+ "id": p_dict["id"],
976
+ "type": p_dict["type"],
977
+ "rows": str(data.shape[0]),
978
+ "columns": str(data.shape[1]),
979
+ })
980
+ if codec:
981
+ compressed, comp_str = XISF._compress_data_block(raw_bytes, codec, shuffle, item_size)
982
+ attrs["location"] = XISF._to_location(("attachment", "", len(compressed)))
983
+ attrs["compression"] = comp_str
984
+ xml = ET.SubElement(parent, "Property", attrs)
985
+ return {"xml": xml, "location": 0, "size": len(compressed), "data": compressed}
986
+ else:
987
+ attrs["location"] = XISF._to_location(("attachment", "", sz))
988
+ xml = ET.SubElement(parent, "Property", attrs)
989
+ return {"xml": xml, "location": 0, "size": sz, "data": data}
990
+ else:
991
+ # Inline data block (assuming base64)
992
+ attrs = _build_attrs({
993
+ "id": p_dict["id"],
994
+ "type": p_dict["type"],
995
+ "rows": str(data.shape[0]),
996
+ "columns": str(data.shape[1]),
997
+ "location": XISF._to_location(("inline", "base64")),
998
+ })
999
+ ET.SubElement(parent, "Property", attrs).text = str(base64.b64encode(data.tobytes()), "ascii")
1000
+ else:
1001
+ print(f"Warning: skipping unsupported property {p_dict}")
1002
+
1003
+ return False
1004
+
1005
+ # Insert FITS Keywords in the XML tree
1006
+ @staticmethod
1007
+ def _insert_fitskeyword(image_xml, keyword_name, keyword_values):
1008
+ for entry in keyword_values:
1009
+ ET.SubElement(
1010
+ image_xml,
1011
+ "FITSKeyword",
1012
+ {
1013
+ "name": keyword_name,
1014
+ "value": entry["value"],
1015
+ "comment": entry["comment"],
1016
+ },
1017
+ )
1018
+
1019
+ # Returns image shape, e.g. (x, y, channels)
1020
+ @staticmethod
1021
+ def _parse_geometry(g):
1022
+ return tuple(map(int, g.split(":")))
1023
+
1024
+ # Returns ("attachment", position, size), ("inline", encoding) or ("embedded")
1025
+ @staticmethod
1026
+ def _parse_location(l):
1027
+ ll = l.split(":")
1028
+ if ll[0] not in ["inline", "embedded", "attachment"]:
1029
+ raise NotImplementedError(f"Data block location type '{ll[0]}' not implemented")
1030
+ return (ll[0], int(ll[1]), int(ll[2])) if ll[0] == "attachment" else ll
1031
+
1032
+ # Serialize location tuple to string, as value for location attribute
1033
+ @staticmethod
1034
+ def _to_location(location_tuple):
1035
+ return ":".join([str(e) for e in location_tuple])
1036
+
1037
+ # Returns (codec, uncompressed_size, item_size); item_size is None if not using byte shuffling
1038
+ @staticmethod
1039
+ def _parse_compression(c):
1040
+ cl = c.split(":")
1041
+ if len(cl) == 3:
1042
+ # (codec+byteshuffling, uncompressed_size, shuffling_item_size)
1043
+ return (cl[0], int(cl[1]), int(cl[2]))
1044
+ else:
1045
+ # (codec, uncompressed_size, None)
1046
+ return (cl[0], int(cl[1]), None)
1047
+
1048
+ # Return equivalent numpy dtype
1049
+ @staticmethod
1050
+ def _parse_sampleFormat(s):
1051
+ # Translate alternate names to "canonical" type names
1052
+ alternate_names = {
1053
+ 'Byte': 'UInt8',
1054
+ 'Short': 'Int16',
1055
+ 'UShort': 'UInt16',
1056
+ 'Int': 'Int32',
1057
+ 'UInt': 'UInt32',
1058
+ 'Float': 'Float32',
1059
+ 'Double': 'Float64',
1060
+ }
1061
+ try:
1062
+ s = alternate_names[s]
1063
+ except KeyError:
1064
+ pass
1065
+
1066
+ _dtypes = {
1067
+ "UInt8": np.dtype("uint8"),
1068
+ "UInt16": np.dtype("uint16"),
1069
+ "UInt32": np.dtype("uint32"),
1070
+ "Float32": np.dtype("float32"),
1071
+ "Float64": np.dtype("float64"),
1072
+ }
1073
+ try:
1074
+ return _dtypes[s]
1075
+ except:
1076
+ raise NotImplementedError(f"sampleFormat {s} not implemented")
1077
+
1078
+ # Return XISF data type from numpy dtype
1079
+ @staticmethod
1080
+ def _get_sampleFormat(dtype):
1081
+ _sampleFormats = {
1082
+ "uint8": "UInt8",
1083
+ "uint16": "UInt16",
1084
+ "uint32": "UInt32",
1085
+ "float32": "Float32",
1086
+ "float64": "Float64",
1087
+ }
1088
+ try:
1089
+ return _sampleFormats[str(dtype)]
1090
+ except:
1091
+ raise NotImplementedError(f"sampleFormat for {dtype} not implemented")
1092
+
1093
+ @staticmethod
1094
+ def _parse_vector_dtype(type_name):
1095
+ # Translate alternate names to "canonical" type names
1096
+ alternate_names = {
1097
+ 'ByteArray': 'UI8Vector',
1098
+ 'IVector': 'I32Vector',
1099
+ 'UIVector': 'UI32Vector',
1100
+ 'Vector': 'F64Vector',
1101
+ }
1102
+ try:
1103
+ type_name = alternate_names[type_name]
1104
+ except KeyError:
1105
+ pass
1106
+
1107
+ type_prefix = type_name[:-6] # removes "Vector" and "Matrix" suffixes
1108
+ _dtypes = {
1109
+ "I8": np.dtype("int8"),
1110
+ "UI8": np.dtype("uint8"),
1111
+ "I16": np.dtype("int16"),
1112
+ "UI16": np.dtype("uint16"),
1113
+ "I32": np.dtype("int32"),
1114
+ "UI32": np.dtype("uint32"),
1115
+ "I64": np.dtype("int64"),
1116
+ "UI64": np.dtype("uint64"),
1117
+ "F32": np.dtype("float32"),
1118
+ "F64": np.dtype("float64"),
1119
+ "C32": np.dtype("csingle"),
1120
+ "C64": np.dtype("cdouble"),
1121
+ }
1122
+ try:
1123
+ return _dtypes[type_prefix]
1124
+ except:
1125
+ raise NotImplementedError(f"data type {type_name} not implemented")
1126
+
1127
+ # __/ Auxiliary functions for compression/shuffling \________
1128
+
1129
+ # Un-byteshuffling implementation based on numpy
1130
+ @staticmethod
1131
+ def _unshuffle(d, item_size):
1132
+ a = np.frombuffer(d, dtype=np.dtype("uint8"))
1133
+ a = a.reshape((item_size, -1))
1134
+ return np.transpose(a).tobytes()
1135
+
1136
+ # Byteshuffling implementation based on numpy
1137
+ @staticmethod
1138
+ def _shuffle(d, item_size):
1139
+ a = np.frombuffer(d, dtype=np.dtype("uint8"))
1140
+ a = a.reshape((-1, item_size))
1141
+ return np.transpose(a).tobytes()
1142
+
1143
+ # LZ4/zlib/zstd decompression
1144
+ @staticmethod
1145
+ def _decompress(data, elem):
1146
+ # (codec, uncompressed-size, item-size); item-size is None if not using byte shuffling
1147
+ codec, uncompressed_size, item_size = elem["compression"]
1148
+
1149
+ if codec.startswith("lz4"):
1150
+ data = lz4.block.decompress(data, uncompressed_size=uncompressed_size)
1151
+ elif codec.startswith("zstd"):
1152
+ data = zstandard.decompress(data, max_output_size=uncompressed_size)
1153
+ elif codec.startswith("zlib"):
1154
+ data = zlib.decompress(data)
1155
+ else:
1156
+ raise NotImplementedError(f"Unimplemented compression codec {codec}")
1157
+
1158
+ if item_size: # using byte-shuffling
1159
+ data = XISF._unshuffle(data, item_size)
1160
+
1161
+ return data
1162
+
1163
+ @staticmethod
1164
+ def _compress_data_block(data, codec, shuffle=False, itemsize=1):
1165
+ """Compress a data block and return (compressed_bytes, compression_attr_string).
1166
+
1167
+ Args:
1168
+ data: bytes or numpy array to compress
1169
+ codec: 'zlib', 'lz4', 'lz4hc', or 'zstd'
1170
+ shuffle: enable byte shuffling
1171
+ itemsize: item size for byte shuffling (1 for strings, dtype.itemsize for arrays)
1172
+
1173
+ Returns:
1174
+ tuple: (compressed_bytes, compression_attribute_string)
1175
+ """
1176
+ if hasattr(data, 'tobytes'):
1177
+ raw_bytes = data.tobytes()
1178
+ else:
1179
+ raw_bytes = bytes(data)
1180
+
1181
+ uncompressed_size = len(raw_bytes)
1182
+ compressed = XISF._compress(raw_bytes, codec, shuffle=shuffle, itemsize=itemsize if shuffle else None)
1183
+
1184
+ # Build compression attribute string: "codec:uncompressed_size" or "codec+sh:uncompressed_size:itemsize"
1185
+ if shuffle and itemsize > 1:
1186
+ comp_str = f"{codec}+sh:{uncompressed_size}:{itemsize}"
1187
+ else:
1188
+ comp_str = f"{codec}:{uncompressed_size}"
1189
+
1190
+ return compressed, comp_str
1191
+
1192
+ # LZ4/zlib/zstd compression
1193
+ @staticmethod
1194
+ def _compress(data, codec, level=None, shuffle=False, itemsize=None):
1195
+ compressed = XISF._shuffle(data, itemsize) if shuffle else data
1196
+
1197
+ if codec == "lz4hc":
1198
+ level = level if level else XISF._compression_def_level["lz4hc"]
1199
+ compressed = lz4.block.compress(
1200
+ compressed, mode="high_compression", compression=level, store_size=False
1201
+ )
1202
+ elif codec == "lz4":
1203
+ compressed = lz4.block.compress(compressed, store_size=False)
1204
+ elif codec == "zstd":
1205
+ level = level if level else XISF._compression_def_level["zstd"]
1206
+ compressed = zstandard.compress(compressed, level=level)
1207
+ elif codec == "zlib":
1208
+ level = level if level else XISF._compression_def_level["zlib"]
1209
+ compressed = zlib.compress(compressed, level=level)
1210
+ else:
1211
+ raise NotImplementedError(f"Unimplemented compression codec {codec}")
1212
+
1213
+ return compressed