setiastrosuitepro 1.7.5__py3-none-any.whl → 1.7.5.post1__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.

@@ -1,3 +1,3 @@
1
1
  # Auto-generated at build time. Do not edit.
2
- BUILD_TIMESTAMP = "2026-01-22T14:55:21Z"
3
- APP_VERSION = "1.7.5"
2
+ BUILD_TIMESTAMP = "2026-01-24T17:02:32Z"
3
+ APP_VERSION = "1.7.5.post1"
@@ -63,6 +63,7 @@ class MetricsPanel(QWidget):
63
63
  self.flags = None # list of bools
64
64
  self._threshold_initialized = [False]*4
65
65
  self._open_previews = []
66
+ self._show_guides = True # default on (or False if you prefer)
66
67
 
67
68
  self.plots, self.scats, self.lines = [], [], []
68
69
  titles = [self.tr("FWHM (px)"), self.tr("Eccentricity"), self.tr("Background"), self.tr("Star Count")]
@@ -86,11 +87,101 @@ class MetricsPanel(QWidget):
86
87
  lambda ln, m=idx: self._on_line_move(m, ln))
87
88
  pw.addItem(line)
88
89
 
90
+ # --- dashed reference lines: median + ±3σ (robust) ---
91
+ median_ln = pg.InfiniteLine(pos=0, angle=0, movable=False,
92
+ pen=pg.mkPen((220, 220, 220, 170), width=1, style=Qt.PenStyle.DashLine))
93
+ sigma_lo = pg.InfiniteLine(pos=0, angle=0, movable=False,
94
+ pen=pg.mkPen((220, 220, 220, 120), width=1, style=Qt.PenStyle.DashLine))
95
+ sigma_hi = pg.InfiniteLine(pos=0, angle=0, movable=False,
96
+ pen=pg.mkPen((220, 220, 220, 120), width=1, style=Qt.PenStyle.DashLine))
97
+
98
+ # keep them behind points/threshold visually
99
+ median_ln.setZValue(-10)
100
+ sigma_lo.setZValue(-10)
101
+ sigma_hi.setZValue(-10)
102
+
103
+ pw.addItem(median_ln)
104
+ pw.addItem(sigma_lo)
105
+ pw.addItem(sigma_hi)
106
+
107
+ # create the lists once
108
+ if not hasattr(self, "median_lines"):
109
+ self.median_lines = []
110
+ self.sigma_lines = [] # list of (lo, hi)
111
+
112
+ self.median_lines.append(median_ln)
113
+ self.sigma_lines.append((sigma_lo, sigma_hi))
89
114
  grid.addWidget(pw, idx//2, idx%2)
90
115
  self.plots.append(pw)
91
116
  self.scats.append(scat)
92
117
  self.lines.append(line)
93
118
 
119
+ def set_guides_visible(self, on: bool):
120
+ self._show_guides = bool(on)
121
+
122
+ if not self._show_guides:
123
+ # ✅ hide immediately
124
+ if hasattr(self, "median_lines"):
125
+ for ln in self.median_lines:
126
+ ln.hide()
127
+ if hasattr(self, "sigma_lines"):
128
+ for lo, hi in self.sigma_lines:
129
+ lo.hide()
130
+ hi.hide()
131
+ return
132
+
133
+ # ✅ turning ON: recompute/restore based on what’s currently plotted
134
+ self._refresh_guides_from_current_plot()
135
+
136
+ def _refresh_guides_from_current_plot(self):
137
+ """Recompute/position guide lines using current plot data (if any)."""
138
+ if not getattr(self, "_show_guides", True):
139
+ return
140
+ if not hasattr(self, "median_lines") or not hasattr(self, "sigma_lines"):
141
+ return
142
+ # Use the scatter data already in each panel
143
+ for m, scat in enumerate(self.scats):
144
+ x, y = scat.getData()[:2]
145
+ if y is None or len(y) == 0:
146
+ self.median_lines[m].hide()
147
+ lo, hi = self.sigma_lines[m]
148
+ lo.hide(); hi.hide()
149
+ continue
150
+
151
+ med, sig = self._median_and_robust_sigma(np.asarray(y, dtype=np.float32))
152
+ mline = self.median_lines[m]
153
+ lo_ln, hi_ln = self.sigma_lines[m]
154
+
155
+ if np.isfinite(med):
156
+ mline.setPos(med); mline.show()
157
+ else:
158
+ mline.hide()
159
+
160
+ if np.isfinite(med) and np.isfinite(sig) and sig > 0:
161
+ lo = med - 3.0 * sig
162
+ hi = med + 3.0 * sig
163
+ if m == 3:
164
+ lo = max(0.0, lo)
165
+ lo_ln.setPos(lo); hi_ln.setPos(hi)
166
+ lo_ln.show(); hi_ln.show()
167
+ else:
168
+ lo_ln.hide(); hi_ln.hide()
169
+
170
+
171
+ @staticmethod
172
+ def _median_and_robust_sigma(y: np.ndarray):
173
+ """Return (median, sigma) using MAD-based robust sigma. Ignores NaN/Inf."""
174
+ y = np.asarray(y, dtype=np.float32)
175
+ finite = np.isfinite(y)
176
+ if not finite.any():
177
+ return np.nan, np.nan
178
+ v = y[finite]
179
+ med = float(np.nanmedian(v))
180
+ mad = float(np.nanmedian(np.abs(v - med)))
181
+ sigma = 1.4826 * mad # robust sigma estimate
182
+ return med, float(sigma)
183
+
184
+
94
185
  @staticmethod
95
186
  def _compute_one(i_entry):
96
187
  """
@@ -324,6 +415,15 @@ class MetricsPanel(QWidget):
324
415
  line.setPos(0)
325
416
  pw.getPlotItem().getViewBox().update()
326
417
  pw.repaint()
418
+
419
+ # ✅ hide guides too
420
+ if hasattr(self, "median_lines"):
421
+ for ln in self.median_lines:
422
+ ln.hide()
423
+ if hasattr(self, "sigma_lines"):
424
+ for lo, hi in self.sigma_lines:
425
+ lo.hide()
426
+ hi.hide()
327
427
  return
328
428
 
329
429
  # compute & cache on first call or new image list
@@ -358,6 +458,41 @@ class MetricsPanel(QWidget):
358
458
  ]
359
459
  scat.setData(x=x, y=y, brush=brushes, pen=pg.mkPen(None), size=8)
360
460
 
461
+ # --- update dashed reference lines (median + ±3σ) ---
462
+ if getattr(self, "_show_guides", True):
463
+ try:
464
+ med, sig = self._median_and_robust_sigma(y)
465
+ mline = self.median_lines[m]
466
+ lo_ln, hi_ln = self.sigma_lines[m]
467
+
468
+ if np.isfinite(med):
469
+ mline.setPos(med)
470
+ mline.show()
471
+ else:
472
+ mline.hide()
473
+
474
+ if np.isfinite(med) and np.isfinite(sig) and sig > 0:
475
+ lo = med - 3.0 * sig
476
+ hi = med + 3.0 * sig
477
+ if m == 3:
478
+ lo = max(0.0, lo)
479
+ lo_ln.setPos(lo); hi_ln.setPos(hi)
480
+ lo_ln.show(); hi_ln.show()
481
+ else:
482
+ lo_ln.hide(); hi_ln.hide()
483
+ except Exception:
484
+ if hasattr(self, "median_lines") and m < len(self.median_lines):
485
+ self.median_lines[m].hide()
486
+ a, b = self.sigma_lines[m]
487
+ a.hide(); b.hide()
488
+ else:
489
+ # guides disabled -> force-hide
490
+ if hasattr(self, "median_lines") and m < len(self.median_lines):
491
+ self.median_lines[m].hide()
492
+ a, b = self.sigma_lines[m]
493
+ a.hide(); b.hide()
494
+
495
+
361
496
  # initialize threshold line once
362
497
  if not self._threshold_initialized[m]:
363
498
  mx, mn = np.nanmax(y), np.nanmin(y)
@@ -456,7 +591,10 @@ class MetricsWindow(QWidget):
456
591
  instr.setWordWrap(True)
457
592
  instr.setStyleSheet("color: #ccc; font-size: 12px;")
458
593
  vbox.addWidget(instr)
459
-
594
+ self.chk_guides = QCheckBox(self.tr("Show median and ±3σ guides"), self)
595
+ self.chk_guides.setChecked(True) # default on
596
+ self.chk_guides.toggled.connect(self._on_toggle_guides)
597
+ vbox.addWidget(self.chk_guides)
460
598
  # → filter selector
461
599
  self.group_combo = QComboBox(self)
462
600
  self.group_combo.addItem(self.tr("All"))
@@ -479,6 +617,10 @@ class MetricsWindow(QWidget):
479
617
  self._all_images = []
480
618
  self._current_indices: Optional[List[int]] = None
481
619
 
620
+ def _on_toggle_guides(self, on: bool):
621
+ if hasattr(self, "metrics_panel") and self.metrics_panel is not None:
622
+ self.metrics_panel.set_guides_visible(on)
623
+
482
624
 
483
625
  def _update_status(self, *args):
484
626
  """Recompute and show: Flagged Items X / Y (Z%). Robust to stale indices."""
@@ -537,6 +679,7 @@ class MetricsWindow(QWidget):
537
679
  self._current_indices = self._order_all
538
680
  self._apply_thresholds("All")
539
681
  self.metrics_panel.plot(self._all_images, indices=self._current_indices)
682
+ self.metrics_panel.set_guides_visible(self.chk_guides.isChecked())
540
683
  self._update_status()
541
684
 
542
685
  def _reindex_list_after_remove(self, lst: List[int] | None, removed: List[int]) -> List[int] | None:
@@ -666,7 +809,8 @@ class MetricsWindow(QWidget):
666
809
  grp = self.group_combo.currentText()
667
810
  # save it for this group
668
811
  self._thresholds_per_group[grp][metric_idx] = new_val
669
-
812
+ self.metrics_panel.plot(self._all_images, indices=self._current_indices)
813
+ self.metrics_panel.set_guides_visible(self.chk_guides.isChecked())
670
814
  # (if you also want immediate re-flagging in the tree, keep your BlinkTab logic hooked here)
671
815
 
672
816
  def _apply_thresholds(self, group_name: str):
@@ -2296,35 +2296,91 @@ def compute_safe_chunk(height, width, N, channels, dtype, pref_h, pref_w):
2296
2296
  _DIM_RE = re.compile(r"\s*\(\d+\s*x\s*\d+\)\s*")
2297
2297
 
2298
2298
 
2299
- def build_rejection_layers(rejection_map: dict, ref_H: int, ref_W: int, n_frames: int,
2300
- *, comb_threshold_frac: float = 0.02):
2299
+ def build_rejection_layers(
2300
+ rejection_map: dict,
2301
+ ref_H: int,
2302
+ ref_W: int,
2303
+ n_frames: int,
2304
+ *,
2305
+ comb_threshold_frac: float = 0.02
2306
+ ):
2301
2307
  """
2302
- Returns:
2303
- rej_cnt : (H,W) uint16 (or uint32 if needed)
2304
- rej_frac : (H,W) float32 in [0..1]
2305
- rej_any : (H,W) bool (thresholded, not "ever rejected")
2308
+ Supports BOTH formats:
2309
+
2310
+ Old format:
2311
+ rejection_map[file] = [(x, y), (x, y), ...]
2312
+
2313
+ New format (tile masks):
2314
+ rejection_map[file] = [(x0, y0, mask_bool_thx_tw), ...]
2315
+ where mask_bool is (th, tw) boolean array for the tile at (x0,y0) in ref space.
2306
2316
  """
2317
+ import numpy as np
2318
+
2307
2319
  if n_frames <= 0:
2308
2320
  raise ValueError("n_frames must be > 0")
2309
2321
 
2310
- # Use uint32 if you might exceed 65535 rejections (rare)
2311
2322
  rej_cnt = np.zeros((ref_H, ref_W), dtype=np.uint32)
2312
2323
 
2313
- # rejection_map keys are per-file, values are coords in REGISTERED space
2314
- for _fpath, coords in (rejection_map or {}).items():
2315
- for (x, y) in coords:
2316
- xi = int(x); yi = int(y)
2317
- if 0 <= xi < ref_W and 0 <= yi < ref_H:
2318
- rej_cnt[yi, xi] += 1
2324
+ for _fpath, items in (rejection_map or {}).items():
2325
+ if not items:
2326
+ continue
2327
+
2328
+ # Detect format by peeking at first item
2329
+ first = items[0]
2319
2330
 
2320
- rej_frac = (rej_cnt.astype(np.float32) / float(n_frames))
2331
+ # ---- NEW tile-mask format ----
2332
+ if isinstance(first, (tuple, list)) and len(first) == 3:
2333
+ for it in items:
2334
+ try:
2335
+ x0, y0, m = it
2336
+ except Exception:
2337
+ continue
2338
+ if m is None:
2339
+ continue
2340
+
2341
+ mm = np.asarray(m, dtype=np.bool_)
2342
+ if mm.ndim != 2:
2343
+ # if somehow stored as (th,tw,1) etc.
2344
+ mm = np.squeeze(mm)
2345
+ if mm.ndim != 2:
2346
+ continue
2347
+
2348
+ th, tw = mm.shape
2349
+ if th <= 0 or tw <= 0:
2350
+ continue
2351
+
2352
+ x0i = int(x0)
2353
+ y0i = int(y0)
2354
+ if x0i >= ref_W or y0i >= ref_H:
2355
+ continue
2356
+
2357
+ x1i = min(ref_W, x0i + tw)
2358
+ y1i = min(ref_H, y0i + th)
2359
+ if x1i <= x0i or y1i <= y0i:
2360
+ continue
2361
+
2362
+ # add 1 rejection count wherever mask is True
2363
+ sub = rej_cnt[y0i:y1i, x0i:x1i]
2364
+ sub += mm[: (y1i - y0i), : (x1i - x0i)].astype(np.uint32)
2321
2365
 
2322
- # “combined mask” becomes “rejected in >= threshold% of frames”
2366
+ # ---- OLD coord-list format ----
2367
+ else:
2368
+ for it in items:
2369
+ try:
2370
+ x, y = it
2371
+ except Exception:
2372
+ # tolerate weird entries
2373
+ continue
2374
+ xi = int(x)
2375
+ yi = int(y)
2376
+ if 0 <= xi < ref_W and 0 <= yi < ref_H:
2377
+ rej_cnt[yi, xi] += 1
2378
+
2379
+ rej_frac = (rej_cnt.astype(np.float32) / float(n_frames))
2323
2380
  rej_any = (rej_frac >= float(comb_threshold_frac))
2324
2381
 
2325
2382
  return rej_cnt, rej_frac, rej_any
2326
2383
 
2327
-
2328
2384
  class _Responder(QObject):
2329
2385
  finished = pyqtSignal(object) # emits the edited dict or None
2330
2386
 
@@ -3835,6 +3891,94 @@ class _MMImage:
3835
3891
  return arr / 65535.0
3836
3892
  return arr
3837
3893
 
3894
+ def read_tile_into(self, dst: np.ndarray, y0, y1, x0, x1, channels: int):
3895
+ """
3896
+ Fill dst (shape (th,tw,C) float32) with the tile.
3897
+ No per-tile allocations.
3898
+ """
3899
+ import numpy as np
3900
+
3901
+ th = y1 - y0
3902
+ tw = x1 - x0
3903
+
3904
+ # dst is (th,tw,C)
3905
+ if self._kind == "fits":
3906
+ d = self._fits_data
3907
+
3908
+ if self.ndim == 2:
3909
+ tile = d[y0:y1, x0:x1] # may be view (memmap) or array
3910
+ # scale BEFORE float cast (tile dtype still int if int on disk)
3911
+ if tile.dtype.kind in ("u", "i"):
3912
+ if self._bitpix == 8:
3913
+ np.multiply(tile, (1.0/255.0), out=dst[..., 0], casting="unsafe")
3914
+ elif self._bitpix == 16:
3915
+ np.multiply(tile, (1.0/65535.0), out=dst[..., 0], casting="unsafe")
3916
+ else:
3917
+ dst[..., 0] = tile
3918
+ else:
3919
+ dst[..., 0] = tile # astropy already float-scaled
3920
+
3921
+ if channels == 3:
3922
+ dst[..., 1] = dst[..., 0]
3923
+ dst[..., 2] = dst[..., 0]
3924
+ return
3925
+
3926
+ # ndim==3
3927
+ sl = [slice(None)] * 3
3928
+ sl[self._spat_axes[0]] = slice(y0, y1)
3929
+ sl[self._spat_axes[1]] = slice(x0, x1)
3930
+ tile = d[tuple(sl)]
3931
+ if (self._color_axis is not None) and (self._color_axis != 2):
3932
+ tile = np.moveaxis(tile, self._color_axis, -1) # now HWC-ish
3933
+
3934
+ # tile might be (th,tw,3) or (3,th,tw) in weird cases; handle quick
3935
+ if tile.ndim == 3 and tile.shape[-1] != 3 and tile.shape[0] == 3:
3936
+ tile = np.moveaxis(tile, 0, -1)
3937
+
3938
+ # scale if integer
3939
+ if tile.dtype.kind in ("u", "i"):
3940
+ div = 1.0
3941
+ if self._bitpix == 8:
3942
+ div = 255.0
3943
+ elif self._bitpix == 16:
3944
+ div = 65535.0
3945
+ # dst float32 = tile / div
3946
+ np.divide(tile, div, out=dst[..., :3], casting="unsafe")
3947
+ else:
3948
+ dst[..., :3] = tile
3949
+
3950
+ # if caller requested mono, collapse to dst[...,0]
3951
+ if channels == 1:
3952
+ # simple luma or first channel; you can choose policy
3953
+ dst[..., 0] = dst[..., 0] # keep R
3954
+ return
3955
+
3956
+ # XISF
3957
+ if self._xisf_memmap is not None:
3958
+ C = 1 if self.ndim == 2 else self.shape[2]
3959
+ if C == 1:
3960
+ tile = self._xisf_memmap[0, y0:y1, x0:x1]
3961
+ dst[..., 0] = tile
3962
+ if channels == 3:
3963
+ dst[..., 1] = dst[..., 0]
3964
+ dst[..., 2] = dst[..., 0]
3965
+ else:
3966
+ tile = np.moveaxis(self._xisf_memmap[:, y0:y1, x0:x1], 0, -1)
3967
+ dst[..., :3] = tile
3968
+ if channels == 1:
3969
+ dst[..., 0] = dst[..., 0]
3970
+ else:
3971
+ tile = self._xisf_arr[y0:y1, x0:x1]
3972
+ if tile.ndim == 2:
3973
+ dst[..., 0] = tile
3974
+ if channels == 3:
3975
+ dst[..., 1] = dst[..., 0]
3976
+ dst[..., 2] = dst[..., 0]
3977
+ else:
3978
+ dst[..., :3] = tile
3979
+ if channels == 1:
3980
+ dst[..., 0] = dst[..., 0]
3981
+
3838
3982
 
3839
3983
  def read_tile(self, y0, y1, x0, x1) -> np.ndarray:
3840
3984
  import os
@@ -11382,10 +11526,7 @@ class StackingSuiteDialog(QDialog):
11382
11526
  tw = x1 - x0
11383
11527
  ts = buf[:N, :th, :tw, :channels]
11384
11528
  for i, src in enumerate(sources):
11385
- sub = src.read_tile(y0, y1, x0, x1)
11386
- if sub.ndim == 2:
11387
- sub = sub[:, :, None] if channels == 1 else sub[:, :, None].repeat(3, axis=2)
11388
- ts[i, :, :, :] = sub
11529
+ src.read_tile_into(ts[i], y0, y1, x0, x1, channels)
11389
11530
  return th, tw
11390
11531
 
11391
11532
  tp = ThreadPoolExecutor(max_workers=1)
@@ -16965,33 +17106,217 @@ class StackingSuiteDialog(QDialog):
16965
17106
  QApplication.processEvents()
16966
17107
 
16967
17108
 
16968
- def save_rejection_map_sasr(self, rejection_map, out_file):
17109
+ def save_rejection_map_sasr(self, rejection_map: dict, out_path: str):
16969
17110
  """
16970
- Writes the per-file rejection map to a custom text file.
16971
- Format:
16972
- FILE: path/to/file1
16973
- x1, y1
16974
- x2, y2
16975
-
16976
- FILE: path/to/file2
16977
- ...
17111
+ Save per-file rejection information to .sasr (JSON).
17112
+
17113
+ Supports BOTH formats:
17114
+ Old: rejection_map[file] = [(x, y), ...]
17115
+ New: rejection_map[file] = [(x0, y0, mask_bool_thx_tw), ...]
17116
+ where mask_bool is a 2D boolean ndarray for that tile in REF space.
17117
+
17118
+ For the new format we bit-pack masks to keep files small.
16978
17119
  """
16979
- with open(out_file, "w") as f:
16980
- for fpath, coords_list in rejection_map.items():
16981
- f.write(f"FILE: {fpath}\n")
16982
- for (x, y) in coords_list:
16983
- # Convert to Python int in case they're NumPy int64
16984
- f.write(f"{int(x)}, {int(y)}\n")
16985
- f.write("\n") # blank line to separate blocks
17120
+ import os, json, base64
17121
+ import numpy as np
17122
+
17123
+ def _is_tile_item(it) -> bool:
17124
+ return isinstance(it, (tuple, list)) and len(it) == 3
17125
+
17126
+ def _is_xy_item(it) -> bool:
17127
+ return isinstance(it, (tuple, list)) and len(it) == 2
17128
+
17129
+ payload = {
17130
+ "version": 2, # bump because we now support tile masks
17131
+ "kind": "mixed", # per-file can be xy or tiles
17132
+ "files": {}
17133
+ }
17134
+
17135
+ for fpath, items in (rejection_map or {}).items():
17136
+ if not items:
17137
+ continue
17138
+
17139
+ # detect which format this file uses
17140
+ first = items[0]
17141
+ if _is_tile_item(first):
17142
+ out_items = []
17143
+ for it in items:
17144
+ try:
17145
+ x0, y0, m = it
17146
+ except Exception:
17147
+ continue
17148
+ if m is None:
17149
+ continue
17150
+
17151
+ mm = np.asarray(m, dtype=np.bool_)
17152
+ if mm.ndim != 2:
17153
+ mm = np.squeeze(mm)
17154
+ if mm.ndim != 2:
17155
+ continue
17156
+
17157
+ th, tw = mm.shape
17158
+ if th <= 0 or tw <= 0:
17159
+ continue
17160
+
17161
+ packed = np.packbits(mm.reshape(-1).astype(np.uint8))
17162
+ b64 = base64.b64encode(packed.tobytes()).decode("ascii")
17163
+
17164
+ out_items.append({
17165
+ "x0": int(x0),
17166
+ "y0": int(y0),
17167
+ "h": int(th),
17168
+ "w": int(tw),
17169
+ "bits": b64
17170
+ })
17171
+
17172
+ payload["files"][os.path.normpath(fpath)] = {
17173
+ "format": "tilemask",
17174
+ "tiles": out_items
17175
+ }
17176
+
17177
+ elif _is_xy_item(first):
17178
+ # old (x,y) list
17179
+ xy = []
17180
+ for it in items:
17181
+ try:
17182
+ x, y = it
17183
+ except Exception:
17184
+ continue
17185
+ xy.append([int(x), int(y)])
17186
+
17187
+ payload["files"][os.path.normpath(fpath)] = {
17188
+ "format": "xy",
17189
+ "coords": xy
17190
+ }
17191
+
17192
+ else:
17193
+ # Unknown / mixed junk — try to salvage
17194
+ xy = []
17195
+ tiles = []
17196
+ for it in items:
17197
+ if _is_xy_item(it):
17198
+ x, y = it
17199
+ xy.append([int(x), int(y)])
17200
+ elif _is_tile_item(it):
17201
+ x0, y0, m = it
17202
+ mm = np.asarray(m, dtype=np.bool_)
17203
+ if mm.ndim != 2:
17204
+ mm = np.squeeze(mm)
17205
+ if mm.ndim != 2:
17206
+ continue
17207
+ th, tw = mm.shape
17208
+ packed = np.packbits(mm.reshape(-1).astype(np.uint8))
17209
+ b64 = base64.b64encode(packed.tobytes()).decode("ascii")
17210
+ tiles.append({"x0": int(x0), "y0": int(y0), "h": int(th), "w": int(tw), "bits": b64})
17211
+
17212
+ if tiles:
17213
+ payload["files"][os.path.normpath(fpath)] = {"format": "tilemask", "tiles": tiles}
17214
+ elif xy:
17215
+ payload["files"][os.path.normpath(fpath)] = {"format": "xy", "coords": xy}
17216
+
17217
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
17218
+ with open(out_path, "w", encoding="utf-8") as f:
17219
+ json.dump(payload, f, indent=2)
17220
+
16986
17221
 
16987
17222
  def load_rejection_map_sasr(self, in_file):
16988
17223
  """
16989
- Reads a .sasr text file and rebuilds the rejection map dictionary.
16990
- Returns a dict { fpath: [(x, y), (x, y), ...], ... }
17224
+ Load a .sasr rejection map.
17225
+
17226
+ Supports:
17227
+ - V1 legacy text format:
17228
+ FILE: <path>
17229
+ x, y
17230
+ x, y
17231
+
17232
+ Returns: { fpath: [(x, y), ...] }
17233
+
17234
+ - V2 JSON format (tile masks, bit-packed):
17235
+ Returns: { fpath: [(x0, y0, mask_bool_2d), ...] }
17236
+
17237
+ Notes:
17238
+ - This function returns whatever the file contains (xy or tilemask).
17239
+ - Your drizzle path expects tilemask format keyed by *aligned path*.
16991
17240
  """
17241
+ import os, re, json, base64
17242
+ import numpy as np
17243
+
17244
+ # Read file
17245
+ with open(in_file, "r", encoding="utf-8") as f:
17246
+ content = f.read()
17247
+
17248
+ s = content.lstrip()
17249
+
17250
+ # -----------------------
17251
+ # V2: JSON tilemask format
17252
+ # -----------------------
17253
+ if s.startswith("{") or s.startswith("["):
17254
+ try:
17255
+ obj = json.loads(content)
17256
+ except Exception:
17257
+ obj = None
17258
+
17259
+ if isinstance(obj, dict) and "files" in obj:
17260
+ files = obj.get("files") or {}
17261
+ rejections = {}
17262
+
17263
+ for raw_path, entry in files.items():
17264
+ if not isinstance(entry, dict):
17265
+ continue
17266
+ fmt = (entry.get("format") or "").lower()
17267
+
17268
+ if fmt == "xy":
17269
+ coords = []
17270
+ for xy in entry.get("coords") or []:
17271
+ try:
17272
+ x, y = xy
17273
+ coords.append((int(x), int(y)))
17274
+ except Exception:
17275
+ continue
17276
+ if coords:
17277
+ rejections[os.path.normpath(raw_path)] = coords
17278
+
17279
+ elif fmt == "tilemask":
17280
+ tiles_out = []
17281
+ for t in entry.get("tiles") or []:
17282
+ try:
17283
+ x0 = int(t["x0"])
17284
+ y0 = int(t["y0"])
17285
+ h = int(t["h"])
17286
+ w = int(t["w"])
17287
+ b64 = t["bits"]
17288
+ except Exception:
17289
+ continue
17290
+
17291
+ try:
17292
+ packed = np.frombuffer(base64.b64decode(b64.encode("ascii")), dtype=np.uint8)
17293
+ bits = np.unpackbits(packed) # 1D, length >= h*w
17294
+ bits = bits[: (h * w)]
17295
+ mask = (bits.reshape(h, w).astype(bool))
17296
+ except Exception:
17297
+ continue
17298
+
17299
+ tiles_out.append((x0, y0, mask))
17300
+
17301
+ if tiles_out:
17302
+ rejections[os.path.normpath(raw_path)] = tiles_out
17303
+
17304
+ else:
17305
+ # Unknown entry type; ignore
17306
+ continue
17307
+
17308
+ return rejections
17309
+
17310
+ # If JSON but not our format, fall through to legacy parse attempt
17311
+ # (some people may have old text that starts with '{' accidentally)
17312
+
17313
+ # -----------------------
17314
+ # V1: legacy text format
17315
+ # -----------------------
16992
17316
  rejections = {}
16993
- with open(in_file, "r") as f:
16994
- content = f.read().strip()
17317
+ content = content.strip()
17318
+ if not content:
17319
+ return rejections
16995
17320
 
16996
17321
  # Split on blank lines
16997
17322
  blocks = re.split(r"\n\s*\n", content)
@@ -17000,21 +17325,23 @@ class StackingSuiteDialog(QDialog):
17000
17325
  if not lines:
17001
17326
  continue
17002
17327
 
17003
- # First line should be 'FILE: <path>'
17004
17328
  if lines[0].startswith("FILE:"):
17005
17329
  raw_path = lines[0].replace("FILE:", "").strip()
17006
17330
  coords = []
17007
17331
  for line in lines[1:]:
17008
- # Each subsequent line is "x, y"
17009
17332
  parts = line.split(",")
17010
17333
  if len(parts) == 2:
17011
17334
  x_str, y_str = parts
17012
- x = int(x_str.strip())
17013
- y = int(y_str.strip())
17335
+ try:
17336
+ x = int(x_str.strip())
17337
+ y = int(y_str.strip())
17338
+ except Exception:
17339
+ continue
17014
17340
  coords.append((x, y))
17015
- rejections[raw_path] = coords
17016
- return rejections
17341
+ if coords:
17342
+ rejections[os.path.normpath(raw_path)] = coords
17017
17343
 
17344
+ return rejections
17018
17345
 
17019
17346
  @pyqtSlot(list, dict, result=object) # (files: list[str], initial_xy: dict[str, (x,y)]) -> dict|None
17020
17347
  def show_comet_preview(self, files, initial_xy):
@@ -17532,6 +17859,14 @@ class StackingSuiteDialog(QDialog):
17532
17859
 
17533
17860
  # ---- Drizzle bookkeeping for this group ----
17534
17861
  if drizzle_enabled_global:
17862
+
17863
+ try:
17864
+ any_key = next(iter(rejection_map))
17865
+ first_item = rejection_map[any_key][0] if rejection_map[any_key] else None
17866
+ log(f"🔎 rej_map sample: key={os.path.basename(any_key)} item_type={type(first_item)} len={len(first_item) if hasattr(first_item,'__len__') else 'NA'}")
17867
+ except Exception:
17868
+ pass
17869
+
17535
17870
  sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
17536
17871
  self.save_rejection_map_sasr(rejection_map, sasr_path)
17537
17872
  log(f"✅ Saved rejection map to {sasr_path}")
@@ -17650,7 +17985,8 @@ class StackingSuiteDialog(QDialog):
17650
17985
  *,
17651
17986
  algo_override: str | None = None
17652
17987
  ):
17653
-
17988
+ collect_per_file = bool(self._get_drizzle_enabled())
17989
+ per_file_rejections = {f: [] for f in file_list} if collect_per_file else None
17654
17990
  debug_starrem = bool(self.settings.value("stacking/comet_starrem/debug_dump", False, type=bool))
17655
17991
  debug_dir = os.path.join(self.stacking_directory, "debug_comet_starrem")
17656
17992
  os.makedirs(debug_dir, exist_ok=True)
@@ -17969,10 +18305,13 @@ class StackingSuiteDialog(QDialog):
17969
18305
  rej_any[y0:y1, x0:x1] |= np.any(trm, axis=0)
17970
18306
  rej_count[y0:y1, x0:x1] += trm.sum(axis=0).astype(np.uint16)
17971
18307
 
17972
- for i, fpath in enumerate(file_list):
17973
- ys, xs = np.where(trm[i])
17974
- if ys.size:
17975
- per_file_rejections[fpath].extend(zip(x0 + xs, y0 + ys))
18308
+ if collect_per_file:
18309
+ for i, fpath in enumerate(file_list):
18310
+ m = trm[i]
18311
+ if np.any(m):
18312
+ # store as a tile mask, not coord list
18313
+ per_file_rejections[fpath].append((x0, y0, m.copy()))
18314
+
17976
18315
 
17977
18316
  # Close readers and clean temp
17978
18317
  for s in sources:
@@ -18849,13 +19188,13 @@ class StackingSuiteDialog(QDialog):
18849
19188
  frame_weights,
18850
19189
  scale_factor,
18851
19190
  drop_shrink,
18852
- rejection_map,
19191
+ rejection_map, # NEW FORMAT: { aligned_path: [(x0,y0,mask_bool), ...] }
18853
19192
  autocrop_enabled,
18854
19193
  rect_override,
18855
19194
  status_cb
18856
19195
  ):
18857
19196
  import os
18858
-
19197
+ import numpy as np
18859
19198
  import cv2
18860
19199
  from datetime import datetime
18861
19200
  from astropy.io import fits
@@ -18873,7 +19212,7 @@ class StackingSuiteDialog(QDialog):
18873
19212
  log(f"ℹ️ Using in-memory REF_SHAPE fallback: {ref_H}×{ref_W}")
18874
19213
  else:
18875
19214
  log("⚠️ Missing REF_SHAPE in SASD; cannot drizzle.")
18876
- return
19215
+ return False
18877
19216
 
18878
19217
  log(f"✅ SASD v2: loaded {len(xforms)} transform(s).")
18879
19218
 
@@ -18907,8 +19246,14 @@ class StackingSuiteDialog(QDialog):
18907
19246
  else:
18908
19247
  _kcode = 0 # square
18909
19248
 
18910
- total_rej = sum(len(v) for v in (rejection_map or {}).values())
18911
- log(f"🔭 Drizzle stacking for group '{group_key}' with {total_rej} total rejected pixels.")
19249
+ # Rejection logging: now "tiles", not "pixels"
19250
+ total_tiles = 0
19251
+ if rejection_map:
19252
+ try:
19253
+ total_tiles = sum(len(v) for v in rejection_map.values())
19254
+ except Exception:
19255
+ total_tiles = 0
19256
+ log(f"🔭 Drizzle stacking for group '{group_key}' with {total_tiles} rejection tiles.")
18912
19257
 
18913
19258
  # Need at least 2 frames to be worth it
18914
19259
  if len(file_list) < 2:
@@ -18953,9 +19298,118 @@ class StackingSuiteDialog(QDialog):
18953
19298
  # ---- allocate buffers ----
18954
19299
  out_h = int(canvas_H)
18955
19300
  out_w = int(canvas_W)
18956
- drizzle_buffer = np.zeros((out_h, out_w) if is_mono else (out_h, out_w, c), dtype=self._dtype())
19301
+ drizzle_buffer = np.zeros((out_h, out_w) if is_mono else (out_h, out_w, c), dtype=self._dtype())
18957
19302
  coverage_buffer = np.zeros_like(drizzle_buffer, dtype=self._dtype())
18958
- finalize_func = finalize_drizzle_2d if is_mono else finalize_drizzle_3d
19303
+ finalize_func = finalize_drizzle_2d if is_mono else finalize_drizzle_3d
19304
+
19305
+ # ---- helpers for rejection masks ----
19306
+ dilate_px = int(self.settings.value("stacking/reject_dilate_px", 0, type=int))
19307
+ dilate_shape = (self.settings.value("stacking/reject_dilate_shape", "square", type=str) or "square").lower()
19308
+
19309
+ def _make_dilate_kernel(r: int, shape_name: str):
19310
+ if r <= 0:
19311
+ return None
19312
+ ksz = 2 * r + 1
19313
+ if shape_name.startswith("dia"):
19314
+ k = np.zeros((ksz, ksz), np.uint8)
19315
+ for yy in range(-r, r + 1):
19316
+ for xx in range(-r, r + 1):
19317
+ if (abs(xx) + abs(yy)) <= r:
19318
+ k[yy + r, xx + r] = 1
19319
+ return k
19320
+ # square default
19321
+ return np.ones((ksz, ksz), np.uint8)
19322
+
19323
+ _dilate_kernel = _make_dilate_kernel(dilate_px, dilate_shape)
19324
+
19325
+ def _build_rej_mask_ref(aligned_key: str):
19326
+ """
19327
+ Build a full-frame ref-space rejection mask (ref_H, ref_W) from tile masks:
19328
+ rejection_map[aligned_key] = [(x0,y0,mask_bool_thx_tw), ...]
19329
+ Returns None if no tiles.
19330
+ """
19331
+ if not rejection_map:
19332
+ return None
19333
+ tiles = rejection_map.get(aligned_key, None)
19334
+ if not tiles:
19335
+ return None
19336
+
19337
+ mref = np.zeros((ref_H, ref_W), dtype=np.bool_)
19338
+ for item in tiles:
19339
+ try:
19340
+ x0, y0, m = item
19341
+ except Exception:
19342
+ continue
19343
+ if m is None:
19344
+ continue
19345
+ # ensure bool
19346
+ mm = np.asarray(m, dtype=np.bool_)
19347
+ th, tw = mm.shape[:2]
19348
+ if th <= 0 or tw <= 0:
19349
+ continue
19350
+ x0i = int(x0); y0i = int(y0)
19351
+ if x0i >= ref_W or y0i >= ref_H:
19352
+ continue
19353
+ x1i = min(ref_W, x0i + tw)
19354
+ y1i = min(ref_H, y0i + th)
19355
+ if x1i <= x0i or y1i <= y0i:
19356
+ continue
19357
+ mref[y0i:y1i, x0i:x1i] |= mm[:(y1i - y0i), :(x1i - x0i)]
19358
+
19359
+ if _dilate_kernel is not None:
19360
+ mref = cv2.dilate(mref.astype(np.uint8), _dilate_kernel, iterations=1).astype(bool)
19361
+ return mref
19362
+
19363
+ def _apply_rejections_to_img(img_data: np.ndarray, rej_mask_ref: np.ndarray, pixels_are_registered: bool, A23: np.ndarray):
19364
+ """
19365
+ Apply ref-space rejection mask to img_data:
19366
+ - if pixels_are_registered: img_data is already in ref space -> direct apply
19367
+ - else: img_data is raw/original and A23 maps raw->ref -> warp mask ref->raw once, then apply
19368
+ """
19369
+ if rej_mask_ref is None:
19370
+ return img_data
19371
+
19372
+ # after channel extraction, img_data may be 2D
19373
+ if img_data.ndim == 2:
19374
+ Hraw, Wraw = img_data.shape
19375
+ else:
19376
+ Hraw, Wraw = img_data.shape[0], img_data.shape[1]
19377
+
19378
+ if pixels_are_registered:
19379
+ # Clip mask to actual image dims (paranoia)
19380
+ hh = min(rej_mask_ref.shape[0], Hraw)
19381
+ ww = min(rej_mask_ref.shape[1], Wraw)
19382
+ m = rej_mask_ref[:hh, :ww]
19383
+ if img_data.ndim == 2:
19384
+ img_data[:hh, :ww][m] = 0.0
19385
+ else:
19386
+ img_data[:hh, :ww, :][m, :] = 0.0
19387
+ return img_data
19388
+
19389
+ # affine case: A23 maps raw->ref. Need ref-mask in raw space -> warp with inverse affine.
19390
+ try:
19391
+ invA23 = cv2.invertAffineTransform(A23.astype(np.float32))
19392
+ except Exception:
19393
+ # fallback to numpy invert of 3x3, then slice
19394
+ Hc = np.eye(3, dtype=np.float32)
19395
+ Hc[:2, :] = A23.astype(np.float32)
19396
+ Hinv = np.linalg.inv(Hc)
19397
+ invA23 = Hinv[:2, :].astype(np.float32)
19398
+
19399
+ raw_mask = cv2.warpAffine(
19400
+ rej_mask_ref.astype(np.uint8),
19401
+ invA23,
19402
+ (Wraw, Hraw),
19403
+ flags=cv2.INTER_NEAREST,
19404
+ borderMode=cv2.BORDER_CONSTANT,
19405
+ borderValue=0
19406
+ ).astype(bool)
19407
+
19408
+ if img_data.ndim == 2:
19409
+ img_data[raw_mask] = 0.0
19410
+ else:
19411
+ img_data[raw_mask, :] = 0.0
19412
+ return img_data
18959
19413
 
18960
19414
  # ---- main loop over ENTRIES ----
18961
19415
  # each entry: {"orig": <original path>, "aligned": <aligned path>, "chan": "R"/"G"/None}
@@ -19052,50 +19506,16 @@ class StackingSuiteDialog(QDialog):
19052
19506
  f"canvas {int(ref_W * scale_factor)}×{int(ref_H * scale_factor)} @ {scale_factor}×"
19053
19507
  )
19054
19508
 
19055
- # ---- apply per-file rejections (lookup by ALIGNED file) ----
19056
- if rejection_map and aligned_file in rejection_map:
19057
- coords_for_this_file = rejection_map.get(aligned_file, [])
19058
- if coords_for_this_file:
19059
- dilate_px = int(self.settings.value("stacking/reject_dilate_px", 0, type=int))
19060
- dilate_shape = (self.settings.value("stacking/reject_dilate_shape", "square", type=str) or "square").lower()
19061
-
19062
- offsets = [(0, 0)]
19063
- if dilate_px > 0:
19064
- r = dilate_px
19065
- offsets = [(dx, dy) for dx in range(-r, r + 1) for dy in range(-r, r + 1)]
19066
- if dilate_shape.startswith("dia"):
19067
- offsets = [(dx, dy) for (dx, dy) in offsets if (abs(dx) + abs(dy) <= r)]
19068
-
19069
- # after optional channel extraction, img_data may be 2D
19070
- if img_data.ndim == 2:
19071
- Hraw, Wraw = img_data.shape
19072
- else:
19073
- Hraw, Wraw = img_data.shape[0], img_data.shape[1]
19074
-
19075
- if pixels_are_registered:
19076
- # Directly zero registered pixels
19077
- for (x_r, y_r) in coords_for_this_file:
19078
- for (ox, oy) in offsets:
19079
- xr, yr = x_r + ox, y_r + oy
19080
- if 0 <= xr < Wraw and 0 <= yr < Hraw:
19081
- if img_data.ndim == 2:
19082
- img_data[yr, xr] = 0.0
19083
- else:
19084
- img_data[yr, xr, :] = 0.0
19085
- else:
19086
- # Back-project via inverse affine (H_canvas)
19087
- Hinv = np.linalg.inv(H_canvas)
19088
- for (x_r, y_r) in coords_for_this_file:
19089
- for (ox, oy) in offsets:
19090
- xr, yr = x_r + ox, y_r + oy
19091
- v = Hinv @ np.array([xr, yr, 1.0], np.float32)
19092
- x_raw = int(round(v[0] / max(v[2], 1e-8)))
19093
- y_raw = int(round(v[1] / max(v[2], 1e-8)))
19094
- if 0 <= x_raw < Wraw and 0 <= y_raw < Hraw:
19095
- if img_data.ndim == 2:
19096
- img_data[y_raw, x_raw] = 0.0
19097
- else:
19098
- img_data[y_raw, x_raw, :] = 0.0
19509
+ # ---- apply per-file rejections (FAST: tile masks -> full mask -> optional warp) ----
19510
+ rej_mask_ref = _build_rej_mask_ref(aligned_file)
19511
+ if rej_mask_ref is not None:
19512
+ A23_for_mask = H_canvas[:2, :] # raw->ref affine (or identity)
19513
+ img_data = _apply_rejections_to_img(
19514
+ img_data=img_data,
19515
+ rej_mask_ref=rej_mask_ref,
19516
+ pixels_are_registered=pixels_are_registered,
19517
+ A23=A23_for_mask
19518
+ )
19099
19519
 
19100
19520
  # ---- deposit ----
19101
19521
  A23 = H_canvas[:2, :]
@@ -19131,24 +19551,24 @@ class StackingSuiteDialog(QDialog):
19131
19551
  out_path_orig = self._build_out(self._master_light_dir(), base_stem, "fit")
19132
19552
 
19133
19553
  hdr_orig = hdr.copy() if hdr is not None else fits.Header()
19134
- hdr_orig["IMAGETYP"] = "MASTER STACK - DRIZZLE"
19554
+ hdr_orig["IMAGETYP"] = "MASTER STACK - DRIZZLE"
19135
19555
  hdr_orig["DRIZFACTOR"] = (float(scale_factor), "Drizzle scale factor")
19136
- hdr_orig["DROPFRAC"] = (float(drop_shrink), "Drizzle drop shrink/pixfrac")
19137
- hdr_orig["CREATOR"] = "SetiAstroSuite"
19138
- hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
19556
+ hdr_orig["DROPFRAC"] = (float(drop_shrink), "Drizzle drop shrink/pixfrac")
19557
+ hdr_orig["CREATOR"] = "SetiAstroSuite"
19558
+ hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
19139
19559
 
19140
19560
  n_frames = int(len(file_list))
19141
19561
  hdr_orig["NCOMBINE"] = (n_frames, "Number of frames combined")
19142
- hdr_orig["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19562
+ hdr_orig["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19143
19563
 
19144
19564
  if final_drizzle.ndim == 2:
19145
- hdr_orig["NAXIS"] = 2
19565
+ hdr_orig["NAXIS"] = 2
19146
19566
  hdr_orig["NAXIS1"] = final_drizzle.shape[1]
19147
19567
  hdr_orig["NAXIS2"] = final_drizzle.shape[0]
19148
19568
  if "NAXIS3" in hdr_orig:
19149
19569
  del hdr_orig["NAXIS3"]
19150
19570
  else:
19151
- hdr_orig["NAXIS"] = 3
19571
+ hdr_orig["NAXIS"] = 3
19152
19572
  hdr_orig["NAXIS1"] = final_drizzle.shape[1]
19153
19573
  hdr_orig["NAXIS2"] = final_drizzle.shape[0]
19154
19574
  hdr_orig["NAXIS3"] = final_drizzle.shape[2]
@@ -19175,7 +19595,7 @@ class StackingSuiteDialog(QDialog):
19175
19595
  rect_override=rect_override
19176
19596
  )
19177
19597
  hdr_crop["NCOMBINE"] = (n_frames, "Number of frames combined")
19178
- hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19598
+ hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19179
19599
 
19180
19600
  is_mono_crop = (cropped_drizzle.ndim == 2)
19181
19601
  display_group_driz_crop = self._label_with_dims(group_key, cropped_drizzle.shape[1], cropped_drizzle.shape[0])
@@ -19196,6 +19616,7 @@ class StackingSuiteDialog(QDialog):
19196
19616
  self._autocrop_outputs = []
19197
19617
  self._autocrop_outputs.append((group_key, out_path_crop))
19198
19618
  log(f"✂️ Drizzle (auto-cropped) saved: {out_path_crop}")
19619
+
19199
19620
  return True
19200
19621
 
19201
19622
  def _load_sasd_v2(self, path: str):
@@ -19310,7 +19731,8 @@ class StackingSuiteDialog(QDialog):
19310
19731
  algo_override: str | None = None
19311
19732
  ):
19312
19733
  import errno
19313
-
19734
+ collect_per_file = bool(self._get_drizzle_enabled())
19735
+ per_file_rejections = {f: [] for f in file_list} if collect_per_file else None
19314
19736
  log = status_cb or (lambda *_: None)
19315
19737
  log(f"Starting integration for group '{group_key}' with {len(file_list)} files.")
19316
19738
  if not file_list:
@@ -19622,10 +20044,12 @@ class StackingSuiteDialog(QDialog):
19622
20044
  rej_count[y0:y1, x0:x1] += trm.sum(axis=0).astype(np.uint16)
19623
20045
 
19624
20046
  # per-file coords (existing behavior)
19625
- for i, fpath in enumerate(file_list):
19626
- ys, xs = np.where(trm[i])
19627
- if ys.size:
19628
- per_file_rejections[fpath].extend(zip(x0 + xs, y0 + ys))
20047
+ if collect_per_file:
20048
+ for i, fpath in enumerate(file_list):
20049
+ m = trm[i]
20050
+ if np.any(m):
20051
+ # store as a tile mask, not coord list
20052
+ per_file_rejections[fpath].append((x0, y0, m.copy()))
19629
20053
 
19630
20054
  # perf log
19631
20055
  dt = time.perf_counter() - t0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setiastrosuitepro
3
- Version: 1.7.5
3
+ Version: 1.7.5.post1
4
4
  Summary: Seti Astro Suite Pro - Advanced astrophotography toolkit for image calibration, stacking, registration, photometry, and visualization
5
5
  License: GPL-3.0
6
6
  License-File: LICENSE
@@ -183,7 +183,7 @@ setiastro/qml/ResourceMonitor.qml,sha256=k9_qXKAZLi8vj-5BffJTJu_UkRnxunZKn53Hthd
183
183
  setiastro/saspro/__init__.py,sha256=6o8orhytzBWyJWBdG37xPcT6IVPZOG8d22FBVzn0Kac,902
184
184
  setiastro/saspro/__main__.py,sha256=_zAHsw4SNWYzMXlswBuiMGgTUGYrU5p5-dsxhQ3_GWk,40789
185
185
  setiastro/saspro/_generated/__init__.py,sha256=HbruQfKNbbVL4kh_t4oVG3UeUieaW8MUaqIcDCmnTvA,197
186
- setiastro/saspro/_generated/build_info.py,sha256=RdKarljxfwZqtbmi2IOSxR1gufhzVpP3aN5pC-MNLsA,111
186
+ setiastro/saspro/_generated/build_info.py,sha256=05WXuNKXhrTskHfLfuo-I6cQRkffHcmX6SOeqDMBgSU,117
187
187
  setiastro/saspro/abe.py,sha256=l6o0klT-TqJppzEjCBb9isyIvtuf_UsuMZV8JvqbgPI,59779
188
188
  setiastro/saspro/abe_preset.py,sha256=u9t16yTb9v98tLjhvh496Fsp3Z-dNiBSs5itnAaJwh8,7750
189
189
  setiastro/saspro/aberration_ai.py,sha256=8LtDu_KuqvL-W0MTUIJq9KguMjJPiUEQjMUibY16H-4,41673
@@ -200,7 +200,7 @@ setiastro/saspro/backgroundneutral.py,sha256=MFlBEwR65ASNXIY0S-0ZUaS89FCmNV_qafP
200
200
  setiastro/saspro/batch_convert.py,sha256=O46KyB-DBZiTHokaWt_op1GDRr3juSaDSeFzP3pv1Zs,12377
201
201
  setiastro/saspro/batch_renamer.py,sha256=PZe2MEwjg0n055UETIbwnwdfAux4heTVx5gr3qnNW5g,21900
202
202
  setiastro/saspro/blemish_blaster.py,sha256=aiIpr-qpDmfaLZErUGUtwQgSTnaBHz3l14JvDWRfL3w,22983
203
- setiastro/saspro/blink_comparator_pro.py,sha256=XwXR-0Xj6Z3R3IYheTRwdyhpnGMiwXh3lpziAZzWRdE,132085
203
+ setiastro/saspro/blink_comparator_pro.py,sha256=n4-OQWZ42yT1JfsbUXzUwFopA4-CTlp5kicrVvlJiW4,138274
204
204
  setiastro/saspro/bundles.py,sha256=jWMYpalOcaMQ1QgikseOOmF7bBzDz9aykhRddaTsReQ,1892
205
205
  setiastro/saspro/bundles_dock.py,sha256=IEAJEOEEe7V4NvsmClMygTf55LXN3SkV4e23K1erzkc,4441
206
206
  setiastro/saspro/cheat_sheet.py,sha256=0bLmwY3GBWGcb4M7BBYzzrB_03XfqvZWAhQ2cBPavQ8,7852
@@ -333,7 +333,7 @@ setiastro/saspro/serviewer.py,sha256=QvPtJky2IzrywXaOYjeSZSNY0I64TSrzfgH7vRgGk7M
333
333
  setiastro/saspro/sfcc.py,sha256=4wgXqH-E0OoSl_Ox4tMoExUHSnjosx2EnjjKeOXyZgo,89133
334
334
  setiastro/saspro/shortcuts.py,sha256=QvFBXN_S8jqEwaP9m4pJMLVqzBmxo5HrjWhVCV9etQg,138256
335
335
  setiastro/saspro/signature_insert.py,sha256=pWDxUO1Rxm27_fHSo2Y99bdOD2iG9q4AUjGR20x6TiA,70401
336
- setiastro/saspro/stacking_suite.py,sha256=-eOQN43IngMo4ywpdSyFLn8nfaoxX4KTfks32djr9u4,880749
336
+ setiastro/saspro/stacking_suite.py,sha256=z3aTP10RfXjxilbzLAFBtJ37aPL3ZAKDOrxdVIASmdk,895924
337
337
  setiastro/saspro/star_alignment.py,sha256=lRpDFvDKsMluu2P_kotQ9OumlnGtEWwGE7Gm-ZYccm8,326894
338
338
  setiastro/saspro/star_alignment_preset.py,sha256=1QLRRUsVGr3-iX4kKvV9s-NuDRJVnRQat8qdTIs1nao,13164
339
339
  setiastro/saspro/star_metrics.py,sha256=LRfBdqjwJJ9iz_paY-CkhPIqt2MxoXARLUy73ZKTA_0,1503
@@ -409,9 +409,9 @@ setiastro/saspro/wimi.py,sha256=CNo833Pur9P-A1DUSntlAaQWekf6gzWIvetOMvLDrOw,3146
409
409
  setiastro/saspro/wims.py,sha256=HDfVI3Ckf5OJEJLH8NI36pFc2USZnETpb4UDIvweNX4,27450
410
410
  setiastro/saspro/window_shelf.py,sha256=hpId8uRPq7-UZ3dWW5YzH_v4TchQokGFoPGM8dyaIZA,9448
411
411
  setiastro/saspro/xisf.py,sha256=Ah1CXDAohN__ej1Lq7LPU8vGLnDz8fluLQTGE71aUoc,52669
412
- setiastrosuitepro-1.7.5.dist-info/entry_points.txt,sha256=g7cHWhUSiIP7mkyByG9JXGWWlHKeVC2vL7zvB9U-vEU,236
413
- setiastrosuitepro-1.7.5.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
414
- setiastrosuitepro-1.7.5.dist-info/licenses/license.txt,sha256=KCwYZ9VpVwmzjelDq1BzzWqpBvt9nbbapa-woz47hfQ,123930
415
- setiastrosuitepro-1.7.5.dist-info/METADATA,sha256=4chMNL04rErx-qeV865sObWFXcS1VWxrXd_FjaW8-m4,9919
416
- setiastrosuitepro-1.7.5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
417
- setiastrosuitepro-1.7.5.dist-info/RECORD,,
412
+ setiastrosuitepro-1.7.5.post1.dist-info/entry_points.txt,sha256=g7cHWhUSiIP7mkyByG9JXGWWlHKeVC2vL7zvB9U-vEU,236
413
+ setiastrosuitepro-1.7.5.post1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
414
+ setiastrosuitepro-1.7.5.post1.dist-info/licenses/license.txt,sha256=KCwYZ9VpVwmzjelDq1BzzWqpBvt9nbbapa-woz47hfQ,123930
415
+ setiastrosuitepro-1.7.5.post1.dist-info/METADATA,sha256=-drTlx3V1woZS9yNTvmVFOoJukIVfNd5X_PnRpKlRv8,9925
416
+ setiastrosuitepro-1.7.5.post1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
417
+ setiastrosuitepro-1.7.5.post1.dist-info/RECORD,,