setiastrosuitepro 1.6.1.post1__py3-none-any.whl → 1.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (139) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/images/rotatearbitrary.png +0 -0
  3. setiastro/qml/ResourceMonitor.qml +126 -0
  4. setiastro/saspro/__main__.py +162 -25
  5. setiastro/saspro/_generated/build_info.py +2 -1
  6. setiastro/saspro/abe.py +62 -11
  7. setiastro/saspro/aberration_ai.py +3 -3
  8. setiastro/saspro/add_stars.py +5 -2
  9. setiastro/saspro/astrobin_exporter.py +3 -0
  10. setiastro/saspro/astrospike_python.py +3 -1
  11. setiastro/saspro/autostretch.py +4 -2
  12. setiastro/saspro/backgroundneutral.py +60 -9
  13. setiastro/saspro/batch_convert.py +3 -0
  14. setiastro/saspro/batch_renamer.py +3 -0
  15. setiastro/saspro/blemish_blaster.py +3 -0
  16. setiastro/saspro/blink_comparator_pro.py +474 -251
  17. setiastro/saspro/cheat_sheet.py +50 -15
  18. setiastro/saspro/clahe.py +27 -1
  19. setiastro/saspro/comet_stacking.py +103 -38
  20. setiastro/saspro/convo.py +3 -0
  21. setiastro/saspro/copyastro.py +3 -0
  22. setiastro/saspro/cosmicclarity.py +70 -45
  23. setiastro/saspro/crop_dialog_pro.py +28 -1
  24. setiastro/saspro/curve_editor_pro.py +18 -0
  25. setiastro/saspro/debayer.py +3 -0
  26. setiastro/saspro/doc_manager.py +40 -17
  27. setiastro/saspro/fitsmodifier.py +3 -0
  28. setiastro/saspro/frequency_separation.py +8 -2
  29. setiastro/saspro/function_bundle.py +18 -16
  30. setiastro/saspro/generate_translations.py +715 -1
  31. setiastro/saspro/ghs_dialog_pro.py +3 -0
  32. setiastro/saspro/graxpert.py +3 -0
  33. setiastro/saspro/gui/main_window.py +364 -92
  34. setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
  35. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  36. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  37. setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
  38. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  39. setiastro/saspro/gui/statistics_dialog.py +47 -0
  40. setiastro/saspro/halobgon.py +29 -3
  41. setiastro/saspro/histogram.py +3 -0
  42. setiastro/saspro/history_explorer.py +2 -0
  43. setiastro/saspro/i18n.py +22 -10
  44. setiastro/saspro/image_combine.py +3 -0
  45. setiastro/saspro/image_peeker_pro.py +3 -0
  46. setiastro/saspro/imageops/stretch.py +5 -13
  47. setiastro/saspro/isophote.py +3 -0
  48. setiastro/saspro/legacy/numba_utils.py +64 -47
  49. setiastro/saspro/linear_fit.py +3 -0
  50. setiastro/saspro/live_stacking.py +13 -2
  51. setiastro/saspro/mask_creation.py +3 -0
  52. setiastro/saspro/mfdeconv.py +5 -0
  53. setiastro/saspro/morphology.py +30 -5
  54. setiastro/saspro/multiscale_decomp.py +713 -256
  55. setiastro/saspro/nbtorgb_stars.py +12 -2
  56. setiastro/saspro/numba_utils.py +148 -47
  57. setiastro/saspro/ops/scripts.py +77 -17
  58. setiastro/saspro/ops/settings.py +1 -43
  59. setiastro/saspro/perfect_palette_picker.py +1 -0
  60. setiastro/saspro/pixelmath.py +6 -2
  61. setiastro/saspro/plate_solver.py +1 -0
  62. setiastro/saspro/remove_green.py +18 -1
  63. setiastro/saspro/remove_stars.py +136 -162
  64. setiastro/saspro/remove_stars_preset.py +55 -13
  65. setiastro/saspro/resources.py +36 -10
  66. setiastro/saspro/rgb_combination.py +1 -0
  67. setiastro/saspro/rgbalign.py +4 -4
  68. setiastro/saspro/save_options.py +1 -0
  69. setiastro/saspro/selective_color.py +79 -20
  70. setiastro/saspro/sfcc.py +50 -8
  71. setiastro/saspro/shortcuts.py +94 -21
  72. setiastro/saspro/signature_insert.py +3 -0
  73. setiastro/saspro/stacking_suite.py +924 -446
  74. setiastro/saspro/star_alignment.py +291 -331
  75. setiastro/saspro/star_spikes.py +116 -32
  76. setiastro/saspro/star_stretch.py +38 -1
  77. setiastro/saspro/stat_stretch.py +35 -3
  78. setiastro/saspro/status_log_dock.py +1 -1
  79. setiastro/saspro/subwindow.py +63 -2
  80. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  81. setiastro/saspro/swap_manager.py +77 -42
  82. setiastro/saspro/translations/all_source_strings.json +4726 -0
  83. setiastro/saspro/translations/ar_translations.py +4096 -0
  84. setiastro/saspro/translations/de_translations.py +441 -446
  85. setiastro/saspro/translations/es_translations.py +278 -32
  86. setiastro/saspro/translations/fr_translations.py +280 -32
  87. setiastro/saspro/translations/hi_translations.py +3803 -0
  88. setiastro/saspro/translations/integrate_translations.py +38 -1
  89. setiastro/saspro/translations/it_translations.py +1211 -145
  90. setiastro/saspro/translations/ja_translations.py +556 -307
  91. setiastro/saspro/translations/pt_translations.py +3316 -3322
  92. setiastro/saspro/translations/ru_translations.py +3082 -0
  93. setiastro/saspro/translations/saspro_ar.qm +0 -0
  94. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  95. setiastro/saspro/translations/saspro_de.qm +0 -0
  96. setiastro/saspro/translations/saspro_de.ts +14428 -133
  97. setiastro/saspro/translations/saspro_es.qm +0 -0
  98. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  99. setiastro/saspro/translations/saspro_fr.qm +0 -0
  100. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  101. setiastro/saspro/translations/saspro_hi.qm +0 -0
  102. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  103. setiastro/saspro/translations/saspro_it.qm +0 -0
  104. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  105. setiastro/saspro/translations/saspro_ja.qm +0 -0
  106. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  107. setiastro/saspro/translations/saspro_pt.qm +0 -0
  108. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  109. setiastro/saspro/translations/saspro_ru.qm +0 -0
  110. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  111. setiastro/saspro/translations/saspro_sw.qm +0 -0
  112. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  113. setiastro/saspro/translations/saspro_uk.qm +0 -0
  114. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  115. setiastro/saspro/translations/saspro_zh.qm +0 -0
  116. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  117. setiastro/saspro/translations/sw_translations.py +3897 -0
  118. setiastro/saspro/translations/uk_translations.py +3929 -0
  119. setiastro/saspro/translations/zh_translations.py +283 -32
  120. setiastro/saspro/versioning.py +36 -5
  121. setiastro/saspro/view_bundle.py +20 -17
  122. setiastro/saspro/wavescale_hdr.py +22 -1
  123. setiastro/saspro/wavescalede.py +23 -1
  124. setiastro/saspro/whitebalance.py +39 -3
  125. setiastro/saspro/widgets/minigame/game.js +991 -0
  126. setiastro/saspro/widgets/minigame/index.html +53 -0
  127. setiastro/saspro/widgets/minigame/style.css +241 -0
  128. setiastro/saspro/widgets/resource_monitor.py +263 -0
  129. setiastro/saspro/widgets/spinboxes.py +18 -0
  130. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  131. setiastro/saspro/wimi.py +100 -80
  132. setiastro/saspro/wims.py +33 -33
  133. setiastro/saspro/window_shelf.py +2 -2
  134. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
  135. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
  136. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  137. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  138. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  139. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -24,6 +24,7 @@ def _make_executor(max_workers: int):
24
24
  # return ProcessPoolExecutor(max_workers=max_workers)
25
25
  return ThreadPoolExecutor(max_workers=max_workers)
26
26
 
27
+
27
28
  import gc # For explicit memory cleanup after heavy operations
28
29
  import os as _os
29
30
  import threading as _threading
@@ -158,7 +159,7 @@ def _align_prefs(settings: QSettings | None = None) -> dict:
158
159
  prefs = {
159
160
  "model": model, # "affine" | "homography" | "poly3" | "poly4"
160
161
  "max_cp": _get("max_cp", 250, int),
161
- "downsample": _get("downsample", 2, int),
162
+ "downsample": _get("downsample", 3, int),
162
163
  "h_reproj": _get("h_reproj", 3.0, float),
163
164
 
164
165
  # Star detection / solve limits
@@ -286,8 +287,18 @@ def _warp_like_ref(target_img: np.ndarray, M_2x3: np.ndarray, ref_shape_hw: tupl
286
287
  return cv2.warpAffine(target_img, M_2x3, (W, H),
287
288
  flags=cv2.INTER_LANCZOS4, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
288
289
 
290
+ # Optimization: If standard RGB/BGR (3 channels) or 4 channels, OpenCV handles it natively.
291
+ # Note: OpenCV warpAffine support n-channel images, but typically 1, 3, or 4.
292
+ C = target_img.shape[2]
293
+ if C <= 4:
294
+ if not target_img.flags['C_CONTIGUOUS']:
295
+ target_img = np.ascontiguousarray(target_img)
296
+ return cv2.warpAffine(target_img, M_2x3, (W, H),
297
+ flags=cv2.INTER_LANCZOS4, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
298
+
299
+ # Fallback for >4 channels (e.g. hyperspectral or special stacks)
289
300
  chs = []
290
- for i in range(target_img.shape[2]):
301
+ for i in range(C):
291
302
  ch = target_img[..., i]
292
303
  if not ch.flags['C_CONTIGUOUS']:
293
304
  ch = np.ascontiguousarray(ch)
@@ -618,6 +629,7 @@ class StellarAlignmentDialog(QDialog):
618
629
  super().__init__(parent)
619
630
  self.setWindowTitle("Stellar Alignment")
620
631
  self.setWindowFlag(Qt.WindowType.Window, True)
632
+ self.setWindowModality(Qt.WindowModality.NonModal)
621
633
  self.setModal(False)
622
634
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
623
635
 
@@ -1426,6 +1438,34 @@ class RegistrationWorkerSignals(QObject):
1426
1438
  # Identity transform (2x3)
1427
1439
  IDENTITY_2x3 = np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float64)
1428
1440
 
1441
+ def _to3x3_affine(A2x3: np.ndarray) -> np.ndarray:
1442
+ A = np.asarray(A2x3, np.float64).reshape(2,3)
1443
+ return np.vstack([A, [0,0,1]])
1444
+
1445
+ def _from3x3_affine(A3: np.ndarray) -> np.ndarray:
1446
+ return np.asarray(A3, np.float64)[:2,:]
1447
+
1448
+ def _S(ds: float) -> np.ndarray:
1449
+ ds = float(ds)
1450
+ return np.array([[1.0/ds, 0, 0],
1451
+ [0, 1.0/ds, 0],
1452
+ [0, 0, 1]], np.float64)
1453
+
1454
+ def lift_affine_2x3_from_ds(A_ds_2x3: np.ndarray, ds: float) -> np.ndarray:
1455
+ S = _S(ds); Si = np.linalg.inv(S)
1456
+ A3_full = Si @ _to3x3_affine(A_ds_2x3) @ S
1457
+ return _from3x3_affine(A3_full)
1458
+
1459
+ def downscale_affine_2x3_to_ds(A_full_2x3: np.ndarray, ds: float) -> np.ndarray:
1460
+ S = _S(ds); Si = np.linalg.inv(S)
1461
+ A3_ds = S @ _to3x3_affine(A_full_2x3) @ Si
1462
+ return _from3x3_affine(A3_ds)
1463
+
1464
+ def lift_homography_from_ds(H_ds: np.ndarray, ds: float) -> np.ndarray:
1465
+ S = _S(ds); Si = np.linalg.inv(S)
1466
+ return Si @ np.asarray(H_ds, np.float64) @ S
1467
+
1468
+
1429
1469
  def compute_affine_transform_astroalign_cropped(source_img, reference_img,
1430
1470
  scale: float = 1.20,
1431
1471
  limit_stars: int | None = None,
@@ -1867,31 +1907,34 @@ def project_affine_to_similarity(A2x3: np.ndarray) -> np.ndarray:
1867
1907
  def _solve_delta_job(args):
1868
1908
  """
1869
1909
  Worker: compute incremental affine/similarity delta for one frame against the ref preview.
1870
- args = (orig_path, current_transform_2x3, ref_small, Wref, Href,
1871
- resample_flag, det_sigma, limit_stars, minarea,
1872
- model, h_reproj)
1910
+ args =
1911
+ (orig_path, current_transform_2x3,
1912
+ ref_small_ds, Wref_ds, Href_ds,
1913
+ resample_flag, det_sigma, limit_stars, minarea,
1914
+ model, h_reproj, ds)
1873
1915
  """
1874
1916
  try:
1875
1917
  import os
1876
1918
  import numpy as np
1877
1919
  import cv2
1878
- import sep
1879
1920
  from astropy.io import fits
1880
1921
 
1881
- (orig_path, current_transform_2x3, ref_small, Wref, Href,
1922
+ (orig_path, current_transform_2x3,
1923
+ ref_small_ds, Wref_ds, Href_ds,
1882
1924
  resample_flag, det_sigma, limit_stars, minarea,
1883
- model, h_reproj) = args
1925
+ model, h_reproj, ds) = args
1926
+
1927
+ ds = max(1, int(ds))
1884
1928
 
1885
1929
  try:
1886
1930
  cv2.setNumThreads(1)
1887
1931
  try: cv2.ocl.setUseOpenCL(False)
1888
- except Exception as e:
1889
- import logging
1890
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1932
+ except Exception:
1933
+ pass
1891
1934
  except Exception:
1892
1935
  pass
1893
1936
 
1894
- # 1) read → gray float32
1937
+ # 1) read → gray float32 (full)
1895
1938
  with fits.open(orig_path, memmap=True) as hdul:
1896
1939
  arr = hdul[0].data
1897
1940
  if arr is None:
@@ -1899,48 +1942,66 @@ def _solve_delta_job(args):
1899
1942
  gray = arr if arr.ndim == 2 else np.mean(arr, axis=2)
1900
1943
  gray = np.nan_to_num(gray, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
1901
1944
 
1902
- # 2) pre-warp to REF size
1903
- T_prev = np.asarray(current_transform_2x3, np.float32).reshape(2, 3)
1904
- src_for_match = cv2.warpAffine(
1905
- gray, T_prev, (Wref, Href),
1945
+ # 2) downsample source to DS space
1946
+ if ds > 1:
1947
+ Wds = max(1, int(gray.shape[1] // ds))
1948
+ Hds = max(1, int(gray.shape[0] // ds))
1949
+ gray_ds = cv2.resize(gray, (Wds, Hds), interpolation=cv2.INTER_AREA)
1950
+ else:
1951
+ gray_ds = gray
1952
+
1953
+ # 3) pre-warp in DS space using downscaled transform
1954
+ T_prev_full = np.asarray(current_transform_2x3, np.float64).reshape(2, 3)
1955
+ T_prev_ds = downscale_affine_2x3_to_ds(T_prev_full, ds).astype(np.float32)
1956
+
1957
+ # Warp DS source into DS ref geometry
1958
+ src_for_match_ds = cv2.warpAffine(
1959
+ gray_ds, T_prev_ds, (int(Wref_ds), int(Href_ds)),
1906
1960
  flags=resample_flag, borderMode=cv2.BORDER_REFLECT_101
1907
1961
  )
1908
1962
 
1909
- # 3) denoise sparse islands to stabilize AA
1910
- src_for_match = _suppress_tiny_islands(src_for_match, det_sigma=det_sigma, minarea=minarea)
1911
- ref_small = _suppress_tiny_islands(ref_small, det_sigma=det_sigma, minarea=minarea)
1963
+ # 4) denoise sparse islands in DS space (cheaper)
1964
+ src_for_match_ds = _suppress_tiny_islands(src_for_match_ds, det_sigma=det_sigma, minarea=minarea)
1965
+ ref_for_match_ds = _suppress_tiny_islands(np.asarray(ref_small_ds, np.float32, order="C"),
1966
+ det_sigma=det_sigma, minarea=minarea)
1912
1967
 
1913
- # 4) AA incremental delta on cropped ref
1968
+ # 5) AA delta solve in DS space
1914
1969
  m = (model or "affine").lower()
1915
1970
  if m in ("no_distortion", "nodistortion"):
1916
1971
  m = "similarity"
1917
1972
 
1918
1973
  if m == "similarity":
1919
- tform = compute_similarity_transform_astroalign_cropped(
1920
- src_for_match, ref_small,
1974
+ tform_ds = compute_similarity_transform_astroalign_cropped(
1975
+ src_for_match_ds, ref_for_match_ds,
1921
1976
  limit_stars=int(limit_stars) if limit_stars is not None else None,
1922
1977
  det_sigma=float(det_sigma),
1923
1978
  minarea=int(minarea),
1924
1979
  h_reproj=float(h_reproj)
1925
1980
  )
1926
1981
  else:
1927
- tform = compute_affine_transform_astroalign_cropped(
1928
- src_for_match, ref_small,
1982
+ tform_ds = compute_affine_transform_astroalign_cropped(
1983
+ src_for_match_ds, ref_for_match_ds,
1929
1984
  limit_stars=int(limit_stars) if limit_stars is not None else None,
1930
1985
  det_sigma=float(det_sigma),
1931
1986
  minarea=int(minarea)
1932
1987
  )
1933
1988
 
1934
- if tform is None:
1989
+ if tform_ds is None:
1935
1990
  return (orig_path, None,
1936
1991
  f"Astroalign failed for {os.path.basename(orig_path)} – skipping (no transform returned)")
1937
1992
 
1938
- T_new = np.asarray(tform, np.float64).reshape(2, 3)
1939
- return (orig_path, T_new, None)
1993
+ # 6) lift DS delta back to full-res coords
1994
+ T_new_full = lift_affine_2x3_from_ds(np.asarray(tform_ds, np.float64).reshape(2, 3), ds)
1995
+
1996
+ return (orig_path, np.asarray(T_new_full, np.float64).reshape(2, 3), None)
1940
1997
 
1941
1998
  except Exception as e:
1999
+ try:
2000
+ base = os.path.basename(args[0]) if args else "<unknown>"
2001
+ except Exception:
2002
+ base = "<unknown>"
1942
2003
  return (args[0] if args else "<unknown>", None,
1943
- f"Astroalign failed for {os.path.basename(args[0]) if args else '<unknown>'}: {e}")
2004
+ f"Astroalign failed for {base}: {e}")
1944
2005
 
1945
2006
 
1946
2007
 
@@ -2024,7 +2085,7 @@ def _suppress_tiny_islands(img32: np.ndarray, det_sigma: float, minarea: int) ->
2024
2085
  # ─────────────────────────────────────────────────────────────
2025
2086
  def _finalize_write_job(args):
2026
2087
  """
2027
- Process-safe worker: read full-res, compute/choose model, warp, save.
2088
+ Process-safe worker: read full-res, choose model, warp, save.
2028
2089
  Returns (orig_path, out_path or "", msg, success, drizzle_tuple or None)
2029
2090
  drizzle_tuple = (kind, matrix_or_None)
2030
2091
  """
@@ -2045,17 +2106,19 @@ def _finalize_write_job(args):
2045
2106
  try:
2046
2107
  cv2.setNumThreads(1)
2047
2108
  try: cv2.ocl.setUseOpenCL(False)
2048
- except Exception as e:
2049
- import logging
2050
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
2109
+ except Exception:
2110
+ pass
2051
2111
  except Exception:
2052
2112
  pass
2053
2113
 
2054
2114
  debug_lines = []
2055
2115
  def dbg(s: str):
2056
- # keep it short-ish; UI emits each line
2057
2116
  debug_lines.append(str(s))
2058
2117
 
2118
+ def _A3(A2x3):
2119
+ A = np.asarray(A2x3, np.float64).reshape(2, 3)
2120
+ return np.vstack([A, [0, 0, 1]])
2121
+
2059
2122
  try:
2060
2123
  # 1) load source (full-res)
2061
2124
  with fits.open(orig_path, memmap=True) as hdul:
@@ -2064,12 +2127,12 @@ def _finalize_write_job(args):
2064
2127
  if img is None:
2065
2128
  return (orig_path, "", f"⚠️ Failed to read {os.path.basename(orig_path)}", False, None)
2066
2129
 
2067
- # Fix for white images: Normalize integer types to [0,1]
2130
+ # normalize ints
2068
2131
  if img.dtype == np.uint16:
2069
2132
  img = img.astype(np.float32) / 65535.0
2070
2133
  elif img.dtype == np.uint8:
2071
2134
  img = img.astype(np.float32) / 255.0
2072
-
2135
+
2073
2136
  is_mono = (img.ndim == 2)
2074
2137
  src_gray_full = img if is_mono else np.mean(img, axis=2)
2075
2138
  src_gray_full = np.nan_to_num(src_gray_full, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
@@ -2078,40 +2141,61 @@ def _finalize_write_job(args):
2078
2141
 
2079
2142
  Href, Wref = ref_shape
2080
2143
 
2081
- # 2) load reference via memmap
2144
+ # 2) load reference (full-res) via memmap
2082
2145
  ref2d = np.load(ref_npy_path, mmap_mode="r").astype(np.float32, copy=False)
2083
2146
  if ref2d.shape[:2] != (Href, Wref):
2084
2147
  return (orig_path, "", f"⚠️ Ref shape mismatch for {os.path.basename(orig_path)}", False, None)
2085
2148
 
2086
2149
  base = os.path.basename(orig_path)
2087
2150
 
2088
- # helper: force affine to similarity (no shear)
2089
- def _affine_to_similarity(A2x3: np.ndarray) -> np.ndarray:
2090
- A2x3 = np.asarray(A2x3, np.float64).reshape(2, 3)
2091
- R = A2x3[:, :2]
2092
- t = A2x3[:, 2]
2093
- U, S, Vt = np.linalg.svd(R)
2094
- rot = U @ Vt
2095
- if np.linalg.det(rot) < 0:
2096
- U[:, -1] *= -1
2097
- rot = U @ Vt
2098
- s = float((S[0] + S[1]) * 0.5)
2099
- Rsim = rot * s
2100
- out = np.zeros((2, 3), dtype=np.float64)
2101
- out[:, :2] = Rsim
2102
- out[:, 2] = t
2103
- return out
2104
-
2105
- # 3) choose transform
2106
2151
  model = (align_model or "affine").lower()
2107
2152
  if model in ("no_distortion", "nodistortion"):
2108
2153
  model = "similarity"
2109
2154
 
2155
+ # Base (accumulated) affine from refinement
2156
+ A_prev = np.asarray(affine_2x3, np.float64).reshape(2, 3)
2157
+ A_prev3 = _A3(A_prev)
2158
+
2159
+ # Default finalize is just the affine refinement result
2110
2160
  kind = "affine"
2111
- X = np.asarray(affine_2x3, np.float64).reshape(2, 3)
2161
+ X = A_prev.copy()
2112
2162
 
2163
+ # ---- Non-affine finalize: DS solve + lift, but KEEP affine-as-start ----
2113
2164
  if model != "affine":
2114
- # ---- AA pairs (adaptive tiling) ----
2165
+ dbg(f"[finalize] base={base} model={model} det_sigma={det_sigma} minarea={minarea} limit_stars={limit_stars}")
2166
+
2167
+ ds = 2 # ✅ keep simple/safe; only DS+lift change requested
2168
+ ds = max(1, int(ds))
2169
+
2170
+ # DS reference
2171
+ if ds > 1:
2172
+ ref_ds = cv2.resize(ref2d, (max(1, Wref // ds), max(1, Href // ds)), interpolation=cv2.INTER_AREA)
2173
+ else:
2174
+ ref_ds = np.ascontiguousarray(ref2d)
2175
+
2176
+ ref_ds = np.ascontiguousarray(ref_ds.astype(np.float32, copy=False))
2177
+ Hds, Wds = ref_ds.shape[:2]
2178
+
2179
+ # DS source
2180
+ if ds > 1:
2181
+ src_ds0 = cv2.resize(src_gray_full, (Wds, Hds), interpolation=cv2.INTER_AREA)
2182
+ else:
2183
+ 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
2184
+
2185
+ src_ds0 = np.ascontiguousarray(src_ds0.astype(np.float32, copy=False))
2186
+
2187
+ # Pre-warp source in DS space using downscaled accumulated affine
2188
+ A_prev_ds = downscale_affine_2x3_to_ds(A_prev, ds).astype(np.float32)
2189
+ src_pre_ds = cv2.warpAffine(
2190
+ src_ds0, A_prev_ds, (Wds, Hds),
2191
+ flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT_101
2192
+ )
2193
+
2194
+ # Optional suppress tiny islands (your existing helper)
2195
+ src_pre_ds = _suppress_tiny_islands(src_pre_ds, det_sigma=float(det_sigma), minarea=int(minarea))
2196
+ ref_ds = _suppress_tiny_islands(ref_ds, det_sigma=float(det_sigma), minarea=int(minarea))
2197
+
2198
+ # AA correspondences in DS space: prewarped src vs ref
2115
2199
  max_cp = None
2116
2200
  try:
2117
2201
  if limit_stars is not None and int(limit_stars) > 0:
@@ -2119,13 +2203,10 @@ def _finalize_write_job(args):
2119
2203
  except Exception:
2120
2204
  max_cp = None
2121
2205
 
2122
- dbg(f"[finalize] base={base} model={model} det_sigma={det_sigma} minarea={minarea} limit_stars={limit_stars}")
2123
-
2124
- AA_SCALE = 0.80 # finalize-only
2206
+ AA_SCALE = 0.80
2125
2207
 
2126
- # ---- tiles=1 (center crop) ----
2127
2208
  src_xy, tgt_xy, best_P, best_xy0 = _aa_find_pairs_multitile(
2128
- src_gray_full, ref2d,
2209
+ src_pre_ds, ref_ds,
2129
2210
  scale=AA_SCALE,
2130
2211
  tiles=1,
2131
2212
  det_sigma=float(det_sigma),
@@ -2133,143 +2214,72 @@ def _finalize_write_job(args):
2133
2214
  max_control_points=max_cp,
2134
2215
  _dbg=dbg
2135
2216
  )
2136
-
2137
2217
  if src_xy is None or len(src_xy) < 8:
2138
- dbg("[AA] tiles=1 too few matches")
2139
- raise RuntimeError("astroalign produced too few matches")
2140
-
2141
- dbg(f"[AA] tiles=1 matches={len(src_xy)} best_tile_xy0={best_xy0}")
2142
-
2143
- spread_ok1 = _points_spread_ok(tgt_xy, Wref, Href, _dbg=dbg)
2144
- dbg(f"[AA] spread_ok(tiles=1)={spread_ok1}")
2145
-
2146
- # ---- fallback: tiles=5 (corners + center) ----
2147
- if not spread_ok1:
2148
- src_xy5, tgt_xy5, best_P5, best_xy0_5 = _aa_find_pairs_multitile(
2149
- src_gray_full, ref2d,
2150
- scale=AA_SCALE,
2151
- tiles=5, # <-- NEW primary fallback
2152
- det_sigma=float(det_sigma),
2153
- minarea=int(minarea),
2154
- max_control_points=max_cp,
2155
- _dbg=dbg
2156
- )
2157
-
2158
- if src_xy5 is None or len(src_xy5) < 8:
2159
- dbg("[AA] tiles=5 too few matches; keeping tiles=1")
2160
- else:
2161
- dbg(f"[AA] tiles=5 matches={len(src_xy5)} best_tile_xy0={best_xy0_5}")
2162
- spread_ok5 = _points_spread_ok(tgt_xy5, Wref, Href, _dbg=dbg)
2163
- dbg(f"[AA] spread_ok(tiles=5)={spread_ok5}")
2164
-
2165
- # choose tiles=5 if it spreads better OR gives more matches
2166
- if spread_ok5 or len(src_xy5) > len(src_xy):
2167
- dbg("[AA] switching to tiles=5 result")
2168
- src_xy, tgt_xy = src_xy5, tgt_xy5
2169
- best_P, best_xy0 = best_P5, best_xy0_5
2170
- else:
2171
- dbg("[AA] keeping tiles=1 result (tiles=5 not better)")
2172
-
2173
- # ---- tertiary fallback: tiles=3 grid ----
2174
- spread_ok_after = _points_spread_ok(tgt_xy, Wref, Href, _dbg=dbg)
2175
- dbg(f"[AA] spread_ok(after tiles=5 check)={spread_ok_after}")
2176
-
2177
- if not spread_ok_after:
2178
- src_xy3, tgt_xy3, best_P3, best_xy0_3 = _aa_find_pairs_multitile(
2179
- src_gray_full, ref2d,
2180
- scale=AA_SCALE,
2181
- tiles=3,
2182
- det_sigma=float(det_sigma),
2183
- minarea=int(minarea),
2184
- max_control_points=max_cp,
2185
- _dbg=dbg
2186
- )
2187
-
2188
- if src_xy3 is None or len(src_xy3) < 8:
2189
- dbg("[AA] tiles=3 too few matches; keeping current result")
2190
- else:
2191
- dbg(f"[AA] tiles=3 matches={len(src_xy3)} best_tile_xy0={best_xy0_3}")
2192
- spread_ok3 = _points_spread_ok(tgt_xy3, Wref, Href, _dbg=dbg)
2193
- dbg(f"[AA] spread_ok(tiles=3)={spread_ok3}")
2194
-
2195
- if spread_ok3 or len(src_xy3) > len(src_xy):
2196
- dbg("[AA] switching to tiles=3 result")
2197
- src_xy, tgt_xy = src_xy3, tgt_xy3
2198
- best_P, best_xy0 = best_P3, best_xy0_3
2199
- else:
2200
- dbg("[AA] keeping current result (tiles=3 not better)")
2201
-
2202
- x0, y0 = best_xy0
2203
- P = np.asarray(best_P, np.float64)
2204
-
2205
- # ---- base full-ref from best_P + best_xy0 ----
2206
- if P.shape == (3, 3):
2207
- base_kind0 = "homography"
2208
- T = np.array([[1,0,x0],[0,1,y0],[0,0,1]], dtype=np.float64)
2209
- base_X0 = T @ P
2210
- else:
2211
- base_kind0 = "affine"
2212
- A3 = np.vstack([P[0:2, :], [0,0,1]])
2213
- T = np.array([[1,0,x0],[0,1,y0],[0,0,1]], dtype=np.float64)
2214
- base_X0 = (T @ A3)[0:2, :]
2218
+ raise RuntimeError("astroalign produced too few matches (finalize)")
2215
2219
 
2220
+ # RANSAC threshold in DS pixels
2216
2221
  hth = float(h_reproj)
2217
2222
 
2218
2223
  if model == "homography":
2219
- H, inl = cv2.findHomography(src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth)
2224
+ # Delta homography maps prewarped -> ref (both in DS coords)
2225
+ H_delta_ds, inl = cv2.findHomography(src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth)
2220
2226
  ninl = int(inl.sum()) if inl is not None else 0
2221
- dbg(f"[RANSAC] homography inliers={ninl}/{len(src_xy)} thr={hth}")
2227
+ dbg(f"[RANSAC] homography delta(DS) inliers={ninl}/{len(src_xy)} thr={hth}")
2222
2228
 
2223
- if H is not None:
2224
- kind, X = "homography", np.asarray(H, np.float64)
2229
+ if H_delta_ds is None:
2230
+ # fallback to just affine refinement
2231
+ kind, X = "affine", A_prev.copy()
2225
2232
  else:
2226
- kind, X = base_kind0, base_X0
2233
+ H_delta_full = lift_homography_from_ds(H_delta_ds, ds)
2234
+ H_final = np.asarray(H_delta_full, np.float64) @ A_prev3
2235
+ kind, X = "homography", H_final
2227
2236
 
2228
2237
  elif model == "similarity":
2229
- A, inl = cv2.estimateAffinePartial2D(src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth)
2238
+ # Delta similarity (affine partial) maps prewarped -> ref in DS coords
2239
+ A_delta_ds, inl = cv2.estimateAffinePartial2D(
2240
+ src_xy, tgt_xy, cv2.RANSAC, ransacReprojThreshold=hth
2241
+ )
2230
2242
  ninl = int(inl.sum()) if inl is not None else 0
2231
- dbg(f"[RANSAC] similarity inliers={ninl}/{len(src_xy)} thr={hth}")
2243
+ dbg(f"[RANSAC] similarity delta(DS) inliers={ninl}/{len(src_xy)} thr={hth}")
2232
2244
 
2233
- if A is not None:
2234
- kind, X = "similarity", np.asarray(A, np.float64)
2245
+ if A_delta_ds is None:
2246
+ kind, X = "similarity", _project_to_similarity(A_prev)
2235
2247
  else:
2236
- if base_kind0 == "affine":
2237
- kind, X = "similarity", _affine_to_similarity(base_X0)
2238
- else:
2239
- kind, X = base_kind0, base_X0
2240
-
2241
- elif model == "affine":
2242
- kind, X = "affine", np.asarray(affine_2x3, np.float64)
2248
+ A_delta_full = lift_affine_2x3_from_ds(A_delta_ds, ds)
2249
+ # Compose delta prev in affine space
2250
+ A_final3 = _A3(A_delta_full) @ A_prev3
2251
+ A_final = A_final3[:2, :]
2252
+ kind, X = "similarity", _project_to_similarity(A_final)
2243
2253
 
2244
2254
  elif model in ("poly3", "poly4"):
2255
+ # Keep behavior simple: poly fit in FULL coords using pairs from prewarped DS,
2256
+ # then apply as remap on the ORIGINAL image (same as your current poly path).
2257
+ # (If you later want true "poly residual after affine", we can do that safely,
2258
+ # but that is a pattern change beyond DS+lift.)
2245
2259
  order = 3 if model == "poly3" else 4
2246
- cx, cy = _fit_poly_xy(src_xy, tgt_xy, order=order)
2260
+ src_full = (np.asarray(src_xy, np.float32) * float(ds)).astype(np.float32)
2261
+ tgt_full = (np.asarray(tgt_xy, np.float32) * float(ds)).astype(np.float32)
2262
+
2263
+ cx, cy = _fit_poly_xy(src_full, tgt_full, order=order)
2247
2264
  map_x, map_y = _poly_eval_grid(cx, cy, Wref, Href, order=order)
2248
2265
  kind, X = model, (map_x, map_y)
2249
2266
 
2250
2267
  else:
2251
- dbg(f"[AA] unknown model '{model}', falling back to base {base_kind0}")
2252
- kind, X = base_kind0, base_X0
2268
+ # Unknown model -> just write affine refinement
2269
+ kind, X = "affine", A_prev.copy()
2253
2270
 
2254
- # 4) warp
2271
+ # 4) warp full-res
2255
2272
  Hh, Ww = Href, Wref
2256
2273
 
2257
2274
  if kind in ("affine", "similarity"):
2258
2275
  A = np.asarray(X, np.float64).reshape(2, 3)
2259
-
2260
2276
  if is_mono:
2261
- aligned = cv2.warpAffine(
2262
- img, A, (Ww, Hh),
2263
- flags=cv2.INTER_LANCZOS4,
2264
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2265
- )
2277
+ aligned = cv2.warpAffine(img, A, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2278
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2266
2279
  else:
2267
2280
  aligned = np.stack([
2268
- cv2.warpAffine(
2269
- img[..., c], A, (Ww, Hh),
2270
- flags=cv2.INTER_LANCZOS4,
2271
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2272
- )
2281
+ cv2.warpAffine(img[..., c], A, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2282
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2273
2283
  for c in range(img.shape[2])
2274
2284
  ], axis=2)
2275
2285
 
@@ -2278,34 +2288,27 @@ def _finalize_write_job(args):
2278
2288
 
2279
2289
  elif kind == "homography":
2280
2290
  Hm = np.asarray(X, np.float64).reshape(3, 3)
2281
-
2282
2291
  if is_mono:
2283
- aligned = cv2.warpPerspective(
2284
- img, Hm, (Ww, Hh),
2285
- flags=cv2.INTER_LANCZOS4,
2286
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2287
- )
2292
+ aligned = cv2.warpPerspective(img, Hm, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2293
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2288
2294
  else:
2289
2295
  aligned = np.stack([
2290
- cv2.warpPerspective(
2291
- img[..., c], Hm, (Ww, Hh),
2292
- flags=cv2.INTER_LANCZOS4,
2293
- borderMode=cv2.BORDER_CONSTANT, borderValue=0
2294
- )
2296
+ cv2.warpPerspective(img[..., c], Hm, (Ww, Hh), flags=cv2.INTER_LANCZOS4,
2297
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2295
2298
  for c in range(img.shape[2])
2296
2299
  ], axis=2)
2297
2300
 
2298
2301
  drizzle_tuple = ("homography", Hm.astype(np.float64))
2299
2302
  warp_label = "homography"
2300
2303
 
2301
- elif kind in ("poly3","poly4"):
2304
+ elif kind in ("poly3", "poly4"):
2302
2305
  map_x, map_y = X
2303
2306
  if is_mono:
2304
2307
  aligned = cv2.remap(img, map_x, map_y, cv2.INTER_LANCZOS4,
2305
2308
  borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2306
2309
  else:
2307
2310
  aligned = np.stack([
2308
- cv2.remap(img[...,c], map_x, map_y, cv2.INTER_LANCZOS4,
2311
+ cv2.remap(img[..., c], map_x, map_y, cv2.INTER_LANCZOS4,
2309
2312
  borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2310
2313
  for c in range(img.shape[2])
2311
2314
  ], axis=2)
@@ -2366,14 +2369,10 @@ class StarRegistrationWorker(QRunnable):
2366
2369
 
2367
2370
  def run(self):
2368
2371
  """
2369
- Affine:
2370
- - Apply current transform to a preview-sized image
2371
- - Solve incremental delta vs reference preview
2372
- - Emit the incremental delta (2x3) keyed by ORIGINAL path
2373
-
2374
- Non-affine (homography/poly3/4):
2375
- - This QRunnable does not try to do residuals; it just reports and emits identity.
2376
- The multi-process residual pass is handled by StarRegistrationThread.
2372
+ Refinement worker ALWAYS computes incremental deltas in affine/similarity space,
2373
+ even if the FINAL requested model is homography/poly3/poly4.
2374
+
2375
+ The final non-affine model (if any) is applied in _finalize_write_job only.
2377
2376
  """
2378
2377
  try:
2379
2378
  _cap_native_threads_once()
@@ -2399,21 +2398,19 @@ class StarRegistrationWorker(QRunnable):
2399
2398
  return
2400
2399
  Href, Wref = ref_small.shape[:2]
2401
2400
 
2402
- model = (self.model_name or "affine").lower()
2403
-
2404
- # --- Non-affine: don't accumulate here; identity + progress line only
2405
- if model in ("homography", "poly3", "poly4"):
2406
- self.signals.progress.emit(
2407
- f"Residual-only mode for {os.path.basename(self.original_file)} (model={model}); "
2408
- "emitting identity transform (handled by thread pass)."
2409
- )
2410
- self.signals.result_transform.emit(os.path.normpath(self.original_file), IDENTITY_2x3.copy())
2411
- self.signals.result.emit(self.original_file)
2412
- return
2401
+ # ✅ Refinement solve model: always affine or similarity
2402
+ model_req = (self.model_name or "affine").lower()
2403
+ if model_req in ("no_distortion", "nodistortion", "similarity"):
2404
+ refine_model = "similarity"
2405
+ else:
2406
+ refine_model = "affine" # includes when final requested is homography/poly*
2413
2407
 
2414
- # --- Affine incremental
2415
2408
  T_prev = np.array(self.current_transform, dtype=np.float32).reshape(2, 3)
2416
- use_warp = not np.allclose(T_prev, np.array([[1,0,0],[0,1,0]], dtype=np.float32), rtol=1e-5, atol=1e-5)
2409
+ use_warp = not np.allclose(
2410
+ T_prev,
2411
+ np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float32),
2412
+ rtol=1e-5, atol=1e-5
2413
+ )
2417
2414
 
2418
2415
  if use_warp and cv2 is not None:
2419
2416
  src_for_match = cv2.warpAffine(
@@ -2427,9 +2424,21 @@ class StarRegistrationWorker(QRunnable):
2427
2424
  src_for_match = gray_small
2428
2425
 
2429
2426
  try:
2430
- transform = self.compute_affine_transform_astroalign(
2431
- src_for_match, ref_small, limit_stars=getattr(self, "limit_stars", None)
2432
- )
2427
+ if refine_model == "similarity":
2428
+ transform = compute_similarity_transform_astroalign_cropped(
2429
+ src_for_match, ref_small,
2430
+ limit_stars=getattr(self, "limit_stars", None),
2431
+ det_sigma=getattr(self, "det_sigma", 12.0),
2432
+ minarea=getattr(self, "minarea", 10),
2433
+ h_reproj=getattr(self, "h_reproj", 3.0),
2434
+ )
2435
+ else:
2436
+ transform = self.compute_affine_transform_astroalign(
2437
+ src_for_match, ref_small,
2438
+ limit_stars=getattr(self, "limit_stars", None),
2439
+ det_sigma=getattr(self, "det_sigma", 12.0),
2440
+ minarea=getattr(self, "minarea", 10),
2441
+ )
2433
2442
  except Exception as e:
2434
2443
  msg = str(e)
2435
2444
  base = os.path.basename(self.original_file)
@@ -2445,19 +2454,22 @@ class StarRegistrationWorker(QRunnable):
2445
2454
  return
2446
2455
 
2447
2456
  transform = np.array(transform, dtype=np.float64).reshape(2, 3)
2457
+
2458
+ # Similarity projection safety (no shear)
2459
+ if refine_model == "similarity":
2460
+ transform = _project_to_similarity(transform)
2461
+
2448
2462
  key = os.path.normpath(self.original_file)
2449
2463
  self.signals.result_transform.emit(key, transform)
2450
2464
  self.signals.progress.emit(
2451
2465
  f"Astroalign delta for {os.path.basename(self.original_file)} "
2452
- f"(model={self.model_name}): dx={transform[0, 2]:.2f}, dy={transform[1, 2]:.2f}"
2466
+ f"(refine={refine_model}, final={self.model_name}): dx={transform[0,2]:.2f}, dy={transform[1,2]:.2f}"
2453
2467
  )
2454
2468
  self.signals.result.emit(self.original_file)
2455
2469
 
2456
2470
  except Exception as e:
2457
2471
  self.signals.error.emit(f"Error processing {self.original_file}: {e}")
2458
2472
 
2459
-
2460
-
2461
2473
  @staticmethod
2462
2474
  def compute_affine_transform_astroalign(source_img, reference_img,
2463
2475
  scale=1.20,
@@ -2607,7 +2619,7 @@ class StarRegistrationThread(QThread):
2607
2619
  self.det_sigma = float(self.align_prefs.get("det_sigma", 12.0))
2608
2620
  self.limit_stars = int(self.align_prefs.get("limit_stars", 500))
2609
2621
  self.minarea = int(self.align_prefs.get("minarea", 10))
2610
- self.downsample = int(self.align_prefs.get("downsample", 2))
2622
+ self.downsample = int(self.align_prefs.get("downsample", 3))
2611
2623
  self.drizzle_xforms = {} # {orig_norm_path: (kind, matrix)}
2612
2624
 
2613
2625
  @staticmethod
@@ -2957,23 +2969,37 @@ class StarRegistrationThread(QThread):
2957
2969
 
2958
2970
  # ✂️ No DAO/RANSAC: astroalign handles detection internally.
2959
2971
 
2960
- # Single shared downsampled ref for workers
2961
- #ds = max(1, int(self.align_prefs.get("downsample", 2)))
2962
- #if ds > 1:
2963
- # new_hw = (max(1, ref2d.shape[1] // ds), max(1, ref2d.shape[0] // ds)) # (W, H)
2964
- # ref_small = cv2.resize(ref2d, new_hw, interpolation=cv2.INTER_AREA)
2965
- #else:
2966
- # ref_small = ref2d
2967
- #self.ref_small = np.ascontiguousarray(ref_small.astype(np.float32))
2968
- self.ref_small = np.ascontiguousarray(ref2d.astype(np.float32))
2972
+ # --- Build shared ref at full + downsampled solve-res ---
2973
+ self.ref_small_full = np.ascontiguousarray(ref2d.astype(np.float32, copy=False))
2974
+
2975
+ # Use existing preference key you already have: self.downsample
2976
+ # (you load it in __init__: self.downsample = int(self.align_prefs.get("downsample", 2)))
2977
+ ds = max(1, int(self.downsample))
2978
+ self.solve_downsample = ds
2979
+
2980
+ if ds > 1 and cv2 is not None:
2981
+ new_hw = (max(1, ref2d.shape[1] // ds), max(1, ref2d.shape[0] // ds)) # (W, H)
2982
+ ref_ds = cv2.resize(self.ref_small_full, new_hw, interpolation=cv2.INTER_AREA)
2983
+ else:
2984
+ ref_ds = self.ref_small_full
2985
+
2986
+ self.ref_small = self.ref_small_full # keep existing attribute name (full)
2987
+ self.ref_small_ds = np.ascontiguousarray(ref_ds.astype(np.float32, copy=False))
2969
2988
 
2970
2989
  # Initialize transforms to identity for EVERY original frame
2971
2990
  self.alignment_matrices = {os.path.normpath(f): IDENTITY_2x3.copy() for f in self.original_files}
2972
2991
  self.delta_transforms = {}
2973
2992
 
2974
2993
  # Progress totals (units = number of worker completions across passes)
2994
+ # Progress totals:
2995
+ # passes = N * passes
2996
+ # finalize = N
2997
+ N = len(self.original_files)
2998
+ P = max(1, int(self.max_refinement_passes))
2999
+
2975
3000
  self._done = 0
2976
- self._total = len(self.original_files) * max(1, int(self.max_refinement_passes))
3001
+ self._total = (N * P) + N # <-- IMPORTANT: include finalize
3002
+ self.progress_step.emit(self._done, self._total) # optional but helps UI reset immediately
2977
3003
 
2978
3004
  # Registration passes (compute deltas only)
2979
3005
  for pass_idx in range(self.max_refinement_passes):
@@ -3015,109 +3041,30 @@ class StarRegistrationThread(QThread):
3015
3041
  def run_one_registration_pass(self, _ref_stars_unused, _ref_triangles_unused, pass_index):
3016
3042
  _cap_native_threads_once()
3017
3043
  import os
3018
- import shutil
3019
- import tempfile
3020
3044
  import cv2
3045
+ import time
3021
3046
 
3022
- model = (self.align_model or "affine").lower()
3023
- ref_small = np.ascontiguousarray(self.ref_small.astype(np.float32, copy=False))
3024
- Href, Wref = ref_small.shape[:2]
3047
+ # Requested final model (used ONLY in finalize)
3048
+ final_model = (self.align_model or "affine").lower()
3049
+
3050
+ # ✅ Refinement model: affine or similarity only
3051
+ if final_model in ("no_distortion", "nodistortion", "similarity"):
3052
+ refine_model = "similarity"
3053
+ else:
3054
+ refine_model = "affine"
3055
+
3056
+ ref_small_ds = np.ascontiguousarray(self.ref_small_ds.astype(np.float32, copy=False))
3057
+ Href_ds, Wref_ds = ref_small_ds.shape[:2]
3058
+ ds = max(1, int(getattr(self, "solve_downsample", 1)))
3025
3059
 
3026
- # --- Build reverse map: current_path -> original_key (handles bin2-upscale / rewrites)
3060
+ # --- reverse map: current_path -> original_key
3027
3061
  rev_current_to_orig = {}
3028
3062
  for orig_k, curr_p in self.file_key_to_current_path.items():
3029
3063
  rev_current_to_orig[os.path.normpath(curr_p)] = os.path.normpath(orig_k)
3030
3064
 
3031
- # ---------- NON-AFFINE PATH: residuals-only ----------
3032
- if model in ("homography", "poly3", "poly4"):
3033
- work_list = list(self.original_files)
3034
-
3035
- from concurrent.futures import ProcessPoolExecutor, as_completed
3036
- procs = max(2, min((os.cpu_count() or 8), 32))
3037
- self.progress_update.emit(f"Using {procs} processes to measure residuals (model={model}).")
3038
-
3039
- tmpdir = tempfile.mkdtemp(prefix="sas_resid_")
3040
- ref_npy = os.path.join(tmpdir, "ref_small.npy")
3041
- try:
3042
- np.save(ref_npy, ref_small)
3043
- except Exception as e:
3044
- try: shutil.rmtree(tmpdir, ignore_errors=True)
3045
- except Exception as e:
3046
- import logging
3047
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
3048
- self.on_worker_error(f"Failed to persist residual reference: {e}")
3049
- return False, "Residual pass aborted."
3050
-
3051
- pass_deltas = []
3052
- try:
3053
-
3054
- import time
3055
-
3056
- jobs = [
3057
- (p, ref_npy, model, self.h_reproj, self.det_sigma, self.minarea, self.limit_stars)
3058
- for p in work_list
3059
- ]
3060
- total = len(jobs)
3061
- done = 0
3062
-
3063
- self.progress_update.emit(f"Using {procs} processes to measure residuals (model={model}).")
3064
- self.progress_step.emit(0, total)
3065
-
3066
- with _make_executor(procs) as ex:
3067
- pending = {ex.submit(_residual_job_worker, j): j[0] for j in jobs}
3068
- last_heartbeat = time.monotonic()
3069
-
3070
- while pending:
3071
- done_set, pending = wait(pending, timeout=0.6, return_when=FIRST_COMPLETED)
3072
- # heartbeat if nothing finished for a bit
3073
- now = time.monotonic()
3074
- if not done_set and (now - last_heartbeat) > 2.0:
3075
- self.progress_update.emit(f"… measuring residuals ({done}/{total} done)")
3076
- last_heartbeat = now
3077
-
3078
- for fut in done_set:
3079
- orig_pth = os.path.normpath(pending.pop(fut, "<unknown>")) if fut in pending else "<unknown>"
3080
- try:
3081
- pth, rms, err = fut.result()
3082
- except Exception as e:
3083
- pth, rms, err = (orig_pth, float("inf"), f"Worker crashed: {e}")
3084
-
3085
- k_orig = os.path.normpath(pth or orig_pth)
3086
- if err:
3087
- self.on_worker_error(f"Residual measure failed for {os.path.basename(k_orig)}: {err}")
3088
- self.delta_transforms[k_orig] = float("inf")
3089
- else:
3090
- self.delta_transforms[k_orig] = float(rms)
3091
- self.progress_update.emit(
3092
- f"[residuals] {os.path.basename(k_orig)} → RMS={rms:.2f}px"
3093
- )
3094
-
3095
- done += 1
3096
- self.progress_step.emit(done, total)
3097
- last_heartbeat = now
3098
-
3099
- for orig in self.original_files:
3100
- pass_deltas.append(self.delta_transforms.get(os.path.normpath(orig), float("inf")))
3101
- self.transform_deltas.append(pass_deltas)
3102
-
3103
- preview = ", ".join([f"{d:.2f}" if np.isfinite(d) else "∞" for d in pass_deltas[:10]])
3104
- if len(pass_deltas) > 10:
3105
- preview += f" … ({len(pass_deltas)} total)"
3106
- self.progress_update.emit(f"Pass {pass_index + 1}: residual RMS px [{preview}]")
3107
-
3108
- aligned_count = sum(1 for d in pass_deltas if np.isfinite(d) and d <= self.shift_tolerance)
3109
- if aligned_count:
3110
- self.progress_update.emit(f"Within tolerance (≤ {self.shift_tolerance:.2f}px): {aligned_count} frame(s)")
3111
- return True, "Residual pass complete."
3112
- finally:
3113
- try: shutil.rmtree(tmpdir, ignore_errors=True)
3114
- except Exception as e:
3115
- import logging
3116
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
3117
-
3118
- # ---------- AFFINE PATH (incremental delta accumulation) ----------
3119
3065
  resample_flag = cv2.INTER_AREA if pass_index == 0 else cv2.INTER_LINEAR
3120
3066
 
3067
+ # Work list: pass 0 all; later passes skip within tolerance
3121
3068
  if pass_index == 0:
3122
3069
  work_list = list(self.original_files)
3123
3070
  else:
@@ -3144,30 +3091,36 @@ class StarRegistrationThread(QThread):
3144
3091
  return True, "Pass complete (nothing to refine)."
3145
3092
 
3146
3093
  procs = max(2, min((os.cpu_count() or 8), 32))
3147
- self.progress_update.emit(f"Using {procs} processes for stellar alignment (HW={os.cpu_count() or 8}).")
3094
+ self.progress_update.emit(f"Using {procs} processes for stellar alignment (refine={refine_model}).")
3148
3095
 
3149
3096
  timeout_sec = int(self.align_prefs.get("timeout_per_job_sec", 300))
3097
+
3150
3098
  jobs = []
3151
3099
  for orig_key in work_list:
3152
3100
  ok = os.path.normpath(orig_key)
3153
- current_path = os.path.normpath(self.file_key_to_current_path.get(ok, ok))
3101
+
3102
+ # IMPORTANT: refinement reads ORIGINAL frame (no intermediate saves)
3103
+ current_path = ok
3104
+
3154
3105
  current_transform = self.alignment_matrices.get(ok, IDENTITY_2x3)
3106
+
3155
3107
  jobs.append((
3156
3108
  current_path,
3157
3109
  current_transform,
3158
- ref_small, Wref, Href,
3159
- resample_flag, float(self.det_sigma), int(self.limit_stars), int(self.minarea),
3160
- model, float(self.h_reproj)
3110
+ ref_small_ds, int(Wref_ds), int(Href_ds),
3111
+ resample_flag, float(self.det_sigma),
3112
+ int(self.limit_stars) if self.limit_stars is not None else None,
3113
+ int(self.minarea),
3114
+ refine_model, float(self.h_reproj),
3115
+ int(ds)
3161
3116
  ))
3162
3117
 
3163
- import time
3164
3118
  executor = _make_executor(procs)
3165
-
3166
3119
  try:
3167
3120
  fut_info, pending = {}, set()
3168
3121
  for j in jobs:
3169
3122
  f = executor.submit(_solve_delta_job, j)
3170
- fut_info[f] = (time.monotonic(), j[0]) # j[0] = current_path
3123
+ fut_info[f] = (time.monotonic(), j[0])
3171
3124
  pending.add(f)
3172
3125
 
3173
3126
  while pending:
@@ -3179,7 +3132,7 @@ class StarRegistrationThread(QThread):
3179
3132
  except Exception as e:
3180
3133
  curr_path_r, T_new, err = (returned_path or "<unknown>", None, f"Worker crashed: {e}")
3181
3134
 
3182
- # Map CURRENT path back to ORIGINAL key for consistent accumulation
3135
+ # Map back to ORIGINAL key
3183
3136
  curr_norm = os.path.normpath(curr_path_r)
3184
3137
  k_orig = rev_current_to_orig.get(curr_norm, curr_norm)
3185
3138
 
@@ -3189,10 +3142,13 @@ class StarRegistrationThread(QThread):
3189
3142
  continue
3190
3143
 
3191
3144
  T_new = np.array(T_new, dtype=np.float64).reshape(2, 3)
3192
- if model in ("no_distortion", "nodistortion", "similarity"):
3193
- T_new = _project_to_similarity(T_new)
3145
+
3146
+ if refine_model == "similarity":
3147
+ T_new = _project_to_similarity(T_new)
3148
+
3194
3149
  self.delta_transforms[k_orig] = float(np.hypot(T_new[0, 2], T_new[1, 2]))
3195
3150
 
3151
+ # Accumulate: T_total = T_new ∘ T_prev
3196
3152
  T_prev = np.array(self.alignment_matrices.get(k_orig, IDENTITY_2x3), dtype=np.float64).reshape(2, 3)
3197
3153
  prev_3 = np.vstack([T_prev, [0, 0, 1]])
3198
3154
  new_3 = np.vstack([T_new, [0, 0, 1]])
@@ -3200,7 +3156,7 @@ class StarRegistrationThread(QThread):
3200
3156
 
3201
3157
  self.on_worker_progress(
3202
3158
  f"Astroalign delta for {os.path.basename(curr_path_r)} "
3203
- f"(model={self.align_model}): dx={T_new[0, 2]:.2f}, dy={T_new[1, 2]:.2f}"
3159
+ f"(refine={refine_model}, final={final_model}): dx={T_new[0,2]:.2f}, dy={T_new[1,2]:.2f}"
3204
3160
  )
3205
3161
  self._increment_progress()
3206
3162
 
@@ -3235,7 +3191,7 @@ class StarRegistrationThread(QThread):
3235
3191
  preview += f" … ({len(pass_deltas)} total)"
3236
3192
  self.progress_update.emit(f"Pass {pass_index + 1} delta shifts: [{preview}]")
3237
3193
  if aligned_count:
3238
- self.progress_update.emit(f"Skipped (delta < {self.shift_tolerance:.2f}px): {aligned_count} frame(s)")
3194
+ self.progress_update.emit(f"Within tolerance ( {self.shift_tolerance:.2f}px): {aligned_count} frame(s)")
3239
3195
  return True, "Pass complete."
3240
3196
  finally:
3241
3197
  try:
@@ -3243,7 +3199,6 @@ class StarRegistrationThread(QThread):
3243
3199
  except Exception:
3244
3200
  pass
3245
3201
 
3246
-
3247
3202
  def on_worker_result_transform(self, persistent_key, new_transform):
3248
3203
  k = os.path.normpath(persistent_key)
3249
3204
  T_new = np.array(new_transform, dtype=np.float64).reshape(2, 3)
@@ -3384,8 +3339,8 @@ class StarRegistrationThread(QThread):
3384
3339
  A = np.asarray(self.alignment_matrices.get(k, IDENTITY_2x3), dtype=np.float64)
3385
3340
 
3386
3341
  # 👉 If non-affine, we pass identity to make workers solve from scratch
3387
- if self.align_model.lower() in ("homography", "poly3", "poly4"):
3388
- A = IDENTITY_2x3.copy()
3342
+ #if self.align_model.lower() in ("homography", "poly3", "poly4"):
3343
+ # A = IDENTITY_2x3.copy()
3389
3344
 
3390
3345
  jobs.append((
3391
3346
  orig_path,
@@ -3411,6 +3366,7 @@ class StarRegistrationThread(QThread):
3411
3366
  orig_path, out_path, msg, success, drizzle = fut.result()
3412
3367
  except Exception as e:
3413
3368
  self.progress_update.emit(f"⚠️ Finalize worker crashed: {e}")
3369
+ self._increment_progress()
3414
3370
  continue
3415
3371
 
3416
3372
  if msg:
@@ -3433,6 +3389,7 @@ class StarRegistrationThread(QThread):
3433
3389
  self.drizzle_xforms[k] = (str(kind), None) # poly3/4
3434
3390
  except Exception:
3435
3391
  pass
3392
+ self._increment_progress()
3436
3393
  finally:
3437
3394
  try: shutil.rmtree(tmpdir, ignore_errors=True)
3438
3395
  except Exception as e:
@@ -4639,6 +4596,9 @@ class MosaicMasterDialog(QDialog):
4639
4596
  self._list_open_docs_fn = list_open_docs_fn
4640
4597
 
4641
4598
  self.setWindowTitle("Mosaic Master")
4599
+ self.setWindowFlag(Qt.WindowType.Window, True)
4600
+ self.setWindowModality(Qt.WindowModality.NonModal)
4601
+ self.setModal(False)
4642
4602
  self.wrench_path = wrench_path
4643
4603
  self.spinner_path = spinner_path
4644
4604