setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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 (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -159,7 +159,7 @@ def _align_prefs(settings: QSettings | None = None) -> dict:
159
159
  prefs = {
160
160
  "model": model, # "affine" | "homography" | "poly3" | "poly4"
161
161
  "max_cp": _get("max_cp", 250, int),
162
- "downsample": _get("downsample", 2, int),
162
+ "downsample": _get("downsample", 3, int),
163
163
  "h_reproj": _get("h_reproj", 3.0, float),
164
164
 
165
165
  # Star detection / solve limits
@@ -631,7 +631,10 @@ class StellarAlignmentDialog(QDialog):
631
631
  self.setWindowFlag(Qt.WindowType.Window, True)
632
632
  self.setWindowModality(Qt.WindowModality.NonModal)
633
633
  self.setModal(False)
634
- #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
634
+ try:
635
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
636
+ except Exception:
637
+ pass # older PyQt6 versions
635
638
 
636
639
  self.settings = settings
637
640
  self.parent_window = parent
@@ -1438,6 +1441,34 @@ class RegistrationWorkerSignals(QObject):
1438
1441
  # Identity transform (2x3)
1439
1442
  IDENTITY_2x3 = np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float64)
1440
1443
 
1444
+ def _to3x3_affine(A2x3: np.ndarray) -> np.ndarray:
1445
+ A = np.asarray(A2x3, np.float64).reshape(2,3)
1446
+ return np.vstack([A, [0,0,1]])
1447
+
1448
+ def _from3x3_affine(A3: np.ndarray) -> np.ndarray:
1449
+ return np.asarray(A3, np.float64)[:2,:]
1450
+
1451
+ def _S(ds: float) -> np.ndarray:
1452
+ ds = float(ds)
1453
+ return np.array([[1.0/ds, 0, 0],
1454
+ [0, 1.0/ds, 0],
1455
+ [0, 0, 1]], np.float64)
1456
+
1457
+ def lift_affine_2x3_from_ds(A_ds_2x3: np.ndarray, ds: float) -> np.ndarray:
1458
+ S = _S(ds); Si = np.linalg.inv(S)
1459
+ A3_full = Si @ _to3x3_affine(A_ds_2x3) @ S
1460
+ return _from3x3_affine(A3_full)
1461
+
1462
+ def downscale_affine_2x3_to_ds(A_full_2x3: np.ndarray, ds: float) -> np.ndarray:
1463
+ S = _S(ds); Si = np.linalg.inv(S)
1464
+ A3_ds = S @ _to3x3_affine(A_full_2x3) @ Si
1465
+ return _from3x3_affine(A3_ds)
1466
+
1467
+ def lift_homography_from_ds(H_ds: np.ndarray, ds: float) -> np.ndarray:
1468
+ S = _S(ds); Si = np.linalg.inv(S)
1469
+ return Si @ np.asarray(H_ds, np.float64) @ S
1470
+
1471
+
1441
1472
  def compute_affine_transform_astroalign_cropped(source_img, reference_img,
1442
1473
  scale: float = 1.20,
1443
1474
  limit_stars: int | None = None,
@@ -1879,31 +1910,34 @@ def project_affine_to_similarity(A2x3: np.ndarray) -> np.ndarray:
1879
1910
  def _solve_delta_job(args):
1880
1911
  """
1881
1912
  Worker: compute incremental affine/similarity delta for one frame against the ref preview.
1882
- args = (orig_path, current_transform_2x3, ref_small, Wref, Href,
1883
- resample_flag, det_sigma, limit_stars, minarea,
1884
- model, h_reproj)
1913
+ args =
1914
+ (orig_path, current_transform_2x3,
1915
+ ref_small_ds, Wref_ds, Href_ds,
1916
+ resample_flag, det_sigma, limit_stars, minarea,
1917
+ model, h_reproj, ds)
1885
1918
  """
1886
1919
  try:
1887
1920
  import os
1888
1921
  import numpy as np
1889
1922
  import cv2
1890
- import sep
1891
1923
  from astropy.io import fits
1892
1924
 
1893
- (orig_path, current_transform_2x3, ref_small, Wref, Href,
1925
+ (orig_path, current_transform_2x3,
1926
+ ref_small_ds, Wref_ds, Href_ds,
1894
1927
  resample_flag, det_sigma, limit_stars, minarea,
1895
- model, h_reproj) = args
1928
+ model, h_reproj, ds) = args
1929
+
1930
+ ds = max(1, int(ds))
1896
1931
 
1897
1932
  try:
1898
1933
  cv2.setNumThreads(1)
1899
1934
  try: cv2.ocl.setUseOpenCL(False)
1900
- except Exception as e:
1901
- import logging
1902
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1935
+ except Exception:
1936
+ pass
1903
1937
  except Exception:
1904
1938
  pass
1905
1939
 
1906
- # 1) read → gray float32
1940
+ # 1) read → gray float32 (full)
1907
1941
  with fits.open(orig_path, memmap=True) as hdul:
1908
1942
  arr = hdul[0].data
1909
1943
  if arr is None:
@@ -1911,48 +1945,66 @@ def _solve_delta_job(args):
1911
1945
  gray = arr if arr.ndim == 2 else np.mean(arr, axis=2)
1912
1946
  gray = np.nan_to_num(gray, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
1913
1947
 
1914
- # 2) pre-warp to REF size
1915
- T_prev = np.asarray(current_transform_2x3, np.float32).reshape(2, 3)
1916
- src_for_match = cv2.warpAffine(
1917
- gray, T_prev, (Wref, Href),
1948
+ # 2) downsample source to DS space
1949
+ if ds > 1:
1950
+ Wds = max(1, int(gray.shape[1] // ds))
1951
+ Hds = max(1, int(gray.shape[0] // ds))
1952
+ gray_ds = cv2.resize(gray, (Wds, Hds), interpolation=cv2.INTER_AREA)
1953
+ else:
1954
+ gray_ds = gray
1955
+
1956
+ # 3) pre-warp in DS space using downscaled transform
1957
+ T_prev_full = np.asarray(current_transform_2x3, np.float64).reshape(2, 3)
1958
+ T_prev_ds = downscale_affine_2x3_to_ds(T_prev_full, ds).astype(np.float32)
1959
+
1960
+ # Warp DS source into DS ref geometry
1961
+ src_for_match_ds = cv2.warpAffine(
1962
+ gray_ds, T_prev_ds, (int(Wref_ds), int(Href_ds)),
1918
1963
  flags=resample_flag, borderMode=cv2.BORDER_REFLECT_101
1919
1964
  )
1920
1965
 
1921
- # 3) denoise sparse islands to stabilize AA
1922
- src_for_match = _suppress_tiny_islands(src_for_match, det_sigma=det_sigma, minarea=minarea)
1923
- ref_small = _suppress_tiny_islands(ref_small, det_sigma=det_sigma, minarea=minarea)
1966
+ # 4) denoise sparse islands in DS space (cheaper)
1967
+ src_for_match_ds = _suppress_tiny_islands(src_for_match_ds, det_sigma=det_sigma, minarea=minarea)
1968
+ ref_for_match_ds = _suppress_tiny_islands(np.asarray(ref_small_ds, np.float32, order="C"),
1969
+ det_sigma=det_sigma, minarea=minarea)
1924
1970
 
1925
- # 4) AA incremental delta on cropped ref
1971
+ # 5) AA delta solve in DS space
1926
1972
  m = (model or "affine").lower()
1927
1973
  if m in ("no_distortion", "nodistortion"):
1928
1974
  m = "similarity"
1929
1975
 
1930
1976
  if m == "similarity":
1931
- tform = compute_similarity_transform_astroalign_cropped(
1932
- src_for_match, ref_small,
1977
+ tform_ds = compute_similarity_transform_astroalign_cropped(
1978
+ src_for_match_ds, ref_for_match_ds,
1933
1979
  limit_stars=int(limit_stars) if limit_stars is not None else None,
1934
1980
  det_sigma=float(det_sigma),
1935
1981
  minarea=int(minarea),
1936
1982
  h_reproj=float(h_reproj)
1937
1983
  )
1938
1984
  else:
1939
- tform = compute_affine_transform_astroalign_cropped(
1940
- src_for_match, ref_small,
1985
+ tform_ds = compute_affine_transform_astroalign_cropped(
1986
+ src_for_match_ds, ref_for_match_ds,
1941
1987
  limit_stars=int(limit_stars) if limit_stars is not None else None,
1942
1988
  det_sigma=float(det_sigma),
1943
1989
  minarea=int(minarea)
1944
1990
  )
1945
1991
 
1946
- if tform is None:
1992
+ if tform_ds is None:
1947
1993
  return (orig_path, None,
1948
1994
  f"Astroalign failed for {os.path.basename(orig_path)} – skipping (no transform returned)")
1949
1995
 
1950
- T_new = np.asarray(tform, np.float64).reshape(2, 3)
1951
- return (orig_path, T_new, None)
1996
+ # 6) lift DS delta back to full-res coords
1997
+ T_new_full = lift_affine_2x3_from_ds(np.asarray(tform_ds, np.float64).reshape(2, 3), ds)
1998
+
1999
+ return (orig_path, np.asarray(T_new_full, np.float64).reshape(2, 3), None)
1952
2000
 
1953
2001
  except Exception as e:
2002
+ try:
2003
+ base = os.path.basename(args[0]) if args else "<unknown>"
2004
+ except Exception:
2005
+ base = "<unknown>"
1954
2006
  return (args[0] if args else "<unknown>", None,
1955
- f"Astroalign failed for {os.path.basename(args[0]) if args else '<unknown>'}: {e}")
2007
+ f"Astroalign failed for {base}: {e}")
1956
2008
 
1957
2009
 
1958
2010
 
@@ -2036,7 +2088,7 @@ def _suppress_tiny_islands(img32: np.ndarray, det_sigma: float, minarea: int) ->
2036
2088
  # ─────────────────────────────────────────────────────────────
2037
2089
  def _finalize_write_job(args):
2038
2090
  """
2039
- Process-safe worker: read full-res, compute/choose model, warp, save.
2091
+ Process-safe worker: read full-res, choose model, warp, save.
2040
2092
  Returns (orig_path, out_path or "", msg, success, drizzle_tuple or None)
2041
2093
  drizzle_tuple = (kind, matrix_or_None)
2042
2094
  """
@@ -2057,17 +2109,19 @@ def _finalize_write_job(args):
2057
2109
  try:
2058
2110
  cv2.setNumThreads(1)
2059
2111
  try: cv2.ocl.setUseOpenCL(False)
2060
- except Exception as e:
2061
- import logging
2062
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
2112
+ except Exception:
2113
+ pass
2063
2114
  except Exception:
2064
2115
  pass
2065
2116
 
2066
2117
  debug_lines = []
2067
2118
  def dbg(s: str):
2068
- # keep it short-ish; UI emits each line
2069
2119
  debug_lines.append(str(s))
2070
2120
 
2121
+ def _A3(A2x3):
2122
+ A = np.asarray(A2x3, np.float64).reshape(2, 3)
2123
+ return np.vstack([A, [0, 0, 1]])
2124
+
2071
2125
  try:
2072
2126
  # 1) load source (full-res)
2073
2127
  with fits.open(orig_path, memmap=True) as hdul:
@@ -2076,12 +2130,12 @@ def _finalize_write_job(args):
2076
2130
  if img is None:
2077
2131
  return (orig_path, "", f"⚠️ Failed to read {os.path.basename(orig_path)}", False, None)
2078
2132
 
2079
- # Fix for white images: Normalize integer types to [0,1]
2133
+ # normalize ints
2080
2134
  if img.dtype == np.uint16:
2081
2135
  img = img.astype(np.float32) / 65535.0
2082
2136
  elif img.dtype == np.uint8:
2083
2137
  img = img.astype(np.float32) / 255.0
2084
-
2138
+
2085
2139
  is_mono = (img.ndim == 2)
2086
2140
  src_gray_full = img if is_mono else np.mean(img, axis=2)
2087
2141
  src_gray_full = np.nan_to_num(src_gray_full, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
@@ -2090,40 +2144,61 @@ def _finalize_write_job(args):
2090
2144
 
2091
2145
  Href, Wref = ref_shape
2092
2146
 
2093
- # 2) load reference via memmap
2147
+ # 2) load reference (full-res) via memmap
2094
2148
  ref2d = np.load(ref_npy_path, mmap_mode="r").astype(np.float32, copy=False)
2095
2149
  if ref2d.shape[:2] != (Href, Wref):
2096
2150
  return (orig_path, "", f"⚠️ Ref shape mismatch for {os.path.basename(orig_path)}", False, None)
2097
2151
 
2098
2152
  base = os.path.basename(orig_path)
2099
2153
 
2100
- # helper: force affine to similarity (no shear)
2101
- def _affine_to_similarity(A2x3: np.ndarray) -> np.ndarray:
2102
- A2x3 = np.asarray(A2x3, np.float64).reshape(2, 3)
2103
- R = A2x3[:, :2]
2104
- t = A2x3[:, 2]
2105
- U, S, Vt = np.linalg.svd(R)
2106
- rot = U @ Vt
2107
- if np.linalg.det(rot) < 0:
2108
- U[:, -1] *= -1
2109
- rot = U @ Vt
2110
- s = float((S[0] + S[1]) * 0.5)
2111
- Rsim = rot * s
2112
- out = np.zeros((2, 3), dtype=np.float64)
2113
- out[:, :2] = Rsim
2114
- out[:, 2] = t
2115
- return out
2116
-
2117
- # 3) choose transform
2118
2154
  model = (align_model or "affine").lower()
2119
2155
  if model in ("no_distortion", "nodistortion"):
2120
2156
  model = "similarity"
2121
2157
 
2158
+ # Base (accumulated) affine from refinement
2159
+ A_prev = np.asarray(affine_2x3, np.float64).reshape(2, 3)
2160
+ A_prev3 = _A3(A_prev)
2161
+
2162
+ # Default finalize is just the affine refinement result
2122
2163
  kind = "affine"
2123
- X = np.asarray(affine_2x3, np.float64).reshape(2, 3)
2164
+ X = A_prev.copy()
2124
2165
 
2166
+ # ---- Non-affine finalize: DS solve + lift, but KEEP affine-as-start ----
2125
2167
  if model != "affine":
2126
- # ---- AA pairs (adaptive tiling) ----
2168
+ dbg(f"[finalize] base={base} model={model} det_sigma={det_sigma} minarea={minarea} limit_stars={limit_stars}")
2169
+
2170
+ ds = 2 # ✅ keep simple/safe; only DS+lift change requested
2171
+ ds = max(1, int(ds))
2172
+
2173
+ # DS reference
2174
+ if ds > 1:
2175
+ ref_ds = cv2.resize(ref2d, (max(1, Wref // ds), max(1, Href // ds)), interpolation=cv2.INTER_AREA)
2176
+ else:
2177
+ ref_ds = np.ascontiguousarray(ref2d)
2178
+
2179
+ ref_ds = np.ascontiguousarray(ref_ds.astype(np.float32, copy=False))
2180
+ Hds, Wds = ref_ds.shape[:2]
2181
+
2182
+ # DS source
2183
+ if ds > 1:
2184
+ src_ds0 = cv2.resize(src_gray_full, (Wds, Hds), interpolation=cv2.INTER_AREA)
2185
+ else:
2186
+ src_ds0 = cv2.resize(src_gray_full, (Wds, Hds), interpolation=cv2.INTER_AREA) if (src_gray_full.shape[:2] != (Hds, Wds)) else src_gray_full
2187
+
2188
+ src_ds0 = np.ascontiguousarray(src_ds0.astype(np.float32, copy=False))
2189
+
2190
+ # Pre-warp source in DS space using downscaled accumulated affine
2191
+ A_prev_ds = downscale_affine_2x3_to_ds(A_prev, ds).astype(np.float32)
2192
+ src_pre_ds = cv2.warpAffine(
2193
+ src_ds0, A_prev_ds, (Wds, Hds),
2194
+ flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT_101
2195
+ )
2196
+
2197
+ # Optional suppress tiny islands (your existing helper)
2198
+ src_pre_ds = _suppress_tiny_islands(src_pre_ds, det_sigma=float(det_sigma), minarea=int(minarea))
2199
+ ref_ds = _suppress_tiny_islands(ref_ds, det_sigma=float(det_sigma), minarea=int(minarea))
2200
+
2201
+ # AA correspondences in DS space: prewarped src vs ref
2127
2202
  max_cp = None
2128
2203
  try:
2129
2204
  if limit_stars is not None and int(limit_stars) > 0:
@@ -2131,13 +2206,10 @@ def _finalize_write_job(args):
2131
2206
  except Exception:
2132
2207
  max_cp = None
2133
2208
 
2134
- dbg(f"[finalize] base={base} model={model} det_sigma={det_sigma} minarea={minarea} limit_stars={limit_stars}")
2135
-
2136
- AA_SCALE = 0.80 # finalize-only
2209
+ AA_SCALE = 0.80
2137
2210
 
2138
- # ---- tiles=1 (center crop) ----
2139
2211
  src_xy, tgt_xy, best_P, best_xy0 = _aa_find_pairs_multitile(
2140
- src_gray_full, ref2d,
2212
+ src_pre_ds, ref_ds,
2141
2213
  scale=AA_SCALE,
2142
2214
  tiles=1,
2143
2215
  det_sigma=float(det_sigma),
@@ -2145,143 +2217,72 @@ def _finalize_write_job(args):
2145
2217
  max_control_points=max_cp,
2146
2218
  _dbg=dbg
2147
2219
  )
2148
-
2149
2220
  if src_xy is None or len(src_xy) < 8:
2150
- dbg("[AA] tiles=1 too few matches")
2151
- raise RuntimeError("astroalign produced too few matches")
2152
-
2153
- dbg(f"[AA] tiles=1 matches={len(src_xy)} best_tile_xy0={best_xy0}")
2154
-
2155
- spread_ok1 = _points_spread_ok(tgt_xy, Wref, Href, _dbg=dbg)
2156
- dbg(f"[AA] spread_ok(tiles=1)={spread_ok1}")
2157
-
2158
- # ---- fallback: tiles=5 (corners + center) ----
2159
- if not spread_ok1:
2160
- src_xy5, tgt_xy5, best_P5, best_xy0_5 = _aa_find_pairs_multitile(
2161
- src_gray_full, ref2d,
2162
- scale=AA_SCALE,
2163
- tiles=5, # <-- NEW primary fallback
2164
- det_sigma=float(det_sigma),
2165
- minarea=int(minarea),
2166
- max_control_points=max_cp,
2167
- _dbg=dbg
2168
- )
2169
-
2170
- if src_xy5 is None or len(src_xy5) < 8:
2171
- dbg("[AA] tiles=5 too few matches; keeping tiles=1")
2172
- else:
2173
- dbg(f"[AA] tiles=5 matches={len(src_xy5)} best_tile_xy0={best_xy0_5}")
2174
- spread_ok5 = _points_spread_ok(tgt_xy5, Wref, Href, _dbg=dbg)
2175
- dbg(f"[AA] spread_ok(tiles=5)={spread_ok5}")
2176
-
2177
- # choose tiles=5 if it spreads better OR gives more matches
2178
- if spread_ok5 or len(src_xy5) > len(src_xy):
2179
- dbg("[AA] switching to tiles=5 result")
2180
- src_xy, tgt_xy = src_xy5, tgt_xy5
2181
- best_P, best_xy0 = best_P5, best_xy0_5
2182
- else:
2183
- dbg("[AA] keeping tiles=1 result (tiles=5 not better)")
2184
-
2185
- # ---- tertiary fallback: tiles=3 grid ----
2186
- spread_ok_after = _points_spread_ok(tgt_xy, Wref, Href, _dbg=dbg)
2187
- dbg(f"[AA] spread_ok(after tiles=5 check)={spread_ok_after}")
2188
-
2189
- if not spread_ok_after:
2190
- src_xy3, tgt_xy3, best_P3, best_xy0_3 = _aa_find_pairs_multitile(
2191
- src_gray_full, ref2d,
2192
- scale=AA_SCALE,
2193
- tiles=3,
2194
- det_sigma=float(det_sigma),
2195
- minarea=int(minarea),
2196
- max_control_points=max_cp,
2197
- _dbg=dbg
2198
- )
2199
-
2200
- if src_xy3 is None or len(src_xy3) < 8:
2201
- dbg("[AA] tiles=3 too few matches; keeping current result")
2202
- else:
2203
- dbg(f"[AA] tiles=3 matches={len(src_xy3)} best_tile_xy0={best_xy0_3}")
2204
- spread_ok3 = _points_spread_ok(tgt_xy3, Wref, Href, _dbg=dbg)
2205
- dbg(f"[AA] spread_ok(tiles=3)={spread_ok3}")
2206
-
2207
- if spread_ok3 or len(src_xy3) > len(src_xy):
2208
- dbg("[AA] switching to tiles=3 result")
2209
- src_xy, tgt_xy = src_xy3, tgt_xy3
2210
- best_P, best_xy0 = best_P3, best_xy0_3
2211
- else:
2212
- dbg("[AA] keeping current result (tiles=3 not better)")
2213
-
2214
- x0, y0 = best_xy0
2215
- P = np.asarray(best_P, np.float64)
2216
-
2217
- # ---- base full-ref from best_P + best_xy0 ----
2218
- if P.shape == (3, 3):
2219
- base_kind0 = "homography"
2220
- T = np.array([[1,0,x0],[0,1,y0],[0,0,1]], dtype=np.float64)
2221
- base_X0 = T @ P
2222
- else:
2223
- base_kind0 = "affine"
2224
- A3 = np.vstack([P[0:2, :], [0,0,1]])
2225
- T = np.array([[1,0,x0],[0,1,y0],[0,0,1]], dtype=np.float64)
2226
- base_X0 = (T @ A3)[0:2, :]
2221
+ raise RuntimeError("astroalign produced too few matches (finalize)")
2227
2222
 
2223
+ # RANSAC threshold in DS pixels
2228
2224
  hth = float(h_reproj)
2229
2225
 
2230
2226
  if model == "homography":
2231
- H, inl = cv2.findHomography(src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth)
2227
+ # Delta homography maps prewarped -> ref (both in DS coords)
2228
+ H_delta_ds, inl = cv2.findHomography(src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth)
2232
2229
  ninl = int(inl.sum()) if inl is not None else 0
2233
- dbg(f"[RANSAC] homography inliers={ninl}/{len(src_xy)} thr={hth}")
2230
+ dbg(f"[RANSAC] homography delta(DS) inliers={ninl}/{len(src_xy)} thr={hth}")
2234
2231
 
2235
- if H is not None:
2236
- kind, X = "homography", np.asarray(H, np.float64)
2232
+ if H_delta_ds is None:
2233
+ # fallback to just affine refinement
2234
+ kind, X = "affine", A_prev.copy()
2237
2235
  else:
2238
- kind, X = base_kind0, base_X0
2236
+ H_delta_full = lift_homography_from_ds(H_delta_ds, ds)
2237
+ H_final = np.asarray(H_delta_full, np.float64) @ A_prev3
2238
+ kind, X = "homography", H_final
2239
2239
 
2240
2240
  elif model == "similarity":
2241
- A, inl = cv2.estimateAffinePartial2D(src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth)
2241
+ # Delta similarity (affine partial) maps prewarped -> ref in DS coords
2242
+ A_delta_ds, inl = cv2.estimateAffinePartial2D(
2243
+ src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth
2244
+ )
2242
2245
  ninl = int(inl.sum()) if inl is not None else 0
2243
- dbg(f"[RANSAC] similarity inliers={ninl}/{len(src_xy)} thr={hth}")
2246
+ dbg(f"[RANSAC] similarity delta(DS) inliers={ninl}/{len(src_xy)} thr={hth}")
2244
2247
 
2245
- if A is not None:
2246
- kind, X = "similarity", np.asarray(A, np.float64)
2248
+ if A_delta_ds is None:
2249
+ kind, X = "similarity", _project_to_similarity(A_prev)
2247
2250
  else:
2248
- if base_kind0 == "affine":
2249
- kind, X = "similarity", _affine_to_similarity(base_X0)
2250
- else:
2251
- kind, X = base_kind0, base_X0
2252
-
2253
- elif model == "affine":
2254
- kind, X = "affine", np.asarray(affine_2x3, np.float64)
2251
+ A_delta_full = lift_affine_2x3_from_ds(A_delta_ds, ds)
2252
+ # Compose delta prev in affine space
2253
+ A_final3 = _A3(A_delta_full) @ A_prev3
2254
+ A_final = A_final3[:2, :]
2255
+ kind, X = "similarity", _project_to_similarity(A_final)
2255
2256
 
2256
2257
  elif model in ("poly3", "poly4"):
2258
+ # Keep behavior simple: poly fit in FULL coords using pairs from prewarped DS,
2259
+ # then apply as remap on the ORIGINAL image (same as your current poly path).
2260
+ # (If you later want true "poly residual after affine", we can do that safely,
2261
+ # but that is a pattern change beyond DS+lift.)
2257
2262
  order = 3 if model == "poly3" else 4
2258
- cx, cy = _fit_poly_xy(src_xy, tgt_xy, order=order)
2263
+ src_full = (np.asarray(src_xy, np.float32) * float(ds)).astype(np.float32)
2264
+ tgt_full = (np.asarray(tgt_xy, np.float32) * float(ds)).astype(np.float32)
2265
+
2266
+ cx, cy = _fit_poly_xy(src_full, tgt_full, order=order)
2259
2267
  map_x, map_y = _poly_eval_grid(cx, cy, Wref, Href, order=order)
2260
2268
  kind, X = model, (map_x, map_y)
2261
2269
 
2262
2270
  else:
2263
- dbg(f"[AA] unknown model '{model}', falling back to base {base_kind0}")
2264
- kind, X = base_kind0, base_X0
2271
+ # Unknown model -> just write affine refinement
2272
+ kind, X = "affine", A_prev.copy()
2265
2273
 
2266
- # 4) warp
2274
+ # 4) warp full-res
2267
2275
  Hh, Ww = Href, Wref
2268
2276
 
2269
2277
  if kind in ("affine", "similarity"):
2270
2278
  A = np.asarray(X, np.float64).reshape(2, 3)
2271
-
2272
2279
  if is_mono:
2273
- aligned = cv2.warpAffine(
2274
- img, A, (Ww, Hh),
2275
- flags=cv2.INTER_LANCZOS4,
2276
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2277
- )
2280
+ aligned = cv2.warpAffine(img, A, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2281
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2278
2282
  else:
2279
2283
  aligned = np.stack([
2280
- cv2.warpAffine(
2281
- img[..., c], A, (Ww, Hh),
2282
- flags=cv2.INTER_LANCZOS4,
2283
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2284
- )
2284
+ cv2.warpAffine(img[..., c], A, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2285
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2285
2286
  for c in range(img.shape[2])
2286
2287
  ], axis=2)
2287
2288
 
@@ -2290,34 +2291,27 @@ def _finalize_write_job(args):
2290
2291
 
2291
2292
  elif kind == "homography":
2292
2293
  Hm = np.asarray(X, np.float64).reshape(3, 3)
2293
-
2294
2294
  if is_mono:
2295
- aligned = cv2.warpPerspective(
2296
- img, Hm, (Ww, Hh),
2297
- flags=cv2.INTER_LANCZOS4,
2298
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2299
- )
2295
+ aligned = cv2.warpPerspective(img, Hm, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2296
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2300
2297
  else:
2301
2298
  aligned = np.stack([
2302
- cv2.warpPerspective(
2303
- img[..., c], Hm, (Ww, Hh),
2304
- flags=cv2.INTER_LANCZOS4,
2305
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2306
- )
2299
+ cv2.warpPerspective(img[..., c], Hm, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2300
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2307
2301
  for c in range(img.shape[2])
2308
2302
  ], axis=2)
2309
2303
 
2310
2304
  drizzle_tuple = ("homography", Hm.astype(np.float64))
2311
2305
  warp_label = "homography"
2312
2306
 
2313
- elif kind in ("poly3","poly4"):
2307
+ elif kind in ("poly3", "poly4"):
2314
2308
  map_x, map_y = X
2315
2309
  if is_mono:
2316
2310
  aligned = cv2.remap(img, map_x, map_y, cv2.INTER_LANCZOS4,
2317
2311
  borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2318
2312
  else:
2319
2313
  aligned = np.stack([
2320
- cv2.remap(img[...,c], map_x, map_y, cv2.INTER_LANCZOS4,
2314
+ cv2.remap(img[..., c], map_x, map_y, cv2.INTER_LANCZOS4,
2321
2315
  borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2322
2316
  for c in range(img.shape[2])
2323
2317
  ], axis=2)
@@ -2378,14 +2372,10 @@ class StarRegistrationWorker(QRunnable):
2378
2372
 
2379
2373
  def run(self):
2380
2374
  """
2381
- Affine:
2382
- - Apply current transform to a preview-sized image
2383
- - Solve incremental delta vs reference preview
2384
- - Emit the incremental delta (2x3) keyed by ORIGINAL path
2385
-
2386
- Non-affine (homography/poly3/4):
2387
- - This QRunnable does not try to do residuals; it just reports and emits identity.
2388
- The multi-process residual pass is handled by StarRegistrationThread.
2375
+ Refinement worker ALWAYS computes incremental deltas in affine/similarity space,
2376
+ even if the FINAL requested model is homography/poly3/poly4.
2377
+
2378
+ The final non-affine model (if any) is applied in _finalize_write_job only.
2389
2379
  """
2390
2380
  try:
2391
2381
  _cap_native_threads_once()
@@ -2411,21 +2401,19 @@ class StarRegistrationWorker(QRunnable):
2411
2401
  return
2412
2402
  Href, Wref = ref_small.shape[:2]
2413
2403
 
2414
- model = (self.model_name or "affine").lower()
2415
-
2416
- # --- Non-affine: don't accumulate here; identity + progress line only
2417
- if model in ("homography", "poly3", "poly4"):
2418
- self.signals.progress.emit(
2419
- f"Residual-only mode for {os.path.basename(self.original_file)} (model={model}); "
2420
- "emitting identity transform (handled by thread pass)."
2421
- )
2422
- self.signals.result_transform.emit(os.path.normpath(self.original_file), IDENTITY_2x3.copy())
2423
- self.signals.result.emit(self.original_file)
2424
- return
2404
+ # ✅ Refinement solve model: always affine or similarity
2405
+ model_req = (self.model_name or "affine").lower()
2406
+ if model_req in ("no_distortion", "nodistortion", "similarity"):
2407
+ refine_model = "similarity"
2408
+ else:
2409
+ refine_model = "affine" # includes when final requested is homography/poly*
2425
2410
 
2426
- # --- Affine incremental
2427
2411
  T_prev = np.array(self.current_transform, dtype=np.float32).reshape(2, 3)
2428
- use_warp = not np.allclose(T_prev, np.array([[1,0,0],[0,1,0]], dtype=np.float32), rtol=1e-5, atol=1e-5)
2412
+ use_warp = not np.allclose(
2413
+ T_prev,
2414
+ np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float32),
2415
+ rtol=1e-5, atol=1e-5
2416
+ )
2429
2417
 
2430
2418
  if use_warp and cv2 is not None:
2431
2419
  src_for_match = cv2.warpAffine(
@@ -2439,9 +2427,21 @@ class StarRegistrationWorker(QRunnable):
2439
2427
  src_for_match = gray_small
2440
2428
 
2441
2429
  try:
2442
- transform = self.compute_affine_transform_astroalign(
2443
- src_for_match, ref_small, limit_stars=getattr(self, "limit_stars", None)
2444
- )
2430
+ if refine_model == "similarity":
2431
+ transform = compute_similarity_transform_astroalign_cropped(
2432
+ src_for_match, ref_small,
2433
+ limit_stars=getattr(self, "limit_stars", None),
2434
+ det_sigma=getattr(self, "det_sigma", 12.0),
2435
+ minarea=getattr(self, "minarea", 10),
2436
+ h_reproj=getattr(self, "h_reproj", 3.0),
2437
+ )
2438
+ else:
2439
+ transform = self.compute_affine_transform_astroalign(
2440
+ src_for_match, ref_small,
2441
+ limit_stars=getattr(self, "limit_stars", None),
2442
+ det_sigma=getattr(self, "det_sigma", 12.0),
2443
+ minarea=getattr(self, "minarea", 10),
2444
+ )
2445
2445
  except Exception as e:
2446
2446
  msg = str(e)
2447
2447
  base = os.path.basename(self.original_file)
@@ -2457,19 +2457,22 @@ class StarRegistrationWorker(QRunnable):
2457
2457
  return
2458
2458
 
2459
2459
  transform = np.array(transform, dtype=np.float64).reshape(2, 3)
2460
+
2461
+ # Similarity projection safety (no shear)
2462
+ if refine_model == "similarity":
2463
+ transform = _project_to_similarity(transform)
2464
+
2460
2465
  key = os.path.normpath(self.original_file)
2461
2466
  self.signals.result_transform.emit(key, transform)
2462
2467
  self.signals.progress.emit(
2463
2468
  f"Astroalign delta for {os.path.basename(self.original_file)} "
2464
- f"(model={self.model_name}): dx={transform[0, 2]:.2f}, dy={transform[1, 2]:.2f}"
2469
+ f"(refine={refine_model}, final={self.model_name}): dx={transform[0,2]:.2f}, dy={transform[1,2]:.2f}"
2465
2470
  )
2466
2471
  self.signals.result.emit(self.original_file)
2467
2472
 
2468
2473
  except Exception as e:
2469
2474
  self.signals.error.emit(f"Error processing {self.original_file}: {e}")
2470
2475
 
2471
-
2472
-
2473
2476
  @staticmethod
2474
2477
  def compute_affine_transform_astroalign(source_img, reference_img,
2475
2478
  scale=1.20,
@@ -2619,7 +2622,7 @@ class StarRegistrationThread(QThread):
2619
2622
  self.det_sigma = float(self.align_prefs.get("det_sigma", 12.0))
2620
2623
  self.limit_stars = int(self.align_prefs.get("limit_stars", 500))
2621
2624
  self.minarea = int(self.align_prefs.get("minarea", 10))
2622
- self.downsample = int(self.align_prefs.get("downsample", 2))
2625
+ self.downsample = int(self.align_prefs.get("downsample", 3))
2623
2626
  self.drizzle_xforms = {} # {orig_norm_path: (kind, matrix)}
2624
2627
 
2625
2628
  @staticmethod
@@ -2969,23 +2972,37 @@ class StarRegistrationThread(QThread):
2969
2972
 
2970
2973
  # ✂️ No DAO/RANSAC: astroalign handles detection internally.
2971
2974
 
2972
- # Single shared downsampled ref for workers
2973
- #ds = max(1, int(self.align_prefs.get("downsample", 2)))
2974
- #if ds > 1:
2975
- # new_hw = (max(1, ref2d.shape[1] // ds), max(1, ref2d.shape[0] // ds)) # (W, H)
2976
- # ref_small = cv2.resize(ref2d, new_hw, interpolation=cv2.INTER_AREA)
2977
- #else:
2978
- # ref_small = ref2d
2979
- #self.ref_small = np.ascontiguousarray(ref_small.astype(np.float32))
2980
- self.ref_small = np.ascontiguousarray(ref2d.astype(np.float32))
2975
+ # --- Build shared ref at full + downsampled solve-res ---
2976
+ self.ref_small_full = np.ascontiguousarray(ref2d.astype(np.float32, copy=False))
2977
+
2978
+ # Use existing preference key you already have: self.downsample
2979
+ # (you load it in __init__: self.downsample = int(self.align_prefs.get("downsample", 2)))
2980
+ ds = max(1, int(self.downsample))
2981
+ self.solve_downsample = ds
2982
+
2983
+ if ds > 1 and cv2 is not None:
2984
+ new_hw = (max(1, ref2d.shape[1] // ds), max(1, ref2d.shape[0] // ds)) # (W, H)
2985
+ ref_ds = cv2.resize(self.ref_small_full, new_hw, interpolation=cv2.INTER_AREA)
2986
+ else:
2987
+ ref_ds = self.ref_small_full
2988
+
2989
+ self.ref_small = self.ref_small_full # keep existing attribute name (full)
2990
+ self.ref_small_ds = np.ascontiguousarray(ref_ds.astype(np.float32, copy=False))
2981
2991
 
2982
2992
  # Initialize transforms to identity for EVERY original frame
2983
2993
  self.alignment_matrices = {os.path.normpath(f): IDENTITY_2x3.copy() for f in self.original_files}
2984
2994
  self.delta_transforms = {}
2985
2995
 
2986
2996
  # Progress totals (units = number of worker completions across passes)
2997
+ # Progress totals:
2998
+ # passes = N * passes
2999
+ # finalize = N
3000
+ N = len(self.original_files)
3001
+ P = max(1, int(self.max_refinement_passes))
3002
+
2987
3003
  self._done = 0
2988
- self._total = len(self.original_files) * max(1, int(self.max_refinement_passes))
3004
+ self._total = (N * P) + N # <-- IMPORTANT: include finalize
3005
+ self.progress_step.emit(self._done, self._total) # optional but helps UI reset immediately
2989
3006
 
2990
3007
  # Registration passes (compute deltas only)
2991
3008
  for pass_idx in range(self.max_refinement_passes):
@@ -3027,109 +3044,30 @@ class StarRegistrationThread(QThread):
3027
3044
  def run_one_registration_pass(self, _ref_stars_unused, _ref_triangles_unused, pass_index):
3028
3045
  _cap_native_threads_once()
3029
3046
  import os
3030
- import shutil
3031
- import tempfile
3032
3047
  import cv2
3048
+ import time
3033
3049
 
3034
- model = (self.align_model or "affine").lower()
3035
- ref_small = np.ascontiguousarray(self.ref_small.astype(np.float32, copy=False))
3036
- Href, Wref = ref_small.shape[:2]
3050
+ # Requested final model (used ONLY in finalize)
3051
+ final_model = (self.align_model or "affine").lower()
3037
3052
 
3038
- # --- Build reverse map: current_path -> original_key (handles bin2-upscale / rewrites)
3053
+ # Refinement model: affine or similarity only
3054
+ if final_model in ("no_distortion", "nodistortion", "similarity"):
3055
+ refine_model = "similarity"
3056
+ else:
3057
+ refine_model = "affine"
3058
+
3059
+ ref_small_ds = np.ascontiguousarray(self.ref_small_ds.astype(np.float32, copy=False))
3060
+ Href_ds, Wref_ds = ref_small_ds.shape[:2]
3061
+ ds = max(1, int(getattr(self, "solve_downsample", 1)))
3062
+
3063
+ # --- reverse map: current_path -> original_key
3039
3064
  rev_current_to_orig = {}
3040
3065
  for orig_k, curr_p in self.file_key_to_current_path.items():
3041
3066
  rev_current_to_orig[os.path.normpath(curr_p)] = os.path.normpath(orig_k)
3042
3067
 
3043
- # ---------- NON-AFFINE PATH: residuals-only ----------
3044
- if model in ("homography", "poly3", "poly4"):
3045
- work_list = list(self.original_files)
3046
-
3047
- from concurrent.futures import ProcessPoolExecutor, as_completed
3048
- procs = max(2, min((os.cpu_count() or 8), 32))
3049
- self.progress_update.emit(f"Using {procs} processes to measure residuals (model={model}).")
3050
-
3051
- tmpdir = tempfile.mkdtemp(prefix="sas_resid_")
3052
- ref_npy = os.path.join(tmpdir, "ref_small.npy")
3053
- try:
3054
- np.save(ref_npy, ref_small)
3055
- except Exception as e:
3056
- try: shutil.rmtree(tmpdir, ignore_errors=True)
3057
- except Exception as e:
3058
- import logging
3059
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
3060
- self.on_worker_error(f"Failed to persist residual reference: {e}")
3061
- return False, "Residual pass aborted."
3062
-
3063
- pass_deltas = []
3064
- try:
3065
-
3066
- import time
3067
-
3068
- jobs = [
3069
- (p, ref_npy, model, self.h_reproj, self.det_sigma, self.minarea, self.limit_stars)
3070
- for p in work_list
3071
- ]
3072
- total = len(jobs)
3073
- done = 0
3074
-
3075
- self.progress_update.emit(f"Using {procs} processes to measure residuals (model={model}).")
3076
- self.progress_step.emit(0, total)
3077
-
3078
- with _make_executor(procs) as ex:
3079
- pending = {ex.submit(_residual_job_worker, j): j[0] for j in jobs}
3080
- last_heartbeat = time.monotonic()
3081
-
3082
- while pending:
3083
- done_set, pending = wait(pending, timeout=0.6, return_when=FIRST_COMPLETED)
3084
- # heartbeat if nothing finished for a bit
3085
- now = time.monotonic()
3086
- if not done_set and (now - last_heartbeat) > 2.0:
3087
- self.progress_update.emit(f"… measuring residuals ({done}/{total} done)")
3088
- last_heartbeat = now
3089
-
3090
- for fut in done_set:
3091
- orig_pth = os.path.normpath(pending.pop(fut, "<unknown>")) if fut in pending else "<unknown>"
3092
- try:
3093
- pth, rms, err = fut.result()
3094
- except Exception as e:
3095
- pth, rms, err = (orig_pth, float("inf"), f"Worker crashed: {e}")
3096
-
3097
- k_orig = os.path.normpath(pth or orig_pth)
3098
- if err:
3099
- self.on_worker_error(f"Residual measure failed for {os.path.basename(k_orig)}: {err}")
3100
- self.delta_transforms[k_orig] = float("inf")
3101
- else:
3102
- self.delta_transforms[k_orig] = float(rms)
3103
- self.progress_update.emit(
3104
- f"[residuals] {os.path.basename(k_orig)} → RMS={rms:.2f}px"
3105
- )
3106
-
3107
- done += 1
3108
- self.progress_step.emit(done, total)
3109
- last_heartbeat = now
3110
-
3111
- for orig in self.original_files:
3112
- pass_deltas.append(self.delta_transforms.get(os.path.normpath(orig), float("inf")))
3113
- self.transform_deltas.append(pass_deltas)
3114
-
3115
- preview = ", ".join([f"{d:.2f}" if np.isfinite(d) else "∞" for d in pass_deltas[:10]])
3116
- if len(pass_deltas) > 10:
3117
- preview += f" … ({len(pass_deltas)} total)"
3118
- self.progress_update.emit(f"Pass {pass_index + 1}: residual RMS px [{preview}]")
3119
-
3120
- aligned_count = sum(1 for d in pass_deltas if np.isfinite(d) and d <= self.shift_tolerance)
3121
- if aligned_count:
3122
- self.progress_update.emit(f"Within tolerance (≤ {self.shift_tolerance:.2f}px): {aligned_count} frame(s)")
3123
- return True, "Residual pass complete."
3124
- finally:
3125
- try: shutil.rmtree(tmpdir, ignore_errors=True)
3126
- except Exception as e:
3127
- import logging
3128
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
3129
-
3130
- # ---------- AFFINE PATH (incremental delta accumulation) ----------
3131
3068
  resample_flag = cv2.INTER_AREA if pass_index == 0 else cv2.INTER_LINEAR
3132
3069
 
3070
+ # Work list: pass 0 all; later passes skip within tolerance
3133
3071
  if pass_index == 0:
3134
3072
  work_list = list(self.original_files)
3135
3073
  else:
@@ -3156,30 +3094,36 @@ class StarRegistrationThread(QThread):
3156
3094
  return True, "Pass complete (nothing to refine)."
3157
3095
 
3158
3096
  procs = max(2, min((os.cpu_count() or 8), 32))
3159
- self.progress_update.emit(f"Using {procs} processes for stellar alignment (HW={os.cpu_count() or 8}).")
3097
+ self.progress_update.emit(f"Using {procs} processes for stellar alignment (refine={refine_model}).")
3160
3098
 
3161
3099
  timeout_sec = int(self.align_prefs.get("timeout_per_job_sec", 300))
3100
+
3162
3101
  jobs = []
3163
3102
  for orig_key in work_list:
3164
3103
  ok = os.path.normpath(orig_key)
3165
- current_path = os.path.normpath(self.file_key_to_current_path.get(ok, ok))
3104
+
3105
+ # IMPORTANT: refinement reads ORIGINAL frame (no intermediate saves)
3106
+ current_path = ok
3107
+
3166
3108
  current_transform = self.alignment_matrices.get(ok, IDENTITY_2x3)
3109
+
3167
3110
  jobs.append((
3168
3111
  current_path,
3169
3112
  current_transform,
3170
- ref_small, Wref, Href,
3171
- resample_flag, float(self.det_sigma), int(self.limit_stars), int(self.minarea),
3172
- model, float(self.h_reproj)
3113
+ ref_small_ds, int(Wref_ds), int(Href_ds),
3114
+ resample_flag, float(self.det_sigma),
3115
+ int(self.limit_stars) if self.limit_stars is not None else None,
3116
+ int(self.minarea),
3117
+ refine_model, float(self.h_reproj),
3118
+ int(ds)
3173
3119
  ))
3174
3120
 
3175
- import time
3176
3121
  executor = _make_executor(procs)
3177
-
3178
3122
  try:
3179
3123
  fut_info, pending = {}, set()
3180
3124
  for j in jobs:
3181
3125
  f = executor.submit(_solve_delta_job, j)
3182
- fut_info[f] = (time.monotonic(), j[0]) # j[0] = current_path
3126
+ fut_info[f] = (time.monotonic(), j[0])
3183
3127
  pending.add(f)
3184
3128
 
3185
3129
  while pending:
@@ -3191,7 +3135,7 @@ class StarRegistrationThread(QThread):
3191
3135
  except Exception as e:
3192
3136
  curr_path_r, T_new, err = (returned_path or "<unknown>", None, f"Worker crashed: {e}")
3193
3137
 
3194
- # Map CURRENT path back to ORIGINAL key for consistent accumulation
3138
+ # Map back to ORIGINAL key
3195
3139
  curr_norm = os.path.normpath(curr_path_r)
3196
3140
  k_orig = rev_current_to_orig.get(curr_norm, curr_norm)
3197
3141
 
@@ -3201,10 +3145,13 @@ class StarRegistrationThread(QThread):
3201
3145
  continue
3202
3146
 
3203
3147
  T_new = np.array(T_new, dtype=np.float64).reshape(2, 3)
3204
- if model in ("no_distortion", "nodistortion", "similarity"):
3205
- T_new = _project_to_similarity(T_new)
3148
+
3149
+ if refine_model == "similarity":
3150
+ T_new = _project_to_similarity(T_new)
3151
+
3206
3152
  self.delta_transforms[k_orig] = float(np.hypot(T_new[0, 2], T_new[1, 2]))
3207
3153
 
3154
+ # Accumulate: T_total = T_new ∘ T_prev
3208
3155
  T_prev = np.array(self.alignment_matrices.get(k_orig, IDENTITY_2x3), dtype=np.float64).reshape(2, 3)
3209
3156
  prev_3 = np.vstack([T_prev, [0, 0, 1]])
3210
3157
  new_3 = np.vstack([T_new, [0, 0, 1]])
@@ -3212,7 +3159,7 @@ class StarRegistrationThread(QThread):
3212
3159
 
3213
3160
  self.on_worker_progress(
3214
3161
  f"Astroalign delta for {os.path.basename(curr_path_r)} "
3215
- f"(model={self.align_model}): dx={T_new[0, 2]:.2f}, dy={T_new[1, 2]:.2f}"
3162
+ f"(refine={refine_model}, final={final_model}): dx={T_new[0,2]:.2f}, dy={T_new[1,2]:.2f}"
3216
3163
  )
3217
3164
  self._increment_progress()
3218
3165
 
@@ -3247,7 +3194,7 @@ class StarRegistrationThread(QThread):
3247
3194
  preview += f" … ({len(pass_deltas)} total)"
3248
3195
  self.progress_update.emit(f"Pass {pass_index + 1} delta shifts: [{preview}]")
3249
3196
  if aligned_count:
3250
- self.progress_update.emit(f"Skipped (delta < {self.shift_tolerance:.2f}px): {aligned_count} frame(s)")
3197
+ self.progress_update.emit(f"Within tolerance ( {self.shift_tolerance:.2f}px): {aligned_count} frame(s)")
3251
3198
  return True, "Pass complete."
3252
3199
  finally:
3253
3200
  try:
@@ -3255,7 +3202,6 @@ class StarRegistrationThread(QThread):
3255
3202
  except Exception:
3256
3203
  pass
3257
3204
 
3258
-
3259
3205
  def on_worker_result_transform(self, persistent_key, new_transform):
3260
3206
  k = os.path.normpath(persistent_key)
3261
3207
  T_new = np.array(new_transform, dtype=np.float64).reshape(2, 3)
@@ -3396,8 +3342,8 @@ class StarRegistrationThread(QThread):
3396
3342
  A = np.asarray(self.alignment_matrices.get(k, IDENTITY_2x3), dtype=np.float64)
3397
3343
 
3398
3344
  # 👉 If non-affine, we pass identity to make workers solve from scratch
3399
- if self.align_model.lower() in ("homography", "poly3", "poly4"):
3400
- A = IDENTITY_2x3.copy()
3345
+ #if self.align_model.lower() in ("homography", "poly3", "poly4"):
3346
+ # A = IDENTITY_2x3.copy()
3401
3347
 
3402
3348
  jobs.append((
3403
3349
  orig_path,
@@ -3423,6 +3369,7 @@ class StarRegistrationThread(QThread):
3423
3369
  orig_path, out_path, msg, success, drizzle = fut.result()
3424
3370
  except Exception as e:
3425
3371
  self.progress_update.emit(f"⚠️ Finalize worker crashed: {e}")
3372
+ self._increment_progress()
3426
3373
  continue
3427
3374
 
3428
3375
  if msg:
@@ -3445,6 +3392,7 @@ class StarRegistrationThread(QThread):
3445
3392
  self.drizzle_xforms[k] = (str(kind), None) # poly3/4
3446
3393
  except Exception:
3447
3394
  pass
3395
+ self._increment_progress()
3448
3396
  finally:
3449
3397
  try: shutil.rmtree(tmpdir, ignore_errors=True)
3450
3398
  except Exception as e:
@@ -4638,11 +4586,12 @@ def load_api_key():
4638
4586
  class MosaicMasterDialog(QDialog):
4639
4587
  def __init__(self, settings: QSettings, parent=None, image_manager=None,
4640
4588
  doc_manager=None, wrench_path=None, spinner_path=None,
4641
- list_open_docs_fn=None): # ← add param
4589
+ list_open_docs_fn=None):
4642
4590
  super().__init__(parent)
4643
4591
  self.settings = settings
4644
4592
  self.image_manager = image_manager
4645
4593
  self._docman = doc_manager or getattr(parent, "doc_manager", None)
4594
+
4646
4595
  # same pattern as StellarAlignmentDialog
4647
4596
  if list_open_docs_fn is None:
4648
4597
  cand = getattr(parent, "_list_open_docs", None)
@@ -4654,19 +4603,22 @@ class MosaicMasterDialog(QDialog):
4654
4603
  self.setWindowFlag(Qt.WindowType.Window, True)
4655
4604
  self.setWindowModality(Qt.WindowModality.NonModal)
4656
4605
  self.setModal(False)
4606
+
4657
4607
  self.wrench_path = wrench_path
4658
4608
  self.spinner_path = spinner_path
4659
4609
 
4660
4610
  self.resize(600, 400)
4661
- self.loaded_images = []
4611
+ self.loaded_images = []
4662
4612
  self.final_mosaic = None
4663
4613
  self.weight_mosaic = None
4664
4614
  self.wcs_metadata = None # To store mosaic WCS header
4665
4615
  self.astap_exe = self.settings.value("paths/astap", "", type=str)
4616
+
4666
4617
  # Variables to store stretching parameters:
4667
4618
  self.stretch_original_mins = []
4668
4619
  self.stretch_original_medians = []
4669
4620
  self.was_single_channel = False
4621
+
4670
4622
  self.initUI()
4671
4623
 
4672
4624
  def initUI(self):
@@ -4687,12 +4639,11 @@ class MosaicMasterDialog(QDialog):
4687
4639
  layout.addWidget(instructions)
4688
4640
 
4689
4641
  btn_layout = QHBoxLayout()
4690
- # Button to add image from disk
4642
+
4691
4643
  add_btn = QPushButton("Add Image")
4692
4644
  add_btn.clicked.connect(self.add_image)
4693
4645
  btn_layout.addWidget(add_btn)
4694
4646
 
4695
- # New button to add an image from one of the ImageManager slots
4696
4647
  add_from_view_btn = QPushButton("Add from View")
4697
4648
  add_from_view_btn.setToolTip("Add an image from any open View")
4698
4649
  add_from_view_btn.clicked.connect(self.add_image_from_view)
@@ -4714,25 +4665,80 @@ class MosaicMasterDialog(QDialog):
4714
4665
  save_btn.clicked.connect(self.save_mosaic_to_new_view)
4715
4666
  btn_layout.addWidget(save_btn)
4716
4667
 
4717
- # Add the wrench button for settings.
4718
4668
  wrench_btn = QPushButton()
4719
- wrench_btn.setIcon(QIcon(self.wrench_path))
4669
+ if self.wrench_path:
4670
+ wrench_btn.setIcon(QIcon(self.wrench_path))
4720
4671
  wrench_btn.setToolTip("Mosaic Settings")
4721
4672
  wrench_btn.clicked.connect(self.openSettings)
4722
4673
  btn_layout.addWidget(wrench_btn)
4723
4674
 
4724
4675
  layout.addLayout(btn_layout)
4725
4676
 
4726
- # Horizontal sizer for checkboxes.
4677
+ # ------------------------------------------------------------------
4678
+ # Mode checkboxes (mutually exclusive: Seestar vs WCS-only)
4679
+ # ------------------------------------------------------------------
4727
4680
  checkbox_layout = QHBoxLayout()
4681
+
4728
4682
  self.forceBlindCheckBox = QCheckBox("Force Blind Solve (ignore existing WCS)")
4729
4683
  checkbox_layout.addWidget(self.forceBlindCheckBox)
4730
- # New Seestar Mode checkbox:
4684
+
4731
4685
  self.seestarCheckBox = QCheckBox("Seestar Mode")
4732
- self.seestarCheckBox.setToolTip("Wwen enabled, images are aligned iteratively using astroalign without plate solving.")
4686
+ self.seestarCheckBox.setToolTip(
4687
+ "When enabled, images are aligned iteratively using astroalign without plate solving."
4688
+ )
4733
4689
  checkbox_layout.addWidget(self.seestarCheckBox)
4690
+
4691
+ self.wcsOnlyCheckBox = QCheckBox("Disable Star Alignment (WCS placement only)")
4692
+ self.wcsOnlyCheckBox.setToolTip(
4693
+ "Skips astroalign + refined alignment.\n"
4694
+ "Panels are only reprojected into the mosaic celestial-sphere frame using WCS, then blended.\n"
4695
+ "Useful when panels have little/no overlap but have valid WCS."
4696
+ )
4697
+ checkbox_layout.addWidget(self.wcsOnlyCheckBox)
4698
+
4734
4699
  layout.addLayout(checkbox_layout)
4735
4700
 
4701
+ # Persisted WCS-only
4702
+ _settings = QSettings("SetiAstro", "SASpro")
4703
+ self.wcsOnlyCheckBox.setChecked(_settings.value("mosaic/wcs_only", False, type=bool))
4704
+
4705
+ # Helpers ----------------------------------------------------------
4706
+ def _set_checked(cb: QCheckBox, checked: bool):
4707
+ cb.blockSignals(True)
4708
+ cb.setChecked(checked)
4709
+ cb.blockSignals(False)
4710
+
4711
+ def _sync_wcs_only_ui():
4712
+ # WCS-only disables transform selection (because refinement is unused)
4713
+ wcs_only = self.wcsOnlyCheckBox.isChecked()
4714
+ if hasattr(self, "transform_combo"):
4715
+ self.transform_combo.setEnabled(not wcs_only)
4716
+ self.transform_combo.setToolTip(
4717
+ "" if not wcs_only else "Disabled because WCS-only placement is enabled."
4718
+ )
4719
+
4720
+ def _on_seestar_changed(state: int):
4721
+ # If Seestar is turned ON, force WCS-only OFF
4722
+ if self.seestarCheckBox.isChecked():
4723
+ _set_checked(self.wcsOnlyCheckBox, False)
4724
+ _sync_wcs_only_ui()
4725
+
4726
+ def _on_wcs_only_changed(state: int):
4727
+ # Persist setting first (this one is user-visible preference)
4728
+ QSettings("SetiAstro", "SASpro").setValue("mosaic/wcs_only", self.wcsOnlyCheckBox.isChecked())
4729
+
4730
+ # If WCS-only is turned ON, force Seestar OFF
4731
+ if self.wcsOnlyCheckBox.isChecked():
4732
+ _set_checked(self.seestarCheckBox, False)
4733
+ _sync_wcs_only_ui()
4734
+
4735
+ # Wire handlers (NO lambdas, no duplicates)
4736
+ self.seestarCheckBox.stateChanged.connect(_on_seestar_changed)
4737
+ self.wcsOnlyCheckBox.stateChanged.connect(_on_wcs_only_changed)
4738
+
4739
+ # ------------------------------------------------------------------
4740
+ # Other controls
4741
+ # ------------------------------------------------------------------
4736
4742
  self.normalizeCheckBox = QCheckBox("Normalize images (median match)")
4737
4743
  self.normalizeCheckBox.setChecked(True)
4738
4744
  layout.addWidget(self.normalizeCheckBox)
@@ -4756,24 +4762,21 @@ class MosaicMasterDialog(QDialog):
4756
4762
  "Precise — Full WCS: astropy.reproject per channel; slowest, most exact."
4757
4763
  )
4758
4764
 
4759
- # Persist user choice
4760
- _settings = QSettings("SetiAstro", "SASpro")
4761
- _default_mode = _settings.value("mosaic/reproject_mode",
4762
- "Fast — SIP-aware (Exact Remap)")
4763
- if _default_mode not in [self.reprojectModeCombo.itemText(i) for i in range(self.reprojectModeCombo.count())]:
4765
+ _default_mode = _settings.value("mosaic/reproject_mode", "Fast — SIP-aware (Exact Remap)")
4766
+ valid_modes = [self.reprojectModeCombo.itemText(i) for i in range(self.reprojectModeCombo.count())]
4767
+ if _default_mode not in valid_modes:
4764
4768
  _default_mode = "Fast — SIP-aware (Exact Remap)"
4765
4769
  self.reprojectModeCombo.setCurrentText(_default_mode)
4766
4770
  self.reprojectModeCombo.currentTextChanged.connect(
4767
4771
  lambda t: QSettings("SetiAstro", "SASpro").setValue("mosaic/reproject_mode", t)
4768
4772
  )
4769
4773
 
4770
- # Add to layout where the old checkbox lived
4771
4774
  row = QHBoxLayout()
4772
4775
  row.addWidget(self.reprojectModeLabel)
4773
4776
  row.addWidget(self.reprojectModeCombo, 1)
4774
4777
  layout.addLayout(row)
4775
4778
 
4776
-
4779
+ # Transform selection
4777
4780
  self.transform_combo = QComboBox()
4778
4781
  self.transform_combo.addItems([
4779
4782
  "Partial Affine Transform",
@@ -4781,11 +4784,14 @@ class MosaicMasterDialog(QDialog):
4781
4784
  "Homography Transform",
4782
4785
  "Polynomial Warp Based Transform"
4783
4786
  ])
4784
- # Set the default selection to "Affine Transform" (index 1)
4785
4787
  self.transform_combo.setCurrentIndex(1)
4786
4788
  layout.addWidget(QLabel("Select Transformation Method:"))
4787
4789
  layout.addWidget(self.transform_combo)
4788
4790
 
4791
+ # Now that transform_combo exists, apply WCS-only UI state
4792
+ _sync_wcs_only_ui()
4793
+
4794
+ # List + status + spinner
4789
4795
  self.images_list = QListWidget()
4790
4796
  self.images_list.setSelectionMode(self.images_list.SelectionMode.SingleSelection)
4791
4797
  layout.addWidget(self.images_list)
@@ -4795,13 +4801,15 @@ class MosaicMasterDialog(QDialog):
4795
4801
 
4796
4802
  self.spinnerLabel = QLabel(self)
4797
4803
  self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
4798
- self.spinnerMovie = QMovie(self.spinner_path)
4799
- self.spinnerLabel.setMovie(self.spinnerMovie)
4800
- self.spinnerLabel.hide()
4804
+ self.spinnerMovie = QMovie(self.spinner_path) if self.spinner_path else None
4805
+ if self.spinnerMovie:
4806
+ self.spinnerLabel.setMovie(self.spinnerMovie)
4807
+ self.spinnerLabel.hide()
4801
4808
  layout.addWidget(self.spinnerLabel)
4802
4809
 
4803
4810
  self.setLayout(layout)
4804
4811
 
4812
+
4805
4813
  def _target_median_from_first(self, items):
4806
4814
  # Pick a stable target (median of first image after safe clipping)
4807
4815
  if not items:
@@ -5348,6 +5356,7 @@ class MosaicMasterDialog(QDialog):
5348
5356
 
5349
5357
  # ---------- Align (Entry Point) ----------
5350
5358
  def align_images(self):
5359
+ # Seestar mode is its own pipeline
5351
5360
  if self.seestarCheckBox.isChecked():
5352
5361
  self.align_images_seestar_mode()
5353
5362
  return
@@ -5358,28 +5367,33 @@ class MosaicMasterDialog(QDialog):
5358
5367
 
5359
5368
  # Show spinner and start animation.
5360
5369
  self.spinnerLabel.show()
5361
- self.spinnerMovie.start()
5370
+ if self.spinnerMovie:
5371
+ self.spinnerMovie.start()
5362
5372
  QApplication.processEvents()
5363
5373
 
5364
- # Step 1: Force blind solve if requested.
5374
+ # ------------------------------------------------------------
5375
+ # 1) Plate solve (unless already solved and not forcing blind)
5376
+ # ------------------------------------------------------------
5365
5377
  force_blind = self.forceBlindCheckBox.isChecked()
5366
- images_to_process = (self.loaded_images if force_blind
5367
- else [item for item in self.loaded_images if item.get("wcs") is None])
5378
+ images_to_process = (
5379
+ self.loaded_images if force_blind
5380
+ else [item for item in self.loaded_images if item.get("wcs") is None]
5381
+ )
5368
5382
 
5369
- # Process each image for plate solving.
5370
5383
  for item in images_to_process:
5371
- # Check if ASTAP is set.
5384
+ # Ensure ASTAP path (or fall back to blind solve)
5372
5385
  if not self.astap_exe or not os.path.exists(self.astap_exe):
5373
5386
  executable_filter = "Executables (*.exe);;All Files (*)" if sys.platform.startswith("win") else "Executables (*)"
5374
5387
  new_path, _ = QFileDialog.getOpenFileName(self, "Select ASTAP Executable", "", executable_filter)
5375
5388
  if new_path:
5376
5389
  self._save_astap_exe_to_settings(new_path)
5390
+ self.astap_exe = new_path
5377
5391
  QMessageBox.information(self, "Mosaic Master", "ASTAP path updated successfully.")
5378
5392
  else:
5379
- QMessageBox.warning(self, "Mosaic Master", "ASTAP path not provided. Falling back to blind solve.")
5393
+ self.status_label.setText(f"No ASTAP path; blind solving {item['path']}...")
5394
+ QApplication.processEvents()
5380
5395
  solved_header = self.perform_blind_solve(item)
5381
5396
  if solved_header:
5382
- # normalize + build WCS with relax=True so SIP is retained
5383
5397
  solved_header = self._normalize_wcs_header(solved_header, item["image"].shape)
5384
5398
  item["wcs"] = self._build_wcs(solved_header, item["image"].shape)
5385
5399
  continue
@@ -5393,49 +5407,53 @@ class MosaicMasterDialog(QDialog):
5393
5407
  self.status_label.setText(f"ASTAP failed for {item['path']}. Falling back to blind solve...")
5394
5408
  QApplication.processEvents()
5395
5409
  solved_header = self.perform_blind_solve(item)
5396
- else:
5397
- self.status_label.setText(f"Plate solve successful using ASTAP for {item['path']}.")
5398
5410
 
5399
5411
  if solved_header:
5400
- # Single, centralized sanitize → keeps SIP and fixes types
5401
5412
  solved_header = self._normalize_wcs_header(solved_header, item["image"].shape)
5402
5413
  item["wcs"] = self._build_wcs(solved_header, item["image"].shape)
5403
5414
  else:
5404
- print(f"Plate solving failed for {item['path']}.")
5415
+ print(f"[Mosaic] Plate solving failed for {item['path']}.")
5405
5416
 
5406
- # After processing, get all images with valid WCS.
5417
+ # ------------------------------------------------------------
5418
+ # 2) Gather WCS-valid panels
5419
+ # ------------------------------------------------------------
5407
5420
  wcs_items = [x for x in self.loaded_images if x.get("wcs") is not None]
5408
5421
  if not wcs_items:
5409
- print("No images have WCS, skipping WCS alignment.")
5410
- self.spinnerMovie.stop()
5422
+ print("[Mosaic] No images have WCS; cannot build WCS mosaic.")
5423
+ if self.spinnerMovie:
5424
+ self.spinnerMovie.stop()
5411
5425
  self.spinnerLabel.hide()
5412
5426
  return
5413
5427
 
5428
+ # ------------------------------------------------------------
5429
+ # 3) Establish mosaic WCS + output bounding box
5430
+ # ------------------------------------------------------------
5431
+ reference_wcs = self._build_wcs(
5432
+ wcs_items[0]["wcs"].to_header(relax=True),
5433
+ wcs_items[0]["image"].shape
5434
+ ).deepcopy()
5414
5435
 
5415
- # Use the first image's WCS as reference and compute the mosaic bounding box.
5416
- # (Rebuild with relax=True just in case, then deepcopy.)
5417
- reference_wcs = self._build_wcs(wcs_items[0]["wcs"].to_header(relax=True), wcs_items[0]["image"].shape).deepcopy()
5418
5436
  min_x, min_y, max_x, max_y = self.compute_mosaic_bounding_box(wcs_items, reference_wcs)
5419
- mosaic_width = int(max_x - min_x)
5437
+ mosaic_width = int(max_x - min_x)
5420
5438
  mosaic_height = int(max_y - min_y)
5421
5439
 
5422
5440
  if mosaic_width < 1 or mosaic_height < 1:
5423
- print("ERROR: Computed mosaic size is invalid. Check WCS or inputs.")
5424
- self.spinnerMovie.stop()
5441
+ print("[Mosaic] ERROR: Computed mosaic size invalid. Check WCS/inputs.")
5442
+ if self.spinnerMovie:
5443
+ self.spinnerMovie.stop()
5425
5444
  self.spinnerLabel.hide()
5426
5445
  return
5427
5446
 
5428
- # Adjust the reference WCS so that (min_x, min_y) becomes (0,0).
5429
5447
  mosaic_wcs = reference_wcs.deepcopy()
5430
5448
  mosaic_wcs.wcs.crpix[0] -= min_x
5431
5449
  mosaic_wcs.wcs.crpix[1] -= min_y
5432
- # keep SIP in the stored header
5433
5450
  self.wcs_metadata = mosaic_wcs.to_header(relax=True)
5434
5451
 
5435
- # Set up accumulators.
5452
+ # ------------------------------------------------------------
5453
+ # 4) Allocate accumulators
5454
+ # ------------------------------------------------------------
5436
5455
  is_color = any(not item["is_mono"] for item in wcs_items)
5437
5456
 
5438
- # stats for optional "unstretch"
5439
5457
  self.stretch_original_mins = []
5440
5458
  self.stretch_original_medians = []
5441
5459
  self.was_single_channel = (not is_color)
@@ -5446,49 +5464,77 @@ class MosaicMasterDialog(QDialog):
5446
5464
  self.final_mosaic, _ = smart_zeros((mosaic_height, mosaic_width), dtype=np.float32)
5447
5465
  self.weight_mosaic, _ = smart_zeros((mosaic_height, mosaic_width), dtype=np.float32)
5448
5466
 
5467
+ # ------------------------------------------------------------
5468
+ # 5) Normalization target — compute ONCE, apply ALWAYS
5469
+ # ------------------------------------------------------------
5470
+ did_normalize = bool(self.normalizeCheckBox.isChecked())
5471
+
5472
+ # IMPORTANT: compute from RAW first panel image, not from dict list in a way that can drift
5473
+ if did_normalize:
5474
+ try:
5475
+ first_raw = wcs_items[0]["image"]
5476
+ self._mosaic_target_median = self._target_median_from_first([{"image": first_raw}])
5477
+ except Exception:
5478
+ # fallback: compute directly
5479
+ a0 = wcs_items[0]["image"].astype(np.float32, copy=False)
5480
+ if a0.ndim == 3:
5481
+ a0m = np.mean(a0, axis=2)
5482
+ else:
5483
+ a0m = a0
5484
+ lo = np.percentile(a0m, 1)
5485
+ hi = np.percentile(a0m, 99)
5486
+ self._mosaic_target_median = float(max(np.median(np.clip(a0m, lo, hi)), 1e-6))
5487
+
5488
+ print(f"[Mosaic] normalization target median = {self._mosaic_target_median:.6g}")
5489
+
5490
+ # Reprojection helpers cache
5491
+ if not hasattr(self, "_H_cache"):
5492
+ self._H_cache = {}
5493
+
5494
+ # WCS-only toggle (no star alignment/refinement)
5495
+ wcs_only = bool(getattr(self, "wcsOnlyCheckBox", None) and self.wcsOnlyCheckBox.isChecked())
5496
+
5497
+ # ------------------------------------------------------------
5498
+ # 6) Main loop: normalize -> reproject -> (optional) star align -> accumulate
5499
+ # ------------------------------------------------------------
5449
5500
  first_image = True
5501
+
5450
5502
  for idx, itm in enumerate(wcs_items):
5451
5503
  arr = itm["image"]
5504
+
5452
5505
  self.status_label.setText(f"Mapping {itm['path']} into mosaic frame...")
5453
5506
  QApplication.processEvents()
5454
5507
 
5455
5508
  img_lin = arr.astype(np.float32, copy=False)
5456
5509
 
5457
- # --- record original stats for optional "unstretch" ---
5510
+ # --- record original stats (pre-normalization) for optional unstretch ---
5458
5511
  mono_for_stats = img_lin if img_lin.ndim == 2 else np.mean(img_lin, axis=2)
5459
5512
  self.stretch_original_mins.append(float(np.min(mono_for_stats)))
5460
5513
  self.stretch_original_medians.append(float(np.median(mono_for_stats)))
5461
5514
 
5462
- # 1) optional median normalization only
5463
- if self.normalizeCheckBox.isChecked():
5464
- target_med = getattr(self, "_mosaic_target_median", None)
5465
- if target_med is None:
5466
- self._mosaic_target_median = self._target_median_from_first(wcs_items)
5467
- target_med = self._mosaic_target_median
5468
- img_lin = self._normalize_linear(img_lin, target_med)
5469
-
5470
- # 2) Reprojection (3 modes)
5471
- if not hasattr(self, "_H_cache"):
5472
- self._H_cache = {}
5515
+ # --- ALWAYS normalize here if enabled (even in WCS-only) ---
5516
+ if did_normalize:
5517
+ img_lin = self._normalize_linear(img_lin, float(self._mosaic_target_median))
5473
5518
 
5519
+ # --- Reprojection mode ---
5474
5520
  mode = self.reprojectModeCombo.currentText()
5475
5521
 
5476
5522
  if mode.startswith("Fast — SIP"):
5477
- # Exact dense remap (SIP-aware), tiled; keep mono as 2D
5478
5523
  reprojected = self._warp_via_wcs_remap_exact(
5479
5524
  img_lin, itm["wcs"], mosaic_wcs, (mosaic_height, mosaic_width), tile=512
5480
- ).astype(np.float32)
5525
+ ).astype(np.float32, copy=False)
5526
+ reprojected = self._polish_reprojected(reprojected)
5481
5527
  reproj_red = reprojected[..., 0] if reprojected.ndim == 3 else reprojected
5482
5528
 
5483
5529
  elif mode.startswith("Fast — Homography"):
5484
- # Single global homography; keep mono as 2D
5485
5530
  reprojected = self._warp_via_wcs_homography(
5486
5531
  img_lin, itm["wcs"], mosaic_wcs, (mosaic_height, mosaic_width), H_cache=self._H_cache
5487
- ).astype(np.float32)
5532
+ ).astype(np.float32, copy=False)
5533
+ reprojected = self._polish_reprojected(reprojected)
5488
5534
  reproj_red = reprojected[..., 0] if reprojected.ndim == 3 else reprojected
5489
5535
 
5490
5536
  else:
5491
- # Precise — Full WCS (astropy.reproject); mono stays 2D
5537
+ # Precise — Full WCS
5492
5538
  if img_lin.ndim == 3:
5493
5539
  channels = []
5494
5540
  for c in range(3):
@@ -5498,52 +5544,49 @@ class MosaicMasterDialog(QDialog):
5498
5544
  reprojected = np.stack(channels, axis=-1)
5499
5545
  reproj_red = reprojected[..., 0]
5500
5546
  else:
5501
- reproj_red, _ = reproject_interp((img_lin, itm["wcs"]), mosaic_wcs,
5502
- shape_out=(mosaic_height, mosaic_width))
5503
- reprojected = np.nan_to_num(reproj_red, nan=0.0).astype(np.float32) # 2D mono
5504
- # no fake stacking here
5505
-
5506
- self.status_label.setText(f"WCS map: {itm['path']} processed.")
5507
- QApplication.processEvents()
5508
-
5509
- # --- Stellar Alignment ---
5510
- if not first_image:
5511
- transform_method = self.transform_combo.currentText()
5512
- mosaic_gray = (self.final_mosaic if self.final_mosaic.ndim == 2
5513
- else np.mean(self.final_mosaic, axis=-1))
5514
- try:
5515
- self.status_label.setText("Computing affine transform with astroalign...")
5516
- QApplication.processEvents()
5517
- transform_obj, (src_pts, dst_pts) = self._aa_find_transform_with_backoff(reproj_red, mosaic_gray)
5518
- transform_matrix = transform_obj.params[0:2, :].astype(np.float32)
5519
- self.status_label.setText("Astroalign computed transform successfully.")
5520
- except Exception as e:
5521
- self.status_label.setText(f"Astroalign failed: {e}. Using identity transform.")
5522
- transform_matrix = np.eye(2, 3, dtype=np.float32)
5547
+ rpj, _ = reproject_interp((img_lin, itm["wcs"]), mosaic_wcs,
5548
+ shape_out=(mosaic_height, mosaic_width))
5549
+ reprojected = np.nan_to_num(rpj, nan=0.0).astype(np.float32)
5550
+ reproj_red = reprojected # 2D
5523
5551
 
5524
- A = transform_matrix[:, :2]
5525
- scale1 = np.linalg.norm(A[:, 0])
5526
- scale2 = np.linalg.norm(A[:, 1])
5527
- print(f"Computed affine scales: {scale1:.6f}, {scale2:.6f}")
5552
+ # --- Optional star alignment/refinement (skipped in WCS-only) ---
5553
+ if wcs_only:
5554
+ aligned = reprojected
5555
+ if first_image:
5556
+ first_image = False
5557
+ else:
5558
+ if first_image:
5559
+ aligned = reprojected
5560
+ first_image = False
5561
+ else:
5562
+ transform_method = self.transform_combo.currentText()
5563
+ mosaic_gray = (self.final_mosaic if self.final_mosaic.ndim == 2
5564
+ else np.mean(self.final_mosaic, axis=-1))
5528
5565
 
5529
- self.status_label.setText("Affine alignment computed. Warping image...")
5530
- QApplication.processEvents()
5531
- affine_aligned = cv2.warpAffine(reprojected, transform_matrix, (mosaic_width, mosaic_height),
5532
- flags=cv2.INTER_LANCZOS4)
5533
- aligned = affine_aligned
5566
+ try:
5567
+ self.status_label.setText("Computing affine transform with astroalign...")
5568
+ QApplication.processEvents()
5569
+ transform_obj, (src_pts, dst_pts) = self._aa_find_transform_with_backoff(reproj_red, mosaic_gray)
5570
+ transform_matrix = transform_obj.params[0:2, :].astype(np.float32)
5571
+ except Exception as e:
5572
+ self.status_label.setText(f"Astroalign failed: {e}. Using identity transform.")
5573
+ transform_matrix = np.eye(2, 3, dtype=np.float32)
5534
5574
 
5535
- if transform_method in ["Homography Transform", "Polynomial Warp Based Transform"]:
5536
- self.status_label.setText(f"Starting refined alignment using {transform_method}...")
5537
- QApplication.processEvents()
5538
- refined_result = self.refined_alignment(affine_aligned, mosaic_gray, method=transform_method)
5539
- if refined_result is not None:
5540
- aligned, best_inliers2 = refined_result
5541
- self.status_label.setText(f"Refined alignment succeeded with {best_inliers2} inliers.")
5542
- else:
5543
- self.status_label.setText("Refined alignment failed; falling back to affine alignment.")
5544
- else:
5545
- aligned = reprojected
5546
- first_image = False
5575
+ affine_aligned = cv2.warpAffine(
5576
+ reprojected, transform_matrix, (mosaic_width, mosaic_height),
5577
+ flags=cv2.INTER_LANCZOS4
5578
+ )
5579
+ aligned = affine_aligned
5580
+
5581
+ if transform_method in ["Homography Transform", "Polynomial Warp Based Transform"]:
5582
+ self.status_label.setText(f"Starting refined alignment using {transform_method}...")
5583
+ QApplication.processEvents()
5584
+ refined_result = self.refined_alignment(affine_aligned, mosaic_gray, method=transform_method)
5585
+ if refined_result is not None:
5586
+ aligned, best_inliers2 = refined_result
5587
+ self.status_label.setText(f"Refined alignment succeeded with {best_inliers2} inliers.")
5588
+ else:
5589
+ self.status_label.setText("Refined alignment failed; falling back to affine alignment.")
5547
5590
 
5548
5591
  # If mosaic is color but aligned is mono, expand for accumulation only
5549
5592
  if is_color and aligned.ndim == 2:
@@ -5551,13 +5594,13 @@ class MosaicMasterDialog(QDialog):
5551
5594
 
5552
5595
  gray_aligned = aligned[..., 0] if aligned.ndim == 3 else aligned
5553
5596
 
5554
- # Compute weight mask
5597
+ # --- Weight mask ---
5555
5598
  binary_mask = (gray_aligned > 0).astype(np.uint8)
5556
5599
  smooth_mask = cv2.distanceTransform(binary_mask, cv2.DIST_L2, 5)
5557
5600
  smooth_mask = (smooth_mask / np.max(smooth_mask)) if np.max(smooth_mask) > 0 else binary_mask.astype(np.float32)
5558
5601
  smooth_mask = cv2.GaussianBlur(smooth_mask, (15, 15), 0)
5559
5602
 
5560
- # Accumulate
5603
+ # --- Accumulate ---
5561
5604
  if is_color:
5562
5605
  self.final_mosaic += aligned * smooth_mask[..., np.newaxis]
5563
5606
  else:
@@ -5567,22 +5610,27 @@ class MosaicMasterDialog(QDialog):
5567
5610
  self.status_label.setText(f"Processed: {itm['path']}")
5568
5611
  QApplication.processEvents()
5569
5612
 
5570
- # Final blending.
5571
- nonzero_mask = (self.weight_mosaic > 0)
5613
+ # ------------------------------------------------------------
5614
+ # 7) Final blend
5615
+ # ------------------------------------------------------------
5572
5616
  if is_color:
5573
- self.final_mosaic = np.where(self.weight_mosaic[..., None] > 0,
5574
- self.final_mosaic / self.weight_mosaic[..., None],
5575
- self.final_mosaic)
5617
+ self.final_mosaic = np.where(
5618
+ self.weight_mosaic[..., None] > 0,
5619
+ self.final_mosaic / np.maximum(self.weight_mosaic[..., None], 1e-12),
5620
+ self.final_mosaic
5621
+ )
5576
5622
  else:
5577
- self.final_mosaic[nonzero_mask] = self.final_mosaic[nonzero_mask] / self.weight_mosaic[nonzero_mask]
5623
+ nz = (self.weight_mosaic > 0)
5624
+ self.final_mosaic[nz] = self.final_mosaic[nz] / np.maximum(self.weight_mosaic[nz], 1e-12)
5578
5625
 
5579
- print("WCS + Star Alignment Complete.")
5580
- self.status_label.setText("WCS + Star Alignment Complete. De-Normalizing Mosaic...")
5626
+ self.status_label.setText("Mosaic built. De-normalizing mosaic...")
5627
+ QApplication.processEvents()
5581
5628
 
5582
- # Call-guard: only unstretch if we normalized AND we actually recorded stats
5583
- did_normalize = self.normalizeCheckBox.isChecked()
5629
+ # ------------------------------------------------------------
5630
+ # 8) Optional “unstretch” (your existing logic)
5631
+ # ------------------------------------------------------------
5584
5632
  if (did_normalize and
5585
- hasattr(self, "_mosaic_target_median") and self._mosaic_target_median > 0 and
5633
+ hasattr(self, "_mosaic_target_median") and float(self._mosaic_target_median) > 0 and
5586
5634
  getattr(self, "stretch_original_medians", None) and len(self.stretch_original_medians) > 0 and
5587
5635
  getattr(self, "stretch_original_mins", None) and len(self.stretch_original_mins) > 0):
5588
5636
  self.final_mosaic = self.unstretch_image(self.final_mosaic)
@@ -5590,16 +5638,37 @@ class MosaicMasterDialog(QDialog):
5590
5638
  self.status_label.setText("Final Mosaic Ready.")
5591
5639
  QApplication.processEvents()
5592
5640
 
5593
- display_image = (np.stack([self.final_mosaic]*3, axis=-1)
5641
+ display_image = (np.stack([self.final_mosaic] * 3, axis=-1)
5594
5642
  if self.final_mosaic.ndim == 2 else self.final_mosaic)
5595
5643
  display_image = self._autostretch_if_requested(display_image)
5596
- MosaicPreviewWindow(display_image, title="Final Mosaic", parent=self,
5597
- push_cb=self._push_mosaic_to_new_doc).show()
5598
5644
 
5599
- self.spinnerMovie.stop()
5645
+ MosaicPreviewWindow(
5646
+ display_image,
5647
+ title=("Final Mosaic (WCS-only)" if wcs_only else "Final Mosaic"),
5648
+ parent=self,
5649
+ push_cb=self._push_mosaic_to_new_doc
5650
+ ).show()
5651
+
5652
+ if self.spinnerMovie:
5653
+ self.spinnerMovie.stop()
5600
5654
  self.spinnerLabel.hide()
5601
5655
  QApplication.processEvents()
5656
+
5602
5657
 
5658
+ def _polish_reprojected(self, img: np.ndarray) -> np.ndarray:
5659
+ # 1) kill NaNs/Infs from reprojection
5660
+ out = np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
5661
+
5662
+ # 2) trim tiny negative ringing
5663
+ out[out < 0] = 0.0
5664
+
5665
+ # 3) optional: zero a 1px border to avoid remap edge seams
5666
+ if out.ndim == 2:
5667
+ out[:1, :] = 0; out[-1:, :] = 0; out[:, :1] = 0; out[:, -1:] = 0
5668
+ else:
5669
+ out[:1, :, :] = 0; out[-1:, :, :] = 0; out[:, :1, :] = 0; out[:, -1:, :] = 0
5670
+
5671
+ return out
5603
5672
 
5604
5673
  def debayer_image(self, image, file_path, header):
5605
5674
  from setiastro.saspro.legacy.numba_utils import debayer_raw_fast, debayer_fits_fast