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.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +114 -37
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +548 -275
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +134 -8
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +246 -16
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/function_bundle.py +16 -16
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +519 -289
- setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +5 -1
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +67 -47
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +180 -79
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +748 -255
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_stars_preset.py +55 -13
- setiastro/saspro/resources.py +97 -11
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +83 -21
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +253 -49
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1610 -574
- setiastro/saspro/star_alignment.py +522 -453
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +743 -128
- setiastro/saspro/status_log_dock.py +1 -1
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/swap_manager.py +77 -42
- setiastro/saspro/translations/all_source_strings.json +1588 -516
- setiastro/saspro/translations/ar_translations.py +915 -684
- setiastro/saspro/translations/de_translations.py +442 -463
- setiastro/saspro/translations/es_translations.py +277 -47
- setiastro/saspro/translations/fr_translations.py +279 -47
- setiastro/saspro/translations/hi_translations.py +253 -21
- setiastro/saspro/translations/integrate_translations.py +3 -2
- setiastro/saspro/translations/it_translations.py +1211 -161
- setiastro/saspro/translations/ja_translations.py +3340 -3107
- setiastro/saspro/translations/pt_translations.py +3315 -3337
- setiastro/saspro/translations/ru_translations.py +351 -117
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +15902 -138
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14428 -133
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +11503 -7821
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +11168 -7812
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14733 -135
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +14347 -7821
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14860 -137
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +14904 -137
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11766 -168
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15115 -135
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +11206 -6729
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +10581 -7812
- setiastro/saspro/translations/sw_translations.py +282 -56
- setiastro/saspro/translations/uk_translations.py +264 -35
- setiastro/saspro/translations/zh_translations.py +282 -47
- setiastro/saspro/view_bundle.py +17 -17
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/minigame/game.js +11 -6
- setiastro/saspro/widgets/resource_monitor.py +133 -57
- setiastro/saspro/widgets/spinboxes.py +28 -13
- setiastro/saspro/wimi.py +92 -721
- setiastro/saspro/wims.py +46 -36
- setiastro/saspro/window_shelf.py +2 -2
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {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",
|
|
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
|
-
|
|
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 =
|
|
1883
|
-
|
|
1884
|
-
|
|
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,
|
|
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
|
|
1901
|
-
|
|
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)
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
-
#
|
|
1922
|
-
|
|
1923
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1932
|
-
|
|
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
|
-
|
|
1940
|
-
|
|
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
|
|
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
|
-
|
|
1951
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
2061
|
-
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2236
|
-
|
|
2232
|
+
if H_delta_ds is None:
|
|
2233
|
+
# fallback to just affine refinement
|
|
2234
|
+
kind, X = "affine", A_prev.copy()
|
|
2237
2235
|
else:
|
|
2238
|
-
|
|
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
|
-
|
|
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
|
|
2246
|
-
kind, X = "similarity",
|
|
2248
|
+
if A_delta_ds is None:
|
|
2249
|
+
kind, X = "similarity", _project_to_similarity(A_prev)
|
|
2247
2250
|
else:
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2264
|
-
kind, X =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
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
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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(
|
|
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
|
-
|
|
2443
|
-
|
|
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"(
|
|
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",
|
|
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
|
-
#
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
#
|
|
2976
|
-
#
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
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 =
|
|
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
|
|
3035
|
-
|
|
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
|
-
#
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3171
|
-
resample_flag, float(self.det_sigma),
|
|
3172
|
-
|
|
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])
|
|
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
|
|
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
|
-
|
|
3205
|
-
|
|
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"(
|
|
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"
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
4684
|
+
|
|
4731
4685
|
self.seestarCheckBox = QCheckBox("Seestar Mode")
|
|
4732
|
-
self.seestarCheckBox.setToolTip(
|
|
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
|
-
|
|
4760
|
-
|
|
4761
|
-
_default_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.
|
|
4800
|
-
|
|
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
|
|
5370
|
+
if self.spinnerMovie:
|
|
5371
|
+
self.spinnerMovie.start()
|
|
5362
5372
|
QApplication.processEvents()
|
|
5363
5373
|
|
|
5364
|
-
#
|
|
5374
|
+
# ------------------------------------------------------------
|
|
5375
|
+
# 1) Plate solve (unless already solved and not forcing blind)
|
|
5376
|
+
# ------------------------------------------------------------
|
|
5365
5377
|
force_blind = self.forceBlindCheckBox.isChecked()
|
|
5366
|
-
images_to_process = (
|
|
5367
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
5410
|
-
self.spinnerMovie
|
|
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
|
|
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
|
|
5424
|
-
self.spinnerMovie
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
5463
|
-
if
|
|
5464
|
-
|
|
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
|
|
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
|
-
|
|
5502
|
-
|
|
5503
|
-
reprojected = np.nan_to_num(
|
|
5504
|
-
|
|
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
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
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
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
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
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
5571
|
-
|
|
5613
|
+
# ------------------------------------------------------------
|
|
5614
|
+
# 7) Final blend
|
|
5615
|
+
# ------------------------------------------------------------
|
|
5572
5616
|
if is_color:
|
|
5573
|
-
self.final_mosaic = np.where(
|
|
5574
|
-
|
|
5575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5580
|
-
|
|
5626
|
+
self.status_label.setText("Mosaic built. De-normalizing mosaic...")
|
|
5627
|
+
QApplication.processEvents()
|
|
5581
5628
|
|
|
5582
|
-
#
|
|
5583
|
-
|
|
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
|
-
|
|
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
|