setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/sfcc.py CHANGED
@@ -20,6 +20,7 @@ from datetime import datetime
20
20
  from typing import List, Tuple, Optional
21
21
 
22
22
  import numpy as np
23
+ import numpy.ma as ma
23
24
  import pandas as pd
24
25
 
25
26
  # ── SciPy bits
@@ -32,6 +33,7 @@ from astropy.wcs import WCS
32
33
  import astropy.units as u
33
34
  from astropy.coordinates import SkyCoord
34
35
  from astroquery.simbad import Simbad
36
+ from astropy.wcs.wcs import NoConvergence
35
37
 
36
38
  # ── SEP (Source Extractor)
37
39
  import sep
@@ -969,8 +971,105 @@ class SFCCDialog(QDialog):
969
971
 
970
972
 
971
973
  # ── SIMBAD/Star fetch ──────────────────────────────────────────────
974
+ def initialize_wcs_from_header(self, header):
975
+ """
976
+ Build a robust 2D celestial WCS from the provided header.
977
+
978
+ - Normalizes deprecated RADECSYS/EPOCH keywords.
979
+ - Uses relax=True.
980
+ - Stores:
981
+ self.wcs (WCS)
982
+ self.wcs_header (fits.Header)
983
+ self.pixscale (arcsec/pixel approx)
984
+ self.center_ra, self.center_dec (deg)
985
+ self.orientation (deg, if derivable)
986
+ """
987
+ if header is None:
988
+ print("[SFCC] No FITS header available; cannot build WCS.")
989
+ self.wcs = None
990
+ return
991
+
992
+ try:
993
+ hdr = header.copy()
994
+
995
+ # --- normalize deprecated keywords ---
996
+ if "RADECSYS" in hdr and "RADESYS" not in hdr:
997
+ radesys_val = str(hdr["RADECSYS"]).strip()
998
+ hdr["RADESYS"] = radesys_val
999
+ try:
1000
+ del hdr["RADECSYS"]
1001
+ except Exception:
1002
+ pass
1003
+
1004
+ # Carry to alternate WCS letters if present (CTYPE1A, CTYPE2A, etc.)
1005
+ alt_letters = {
1006
+ k[-1]
1007
+ for k in hdr.keys()
1008
+ if re.match(r"^CTYPE[12][A-Z]$", k)
1009
+ }
1010
+ for a in alt_letters:
1011
+ key = f"RADESYS{a}"
1012
+ if key not in hdr:
1013
+ hdr[key] = radesys_val
1014
+
1015
+ if "EPOCH" in hdr and "EQUINOX" not in hdr:
1016
+ hdr["EQUINOX"] = hdr["EPOCH"]
1017
+ try:
1018
+ del hdr["EPOCH"]
1019
+ except Exception:
1020
+ pass
1021
+
1022
+ # Build WCS
1023
+ self.wcs = WCS(hdr, naxis=2, relax=True)
1024
+
1025
+ # Pixel scale estimate (arcsec/px) from pixel_scale_matrix if available
1026
+ try:
1027
+ psm = self.wcs.pixel_scale_matrix
1028
+ self.pixscale = float(np.hypot(psm[0, 0], psm[1, 0]) * 3600.0)
1029
+ except Exception:
1030
+ self.pixscale = None
1031
+
1032
+ # CRVAL center
1033
+ try:
1034
+ self.center_ra, self.center_dec = [float(x) for x in self.wcs.wcs.crval]
1035
+ except Exception:
1036
+ self.center_ra, self.center_dec = None, None
1037
+
1038
+ # Save normalized header form
1039
+ try:
1040
+ self.wcs_header = self.wcs.to_header(relax=True)
1041
+ except Exception:
1042
+ self.wcs_header = None
1043
+
1044
+ # Orientation (optional)
1045
+ if "CROTA2" in hdr:
1046
+ try:
1047
+ self.orientation = float(hdr["CROTA2"])
1048
+ except Exception:
1049
+ self.orientation = None
1050
+ else:
1051
+ self.orientation = self.calculate_orientation(hdr)
1052
+
1053
+ if getattr(self, "orientation_label", None) is not None:
1054
+ if self.orientation is not None:
1055
+ self.orientation_label.setText(f"Orientation: {self.orientation:.2f}°")
1056
+ else:
1057
+ self.orientation_label.setText("Orientation: N/A")
1058
+
1059
+ except Exception as e:
1060
+ print("[SFCC] WCS initialization error:\n", e)
1061
+ self.wcs = None
1062
+
972
1063
 
973
1064
  def fetch_stars(self):
1065
+ import time
1066
+ from astropy.coordinates import SkyCoord
1067
+ import astropy.units as u
1068
+ from astropy.wcs.wcs import NoConvergence
1069
+ from astroquery.simbad import Simbad
1070
+ from astropy.io import fits
1071
+ from PyQt6.QtWidgets import QMessageBox, QApplication
1072
+
974
1073
  # 0) Grab current image + header from the active document
975
1074
  img, hdr, _meta = self._get_active_image_and_header()
976
1075
  self.current_image = img
@@ -986,7 +1085,7 @@ class SFCCDialog(QDialog):
986
1085
  self.pickles_templates = []
987
1086
  for p in (self.user_custom_path, self.sasp_data_path):
988
1087
  try:
989
- with fits.open(p) as hd:
1088
+ with fits.open(p, memmap=False) as hd:
990
1089
  for hdu in hd:
991
1090
  if (isinstance(hdu, fits.BinTableHDU)
992
1091
  and hdu.header.get("CTYPE", "").upper() == "SED"):
@@ -994,115 +1093,250 @@ class SFCCDialog(QDialog):
994
1093
  if extname and extname not in self.pickles_templates:
995
1094
  self.pickles_templates.append(extname)
996
1095
  except Exception as e:
997
- print(f"[fetch_stars] Could not load Pickles templates from {p}: {e}")
1096
+ print(f"[SFCC] [fetch_stars] Could not load Pickles templates from {p}: {e}")
1097
+ self.pickles_templates.sort()
998
1098
 
999
1099
  # Build WCS
1000
1100
  try:
1001
1101
  self.initialize_wcs_from_header(self.current_header)
1002
1102
  except Exception:
1003
- QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header."); return
1103
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header.")
1104
+ return
1105
+
1106
+ if not getattr(self, "wcs", None):
1107
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header.")
1108
+ return
1109
+
1110
+ # Use celestial WCS if possible (safe when WCS has extra axes)
1111
+ wcs2 = self.wcs.celestial if hasattr(self.wcs, "celestial") else self.wcs
1004
1112
 
1005
1113
  H, W = self.current_image.shape[:2]
1006
- pix = np.array([[W/2, H/2], [0,0], [W,0], [0,H], [W,H]])
1114
+
1115
+ # --- original radius method (center + 4 corners) ---
1116
+ pix = np.array([[W / 2, H / 2], [0, 0], [W, 0], [0, H], [W, H]], dtype=float)
1007
1117
  try:
1008
- sky = self.wcs.all_pix2world(pix, 0)
1118
+ sky = wcs2.all_pix2world(pix, 0)
1009
1119
  except Exception as e:
1010
- QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
1011
- center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
1012
- corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
1013
- radius_deg = center_sky.separation(corners_sky).max().deg
1120
+ QMessageBox.critical(self, "WCS Conversion Error", str(e))
1121
+ return
1014
1122
 
1015
- # Simbad fields
1123
+ center_sky = SkyCoord(ra=float(sky[0, 0]) * u.deg, dec=float(sky[0, 1]) * u.deg, frame="icrs")
1124
+ corners_sky = SkyCoord(ra=sky[1:, 0] * u.deg, dec=sky[1:, 1] * u.deg, frame="icrs")
1125
+ radius = center_sky.separation(corners_sky).max() * 1.05 # small margin
1126
+
1127
+ # --- SIMBAD fields (NEW first, fallback to legacy) ---
1016
1128
  Simbad.reset_votable_fields()
1017
- for attempt in range(1, 6):
1129
+
1130
+ def _try_new_fields():
1131
+ # new names: B,V,R + ra,dec
1132
+ Simbad.add_votable_fields("sp", "B", "V", "R", "ra", "dec")
1133
+
1134
+ def _try_legacy_fields():
1135
+ # legacy names
1136
+ Simbad.add_votable_fields("sp", "flux(B)", "flux(V)", "flux(R)", "ra(d)", "dec(d)")
1137
+
1138
+ ok = False
1139
+ for _ in range(5):
1018
1140
  try:
1019
- Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
1141
+ _try_new_fields()
1142
+ ok = True
1020
1143
  break
1021
1144
  except Exception:
1022
1145
  QApplication.processEvents()
1023
- time.sleep(1.2)
1146
+ time.sleep(0.8)
1147
+
1148
+ if not ok:
1149
+ for _ in range(5):
1150
+ try:
1151
+ _try_legacy_fields()
1152
+ ok = True
1153
+ break
1154
+ except Exception:
1155
+ QApplication.processEvents()
1156
+ time.sleep(0.8)
1157
+
1158
+ if not ok:
1159
+ QMessageBox.critical(self, "SIMBAD Error", "Could not configure SIMBAD votable fields.")
1160
+ return
1161
+
1024
1162
  Simbad.ROW_LIMIT = 10000
1025
1163
 
1164
+ # --- Query SIMBAD ---
1165
+ result = None
1026
1166
  for attempt in range(1, 6):
1027
1167
  try:
1028
- result = Simbad.query_region(center_sky, radius=radius_deg * u.deg)
1168
+ if getattr(self, "count_label", None) is not None:
1169
+ self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
1170
+ QApplication.processEvents()
1171
+ result = Simbad.query_region(center_sky, radius=radius)
1029
1172
  break
1030
- except Exception as e:
1031
- self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
1032
- QApplication.processEvents(); time.sleep(1.2)
1173
+ except Exception:
1174
+ QApplication.processEvents()
1175
+ time.sleep(1.2)
1033
1176
  result = None
1177
+
1034
1178
  if result is None or len(result) == 0:
1035
1179
  QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
1036
- self.star_list = []; self.star_combo.clear(); self.star_combo.addItem("Vega (A0V)", userData="A0V"); return
1180
+ self.star_list = []
1181
+ if getattr(self, "star_combo", None) is not None:
1182
+ self.star_combo.clear()
1183
+ self.star_combo.addItem("Vega (A0V)", userData="A0V")
1184
+ return
1037
1185
 
1038
- def infer_letter(bv):
1039
- if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
1040
- if bv < 0.00: return "B"
1041
- elif bv < 0.30: return "A"
1042
- elif bv < 0.58: return "F"
1043
- elif bv < 0.81: return "G"
1044
- elif bv < 1.40: return "K"
1045
- elif bv > 1.40: return "M"
1046
- else: return "U"
1047
-
1048
- self.star_list = []; templates_for_hist = []
1049
- for row in result:
1050
- raw_sp = row['sp_type']
1051
- bmag, vmag, rmag = row['B'], row['V'], row['R']
1052
- ra_deg, dec_deg = float(row['ra']), float(row['dec'])
1186
+ # --- helpers ---
1187
+ def _unmask_num(x):
1053
1188
  try:
1054
- sc = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
1189
+ if x is None:
1190
+ return None
1191
+ if ma.isMaskedArray(x) and ma.is_masked(x):
1192
+ return None
1193
+ return float(x)
1055
1194
  except Exception:
1056
- continue
1195
+ return None
1196
+
1197
+ def infer_letter(bv):
1198
+ if bv is None or (isinstance(bv, float) and np.isnan(bv)):
1199
+ return None
1200
+ if bv < 0.00:
1201
+ return "B"
1202
+ elif bv < 0.30:
1203
+ return "A"
1204
+ elif bv < 0.58:
1205
+ return "F"
1206
+ elif bv < 0.81:
1207
+ return "G"
1208
+ elif bv < 1.40:
1209
+ return "K"
1210
+ elif bv > 1.40:
1211
+ return "M"
1212
+ return None
1057
1213
 
1058
- def _unmask_num(x):
1214
+ def safe_world2pix(ra_deg, dec_deg):
1215
+ try:
1216
+ xpix, ypix = wcs2.all_world2pix(ra_deg, dec_deg, 0)
1217
+ xpix, ypix = float(xpix), float(ypix)
1218
+ if np.isfinite(xpix) and np.isfinite(ypix):
1219
+ return xpix, ypix
1220
+ return None
1221
+ except NoConvergence as e:
1059
1222
  try:
1060
- if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
1061
- return None
1062
- return float(x)
1223
+ xpix, ypix = e.best_solution
1224
+ xpix, ypix = float(xpix), float(ypix)
1225
+ if np.isfinite(xpix) and np.isfinite(ypix):
1226
+ return xpix, ypix
1063
1227
  except Exception:
1064
- return None
1228
+ pass
1229
+ return None
1230
+ except Exception:
1231
+ return None
1232
+
1233
+ # Column names (astroquery changed these)
1234
+ cols_lower = {c.lower(): c for c in result.colnames}
1235
+
1236
+ # RA/Dec in degrees:
1237
+ ra_col = cols_lower.get("ra", None) or cols_lower.get("ra(d)", None) or cols_lower.get("ra_d", None)
1238
+ dec_col = cols_lower.get("dec", None) or cols_lower.get("dec(d)", None) or cols_lower.get("dec_d", None)
1239
+
1240
+ # Mag columns:
1241
+ b_col = cols_lower.get("b", None) or cols_lower.get("flux_b", None)
1242
+ v_col = cols_lower.get("v", None) or cols_lower.get("flux_v", None)
1243
+ r_col = cols_lower.get("r", None) or cols_lower.get("flux_r", None)
1244
+
1245
+ if ra_col is None or dec_col is None:
1246
+ QMessageBox.critical(
1247
+ self,
1248
+ "SIMBAD Columns",
1249
+ "SIMBAD result did not include degree RA/Dec columns (ra/dec).\n"
1250
+ "Print result.colnames to see what's returned."
1251
+ )
1252
+ return
1253
+
1254
+ # --- main loop ---
1255
+ self.star_list = []
1256
+ templates_for_hist = []
1065
1257
 
1066
- # inside your SIMBAD row loop:
1067
- bmag = _unmask_num(row['B'])
1068
- vmag = _unmask_num(row['V'])
1258
+ for row in result:
1259
+ # spectral type column name in table
1260
+ raw_sp = None
1261
+ if "SP_TYPE" in result.colnames:
1262
+ raw_sp = row["SP_TYPE"]
1263
+ elif "sp_type" in result.colnames:
1264
+ raw_sp = row["sp_type"]
1265
+
1266
+ bmag = _unmask_num(row[b_col]) if b_col is not None else None
1267
+ vmag = _unmask_num(row[v_col]) if v_col is not None else None
1268
+ rmag = _unmask_num(row[r_col]) if r_col is not None else None
1269
+
1270
+ # ra/dec degrees
1271
+ ra_deg = _unmask_num(row[ra_col])
1272
+ dec_deg = _unmask_num(row[dec_col])
1273
+ if ra_deg is None or dec_deg is None:
1274
+ continue
1275
+
1276
+ try:
1277
+ sc = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg, frame="icrs")
1278
+ except Exception:
1279
+ continue
1069
1280
 
1070
1281
  sp_clean = None
1071
1282
  if raw_sp and str(raw_sp).strip():
1072
1283
  sp = str(raw_sp).strip().upper()
1073
1284
  if not (sp.startswith("SN") or sp.startswith("KA")):
1074
1285
  sp_clean = sp
1075
- elif bmag is not None and vmag is not None:
1076
- bv = bmag - vmag
1077
- sp_clean = infer_letter(bv)
1078
- if not sp_clean: continue
1286
+ elif (bmag is not None) and (vmag is not None):
1287
+ sp_clean = infer_letter(bmag - vmag)
1288
+
1289
+ if not sp_clean:
1290
+ continue
1079
1291
 
1080
1292
  match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
1081
1293
  best_template = match_list[0] if match_list else None
1082
- xpix, ypix = self.wcs.all_world2pix(sc.ra.deg, sc.dec.deg, 0)
1294
+
1295
+ xy = safe_world2pix(sc.ra.deg, sc.dec.deg)
1296
+ if xy is None:
1297
+ continue
1298
+
1299
+ xpix, ypix = xy
1083
1300
  if 0 <= xpix < W and 0 <= ypix < H:
1084
1301
  self.star_list.append({
1085
- "ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
1086
- "pickles_match": best_template, "x": xpix, "y": ypix,
1087
- "Bmag": float(bmag) if bmag else None,
1088
- "Vmag": float(vmag) if vmag else None,
1089
- "Rmag": float(rmag) if rmag else None,
1302
+ "ra": sc.ra.deg, "dec": sc.dec.deg,
1303
+ "sp_clean": sp_clean,
1304
+ "pickles_match": best_template,
1305
+ "x": xpix, "y": ypix,
1306
+ # IMPORTANT: do not use "if bmag" (0.0 becomes None)
1307
+ "Bmag": float(bmag) if bmag is not None else None,
1308
+ "Vmag": float(vmag) if vmag is not None else None,
1309
+ "Rmag": float(rmag) if rmag is not None else None,
1090
1310
  })
1091
- if best_template is not None: templates_for_hist.append(best_template)
1311
+ if best_template is not None:
1312
+ templates_for_hist.append(best_template)
1313
+
1314
+ # --- plot / UI feedback (unchanged) ---
1315
+ if getattr(self, "figure", None) is not None:
1316
+ self.figure.clf()
1092
1317
 
1093
- self.figure.clf()
1094
1318
  if templates_for_hist:
1095
1319
  uniq, cnt = np.unique(templates_for_hist, return_counts=True)
1096
- types_str = ", ".join(uniq)
1097
- self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
1098
- ax = self.figure.add_subplot(111)
1099
- ax.bar(uniq, cnt, edgecolor="black")
1100
- ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
1101
- ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
1102
- self.canvas.setVisible(True); self.canvas.draw()
1320
+ types_str = ", ".join([str(u) for u in uniq])
1321
+ if getattr(self, "count_label", None) is not None:
1322
+ self.count_label.setText(f"Found {len(self.star_list)} stars; templates: {types_str}")
1323
+
1324
+ if getattr(self, "figure", None) is not None and getattr(self, "canvas", None) is not None:
1325
+ ax = self.figure.add_subplot(111)
1326
+ ax.bar(uniq, cnt, edgecolor="black")
1327
+ ax.set_xlabel("Spectral Type")
1328
+ ax.set_ylabel("Count")
1329
+ ax.set_title("Spectral Distribution")
1330
+ ax.tick_params(axis='x', rotation=90)
1331
+ ax.grid(axis="y", linestyle="--", alpha=0.3)
1332
+ self.canvas.setVisible(True)
1333
+ self.canvas.draw()
1103
1334
  else:
1104
- self.count_label.setText("Found 0 stars with Pickles matches.")
1105
- self.canvas.setVisible(False); self.canvas.draw()
1335
+ if getattr(self, "count_label", None) is not None:
1336
+ self.count_label.setText(f"Found {len(self.star_list)} in-frame SIMBAD stars (0 with Pickles matches).")
1337
+ if getattr(self, "canvas", None) is not None:
1338
+ self.canvas.setVisible(False)
1339
+ self.canvas.draw()
1106
1340
 
1107
1341
  # ── Core SFCC ───────────────────────────────────────────────────────
1108
1342
  def run_spcc(self):
@@ -503,7 +503,6 @@ class DraggableToolBar(QToolBar):
503
503
 
504
504
  m.exec(gpos)
505
505
 
506
-
507
506
  def contextMenuEvent(self, ev):
508
507
  # Right-click on empty toolbar area
509
508
  m = QMenu(self)
@@ -513,12 +512,12 @@ class DraggableToolBar(QToolBar):
513
512
  act_lock = m.addAction(self.tr("Lock Toolbar Icons"))
514
513
  act_lock.setCheckable(True)
515
514
  act_lock.setChecked(is_locked)
516
-
515
+
517
516
  def _toggle_lock(checked):
518
517
  self._set_locked(checked)
519
-
518
+
520
519
  act_lock.triggered.connect(_toggle_lock)
521
-
520
+
522
521
  m.addSeparator()
523
522
 
524
523
  # Submenu listing hidden actions for this toolbar
@@ -529,12 +528,13 @@ class DraggableToolBar(QToolBar):
529
528
  any_hidden = False
530
529
  if tb_hidden:
531
530
  for act in tb_hidden.actions():
532
- # Skip separators
533
531
  if act.isSeparator():
534
532
  continue
535
533
  any_hidden = True
536
- sub.addAction(act.text() or (act.property("command_id") or act.objectName() or "item"),
537
- lambda a=act: mw._unhide_action_from_hidden_toolbar(a))
534
+ sub.addAction(
535
+ act.text() or (act.property("command_id") or act.objectName() or "item"),
536
+ lambda a=act: mw._unhide_action_from_hidden_toolbar(a)
537
+ )
538
538
 
539
539
  if not any_hidden:
540
540
  sub.setEnabled(False)
@@ -542,8 +542,15 @@ class DraggableToolBar(QToolBar):
542
542
  m.addSeparator()
543
543
  m.addAction(self.tr("Reset hidden icons"), self._reset_hidden_icons)
544
544
 
545
+ # ✅ NEW: Factory reset for all toolbars
546
+ m.addSeparator()
547
+ reset_all = m.addAction(self.tr("Reset ALL Toolbars (Factory Layout)"))
548
+ reset_all.setStatusTip(self.tr("Clears saved toolbar positions/orders/hidden state and restores defaults"))
549
+ reset_all.triggered.connect(lambda: getattr(self.window(), "_reset_all_toolbars_to_factory", lambda: None)())
550
+
545
551
  m.exec(ev.globalPos())
546
552
 
553
+
547
554
  def _reset_hidden_icons(self):
548
555
  mw = self.window()
549
556
  tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
@@ -4324,8 +4324,6 @@ def _arr_stats(a: np.ndarray):
4324
4324
  max=float(v.max()),
4325
4325
  p01=float(np.percentile(v, 1)),
4326
4326
  p50=float(np.percentile(v, 50)),
4327
- p99=float(np.percentile(v, 99)),
4328
- mean=float(v.mean()),
4329
4327
  )
4330
4328
  return dict(dtype=str(a.dtype), shape=tuple(a.shape), finite=0, nan=int(np.isnan(a).sum()), inf=int(np.isinf(a).sum()))
4331
4329
 
@@ -4334,7 +4332,7 @@ def _print_stats(tag: str, a: np.ndarray, *, bit_depth=None, hdr=None):
4334
4332
  bd = f", bit_depth={bit_depth}" if bit_depth is not None else ""
4335
4333
  print(f"🧪 {tag}{bd} dtype={s['dtype']} shape={s['shape']} finite={s['finite']} nan={s['nan']} inf={s['inf']}")
4336
4334
  if s["finite"] > 0:
4337
- print(f" min={s['min']:.6f} p01={s['p01']:.6f} p50={s['p50']:.6f} p99={s['p99']:.6f} max={s['max']:.6f} mean={s['mean']:.6f}")
4335
+ print(f" min={s['min']:.6f} p01={s['p01']:.6f} p50={s['p50']:.6f} max={s['max']:.6f}")
4338
4336
  # Header hints (best-effort)
4339
4337
  if hdr is not None:
4340
4338
  try:
@@ -5583,6 +5581,20 @@ class StackingSuiteDialog(QDialog):
5583
5581
 
5584
5582
  left_col.addWidget(gb_general)
5585
5583
 
5584
+ self.temp_group_step_spin = QDoubleSpinBox()
5585
+ self.temp_group_step_spin.setRange(0.0, 20.0) # 0 disables grouping-by-temp (optional behavior)
5586
+ self.temp_group_step_spin.setDecimals(2)
5587
+ self.temp_group_step_spin.setSingleStep(0.1)
5588
+ self.temp_group_step_spin.setValue(
5589
+ self.settings.value("stacking/temp_group_step", 1.0, type=float)
5590
+ )
5591
+ self.temp_group_step_spin.setToolTip(
5592
+ self.tr("Temperature grouping tolerance in °C.\n"
5593
+ "Frames within ±step are grouped together.\n"
5594
+ "Set 0 to disable temperature-based grouping.")
5595
+ )
5596
+ fl_general.addRow(self.tr("Temp grouping step (°C):"), self.temp_group_step_spin)
5597
+
5586
5598
  # --- Distortion / Transform model ---
5587
5599
  # --- Distortion / Transform model ---
5588
5600
  disto_box = QGroupBox(self.tr("Distortion / Transform"))
@@ -6360,7 +6372,8 @@ class StackingSuiteDialog(QDialog):
6360
6372
  self.settings.setValue("stacking/chunk_width", self.chunk_width)
6361
6373
  self.settings.setValue("stacking/autocrop_enabled", self.autocrop_cb.isChecked())
6362
6374
  self.settings.setValue("stacking/autocrop_pct", float(self.autocrop_pct.value()))
6363
-
6375
+ self.temp_group_step = float(self.temp_group_step_spin.value())
6376
+ self.settings.setValue("stacking/temp_group_step", self.temp_group_step)
6364
6377
  # ----- alignment model (affine | homography | poly3 | poly4) -----
6365
6378
  model_idx = self.align_model_combo.currentIndex()
6366
6379
  if model_idx == 0: model_name = "affine"
@@ -10828,7 +10841,8 @@ class StackingSuiteDialog(QDialog):
10828
10841
  set_t = _get_key_float(header, "SET-TEMP")
10829
10842
  chosen_t = ccd_t if ccd_t is not None else set_t
10830
10843
 
10831
- temp_step = self.settings.value("stacking/temp_group_step", 1.0, type=float)
10844
+ temp_step = float(self.settings.value("stacking/temp_group_step", 1.0, type=float) or 1.0)
10845
+ temp_step = max(0.0, temp_step)
10832
10846
  temp_bucket = self._bucket_temp(chosen_t, step=temp_step)
10833
10847
  temp_label = self._temp_label(temp_bucket, step=temp_step)
10834
10848
 
@@ -11057,7 +11071,8 @@ class StackingSuiteDialog(QDialog):
11057
11071
  # Group darks by (exposure +/- tolerance, image size, session, temp_bucket)
11058
11072
  # TEMP_STEP is the rounding bucket (1.0C default)
11059
11073
  # -------------------------------------------------------------------------
11060
- TEMP_STEP = self.settings.value("stacking/temp_group_step", 1.0, type=float)
11074
+ TEMP_STEP = float(self.settings.value("stacking/temp_group_step", 1.0, type=float) or 1.0)
11075
+ TEMP_STEP = max(0.0, TEMP_STEP)
11061
11076
 
11062
11077
  dark_files_by_group: dict[tuple[float, str, str, float | None], list[str]] = {} # (exp,size,session,temp)->list
11063
11078