setiastrosuitepro 1.7.1.post2__py3-none-any.whl → 1.7.4__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 (62) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/saspro/__init__.py +20 -8
  3. setiastro/saspro/__main__.py +349 -290
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/abe.py +4 -4
  6. setiastro/saspro/autostretch.py +29 -18
  7. setiastro/saspro/doc_manager.py +4 -1
  8. setiastro/saspro/gui/main_window.py +46 -7
  9. setiastro/saspro/gui/mixins/file_mixin.py +6 -2
  10. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  11. setiastro/saspro/gui/mixins/toolbar_mixin.py +9 -2
  12. setiastro/saspro/imageops/serloader.py +101 -17
  13. setiastro/saspro/layers.py +186 -10
  14. setiastro/saspro/layers_dock.py +198 -5
  15. setiastro/saspro/legacy/image_manager.py +10 -4
  16. setiastro/saspro/legacy/numba_utils.py +301 -119
  17. setiastro/saspro/numba_utils.py +998 -270
  18. setiastro/saspro/ops/settings.py +6 -6
  19. setiastro/saspro/pixelmath.py +1 -1
  20. setiastro/saspro/planetprojection.py +4059 -0
  21. setiastro/saspro/resources.py +2 -0
  22. setiastro/saspro/save_options.py +45 -13
  23. setiastro/saspro/ser_stack_config.py +21 -1
  24. setiastro/saspro/ser_stacker.py +8 -2
  25. setiastro/saspro/ser_stacker_dialog.py +37 -10
  26. setiastro/saspro/ser_tracking.py +57 -35
  27. setiastro/saspro/serviewer.py +164 -16
  28. setiastro/saspro/sfcc.py +14 -8
  29. setiastro/saspro/stacking_suite.py +292 -111
  30. setiastro/saspro/subwindow.py +64 -36
  31. setiastro/saspro/translations/all_source_strings.json +2 -2
  32. setiastro/saspro/translations/ar_translations.py +3 -3
  33. setiastro/saspro/translations/de_translations.py +2 -2
  34. setiastro/saspro/translations/es_translations.py +2 -2
  35. setiastro/saspro/translations/fr_translations.py +2 -2
  36. setiastro/saspro/translations/hi_translations.py +2 -2
  37. setiastro/saspro/translations/it_translations.py +2 -2
  38. setiastro/saspro/translations/ja_translations.py +2 -2
  39. setiastro/saspro/translations/pt_translations.py +2 -2
  40. setiastro/saspro/translations/ru_translations.py +2 -2
  41. setiastro/saspro/translations/saspro_ar.ts +2 -2
  42. setiastro/saspro/translations/saspro_de.ts +4 -4
  43. setiastro/saspro/translations/saspro_es.ts +2 -2
  44. setiastro/saspro/translations/saspro_fr.ts +2 -2
  45. setiastro/saspro/translations/saspro_hi.ts +2 -2
  46. setiastro/saspro/translations/saspro_it.ts +4 -4
  47. setiastro/saspro/translations/saspro_ja.ts +2 -2
  48. setiastro/saspro/translations/saspro_pt.ts +2 -2
  49. setiastro/saspro/translations/saspro_ru.ts +2 -2
  50. setiastro/saspro/translations/saspro_sw.ts +2 -2
  51. setiastro/saspro/translations/saspro_uk.ts +2 -2
  52. setiastro/saspro/translations/saspro_zh.ts +2 -2
  53. setiastro/saspro/translations/sw_translations.py +2 -2
  54. setiastro/saspro/translations/uk_translations.py +2 -2
  55. setiastro/saspro/translations/zh_translations.py +2 -2
  56. setiastro/saspro/window_shelf.py +62 -1
  57. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/METADATA +1 -1
  58. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/RECORD +62 -60
  59. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/entry_points.txt +1 -1
  60. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/WHEEL +0 -0
  61. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/LICENSE +0 -0
  62. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/license.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  # Auto-generated at build time. Do not edit.
2
- BUILD_TIMESTAMP = "2026-01-15T16:55:53Z"
3
- APP_VERSION = "1.7.1.post2"
2
+ BUILD_TIMESTAMP = "2026-01-21T15:42:11Z"
3
+ APP_VERSION = "1.7.4"
setiastro/saspro/abe.py CHANGED
@@ -951,7 +951,7 @@ class ABEDialog(QDialog):
951
951
 
952
952
  # show autostretched or raw; siril_style_autostretch() already clips its result
953
953
  src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
954
- linked=False, use_16bit=True)
954
+ linked=False, use_24bit=True)
955
955
  if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
956
956
 
957
957
  if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
@@ -1229,7 +1229,7 @@ class ABEDialog(QDialog):
1229
1229
  self._preview_source_f01 = a # ← no np.clip
1230
1230
 
1231
1231
  src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
1232
- linked=False, use_16bit=True)
1232
+ linked=False, use_24bit=True)
1233
1233
  if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
1234
1234
 
1235
1235
  if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
@@ -1349,7 +1349,7 @@ class ABEDialog(QDialog):
1349
1349
  # Prefer float source (avoids 8-bit clipping); fall back to decoding _last_preview if needed
1350
1350
  arr = self._preview_source_f01 if self._preview_source_f01 is not None else (self._last_preview.astype(np.float32)/255.0)
1351
1351
 
1352
- stretched = hard_autostretch(arr, target_median=0.5, sigma=2, linked=False, use_16bit=True)
1352
+ stretched = hard_autostretch(arr, target_median=0.5, sigma=2, linked=False, use_24bit=True)
1353
1353
 
1354
1354
  buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
1355
1355
  if buf8.ndim == 2:
@@ -1367,7 +1367,7 @@ class ABEDialog(QDialog):
1367
1367
  if self._preview_source_f01 is None:
1368
1368
  return
1369
1369
  stretched = hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
1370
- linked=False, use_16bit=True)
1370
+ linked=False, use_24bit=True)
1371
1371
  buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
1372
1372
  if buf8.ndim == 2:
1373
1373
  buf8 = np.stack([buf8] * 3, axis=-1)
@@ -4,12 +4,18 @@ import numpy as np
4
4
  _MAX_STATS_PIXELS = 1_000_000
5
5
  _DEFAULT_SIGMA = 3
6
6
  _U8_MAX = 4095 # 12-bit output for better gradations than 255
7
- _U16_MAX = 65535
7
+ _U24_MAX = 16777215 # 24-bit output for better gradations
8
8
 
9
9
  # ---------- helpers (generic N-level pipeline) ----------
10
10
  def _to_uN(a: np.ndarray, maxv: int) -> np.ndarray:
11
- """Convert to uint8/uint16 [0..maxv] for cheap hist/LUT work."""
12
- tgt_dtype = np.uint16 if maxv > _U8_MAX else np.uint8
11
+ """Convert to uint8/uint16/uint32 [0..maxv] for cheap hist/LUT work."""
12
+ # uint8 for maxv <= 255, uint16 for 256..65535, uint32 for larger (24-bit)
13
+ if maxv > 65535:
14
+ tgt_dtype = np.uint32
15
+ elif maxv > 255:
16
+ tgt_dtype = np.uint16
17
+ else:
18
+ tgt_dtype = np.uint8
13
19
  if a.dtype == tgt_dtype:
14
20
  return a
15
21
  if np.issubdtype(a.dtype, np.integer):
@@ -17,10 +23,12 @@ def _to_uN(a: np.ndarray, maxv: int) -> np.ndarray:
17
23
  if info.max <= 0:
18
24
  return np.zeros_like(a, dtype=tgt_dtype)
19
25
  scaled = np.clip(a.astype(np.float32), 0, info.max) * (maxv / float(info.max))
20
- return (scaled + 0.5).astype(tgt_dtype)
26
+ # Clamp to maxv to avoid index-out-of-bounds when used as LUT indices
27
+ return np.minimum((scaled + 0.5).astype(tgt_dtype), maxv)
21
28
  # float-ish
22
29
  af = np.clip(a.astype(np.float32), 0.0, 1.0)
23
- return (af * maxv + 0.5).astype(tgt_dtype)
30
+ # Clamp to maxv: when af=1.0, af*maxv+0.5 can exceed maxv
31
+ return np.minimum((af * maxv + 0.5).astype(tgt_dtype), maxv)
24
32
 
25
33
  def _choose_stride(h: int, w: int, max_pixels: int) -> tuple[int, int]:
26
34
  n = h * w
@@ -132,29 +140,32 @@ def autostretch(
132
140
  linked: bool = False,
133
141
  sigma: float = _DEFAULT_SIGMA,
134
142
  *,
135
- use_16bit: bool | None = None,
143
+ use_24bit: bool | None = None,
144
+ use_16bit: bool | None = None, # <-- legacy compat (ignored / mapped)
145
+ **_ignored_kwargs, # <-- swallow any other legacy flags safely
136
146
  ) -> np.ndarray:
137
- """
138
- High-quality autostretch that can operate in 16-bit (HQ, default) or 8-bit (fast) mode.
139
-
140
- • 16-bit mode: smooth gradients, minimal posterization (recommended).
141
- • 8-bit mode: slightly faster on very large images, lower fidelity.
142
147
 
143
- If use_16bit is None, we try to read QSettings("display/autostretch_16bit") and
144
- default to True on failure (no Qt in context).
145
- """
146
148
  if img is None:
147
149
  return None
148
150
 
151
+ # ---- legacy compat -------------------------------------------------
152
+ # Old callers may pass use_16bit. We no longer support 16-bit preview output.
153
+ # If they pass it, we just treat it as "use higher precision display", i.e. 24-bit.
154
+ if use_16bit is not None:
155
+ # If caller explicitly asked for 16-bit, we interpret that as "high precision".
156
+ # Only override if caller didn't explicitly pass use_24bit.
157
+ if use_24bit is None:
158
+ use_24bit = True
159
+
149
160
  # Optional auto-read from QSettings if caller didn’t pass a flag.
150
- if use_16bit is None:
161
+ if use_24bit is None:
151
162
  try:
152
163
  from PyQt6.QtCore import QSettings
153
- use_16bit = QSettings().value("display/autostretch_16bit", True, type=bool)
164
+ use_24bit = QSettings().value("display/autostretch_24bit", True, type=bool)
154
165
  except Exception:
155
- use_16bit = True
166
+ use_24bit = True
156
167
 
157
- maxv = _U16_MAX if use_16bit else _U8_MAX
168
+ maxv = _U24_MAX if use_24bit else _U8_MAX
158
169
  a = np.asarray(img)
159
170
 
160
171
  # MONO (or pseudo-mono)
@@ -2276,6 +2276,7 @@ class DocManager(QObject):
2276
2276
  bit_depth: str | None = None,
2277
2277
  *,
2278
2278
  bit_depth_override: str | None = None,
2279
+ jpeg_quality: int | None = None, # <-- NEW
2279
2280
  ):
2280
2281
  """
2281
2282
  Save the given ImageDocument to 'path'.
@@ -2289,7 +2290,8 @@ class DocManager(QObject):
2289
2290
  ext = _normalize_ext(os.path.splitext(path)[1])
2290
2291
  img = doc.image
2291
2292
  meta = doc.metadata or {}
2292
-
2293
+ if jpeg_quality is not None:
2294
+ meta["jpeg_quality"] = int(jpeg_quality)
2293
2295
  # ── MASSIVE DEBUG: show everything we know coming in ───────────────
2294
2296
  debug_dump_metadata_print(meta, context="save_document: BEFORE HEADER PICK")
2295
2297
 
@@ -2359,6 +2361,7 @@ class DocManager(QObject):
2359
2361
  image_meta=meta.get("image_meta"),
2360
2362
  file_meta=meta.get("file_meta"),
2361
2363
  wcs_header=meta.get("wcs_header"),
2364
+ jpeg_quality=jpeg_quality,
2362
2365
  )
2363
2366
 
2364
2367
  # ── Update metadata in memory to match what we just wrote ─────────
@@ -196,7 +196,7 @@ from setiastro.saspro.resources import (
196
196
  colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path, narrowbandnormalization_path,
197
197
  wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
198
198
  functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_path,
199
- background_path, script_icon_path
199
+ background_path, script_icon_path, planetprojection_path,
200
200
  )
201
201
 
202
202
  import faulthandler
@@ -435,7 +435,23 @@ class AstroSuiteProMainWindow(
435
435
  version: str = "dev", build_timestamp: str = "dev"):
436
436
  super().__init__(parent)
437
437
  # Prevent white flash: start strictly transparent and force dark bg
438
- self.setWindowOpacity(0.0)
438
+ from PyQt6.QtGui import QGuiApplication
439
+
440
+ def _is_wayland() -> bool:
441
+ try:
442
+ plat = (QGuiApplication.platformName() or "").lower()
443
+ if "wayland" in plat:
444
+ return True
445
+ except Exception:
446
+ pass
447
+ # fallback env checks
448
+ return bool(os.environ.get("WAYLAND_DISPLAY")) and not bool(os.environ.get("DISPLAY"))
449
+
450
+ # Prevent white flash: start strictly transparent and force dark bg
451
+ if not _is_wayland():
452
+ self.setWindowOpacity(0.0)
453
+ self.setStyleSheet("QMainWindow { background-color: #0F0F19; }")
454
+
439
455
  self.setStyleSheet("QMainWindow { background-color: #0F0F19; }")
440
456
  #self._stall = UiStallDetector(self, interval_ms=50, threshold_ms=250)
441
457
  #self._stall.start()
@@ -675,8 +691,8 @@ class AstroSuiteProMainWindow(
675
691
  self.apply_theme_from_settings()
676
692
  self._populate_view_panels_menu()
677
693
  # Startup check (no lambdas)
678
- if self.settings.value("updates/check_on_startup", True, type=bool):
679
- QTimer.singleShot(1500, self.check_for_updates_startup)
694
+ #if self.settings.value("updates/check_on_startup", True, type=bool):
695
+ # QTimer.singleShot(1500, self.check_for_updates_startup)
680
696
 
681
697
  self._hdr_refresh_timer = QTimer(self)
682
698
  self._hdr_refresh_timer.setSingleShot(True)
@@ -1457,7 +1473,7 @@ class AstroSuiteProMainWindow(
1457
1473
 
1458
1474
  def _open_user_scripts_github(self):
1459
1475
  # User script examples on GitHub
1460
- url = QUrl("https://github.com/setiastro/setiastrosuitepro/tree/main/scripts")
1476
+ url = QUrl("https://drive.google.com/drive/folders/1TSxKZey4R_t7F2RsB53Hd1SBIGXv3-Nl?usp=drive_link")
1461
1477
  QDesktopServices.openUrl(url)
1462
1478
 
1463
1479
  def _open_scripts_discord_forum(self):
@@ -2782,7 +2798,7 @@ class AstroSuiteProMainWindow(
2782
2798
  target = float(self.settings.value("display/target", 0.30, type=float))
2783
2799
  sigma = float(self.settings.value("display/sigma", 5.0, type=float))
2784
2800
  linked = bool(self.settings.value("display/stretch_linked", False, type=bool))
2785
- use_16 = self.settings.value("display/autostretch_16bit", True, type=bool)
2801
+ use_24 = self.settings.value("display/autostretch_24bit", True, type=bool)
2786
2802
 
2787
2803
  # if your view exposes per-view overrides, prefer those
2788
2804
  if hasattr(view, "autostretch_target"):
@@ -2808,7 +2824,7 @@ class AstroSuiteProMainWindow(
2808
2824
  target_median=target,
2809
2825
  linked=linked,
2810
2826
  sigma=sigma,
2811
- use_16bit=use_16,
2827
+ use_24bit=use_24,
2812
2828
  )
2813
2829
  except Exception as e:
2814
2830
  QMessageBox.warning(self, "Display-Stretch", f"Failed to apply autostretch:\n{e}")
@@ -4280,6 +4296,29 @@ class AstroSuiteProMainWindow(
4280
4296
  dlg.setWindowIcon(QIcon(planetarystacker_path))
4281
4297
  dlg.show()
4282
4298
 
4299
+ def _open_planet_projection(self):
4300
+ from setiastro.saspro.planetprojection import PlanetProjectionDialog
4301
+ sw = self.mdi.activeSubWindow()
4302
+ if not sw:
4303
+ QMessageBox.information(self, "No image", "Open an image first.")
4304
+ return
4305
+
4306
+ view = sw.widget()
4307
+ doc = self.doc_manager.get_document_for_view(view)
4308
+
4309
+ dlg = PlanetProjectionDialog(self, doc)
4310
+ try:
4311
+ # dlg.setWindowIcon(QIcon(planetprojection_path))
4312
+ pass
4313
+ except Exception:
4314
+ pass
4315
+ dlg.resize(980, 720)
4316
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
4317
+ dlg.setWindowIcon(QIcon(planetprojection_path))
4318
+ dlg.show()
4319
+ self._log("Functions: opened Planet Projection.")
4320
+
4321
+
4283
4322
  def _open_stacking_suite(self):
4284
4323
  # Reuse if we already have one
4285
4324
  dlg = getattr(self, "_stacking_suite", None)
@@ -216,14 +216,18 @@ class FileMixin:
216
216
  # --- Bit depth selection ----------------------------------------
217
217
  from setiastro.saspro.save_options import SaveOptionsDialog
218
218
  current_bd = doc.metadata.get("bit_depth")
219
- dlg = SaveOptionsDialog(self, ext_norm, current_bd)
219
+ current_jq = (doc.metadata or {}).get("jpeg_quality", None)
220
+
221
+ dlg = SaveOptionsDialog(self, ext_norm, current_bd, current_jpeg_quality=current_jq)
220
222
  if dlg.exec() != dlg.DialogCode.Accepted:
221
223
  return
224
+
222
225
  chosen_bd = dlg.selected_bit_depth()
226
+ chosen_jq = dlg.selected_jpeg_quality()
223
227
 
224
228
  # --- Save & remember folder ----------------------------------------
225
229
  try:
226
- self.docman.save_document(doc, path, bit_depth_override=chosen_bd)
230
+ self.docman.save_document(doc, path, bit_depth_override=chosen_bd, jpeg_quality=chosen_jq)
227
231
  self._log(f"Saved: {path} ({chosen_bd})")
228
232
  self.settings.setValue("paths/last_save_dir", os.path.dirname(path))
229
233
  except Exception as e:
@@ -207,6 +207,7 @@ class MenuMixin:
207
207
  m_star.addAction(self.act_isophote)
208
208
  m_star.addAction(self.act_live_stacking)
209
209
  m_star.addAction(self.act_mosaic_master)
210
+ m_star.addAction(self.act_planet_projection)
210
211
  m_star.addAction(self.act_planetary_stacker)
211
212
  m_star.addAction(self.act_plate_solve)
212
213
  m_star.addAction(self.act_psf_viewer)
@@ -36,7 +36,7 @@ from setiastro.saspro.resources import (
36
36
  nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
37
37
  satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
38
38
  debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
39
- selectivecolor_path, rgbalign_path,
39
+ selectivecolor_path, rgbalign_path, planetprojection_path,
40
40
  )
41
41
 
42
42
  # Import shortcuts module
@@ -307,6 +307,7 @@ class ToolbarMixin:
307
307
  tb_star.addAction(self.act_stacking_suite)
308
308
  tb_star.addAction(self.act_live_stacking)
309
309
  tb_star.addAction(self.act_planetary_stacker)
310
+ tb_star.addAction(self.act_planet_projection)
310
311
  tb_star.addAction(self.act_plate_solve)
311
312
  tb_star.addAction(self.act_star_align)
312
313
  tb_star.addAction(self.act_star_register)
@@ -1195,6 +1196,11 @@ class ToolbarMixin:
1195
1196
  self.act_planetary_stacker.setStatusTip(self.tr("Stack SER videos (planetary/solar/lunar)"))
1196
1197
  self.act_planetary_stacker.triggered.connect(self._open_planetary_stacker)
1197
1198
 
1199
+ self.act_planet_projection = QAction(QIcon(planetprojection_path), self.tr("3D Projection..."), self)
1200
+ self.act_planet_projection.setIconVisibleInMenu(True)
1201
+ self.act_planet_projection.setStatusTip(self.tr("View your planets with stereographic projection"))
1202
+ self.act_planet_projection.triggered.connect(self._open_planet_projection)
1203
+
1198
1204
  self.act_plate_solve = QAction(QIcon(platesolve_path), self.tr("Plate Solver..."), self)
1199
1205
  self.act_plate_solve.setIconVisibleInMenu(True)
1200
1206
  self.act_plate_solve.setStatusTip(self.tr("Solve WCS/SIP for the active image or a file"))
@@ -1272,7 +1278,7 @@ class ToolbarMixin:
1272
1278
  self.act_script_editor.setStatusTip(self.tr("Open the built-in script editor"))
1273
1279
  self.act_script_editor.triggered.connect(self._show_script_editor)
1274
1280
 
1275
- self.act_open_user_scripts_github = QAction(self.tr("Open User Scripts (GitHub)..."), self)
1281
+ self.act_open_user_scripts_github = QAction(self.tr("Open User Scripts (GoogleDrive)..."), self)
1276
1282
  self.act_open_user_scripts_github.triggered.connect(self._open_user_scripts_github)
1277
1283
 
1278
1284
  self.act_open_scripts_discord = QAction(self.tr("Open Scripts Forum (Discord)..."), self)
@@ -1439,6 +1445,7 @@ class ToolbarMixin:
1439
1445
  reg("image_peeker", self.act_image_peeker)
1440
1446
  reg("live_stacking", self.act_live_stacking)
1441
1447
  reg("stacking_suite", self.act_stacking_suite)
1448
+ reg("planet_projection", self.act_planet_projection)
1442
1449
  reg("supernova_hunter", self.act_supernova_hunter)
1443
1450
  reg("star_spikes", self.act_star_spikes)
1444
1451
  reg("astrospike", self.act_astrospike)
@@ -10,6 +10,7 @@ from typing import Optional, Tuple, Dict, List, Sequence, Union, Callable
10
10
  from collections import OrderedDict
11
11
  import numpy as np
12
12
  import time
13
+ from PyQt6 import sip
13
14
 
14
15
  import cv2
15
16
 
@@ -115,6 +116,20 @@ def _bytes_per_sample(pixel_depth_bits: int) -> int:
115
116
  def _is_bayer(color_name: str) -> bool:
116
117
  return color_name in BAYER_NAMES
117
118
 
119
+ def _roi_unrotate_180(roi: Tuple[int, int, int, int], W: int, H: int) -> Tuple[int, int, int, int]:
120
+ """
121
+ Convert an ROI specified in *post-rot180/display coords* into *raw/pre-rot coords*.
122
+ """
123
+ x, y, w, h = [int(v) for v in roi]
124
+ x2 = x + w
125
+ y2 = y + h
126
+
127
+ x_raw = int(W - x2)
128
+ y_raw = int(H - y2)
129
+
130
+ return (x_raw, y_raw, int(w), int(h))
131
+
132
+
118
133
  def _rot180(img: np.ndarray) -> np.ndarray:
119
134
  # Works for mono (H,W) and RGB(A) (H,W,C)
120
135
  return img[::-1, ::-1].copy()
@@ -847,7 +862,9 @@ class SERReader:
847
862
 
848
863
  # ROI (apply before debayer; for Bayer enforce even-even origin)
849
864
  if roi is not None:
850
- x, y, w, h = [int(v) for v in roi]
865
+ # roi is specified in DISPLAY coords (post-rot180)
866
+ x, y, w, h = _roi_unrotate_180(roi, meta.width, meta.height)
867
+
851
868
  x = max(0, min(meta.width - 1, x))
852
869
  y = max(0, min(meta.height - 1, y))
853
870
  w = max(1, min(meta.width - x, w))
@@ -862,7 +879,6 @@ class SERReader:
862
879
 
863
880
  # --- SER global orientation fix (rotate 180) ---
864
881
  img = _rot180(img)
865
-
866
882
  # Convert BGR->RGB if needed
867
883
  if color_name == "BGR" and img.ndim == 3 and img.shape[2] >= 3:
868
884
  img = img[..., ::-1].copy()
@@ -1075,15 +1091,16 @@ class AVIReader(PlanetaryFrameSource):
1075
1091
 
1076
1092
  # ROI first (but if we are going to debayer mosaic, ROI origin must be even-even)
1077
1093
  if roi is not None:
1078
- x, y, w, h = [int(v) for v in roi]
1079
1094
  H, W = frame.shape[:2]
1095
+
1096
+ # ✅ roi is specified in DISPLAY coords (post-rot180)
1097
+ x, y, w, h = _roi_unrotate_180(roi, W, H)
1098
+
1080
1099
  x = max(0, min(W - 1, x))
1081
1100
  y = max(0, min(H - 1, y))
1082
1101
  w = max(1, min(W - x, w))
1083
1102
  h = max(1, min(H - y, h))
1084
1103
 
1085
- # If user explicitly requests debayering, preserve Bayer phase
1086
- # (even-even origin) exactly like SER
1087
1104
  if debayer and user_pat is not None:
1088
1105
  x, y = _roi_evenize_for_bayer(x, y)
1089
1106
  w = max(1, min(W - x, w))
@@ -1160,13 +1177,45 @@ class AVIReader(PlanetaryFrameSource):
1160
1177
 
1161
1178
  def _imread_any(path: str) -> np.ndarray:
1162
1179
  """
1163
- Read PNG/JPG/TIF/etc into numpy.
1164
- Tries cv2 first (fast), falls back to PIL.
1180
+ Read PNG/JPG/TIF/FITS/etc into numpy.
1165
1181
  Returns:
1166
- - grayscale: (H,W) uint8/uint16
1167
- - color: (H,W,3) uint8/uint16 in RGB (we normalize to RGB)
1182
+ - grayscale: (H,W)
1183
+ - color: (H,W,3) in RGB when applicable
1168
1184
  """
1169
1185
  p = os.fspath(path)
1186
+ ext = os.path.splitext(p)[1].lower()
1187
+
1188
+ # ---- FITS / FIT ----
1189
+ if ext in (".fit", ".fits"):
1190
+ try:
1191
+ from astropy.io import fits
1192
+
1193
+ data = fits.getdata(p, memmap=False)
1194
+ if data is None:
1195
+ raise ValueError("Empty FITS data.")
1196
+
1197
+ arr = np.asarray(data)
1198
+
1199
+ # Common shapes:
1200
+ # (H,W) -> mono
1201
+ # (C,H,W) -> convert to (H,W,C)
1202
+ # (H,W,C) -> already fine
1203
+ if arr.ndim == 3:
1204
+ # If first axis is small (1/3/4), assume CHW
1205
+ if arr.shape[0] in (1, 3, 4) and arr.shape[1] > 8 and arr.shape[2] > 8:
1206
+ arr = np.moveaxis(arr, 0, -1) # CHW -> HWC
1207
+
1208
+ # If now HWC and has alpha, drop it
1209
+ if arr.shape[-1] >= 4:
1210
+ arr = arr[..., :3]
1211
+
1212
+ # If single channel, squeeze
1213
+ if arr.shape[-1] == 1:
1214
+ arr = arr[..., 0]
1215
+
1216
+ return arr
1217
+ except Exception as e:
1218
+ raise RuntimeError(f"Failed to read FITS: {p}\n{e}")
1170
1219
 
1171
1220
  # Prefer cv2 if available
1172
1221
  if cv2 is not None:
@@ -1194,6 +1243,47 @@ def _imread_any(path: str) -> np.ndarray:
1194
1243
  im = im.convert("RGB")
1195
1244
  return np.array(im)
1196
1245
 
1246
+ def _to_float01_robust(img: np.ndarray) -> np.ndarray:
1247
+ """
1248
+ Robust float01 conversion for preview:
1249
+ - uint8/uint16/int types scale by dtype max
1250
+ - float types:
1251
+ * if already ~[0,1], keep
1252
+ * else percentile-scale (0.1..99.9) to [0,1]
1253
+ Works for mono or RGB arrays.
1254
+ """
1255
+ a = np.asarray(img)
1256
+
1257
+ if a.dtype == np.uint8:
1258
+ return a.astype(np.float32) / 255.0
1259
+ if a.dtype == np.uint16:
1260
+ return a.astype(np.float32) / 65535.0
1261
+ if np.issubdtype(a.dtype, np.integer):
1262
+ info = np.iinfo(a.dtype)
1263
+ denom = float(info.max) if info.max > 0 else 1.0
1264
+ return (a.astype(np.float32) / denom).clip(0.0, 1.0)
1265
+
1266
+ # float path
1267
+ f = a.astype(np.float32, copy=False)
1268
+
1269
+ # If it already looks like [0,1], don’t mess with it
1270
+ mn = float(np.nanmin(f))
1271
+ mx = float(np.nanmax(f))
1272
+ if (mn >= -1e-3) and (mx <= 1.0 + 1e-3):
1273
+ return np.clip(f, 0.0, 1.0)
1274
+
1275
+ # Percentile scale (planetary-friendly)
1276
+ lo = float(np.nanpercentile(f, 0.1))
1277
+ hi = float(np.nanpercentile(f, 99.9))
1278
+ if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo + 1e-12:
1279
+ # fallback to min/max
1280
+ lo, hi = mn, mx
1281
+ if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo + 1e-12:
1282
+ return np.zeros_like(f, dtype=np.float32)
1283
+
1284
+ out = (f - lo) / (hi - lo)
1285
+ return np.clip(out, 0.0, 1.0)
1286
+
1197
1287
 
1198
1288
  def _infer_bit_depth(arr: np.ndarray) -> int:
1199
1289
  if arr.dtype == np.uint16:
@@ -1295,13 +1385,7 @@ class ImageSequenceReader(PlanetaryFrameSource):
1295
1385
 
1296
1386
  # Normalize to float01
1297
1387
  if to_float01:
1298
- if img.dtype == np.uint8:
1299
- img = img.astype(np.float32) / 255.0
1300
- elif img.dtype == np.uint16:
1301
- img = img.astype(np.float32) / 65535.0
1302
- else:
1303
- img = img.astype(np.float32)
1304
- img = np.clip(img, 0.0, 1.0)
1388
+ img = _to_float01_robust(img)
1305
1389
 
1306
1390
  self._cache.put(key, img)
1307
1391
  return img
@@ -1339,7 +1423,7 @@ def open_planetary_source(
1339
1423
  return AVIReader(path, cache_items=cache_items)
1340
1424
 
1341
1425
  # If user passes a single image, treat it as a 1-frame sequence
1342
- if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp"):
1426
+ if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp", ".fit", ".fits"):
1343
1427
  return ImageSequenceReader([path], cache_items=cache_items)
1344
1428
 
1345
1429
  raise ValueError(f"Unsupported input: {path}")