setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__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 (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -129,7 +129,7 @@ from PyQt6.QtGui import (QPixmap, QColor, QIcon, QKeySequence, QShortcut,
129
129
 
130
130
  # ----- QtCore -----
131
131
  from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QTimer, QSize, QSignalBlocker, QModelIndex, QThread, QUrl, QSettings, QEvent, QByteArray, QObject,
132
- QPropertyAnimation, QEasingCurve, QElapsedTimer
132
+ QPropertyAnimation, QEasingCurve, QElapsedTimer, QPoint
133
133
  )
134
134
 
135
135
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -193,10 +193,10 @@ from setiastro.saspro.resources import (
193
193
  nbtorgb_path, freqsep_path, contsub_path, halo_path, cosmic_path,
194
194
  satellite_path, imagecombine_path, wrench_path, eye_icon_path,multiscale_decomp_path,
195
195
  disk_icon_path, nuke_path, hubble_path, collage_path, annotated_path,
196
- colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path,
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
- functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path,
199
- background_path, script_icon_path
198
+ functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_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()
@@ -604,7 +620,10 @@ class AstroSuiteProMainWindow(
604
620
  self.docman.documentAdded.connect(self._on_document_added)
605
621
  self.mdi.viewStateDropped.connect(self._on_mdi_viewstate_drop)
606
622
  self.mdi.linkViewDropped.connect(self._on_linkview_drop)
607
-
623
+ self._mdi_open_batch = 0
624
+ self._mdi_place_mode = "cascade" # or "tile"
625
+ self._mdi_next_pos = None # QPoint in MDI coords
626
+ self._mdi_cascade_step = 28
608
627
  self.doc_manager.set_mdi_area(self.mdi)
609
628
  # Coalesce undo/redo label refreshes
610
629
  self._undo_redo_refresh_pending = False
@@ -3905,6 +3924,19 @@ class AstroSuiteProMainWindow(
3905
3924
 
3906
3925
  dlg.show()
3907
3926
 
3927
+ def _open_narrowband_normalization_tool(self):
3928
+ # Correct module import
3929
+ from setiastro.saspro.narrowband_normalization import NarrowbandNormalization
3930
+
3931
+ w = NarrowbandNormalization(doc_manager=self.docman, parent=self)
3932
+ w.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
3933
+ w.setWindowTitle("Narrowband Normalization")
3934
+ try:
3935
+ w.setWindowIcon(QIcon(narrowbandnormalization_path))
3936
+ except Exception:
3937
+ pass
3938
+ w.show()
3939
+
3908
3940
  def _open_ppp_tool(self):
3909
3941
  from setiastro.saspro.perfect_palette_picker import PerfectPalettePicker
3910
3942
  w = PerfectPalettePicker(doc_manager=self.docman) # parent gives access to _spawn_subwindow_for
@@ -4256,6 +4288,37 @@ class AstroSuiteProMainWindow(
4256
4288
  dlg.setWindowIcon(QIcon(livestacking_path))
4257
4289
  dlg.show()
4258
4290
 
4291
+ def _open_planetary_stacker(self):
4292
+ # import locally to avoid startup cost / circular imports
4293
+ from setiastro.saspro.serviewer import SERViewer
4294
+ dlg = SERViewer(self)
4295
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
4296
+ dlg.setWindowIcon(QIcon(planetarystacker_path))
4297
+ dlg.show()
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
+
4259
4322
  def _open_stacking_suite(self):
4260
4323
  # Reuse if we already have one
4261
4324
  dlg = getattr(self, "_stacking_suite", None)
@@ -4299,6 +4362,202 @@ class AstroSuiteProMainWindow(
4299
4362
  except Exception:
4300
4363
  pass
4301
4364
 
4365
+ def _convert_mono_to_rgb_active(self):
4366
+ """
4367
+ Convert active mono document to RGB by duplicating the channel.
4368
+ Updates the active document in-place (undoable).
4369
+ """
4370
+ dm = getattr(self, "docman", None)
4371
+ if dm is None:
4372
+ return
4373
+
4374
+ try:
4375
+ doc = dm.get_active_document()
4376
+ except Exception:
4377
+ doc = None
4378
+ if doc is None:
4379
+ return
4380
+
4381
+ img = getattr(doc, "image", None)
4382
+ if img is None:
4383
+ return
4384
+
4385
+ import numpy as np
4386
+
4387
+ x = np.asarray(img)
4388
+
4389
+ # Already RGB?
4390
+ if x.ndim == 3 and x.shape[-1] == 3:
4391
+ try:
4392
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4393
+ except Exception:
4394
+ name = "Active"
4395
+ if hasattr(self, "_log"):
4396
+ self._log(f"Mono → RGB: '{name}' is already RGB (shape={getattr(x,'shape',None)}).")
4397
+ return
4398
+
4399
+ # Determine what we're converting FROM
4400
+ src_desc = "unknown"
4401
+ if x.ndim == 2:
4402
+ mono = x
4403
+ src_desc = "mono (H×W)"
4404
+ elif x.ndim == 3 and x.shape[-1] == 1:
4405
+ mono = x[..., 0]
4406
+ src_desc = "mono (H×W×1)"
4407
+ else:
4408
+ # Unknown format (e.g., multi-channel >3)
4409
+ try:
4410
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4411
+ except Exception:
4412
+ name = "Active"
4413
+ if hasattr(self, "_log"):
4414
+ self._log(f"Mono → RGB: '{name}' not convertible (shape={getattr(x,'shape',None)}).")
4415
+ return
4416
+
4417
+ before_shape = getattr(x, "shape", None)
4418
+ before_dtype = getattr(x, "dtype", None)
4419
+
4420
+ mono = mono.astype(np.float32, copy=False)
4421
+ rgb = np.stack([mono, mono, mono], axis=-1)
4422
+
4423
+ # metadata: preserve existing, but force "not mono"
4424
+ try:
4425
+ md = dict(getattr(doc, "metadata", None) or {})
4426
+ except Exception:
4427
+ md = {}
4428
+
4429
+ md["is_mono"] = False
4430
+ md["color_model"] = "RGB"
4431
+ md["channels"] = 3
4432
+ md["source"] = (md.get("source") or "Edit")
4433
+
4434
+ # If you track op params for history explorer
4435
+ md["__op_params__"] = {
4436
+ "op": "mono_to_rgb",
4437
+ "mode": "triplicate",
4438
+ "from": str(src_desc),
4439
+ "from_shape": tuple(before_shape) if before_shape is not None else None,
4440
+ "to_shape": tuple(rgb.shape),
4441
+ }
4442
+
4443
+ # name for logging
4444
+ try:
4445
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4446
+ except Exception:
4447
+ name = "Active"
4448
+
4449
+ try:
4450
+ dm.update_active_document(
4451
+ rgb,
4452
+ metadata=md,
4453
+ step_name="Mono → RGB",
4454
+ doc=doc, # explicit is safer
4455
+ )
4456
+
4457
+ if hasattr(self, "_log"):
4458
+ self._log(
4459
+ f"Mono → RGB: '{name}' converted {src_desc} "
4460
+ f"(shape={before_shape}, dtype={before_dtype}) → "
4461
+ f"RGB (shape={rgb.shape}, dtype={rgb.dtype})."
4462
+ )
4463
+
4464
+ except Exception:
4465
+ import traceback
4466
+ try:
4467
+ from PyQt6.QtWidgets import QMessageBox
4468
+ QMessageBox.critical(self, "Mono → RGB", traceback.format_exc())
4469
+ except Exception:
4470
+ pass
4471
+
4472
+ def _swap_rb_active(self):
4473
+ """
4474
+ Swap R and B channels in the active RGB document (undoable).
4475
+ Intended for debayer/channel-order mismatches.
4476
+ """
4477
+ dm = getattr(self, "docman", None)
4478
+ if dm is None:
4479
+ return
4480
+
4481
+ try:
4482
+ doc = dm.get_active_document()
4483
+ except Exception:
4484
+ doc = None
4485
+ if doc is None:
4486
+ return
4487
+
4488
+ img = getattr(doc, "image", None)
4489
+ if img is None:
4490
+ return
4491
+
4492
+ import numpy as np
4493
+ x = np.asarray(img)
4494
+
4495
+ # Must be RGB
4496
+ if not (x.ndim == 3 and x.shape[-1] == 3):
4497
+ try:
4498
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4499
+ except Exception:
4500
+ name = "Active"
4501
+
4502
+ if hasattr(self, "_log"):
4503
+ self._log(f"Swap R/B: '{name}' is not RGB (shape={getattr(x,'shape',None)}).")
4504
+ return
4505
+
4506
+ before_shape = x.shape
4507
+ before_dtype = x.dtype
4508
+
4509
+ # swap channels without changing dtype
4510
+ # (copy is safest so we don't mutate shared views)
4511
+ out = x.copy()
4512
+ out[..., 0], out[..., 2] = x[..., 2], x[..., 0]
4513
+
4514
+ # metadata: preserve existing, but annotate operation
4515
+ try:
4516
+ md = dict(getattr(doc, "metadata", None) or {})
4517
+ except Exception:
4518
+ md = {}
4519
+
4520
+ md["color_model"] = md.get("color_model", "RGB")
4521
+ md["channels"] = 3
4522
+ md["is_mono"] = False
4523
+ md["source"] = (md.get("source") or "Edit")
4524
+
4525
+ # If you track op params for history explorer
4526
+ md["__op_params__"] = {
4527
+ "op": "swap_rb",
4528
+ "from_shape": tuple(before_shape),
4529
+ "to_shape": tuple(out.shape),
4530
+ "dtype": str(before_dtype),
4531
+ }
4532
+
4533
+ try:
4534
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4535
+ except Exception:
4536
+ name = "Active"
4537
+
4538
+ try:
4539
+ dm.update_active_document(
4540
+ out,
4541
+ metadata=md,
4542
+ step_name="Swap R ↔ B",
4543
+ doc=doc,
4544
+ )
4545
+
4546
+ if hasattr(self, "_log"):
4547
+ self._log(
4548
+ f"Swap R/B: '{name}' swapped channels "
4549
+ f"(shape={before_shape}, dtype={before_dtype})."
4550
+ )
4551
+
4552
+ except Exception:
4553
+ import traceback
4554
+ try:
4555
+ from PyQt6.QtWidgets import QMessageBox
4556
+ QMessageBox.critical(self, "Swap R/B", traceback.format_exc())
4557
+ except Exception:
4558
+ pass
4559
+
4560
+
4302
4561
  def _on_stackingsuite_relaunch(self, old_dir: str, new_dir: str):
4303
4562
  # Optional: respond to dialog's relaunch request
4304
4563
  try:
@@ -4399,9 +4658,16 @@ class AstroSuiteProMainWindow(
4399
4658
  # Create a callback to set the image back to the document
4400
4659
  def set_image_callback(image_data, step_name):
4401
4660
  """Apply the result image back to the active document."""
4402
- if active_doc and hasattr(active_doc, "set_image"):
4661
+ if active_doc and hasattr(active_doc, "apply_edit"):
4403
4662
  print(f"[AstroSpike] Setting image back to document, shape: {image_data.shape}")
4404
- # Pass metadata as empty dict and step_name separately
4663
+ # Use apply_edit for proper undo/redo integration
4664
+ meta = {
4665
+ "step_name": step_name,
4666
+ "astrospike": True
4667
+ }
4668
+ active_doc.apply_edit(image_data.astype(np.float32, copy=False), metadata=meta, step_name=step_name)
4669
+ elif active_doc and hasattr(active_doc, "set_image"):
4670
+ print(f"[AstroSpike] Setting image via set_image, shape: {image_data.shape}")
4405
4671
  active_doc.set_image(image_data, metadata={}, step_name=step_name)
4406
4672
  elif active_doc and hasattr(active_doc, "image"):
4407
4673
  print(f"[AstroSpike] Setting image directly, shape: {image_data.shape}")
@@ -7888,6 +8154,43 @@ class AstroSuiteProMainWindow(
7888
8154
 
7889
8155
  return t
7890
8156
 
8157
+ def _mdi_begin_open_batch(self, mode: str = "cascade"):
8158
+ self._mdi_open_batch += 1
8159
+ self._mdi_place_mode = mode or "cascade"
8160
+ self._mdi_next_pos = None
8161
+
8162
+ def _mdi_end_open_batch(self):
8163
+ self._mdi_open_batch = max(0, self._mdi_open_batch - 1)
8164
+ if self._mdi_open_batch == 0:
8165
+ self._mdi_next_pos = None
8166
+
8167
+ def _mdi_compute_initial_pos(self) -> QPoint:
8168
+ area = (self.mdi.viewport().geometry() if self.mdi.viewport() else self.mdi.contentsRect())
8169
+ # Put first window a bit inset so titlebars don’t clip
8170
+ return QPoint(area.left() + 18, area.top() + 18)
8171
+
8172
+ def _mdi_place_subwindow(self, sw, target_w: int, target_h: int):
8173
+ """Deterministic placement. Uses a stable cursor during batch opens."""
8174
+ vp = self.mdi.viewport()
8175
+ area = vp.geometry() if vp else self.mdi.contentsRect()
8176
+
8177
+ if self._mdi_next_pos is None:
8178
+ self._mdi_next_pos = self._mdi_compute_initial_pos()
8179
+
8180
+ x = self._mdi_next_pos.x()
8181
+ y = self._mdi_next_pos.y()
8182
+
8183
+ # keep inside viewport; reset when we hit edge
8184
+ if (x + target_w > area.right() - 10) or (y + 40 > area.bottom() - 10):
8185
+ x = area.left() + 18
8186
+ y = area.top() + 18
8187
+
8188
+ sw.move(x, y)
8189
+
8190
+ # advance cursor
8191
+ step = int(self._mdi_cascade_step)
8192
+ self._mdi_next_pos = QPoint(x + step, y + step)
8193
+
7891
8194
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7892
8195
  """
7893
8196
  Open a subwindow for `doc`. If one already exists and force_new=False,
@@ -8061,52 +8364,22 @@ class AstroSuiteProMainWindow(
8061
8364
  target_h = max(200, target_h)
8062
8365
 
8063
8366
  sw.resize(target_w, target_h)
8064
- sw.showNormal() # CRITICAL: clears any "maximized" flag from previous active window
8367
+ sw.showNormal() # clears any "maximized" flag from previous active window
8065
8368
 
8066
- # -------------------------------------------------------------------------
8067
- # Smart Cascade: Position relative to the *currently active* window
8068
- # (before we make the new one active).
8069
- # -------------------------------------------------------------------------
8070
- new_x, new_y = area.left(), area.top()
8071
-
8072
- # Get dominant/active window *before* we activate the new one
8073
- active = self.mdi.activeSubWindow()
8074
- if active and active.isVisible() and not (active.windowState() & Qt.WindowState.WindowMinimized):
8075
- # Cascade from the active window
8076
- geo = active.geometry()
8077
- new_x = geo.x() + 30
8078
- new_y = geo.y() + 30
8079
- else:
8080
- # Fallback: try to find the "last added" visible window to cascade from
8081
- # (useful if active is None but windows exist)
8082
- try:
8083
- subs = [s for s in self.mdi.subWindowList() if s.isVisible() and s is not sw]
8084
- if subs:
8085
- # simplistic "last created" might be at end of list
8086
- last = subs[-1]
8087
- geo = last.geometry()
8088
- new_x = geo.x() + 30
8089
- new_y = geo.y() + 30
8369
+ # Deterministic placement (batch-aware)
8370
+ try:
8371
+ self._mdi_place_subwindow(sw, target_w, target_h)
8372
+ except Exception:
8373
+ # absolute fallback: top-left-ish
8374
+ try:
8375
+ sw.move(area.left() + 18, area.top() + 18)
8090
8376
  except Exception:
8091
8377
  pass
8092
8378
 
8093
- # Bounds check: keep titlebar visible and stay inside viewport
8094
- if (new_x + target_w > area.right() - 10) or (new_y + 40 > area.bottom() - 10):
8095
- new_x = area.left()
8096
- new_y = area.top()
8097
-
8098
- new_x = max(area.left(), new_x)
8099
- new_y = max(area.top(), new_y)
8100
-
8101
- sw.move(new_x, new_y)
8102
-
8103
- # ❌ removed the "fill MDI viewport" block - we *don't* want full-monitor first window
8104
-
8105
8379
  # Show / activate
8106
8380
  sw.show()
8107
8381
  sw.raise_()
8108
8382
  self.mdi.setActiveSubWindow(sw)
8109
- # (no second setWindowTitle() here)
8110
8383
 
8111
8384
  # Optional minimize/restore interceptor
8112
8385
  if hasattr(self, "_minimize_interceptor"):
@@ -8887,6 +9160,13 @@ class AstroSuiteProMainWindow(
8887
9160
 
8888
9161
  super().keyPressEvent(event)
8889
9162
 
9163
+ def _open_texture_clarity(self):
9164
+ try:
9165
+ from setiastro.saspro.texture_clarity import open_texture_clarity_dialog
9166
+ open_texture_clarity_dialog(self)
9167
+ except Exception as e:
9168
+ print(f"Error opening Texture & Clarity: {e}")
9169
+
8890
9170
  def _update_usage_stats(self):
8891
9171
  try:
8892
9172
  now = time.time()
@@ -7,7 +7,7 @@ import os
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  from PyQt6.QtCore import Qt
10
- from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog
10
+ from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog, QApplication
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  pass
@@ -85,7 +85,6 @@ class FileMixin:
85
85
  self._save_recent_lists()
86
86
  if hasattr(self, "_rebuild_recent_menus"):
87
87
  self._rebuild_recent_menus()
88
- # Extracted FILE methods
89
88
 
90
89
  def open_files(self):
91
90
  # One-stop "All Supported" plus focused groups the user can switch to
@@ -114,21 +113,41 @@ class FileMixin:
114
113
  except Exception:
115
114
  pass
116
115
 
117
- # open each path (doc_manager should emit documentAdded; no manual spawn)
118
- for p in paths:
119
- try:
120
- doc = self.docman.open_path(p) # this emits documentAdded
121
- self._log(f"Opened: {p}")
122
- self._add_recent_image(p) # âœ... track in MRU
123
-
124
- # Increment statistics
116
+ # ---- BEGIN batch open (stable placement) ----
117
+ try:
118
+ self._mdi_begin_open_batch(mode="cascade")
119
+ except Exception:
120
+ pass
121
+
122
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
123
+ try:
124
+ # open each path (doc_manager should emit documentAdded; no manual spawn)
125
+ for p in paths:
125
126
  try:
126
- count = self.settings.value("stats/opened_images_count", 0, type=int)
127
- self.settings.setValue("stats/opened_images_count", count + 1)
128
- except Exception:
129
- pass
130
- except Exception as e:
131
- QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
127
+ _ = self.docman.open_path(p) # emits documentAdded; spawn will happen
128
+ self._log(f"Opened: {p}")
129
+ self._add_recent_image(p) # track MRU
130
+
131
+ # Increment statistics
132
+ try:
133
+ count = self.settings.value("stats/opened_images_count", 0, type=int)
134
+ self.settings.setValue("stats/opened_images_count", count + 1)
135
+ except Exception:
136
+ pass
137
+
138
+ # Let Qt paint newly spawned subwindows as we go
139
+ QApplication.processEvents()
140
+
141
+ except Exception as e:
142
+ QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
143
+ QApplication.processEvents()
144
+ finally:
145
+ QApplication.restoreOverrideCursor()
146
+ try:
147
+ self._mdi_end_open_batch()
148
+ except Exception:
149
+ pass
150
+
132
151
 
133
152
  def save_active(self):
134
153
  from setiastro.saspro.main_helpers import (
@@ -197,14 +216,18 @@ class FileMixin:
197
216
  # --- Bit depth selection ----------------------------------------
198
217
  from setiastro.saspro.save_options import SaveOptionsDialog
199
218
  current_bd = doc.metadata.get("bit_depth")
200
- 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)
201
222
  if dlg.exec() != dlg.DialogCode.Accepted:
202
223
  return
224
+
203
225
  chosen_bd = dlg.selected_bit_depth()
226
+ chosen_jq = dlg.selected_jpeg_quality()
204
227
 
205
228
  # --- Save & remember folder ----------------------------------------
206
229
  try:
207
- 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)
208
231
  self._log(f"Saved: {path} ({chosen_bd})")
209
232
  self.settings.setValue("paths/last_save_dir", os.path.dirname(path))
210
233
  except Exception as e:
@@ -121,6 +121,10 @@ class MenuMixin:
121
121
  m_edit = mb.addMenu(self.tr("&Edit"))
122
122
  m_edit.addAction(self.act_undo)
123
123
  m_edit.addAction(self.act_redo)
124
+ m_edit.addSeparator()
125
+ m_edit.addAction(self.act_mono_to_rgb)
126
+ m_edit.addAction(self.act_swap_rb)
127
+
124
128
 
125
129
  # Functions
126
130
  m_fn = mb.addMenu(self.tr("&Functions"))
@@ -151,6 +155,7 @@ class MenuMixin:
151
155
  m_fn.addAction(self.act_signature)
152
156
  m_fn.addAction(self.act_star_stretch)
153
157
  m_fn.addAction(self.act_stat_stretch)
158
+ m_fn.addAction(self.act_texture_clarity)
154
159
  m_fn.addAction(self.act_wavescale_de)
155
160
  m_fn.addAction(self.act_wavescale_hdr)
156
161
  m_fn.addAction(self.act_white_balance)
@@ -170,8 +175,10 @@ class MenuMixin:
170
175
  m_tools.addAction(self.act_freqsep)
171
176
  m_tools.addAction(self.act_image_combine)
172
177
  m_tools.addAction(self.act_multiscale_decomp)
178
+ m_tools.addAction(self.act_narrowband_normalization)
173
179
  m_tools.addAction(self.act_nbtorgb)
174
180
  m_tools.addAction(self.act_ppp)
181
+
175
182
  m_tools.addAction(self.act_selective_color)
176
183
  m_tools.addSeparator()
177
184
  m_tools.addAction(self.act_view_bundles)
@@ -200,6 +207,8 @@ class MenuMixin:
200
207
  m_star.addAction(self.act_isophote)
201
208
  m_star.addAction(self.act_live_stacking)
202
209
  m_star.addAction(self.act_mosaic_master)
210
+ m_star.addAction(self.act_planet_projection)
211
+ m_star.addAction(self.act_planetary_stacker)
203
212
  m_star.addAction(self.act_plate_solve)
204
213
  m_star.addAction(self.act_psf_viewer)
205
214
  m_star.addAction(self.act_rgb_align)