setiastrosuitepro 1.7.1.post2__py3-none-any.whl → 1.7.4__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (62) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/saspro/__init__.py +20 -8
  3. setiastro/saspro/__main__.py +349 -290
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/abe.py +4 -4
  6. setiastro/saspro/autostretch.py +29 -18
  7. setiastro/saspro/doc_manager.py +4 -1
  8. setiastro/saspro/gui/main_window.py +46 -7
  9. setiastro/saspro/gui/mixins/file_mixin.py +6 -2
  10. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  11. setiastro/saspro/gui/mixins/toolbar_mixin.py +9 -2
  12. setiastro/saspro/imageops/serloader.py +101 -17
  13. setiastro/saspro/layers.py +186 -10
  14. setiastro/saspro/layers_dock.py +198 -5
  15. setiastro/saspro/legacy/image_manager.py +10 -4
  16. setiastro/saspro/legacy/numba_utils.py +301 -119
  17. setiastro/saspro/numba_utils.py +998 -270
  18. setiastro/saspro/ops/settings.py +6 -6
  19. setiastro/saspro/pixelmath.py +1 -1
  20. setiastro/saspro/planetprojection.py +4059 -0
  21. setiastro/saspro/resources.py +2 -0
  22. setiastro/saspro/save_options.py +45 -13
  23. setiastro/saspro/ser_stack_config.py +21 -1
  24. setiastro/saspro/ser_stacker.py +8 -2
  25. setiastro/saspro/ser_stacker_dialog.py +37 -10
  26. setiastro/saspro/ser_tracking.py +57 -35
  27. setiastro/saspro/serviewer.py +164 -16
  28. setiastro/saspro/sfcc.py +14 -8
  29. setiastro/saspro/stacking_suite.py +292 -111
  30. setiastro/saspro/subwindow.py +64 -36
  31. setiastro/saspro/translations/all_source_strings.json +2 -2
  32. setiastro/saspro/translations/ar_translations.py +3 -3
  33. setiastro/saspro/translations/de_translations.py +2 -2
  34. setiastro/saspro/translations/es_translations.py +2 -2
  35. setiastro/saspro/translations/fr_translations.py +2 -2
  36. setiastro/saspro/translations/hi_translations.py +2 -2
  37. setiastro/saspro/translations/it_translations.py +2 -2
  38. setiastro/saspro/translations/ja_translations.py +2 -2
  39. setiastro/saspro/translations/pt_translations.py +2 -2
  40. setiastro/saspro/translations/ru_translations.py +2 -2
  41. setiastro/saspro/translations/saspro_ar.ts +2 -2
  42. setiastro/saspro/translations/saspro_de.ts +4 -4
  43. setiastro/saspro/translations/saspro_es.ts +2 -2
  44. setiastro/saspro/translations/saspro_fr.ts +2 -2
  45. setiastro/saspro/translations/saspro_hi.ts +2 -2
  46. setiastro/saspro/translations/saspro_it.ts +4 -4
  47. setiastro/saspro/translations/saspro_ja.ts +2 -2
  48. setiastro/saspro/translations/saspro_pt.ts +2 -2
  49. setiastro/saspro/translations/saspro_ru.ts +2 -2
  50. setiastro/saspro/translations/saspro_sw.ts +2 -2
  51. setiastro/saspro/translations/saspro_uk.ts +2 -2
  52. setiastro/saspro/translations/saspro_zh.ts +2 -2
  53. setiastro/saspro/translations/sw_translations.py +2 -2
  54. setiastro/saspro/translations/uk_translations.py +2 -2
  55. setiastro/saspro/translations/zh_translations.py +2 -2
  56. setiastro/saspro/window_shelf.py +62 -1
  57. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/METADATA +1 -1
  58. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/RECORD +62 -60
  59. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/entry_points.txt +1 -1
  60. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/WHEEL +0 -0
  61. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/LICENSE +0 -0
  62. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,4059 @@
1
+ # src/setiastro/saspro/planetprojection.py
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import os
6
+ import tempfile, webbrowser
7
+ import plotly.graph_objects as go
8
+ from PyQt6.QtCore import Qt, QTimer, QPoint, QSize
9
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QColor
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QGroupBox,
12
+ QFormLayout, QDoubleSpinBox, QSpinBox, QCheckBox, QComboBox, QMessageBox,
13
+ QSizePolicy, QFileDialog, QLineEdit, QSlider, QWidget
14
+ )
15
+ from PyQt6 import sip
16
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
17
+
18
+ import cv2
19
+
20
+
21
+ # -----------------------------
22
+ # Core math helpers
23
+ # -----------------------------
24
+
25
+ def _planet_centroid_and_area(ch: np.ndarray):
26
+ """
27
+ Estimate planet centroid (cx,cy) and blob area from a single channel.
28
+ Uses percentile scaling + Otsu + largest component.
29
+ Returns (cx, cy, area) or None.
30
+ """
31
+ if cv2 is None:
32
+ return None
33
+
34
+ img = ch.astype(np.float32, copy=False)
35
+
36
+ p1 = float(np.percentile(img, 1.0))
37
+ p99 = float(np.percentile(img, 99.5))
38
+ if p99 <= p1:
39
+ return None
40
+
41
+ scaled = (img - p1) * (255.0 / (p99 - p1))
42
+ scaled = np.clip(scaled, 0, 255).astype(np.uint8)
43
+ scaled = cv2.GaussianBlur(scaled, (0, 0), 1.2)
44
+
45
+ _, bw = cv2.threshold(scaled, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
46
+
47
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
48
+ bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, k, iterations=1)
49
+ bw = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, k, iterations=2)
50
+
51
+ num, labels, stats, cents = cv2.connectedComponentsWithStats(bw, connectivity=8)
52
+ if num <= 1:
53
+ return None
54
+
55
+ areas = stats[1:, cv2.CC_STAT_AREA]
56
+ j = int(np.argmax(areas)) + 1
57
+ area = float(stats[j, cv2.CC_STAT_AREA])
58
+ if area < 200:
59
+ return None
60
+
61
+ cx, cy = cents[j]
62
+ return (float(cx), float(cy), float(area))
63
+
64
+
65
+ def _compute_roi_from_centroid(H: int, W: int, cx: float, cy: float, area: float,
66
+ pad_mul: float = 3.2,
67
+ min_size: int = 240,
68
+ max_size: int = 900):
69
+ """
70
+ Use area->radius estimate to make an ROI around the disk.
71
+ """
72
+ r = max(32.0, float(np.sqrt(area / np.pi)))
73
+ s = int(np.clip(r * float(pad_mul), float(min_size), float(max_size)))
74
+ cx_i, cy_i = int(round(cx)), int(round(cy))
75
+
76
+ x0 = max(0, cx_i - s // 2)
77
+ y0 = max(0, cy_i - s // 2)
78
+ x1 = min(W, x0 + s)
79
+ y1 = min(H, y0 + s)
80
+ return (x0, y0, x1, y1)
81
+
82
+ def _ellipse_annulus_mask(H: int, W: int, cx: float, cy: float,
83
+ a_outer: float, b_outer: float,
84
+ a_inner: float, b_inner: float,
85
+ pa_deg: float) -> np.ndarray:
86
+ """
87
+ Elliptical annulus mask (outer ellipse minus inner ellipse).
88
+ PA rotates ellipse in image plane.
89
+ """
90
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
91
+ x = xx - float(cx)
92
+ y = yy - float(cy)
93
+
94
+ th = np.deg2rad(float(pa_deg))
95
+ c, s = np.cos(th), np.sin(th)
96
+
97
+ # rotate coords into ellipse frame
98
+ xr = x * c + y * s
99
+ yr = -x * s + y * c
100
+
101
+ outer = (xr*xr)/(a_outer*a_outer + 1e-9) + (yr*yr)/(b_outer*b_outer + 1e-9) <= 1.0
102
+ inner = (xr*xr)/(a_inner*a_inner + 1e-9) + (yr*yr)/(b_inner*b_inner + 1e-9) <= 1.0
103
+
104
+ return outer & (~inner)
105
+
106
+
107
+ def _ring_front_back_masks(H: int, W: int, cx: float, cy: float, pa_deg: float,
108
+ ring_mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
109
+ """
110
+ Split ring pixels into 'front' vs 'back' halves for occlusion.
111
+ Approximation: use ring minor-axis direction.
112
+ """
113
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
114
+ x = xx - float(cx)
115
+ y = yy - float(cy)
116
+
117
+ # minor axis is PA + 90 degrees
118
+ th = np.deg2rad(float(pa_deg) + 90.0)
119
+ nx, ny = np.cos(th), np.sin(th)
120
+
121
+ # signed distance along minor-axis normal
122
+ s = x * nx + y * ny
123
+ front = ring_mask & (s > 0)
124
+ back = ring_mask & (s <= 0)
125
+ return front, back
126
+
127
+
128
+ def _yaw_warp_maps(H: int, W: int, theta_deg: float, cx: float, cy: float) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
129
+ """
130
+ Simple planar 'yaw' warp around vertical axis:
131
+ x' = cx + (x-cx)*cos(a)
132
+ y' = y
133
+ Used for rings to create stereo disparity without bending them onto the sphere.
134
+ """
135
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
136
+ x = xx - float(cx)
137
+
138
+ a = np.deg2rad(float(theta_deg))
139
+ ca = np.cos(a)
140
+
141
+ def make(sign: float):
142
+ # left uses +theta, right uses -theta (sign flips)
143
+ # we only need cos for this simple model, so sign doesn't matter here,
144
+ # but keep signature consistent in case we extend to perspective later.
145
+ mapx = (float(cx) + x * ca).astype(np.float32)
146
+ mapy = yy.astype(np.float32)
147
+ return mapx, mapy
148
+
149
+ mapLx, mapLy = make(+1.0)
150
+ mapRx, mapRy = make(-1.0)
151
+ return mapLx, mapLy, mapRx, mapRy
152
+
153
+
154
+ def _to_u8_preview(rgb: np.ndarray) -> np.ndarray:
155
+ """
156
+ Robust per-channel percentile stretch to uint8 for display.
157
+ """
158
+ x = rgb.astype(np.float32, copy=False)
159
+ out = np.empty_like(x, dtype=np.uint8)
160
+ for c in range(3):
161
+ ch = x[..., c]
162
+ p1 = float(np.percentile(ch, 1.0))
163
+ p99 = float(np.percentile(ch, 99.5))
164
+ if p99 <= p1:
165
+ out[..., c] = 0
166
+ else:
167
+ y = (ch - p1) * (255.0 / (p99 - p1))
168
+ out[..., c] = np.clip(y, 0, 255).astype(np.uint8)
169
+ return out
170
+
171
+
172
+ def _add_starfield(bg01_rgb: np.ndarray, density: float = 0.02, seed: int = 1,
173
+ star_sigma: float = 0.8, brightness: float = 0.9):
174
+ """
175
+ Add a visible synthetic starfield to a float32 RGB image in [0,1].
176
+ Applied identically to left/right so it has ZERO parallax (screen-locked).
177
+ """
178
+ H, W = bg01_rgb.shape[:2]
179
+ rng = np.random.default_rng(int(seed))
180
+
181
+ # allow higher density (0..0.2)
182
+ n = int(np.clip(density, 0.0, 0.5) * H * W)
183
+ if n <= 0:
184
+ return bg01_rgb
185
+
186
+ stars = np.zeros((H, W), dtype=np.float32)
187
+
188
+ ys = rng.integers(0, H, size=n, endpoint=False)
189
+ xs = rng.integers(0, W, size=n, endpoint=False)
190
+
191
+ # brighter distribution (more visible than **6)
192
+ vals = rng.random(n).astype(np.float32)
193
+ vals = (vals ** 2.0) # more mid-range stars
194
+ vals = (0.15 + 0.85 * vals) * float(brightness) # visible floor
195
+
196
+ # a few "bright" stars
197
+ bright_n = max(1, n // 80)
198
+ bi = rng.integers(0, n, size=bright_n, endpoint=False)
199
+ vals[bi] = np.clip(vals[bi] * 2.5, 0.0, 1.0)
200
+
201
+ stars[ys, xs] = np.maximum(stars[ys, xs], vals)
202
+
203
+ if star_sigma > 0:
204
+ stars = cv2.GaussianBlur(stars, (0, 0), float(star_sigma))
205
+
206
+ out = bg01_rgb.astype(np.float32, copy=False).copy()
207
+ out[..., 0] = np.clip(out[..., 0] + stars, 0.0, 1.0)
208
+ out[..., 1] = np.clip(out[..., 1] + stars, 0.0, 1.0)
209
+ out[..., 2] = np.clip(out[..., 2] + stars, 0.0, 1.0)
210
+ return out
211
+
212
+ def _make_anaglyph(L_rgb8: np.ndarray, R_rgb8: np.ndarray, *, swap_eyes: bool = False) -> np.ndarray:
213
+ """
214
+ Build a red/cyan anaglyph from two uint8 RGB images.
215
+ Output is uint8 RGB.
216
+
217
+ Red channel comes from LEFT eye luminance.
218
+ Cyan (G+B) comes from RIGHT eye luminance.
219
+
220
+ swap_eyes=True flips which eye feeds red vs cyan (useful if glasses seem inverted).
221
+ """
222
+ if swap_eyes:
223
+ L_rgb8, R_rgb8 = R_rgb8, L_rgb8
224
+
225
+ L = L_rgb8.astype(np.float32)
226
+ R = R_rgb8.astype(np.float32)
227
+
228
+ # luminance (more robust than using only R/G/B)
229
+ Llum = 0.299 * L[..., 0] + 0.587 * L[..., 1] + 0.114 * L[..., 2]
230
+ Rlum = 0.299 * R[..., 0] + 0.587 * R[..., 1] + 0.114 * R[..., 2]
231
+
232
+ out = np.zeros_like(L, dtype=np.float32)
233
+ out[..., 0] = Llum # red
234
+ out[..., 1] = Rlum # green (cyan)
235
+ out[..., 2] = Rlum # blue (cyan)
236
+
237
+ return np.clip(out, 0, 255).astype(np.uint8)
238
+
239
+
240
+
241
+ def _sphere_reproject_maps(H: int, W: int, theta_deg: float, radius_px: float | None = None):
242
+ """
243
+ Build cv2.remap maps for left/right views using sphere reprojection.
244
+ Returns (mapLx, mapLy, mapRx, mapRy, mask_disk)
245
+ """
246
+ cx = (W - 1) * 0.5
247
+ cy = (H - 1) * 0.5
248
+
249
+ if radius_px is None:
250
+ radius_px = 0.49 * min(W, H) # conservative
251
+
252
+ r = float(radius_px)
253
+
254
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
255
+ x = (xx - cx) / r
256
+ y = (yy - cy) / r
257
+ rr2 = x * x + y * y
258
+ mask = rr2 <= 1.0
259
+
260
+ z = np.zeros_like(x, dtype=np.float32)
261
+ z[mask] = np.sqrt(np.maximum(0.0, 1.0 - rr2[mask])).astype(np.float32)
262
+
263
+ a = np.deg2rad(float(theta_deg))
264
+ ca, sa = np.cos(a), np.sin(a)
265
+
266
+ def make(alpha_sign: float):
267
+ ca2 = ca
268
+ sa2 = sa * alpha_sign
269
+ # rotate around Y
270
+ x2 = x * ca2 + z * sa2
271
+ y2 = y
272
+ mapx = (cx + r * x2).astype(np.float32)
273
+ mapy = (cy + r * y2).astype(np.float32)
274
+ # outside disk: invalid → map to -1 so BORDER_CONSTANT applies
275
+ mapx[~mask] = -1.0
276
+ mapy[~mask] = -1.0
277
+ return mapx, mapy
278
+
279
+ mapLx, mapLy = make(+1.0)
280
+ mapRx, mapRy = make(-1.0)
281
+ return mapLx, mapLy, mapRx, mapRy, mask
282
+
283
+ def make_pseudo_surface_pair(
284
+ rgb: np.ndarray,
285
+ theta_deg: float = 10.0,
286
+ *,
287
+ depth_gamma: float = 1.0,
288
+ blur_sigma: float = 1.2,
289
+ invert: bool = False,
290
+ ):
291
+ """
292
+ Pseudo surface stereo (astro-friendly):
293
+ - height = luminance deviation around median (NO 0..1 normalization)
294
+ - robust amplitude scaling using MAD so stars don't dominate
295
+ - per-pixel horizontal disparity warp
296
+
297
+ Returns (left, right, maskL, maskR) where masks are valid sampling regions.
298
+ """
299
+ if cv2 is None:
300
+ m = np.ones(rgb.shape[:2], dtype=bool)
301
+ return rgb, rgb, m, m
302
+
303
+ x = np.asarray(rgb)
304
+ orig_dtype = x.dtype
305
+
306
+ # --- float01 for remap (image sampling) ---
307
+ if x.dtype == np.uint8:
308
+ xf = x.astype(np.float32) / 255.0
309
+ elif x.dtype == np.uint16:
310
+ xf = x.astype(np.float32) / 65535.0
311
+ else:
312
+ xf = x.astype(np.float32, copy=False)
313
+ # for float inputs we assume "image-like" but keep it sane for remap
314
+ xf = np.clip(xf, 0.0, 1.0)
315
+
316
+ H, W = xf.shape[:2]
317
+
318
+ # --- luminance (float32) ---
319
+ lum = (0.299 * xf[..., 0] + 0.587 * xf[..., 1] + 0.114 * xf[..., 2]).astype(np.float32)
320
+
321
+ # Optional smoothing to reduce noisy depth
322
+ if blur_sigma and blur_sigma > 0:
323
+ lum_s = cv2.GaussianBlur(lum, (0, 0), float(blur_sigma))
324
+ else:
325
+ lum_s = lum
326
+
327
+ # Center around median so "background" ~ 0 height
328
+ h = lum_s - float(np.median(lum_s))
329
+
330
+ # Optional gamma shaping (on magnitude, preserving sign)
331
+ g = float(max(1e-6, depth_gamma))
332
+ if abs(g - 1.0) > 1e-6:
333
+ h = np.sign(h) * (np.abs(h) ** g)
334
+
335
+ # Invert AFTER centering/shaping (so it just flips relief)
336
+ if invert:
337
+ h = -h
338
+
339
+ # Robust amplitude scaling so a few bright stars don't explode the height
340
+ # MAD ~ median(|x - median(x)|)
341
+ mad = float(np.median(np.abs(h)) + 1e-9)
342
+
343
+ # "gain" controls how punchy the height is.
344
+ # Larger gain_div => flatter relief (safer for stars).
345
+ gain_div = 6.0
346
+ h = (h / (gain_div * mad)).astype(np.float32)
347
+
348
+ # Keep height in a sane range so disparity doesn't go nuts
349
+ h = np.clip(h, -1.0, 1.0)
350
+
351
+ # --- disparity scale ---
352
+ # Empirical mapping: theta 6deg on ~600px gives ~15-20px max disparity.
353
+ max_disp = (float(theta_deg) / 25.0) * (0.12 * float(min(H, W)))
354
+ max_disp = float(np.clip(max_disp, 0.0, 0.35 * min(H, W)))
355
+
356
+ disp = h * max_disp # signed pixels: positive => "closer"
357
+
358
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
359
+
360
+ # left/right: split disparity
361
+ mapLx = xx + 0.5 * disp
362
+ mapRx = xx - 0.5 * disp
363
+ mapy = yy
364
+
365
+ # IMPORTANT: INTER_LINEAR avoids Lanczos ringing on stars
366
+ left = cv2.remap(
367
+ xf, mapLx, mapy,
368
+ interpolation=cv2.INTER_LINEAR,
369
+ borderMode=cv2.BORDER_CONSTANT,
370
+ borderValue=0,
371
+ )
372
+ right = cv2.remap(
373
+ xf, mapRx, mapy,
374
+ interpolation=cv2.INTER_LINEAR,
375
+ borderMode=cv2.BORDER_CONSTANT,
376
+ borderValue=0,
377
+ )
378
+
379
+ # validity masks (where sampling stays in-bounds)
380
+ maskL = (mapLx >= 0.0) & (mapLx <= (W - 1))
381
+ maskR = (mapRx >= 0.0) & (mapRx <= (W - 1))
382
+
383
+ # Back to original dtype (preserve your existing behavior)
384
+ if orig_dtype == np.uint8:
385
+ left = np.clip(left * 255.0, 0, 255).astype(np.uint8)
386
+ right = np.clip(right * 255.0, 0, 255).astype(np.uint8)
387
+ elif orig_dtype == np.uint16:
388
+ left = np.clip(left * 65535.0, 0, 65535).astype(np.uint16)
389
+ right = np.clip(right * 65535.0, 0, 65535).astype(np.uint16)
390
+ else:
391
+ # float inputs come back as float (same dtype), but remap ran on float32
392
+ left = left.astype(orig_dtype, copy=False)
393
+ right = right.astype(orig_dtype, copy=False)
394
+
395
+ return left, right, maskL, maskR
396
+
397
+
398
+ def make_stereo_pair(
399
+ roi_rgb: np.ndarray,
400
+ theta_deg: float = 10.0,
401
+ disk_mask: np.ndarray | None = None,
402
+ *,
403
+ interp: int = None,
404
+ ):
405
+ if cv2 is None:
406
+ dummy_mask = np.ones(roi_rgb.shape[:2], dtype=bool)
407
+ return roi_rgb, roi_rgb, dummy_mask, dummy_mask
408
+ if interp is None:
409
+ interp = cv2.INTER_LANCZOS4
410
+ x = roi_rgb
411
+ orig_dtype = x.dtype
412
+
413
+ # Build a REAL disk mask from ROI (use green channel)
414
+ disk = disk_mask if disk_mask is not None else _planet_disk_mask(roi_rgb[...,1])
415
+ if disk is None:
416
+ # fallback: simple circle
417
+ H, W = roi_rgb.shape[:2]
418
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
419
+ cx = (W - 1) * 0.5
420
+ cy = (H - 1) * 0.5
421
+ r = 0.49 * min(W, H)
422
+ disk = ((xx - cx) ** 2 + (yy - cy) ** 2) <= (r * r)
423
+
424
+ # work in float32 for remap
425
+ if x.dtype == np.uint8:
426
+ xf = x.astype(np.float32) / 255.0
427
+ elif x.dtype == np.uint16:
428
+ xf = x.astype(np.float32) / 65535.0
429
+ else:
430
+ xf = x.astype(np.float32, copy=False)
431
+
432
+ H, W = xf.shape[:2]
433
+ # estimate radius_px from disk area (in pixels)
434
+ area_px = float(disk.sum())
435
+ radius = np.sqrt(area_px / np.pi)
436
+ radius = np.clip(radius, 16.0, 0.49 * min(W, H))
437
+
438
+ mapLx, mapLy, mapRx, mapRy, _ = _sphere_reproject_maps(H, W, theta_deg, radius_px=radius)
439
+
440
+ left = cv2.remap(xf, mapLx, mapLy, interpolation=interp,
441
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
442
+ right = cv2.remap(xf, mapRx, mapRy, interpolation=interp,
443
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
444
+
445
+ # Warp the disk mask through the SAME maps
446
+ disk_u8 = (disk.astype(np.uint8) * 255)
447
+ mL = cv2.remap(disk_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
448
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
449
+ mR = cv2.remap(disk_u8, mapRx, mapRy, interpolation=cv2.INTER_NEAREST,
450
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
451
+ maskL = mL > 127
452
+ maskR = mR > 127
453
+
454
+ # convert back to original dtype
455
+ if orig_dtype == np.uint8:
456
+ left = np.clip(left * 255.0, 0, 255).astype(np.uint8)
457
+ right = np.clip(right * 255.0, 0, 255).astype(np.uint8)
458
+ elif orig_dtype == np.uint16:
459
+ left = np.clip(left * 65535.0, 0, 65535).astype(np.uint16)
460
+ right = np.clip(right * 65535.0, 0, 65535).astype(np.uint16)
461
+ else:
462
+ left = left.astype(orig_dtype, copy=False)
463
+ right = right.astype(orig_dtype, copy=False)
464
+
465
+ return left, right, maskL, maskR
466
+
467
+
468
+ def _planet_disk_mask(ch: np.ndarray, grow: float = 1.015) -> np.ndarray | None:
469
+ """
470
+ Return a boolean mask of the planet disk.
471
+ We still segment the planet to find the *component*, but then we create a
472
+ circle mask using equivalent radius from area so the limb is not clipped.
473
+ 'grow' slightly expands the radius to avoid eating into the edge.
474
+ """
475
+ if cv2 is None:
476
+ return None
477
+
478
+ img = ch.astype(np.float32, copy=False)
479
+
480
+ p1 = float(np.percentile(img, 1.0))
481
+ p99 = float(np.percentile(img, 99.5))
482
+ if p99 <= p1:
483
+ return None
484
+
485
+ scaled = (img - p1) * (255.0 / (p99 - p1))
486
+ scaled = np.clip(scaled, 0, 255).astype(np.uint8)
487
+ scaled = cv2.GaussianBlur(scaled, (0, 0), 1.2)
488
+
489
+ _, bw = cv2.threshold(scaled, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
490
+
491
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
492
+ bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, k, iterations=1)
493
+ bw = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, k, iterations=2)
494
+
495
+ num, labels, stats, cents = cv2.connectedComponentsWithStats(bw, connectivity=8)
496
+ if num <= 1:
497
+ return None
498
+
499
+ areas = stats[1:, cv2.CC_STAT_AREA]
500
+ j = int(np.argmax(areas)) + 1
501
+ area = float(stats[j, cv2.CC_STAT_AREA])
502
+ if area < 200:
503
+ return None
504
+
505
+ cx, cy = cents[j]
506
+ r = np.sqrt(area / np.pi) * float(grow)
507
+
508
+ H, W = ch.shape[:2]
509
+ yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
510
+ mask = ((xx - float(cx)) ** 2 + (yy - float(cy)) ** 2) <= (float(r) ** 2)
511
+ return mask
512
+
513
+ def _mask_bbox(mask: np.ndarray):
514
+ ys, xs = np.where(mask)
515
+ if xs.size == 0:
516
+ return None
517
+ return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
518
+
519
+
520
+ def _mask_centroid(mask: np.ndarray) -> tuple[float, float] | None:
521
+ m = mask.astype(np.uint8)
522
+ M = cv2.moments(m, binaryImage=True) if cv2 is not None else None
523
+ if not M or M["m00"] <= 1e-6:
524
+ return None
525
+ cx = float(M["m10"] / M["m00"])
526
+ cy = float(M["m01"] / M["m00"])
527
+ return cx, cy
528
+
529
+
530
+ def _shift_image(img: np.ndarray, dx: float, dy: float, *, border_value=0):
531
+ """
532
+ Shift an image by (dx,dy) pixels using warpAffine.
533
+ Works for 2D or 3D arrays.
534
+ """
535
+ H, W = img.shape[:2]
536
+ M = np.array([[1.0, 0.0, dx],
537
+ [0.0, 1.0, dy]], dtype=np.float32)
538
+ return cv2.warpAffine(
539
+ img, M, (W, H),
540
+ flags=cv2.INTER_LINEAR,
541
+ borderMode=cv2.BORDER_CONSTANT,
542
+ borderValue=border_value
543
+ )
544
+
545
+
546
+ def _shift_mask(mask: np.ndarray, dx: float, dy: float):
547
+ H, W = mask.shape[:2]
548
+ M = np.array([[1.0, 0.0, dx],
549
+ [0.0, 1.0, dy]], dtype=np.float32)
550
+ m = mask.astype(np.uint8) * 255
551
+ mw = cv2.warpAffine(
552
+ m, M, (W, H),
553
+ flags=cv2.INTER_NEAREST,
554
+ borderMode=cv2.BORDER_CONSTANT,
555
+ borderValue=0
556
+ )
557
+ return mw > 127
558
+
559
+ def _disk_to_equirect_texture(roi_rgb01: np.ndarray, disk_mask: np.ndarray,
560
+ tex_h: int = 256, tex_w: int = 512) -> np.ndarray:
561
+ """
562
+ Convert a planet disk image (ROI) into an equirectangular texture (lat/lon).
563
+ roi_rgb01: float32 RGB in [0,1]
564
+ disk_mask: bool mask where planet exists in ROI
565
+ Returns tex (tex_h, tex_w, 3) float32 in [0,1]
566
+ """
567
+ H, W = roi_rgb01.shape[:2]
568
+ cx = (W - 1) * 0.5
569
+ cy = (H - 1) * 0.5
570
+
571
+ # estimate radius from mask area
572
+ area = float(disk_mask.sum())
573
+ r = float(np.sqrt(max(area, 1.0) / np.pi))
574
+ r = max(r, 8.0)
575
+
576
+ # lon in [-pi, pi], lat in [-pi/2, pi/2]
577
+ lons = np.linspace(-np.pi, np.pi, tex_w, endpoint=False).astype(np.float32)
578
+ lats = np.linspace(+0.5*np.pi, -0.5*np.pi, tex_h, endpoint=True).astype(np.float32) # top->bottom
579
+
580
+ Lon, Lat = np.meshgrid(lons, lats)
581
+
582
+ # sphere point (unit)
583
+ X = np.cos(Lat) * np.sin(Lon)
584
+ Y = np.sin(Lat)
585
+ Z = np.cos(Lat) * np.cos(Lon)
586
+
587
+ # orthographic projection to disk:
588
+ # image-plane coords: x = X, y = Y, z = Z (visible hemisphere Z>=0)
589
+ vis = Z >= 0.0
590
+
591
+ u = (cx + r * X).astype(np.float32)
592
+ v = (cy + r * Y).astype(np.float32)
593
+
594
+ # sample ROI using cv2.remap
595
+ mapx = u
596
+ mapy = v
597
+ tex = cv2.remap(roi_rgb01, mapx, mapy, interpolation=cv2.INTER_LINEAR,
598
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
599
+
600
+ # kill tex where back hemisphere or outside disk
601
+ # (outside disk also lands in black due to borderConstant, but we enforce)
602
+ tex[~vis] = 0.0
603
+ return tex
604
+
605
+
606
+ def _build_sphere_mesh(n_lat: int = 120, n_lon: int = 240):
607
+ """
608
+ Build sphere vertices and triangle indices.
609
+ Returns:
610
+ verts: (N,3) float32
611
+ lats: (N,) float32
612
+ lons: (N,) float32
613
+ i,j,k triangle index lists
614
+ """
615
+ # lat: +pi/2 (north) to -pi/2 (south)
616
+ lats = np.linspace(+0.5*np.pi, -0.5*np.pi, n_lat, endpoint=True).astype(np.float32)
617
+ lons = np.linspace(-np.pi, np.pi, n_lon, endpoint=False).astype(np.float32)
618
+
619
+ Lon, Lat = np.meshgrid(lons, lats) # (n_lat,n_lon)
620
+
621
+ x = (np.cos(Lat) * np.sin(Lon)).astype(np.float32)
622
+ y = (np.sin(Lat)).astype(np.float32)
623
+ z = (np.cos(Lat) * np.cos(Lon)).astype(np.float32)
624
+
625
+ verts = np.stack([x, y, z], axis=-1).reshape(-1, 3)
626
+
627
+ # triangles on the grid
628
+ def idx(a, b):
629
+ return a * n_lon + b
630
+
631
+ I = []
632
+ J = []
633
+ K = []
634
+ for a in range(n_lat - 1):
635
+ for b in range(n_lon):
636
+ b2 = (b + 1) % n_lon
637
+ p00 = idx(a, b)
638
+ p01 = idx(a, b2)
639
+ p10 = idx(a + 1, b)
640
+ p11 = idx(a + 1, b2)
641
+ # two triangles per quad
642
+ I.extend([p00, p00])
643
+ J.extend([p10, p11])
644
+ K.extend([p11, p01])
645
+
646
+ return verts, Lat.reshape(-1), Lon.reshape(-1), I, J, K
647
+
648
+
649
+ def _sample_tex_colors(tex: np.ndarray, lats: np.ndarray, lons: np.ndarray) -> np.ndarray:
650
+ """
651
+ tex: (H,W,3) float32 [0,1] equirectangular, top=+lat
652
+ lats/lons are per-vertex in radians.
653
+ Returns vertexcolor: (N,4) uint8 RGBA
654
+ """
655
+ H, W = tex.shape[:2]
656
+
657
+ # map lon [-pi,pi) -> u [0,W)
658
+ u = ((lons + np.pi) / (2*np.pi) * W).astype(np.float32)
659
+ # map lat [+pi/2,-pi/2] -> v [0,H)
660
+ v = ((0.5*np.pi - lats) / (np.pi) * (H - 1)).astype(np.float32)
661
+
662
+ # cv2.remap needs maps shaped (H?,W?) but we can sample manually using bilinear
663
+ # We'll do fast bilinear sampling in numpy.
664
+ u0 = np.floor(u).astype(np.int32) % W
665
+ v0 = np.clip(np.floor(v).astype(np.int32), 0, H - 1)
666
+ u1 = (u0 + 1) % W
667
+ v1 = np.clip(v0 + 1, 0, H - 1)
668
+
669
+ fu = (u - np.floor(u)).astype(np.float32)
670
+ fv = (v - np.floor(v)).astype(np.float32)
671
+
672
+ c00 = tex[v0, u0]
673
+ c10 = tex[v1, u0]
674
+ c01 = tex[v0, u1]
675
+ c11 = tex[v1, u1]
676
+
677
+ c0 = c00 * (1 - fv)[:, None] + c10 * fv[:, None]
678
+ c1 = c01 * (1 - fv)[:, None] + c11 * fv[:, None]
679
+ c = c0 * (1 - fu)[:, None] + c1 * fu[:, None]
680
+
681
+ rgba = np.clip(c * 255.0, 0, 255).astype(np.uint8)
682
+ alpha = np.full((rgba.shape[0],), 255, dtype=np.uint8)
683
+ vertexcolor = np.concatenate([rgba, alpha[:, None]], axis=1)
684
+ return vertexcolor
685
+
686
+ def _bilinear_sample_rgb(img01: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray:
687
+ """img01: float32 [0,1] (H,W,3). u,v float pixel coords. returns float32 (N,3)."""
688
+ H, W = img01.shape[:2]
689
+ u = np.asarray(u, np.float32)
690
+ v = np.asarray(v, np.float32)
691
+
692
+ u0 = np.floor(u).astype(np.int32)
693
+ v0 = np.floor(v).astype(np.int32)
694
+ u1 = u0 + 1
695
+ v1 = v0 + 1
696
+
697
+ u0 = np.clip(u0, 0, W - 1); u1 = np.clip(u1, 0, W - 1)
698
+ v0 = np.clip(v0, 0, H - 1); v1 = np.clip(v1, 0, H - 1)
699
+
700
+ fu = (u - np.floor(u)).astype(np.float32)
701
+ fv = (v - np.floor(v)).astype(np.float32)
702
+
703
+ c00 = img01[v0, u0]
704
+ c10 = img01[v1, u0]
705
+ c01 = img01[v0, u1]
706
+ c11 = img01[v1, u1]
707
+
708
+ c0 = c00 * (1 - fv)[:, None] + c10 * fv[:, None]
709
+ c1 = c01 * (1 - fv)[:, None] + c11 * fv[:, None]
710
+ return c0 * (1 - fu)[:, None] + c1 * fu[:, None]
711
+
712
+ def _build_ring_mesh(n_theta: int = 360):
713
+ """Returns (x,y,z,I,J,K) for a unit annulus strip with two radii per theta."""
714
+ th = np.linspace(0, 2*np.pi, n_theta, endpoint=False).astype(np.float32)
715
+
716
+ # we build verts for inner and outer separately later (because radii vary), but indices are constant.
717
+ # vertex order: [inner0, outer0, inner1, outer1, ...]
718
+ I = []
719
+ J = []
720
+ K = []
721
+ for t in range(n_theta):
722
+ t2 = (t + 1) % n_theta
723
+ i0 = 2*t
724
+ o0 = 2*t + 1
725
+ i1 = 2*t2
726
+ o1 = 2*t2 + 1
727
+ # two triangles per quad
728
+ I.extend([i0, o0])
729
+ J.extend([o0, o1])
730
+ K.extend([i1, i1])
731
+ return th, np.asarray(I, np.int32), np.asarray(J, np.int32), np.asarray(K, np.int32)
732
+
733
+ def _build_ring_grid(n_r: int = 160, n_theta: int = 720):
734
+ th = np.linspace(0, 2*np.pi, n_theta, endpoint=False).astype(np.float32)
735
+ rr = np.linspace(0.0, 1.0, n_r, endpoint=True).astype(np.float32) # normalized radius
736
+ TH, RR = np.meshgrid(th, rr) # (n_r, n_theta)
737
+
738
+ # indices
739
+ def vid(r, t): return r*n_theta + t
740
+ I = []; J = []; K = []
741
+ for r in range(n_r - 1):
742
+ for t in range(n_theta):
743
+ t2 = (t + 1) % n_theta
744
+ p00 = vid(r, t)
745
+ p01 = vid(r, t2)
746
+ p10 = vid(r+1, t)
747
+ p11 = vid(r+1, t2)
748
+ I += [p00, p00]
749
+ J += [p10, p11]
750
+ K += [p11, p01]
751
+ return TH.reshape(-1), RR.reshape(-1), np.asarray(I,np.int32), np.asarray(J,np.int32), np.asarray(K,np.int32)
752
+
753
+ def _ring_to_polar_texture(roi01, cx0, cy0, rpx, pa_deg, tilt, k_in, k_out,
754
+ n_r=160, n_theta=720):
755
+ H, W = roi01.shape[:2]
756
+ th = np.linspace(0, 2*np.pi, n_theta, endpoint=False).astype(np.float32)
757
+ rr = np.linspace(k_in, k_out, n_r, endpoint=True).astype(np.float32)
758
+
759
+ TH, RR = np.meshgrid(th, rr) # (n_r, n_theta)
760
+
761
+ x_e = RR * np.cos(TH)
762
+ y_e = RR * np.sin(TH) * tilt
763
+
764
+ a = np.deg2rad(pa_deg)
765
+ c, s = np.cos(a), np.sin(a)
766
+
767
+ x_img = cx0 + rpx * (x_e*c - y_e*s)
768
+ y_img = cy0 + rpx * (x_e*s + y_e*c)
769
+
770
+ mapx = x_img.astype(np.float32)
771
+ mapy = y_img.astype(np.float32)
772
+
773
+ polar = cv2.remap(
774
+ roi01, mapx, mapy,
775
+ interpolation=cv2.INTER_LINEAR,
776
+ borderMode=cv2.BORDER_CONSTANT,
777
+ borderValue=0
778
+ )
779
+ return polar # (n_r, n_theta, 3) float01
780
+
781
+
782
+ def export_planet_sphere_html(
783
+ roi_rgb: np.ndarray,
784
+ disk_mask: np.ndarray,
785
+ out_path: str | None = None,
786
+ n_lat: int = 120,
787
+ n_lon: int = 240,
788
+ title: str = "Planet Sphere",
789
+ rings: dict | None = None,
790
+ ):
791
+ """
792
+ Build an interactive Plotly Mesh3d with the planet texture wrapped to a sphere.
793
+ Optional: add Saturn rings as a second Mesh3d using ROI-sampled vertex colors.
794
+
795
+ IMPORTANT COORDINATE NOTE:
796
+ - Your refinement/UI uses IMAGE coords: +x right, +y down.
797
+ - Plotly scene is effectively +y up.
798
+ We FORCE Plotly to behave like image coords by flipping Y for ALL meshes
799
+ (sphere + rings) and setting camera.up to (0,-1,0).
800
+ This makes ring PA/tilt match what you see in the refinement overlay.
801
+ """
802
+ import plotly.graph_objects as go
803
+
804
+ # -----------------------------
805
+ # 0) Ensure float01 ROI
806
+ # -----------------------------
807
+ if roi_rgb.dtype == np.uint8:
808
+ roi01 = roi_rgb.astype(np.float32) / 255.0
809
+ elif roi_rgb.dtype == np.uint16:
810
+ roi01 = roi_rgb.astype(np.float32) / 65535.0
811
+ else:
812
+ roi01 = roi_rgb.astype(np.float32, copy=False)
813
+ roi01 = np.clip(roi01, 0.0, 1.0)
814
+
815
+ # -----------------------------
816
+ # 1) Build equirect texture from disk
817
+ # -----------------------------
818
+ tex = _disk_to_equirect_texture(roi01, disk_mask, tex_h=256, tex_w=512)
819
+
820
+ # -----------------------------
821
+ # 2) Build sphere mesh (world coords)
822
+ # -----------------------------
823
+ verts, lats, lons, I, J, K = _build_sphere_mesh(n_lat=n_lat, n_lon=n_lon)
824
+
825
+ # -----------------------------
826
+ # 3) Sample per-vertex colors (RGBA uint8)
827
+ # -----------------------------
828
+ vcol = _sample_tex_colors(tex, lats, lons)
829
+ vcol = np.asarray(vcol)
830
+ if vcol.dtype != np.uint8:
831
+ vcol = np.clip(vcol, 0, 255).astype(np.uint8)
832
+
833
+ if vcol.ndim != 2 or vcol.shape[0] != verts.shape[0]:
834
+ raise ValueError(f"vertex colors shape mismatch: vcol={vcol.shape}, verts={verts.shape}")
835
+
836
+ if vcol.shape[1] == 3:
837
+ alpha = np.full((vcol.shape[0], 1), 255, dtype=np.uint8)
838
+ vcol = np.concatenate([vcol, alpha], axis=1)
839
+ elif vcol.shape[1] != 4:
840
+ raise ValueError(f"vertex colors must be RGB or RGBA; got {vcol.shape[1]} channels")
841
+
842
+ # -----------------------------
843
+ # 4) Make back hemisphere black (camera from +Z; z<0 is "back")
844
+ # -----------------------------
845
+ back = verts[:, 2] < 0.0
846
+ if np.any(back):
847
+ vcol = vcol.copy()
848
+ vcol[back, 0:3] = 0
849
+ vcol[back, 3] = 255
850
+
851
+ # -----------------------------
852
+ # 5) Flip Y to match IMAGE coords (x right, y down)
853
+ # -----------------------------
854
+ verts_plot = verts.astype(np.float32, copy=True)
855
+ verts_plot[:, 1] *= -1.0 # <--- key fix: Plotly now behaves like image coords
856
+
857
+ # Flip winding to fix "inside out" sphere
858
+ I = np.asarray(I, dtype=np.int32)
859
+ J = np.asarray(J, dtype=np.int32)
860
+ K = np.asarray(K, dtype=np.int32)
861
+
862
+ sphere_mesh = go.Mesh3d(
863
+ x=verts_plot[:, 0], y=verts_plot[:, 1], z=verts_plot[:, 2],
864
+ i=I, j=K, k=J, # flip winding
865
+ vertexcolor=vcol,
866
+ flatshading=False,
867
+ lighting=dict(
868
+ ambient=0.55, diffuse=0.85, specular=0.25,
869
+ roughness=0.9, fresnel=0.15
870
+ ),
871
+ lightposition=dict(x=2, y=1, z=3),
872
+ name="Planet",
873
+ hoverinfo="skip",
874
+ showscale=False,
875
+ )
876
+
877
+ data = [sphere_mesh]
878
+
879
+ # -----------------------------
880
+ # 6) Optional rings mesh (Saturn) — POLAR TEXTURE + GRID MESH
881
+ # -----------------------------
882
+ if rings is not None:
883
+ try:
884
+ cx0 = float(rings.get("cx"))
885
+ cy0 = float(rings.get("cy"))
886
+ rpx = float(rings.get("r"))
887
+ pa_deg = float(rings.get("pa", 0.0))
888
+ tilt = float(rings.get("tilt", 0.35)) # b/a
889
+ k_out = float(rings.get("k_out", 2.2))
890
+ k_in = float(rings.get("k_in", 1.25))
891
+
892
+ tilt = float(np.clip(tilt, 0.02, 1.0))
893
+ if k_out <= k_in:
894
+ k_out = k_in + 0.05
895
+
896
+ # Pick resolution (you can expose these as args if you want)
897
+ n_r = int(rings.get("n_r", 180))
898
+ n_theta = int(rings.get("n_theta", 720))
899
+
900
+ # --- Build POLAR texture (n_r x n_theta x 3) float01
901
+ # IMPORTANT: sample directly from roi01 (no pre-masking) to avoid bilinear edge darkening
902
+ ring_polar01 = _ring_to_polar_texture(
903
+ roi01,
904
+ cx0=cx0, cy0=cy0,
905
+ rpx=rpx,
906
+ pa_deg=pa_deg,
907
+ tilt=tilt,
908
+ k_in=k_in, k_out=k_out,
909
+ n_r=n_r, n_theta=n_theta
910
+ )
911
+
912
+ # --- Build GRID mesh topology aligned with polar texture
913
+ TH_flat, RRn_flat, I2, J2, K2 = _build_ring_grid(n_r=n_r, n_theta=n_theta)
914
+
915
+ # Convert normalized radius -> actual radius in planet radii
916
+ RR_flat = (k_in + RRn_flat * (k_out - k_in)).astype(np.float32)
917
+
918
+ # Base ring plane coords (world-ish): x right, y up
919
+ x = (RR_flat * np.cos(TH_flat)).astype(np.float32)
920
+ y = (RR_flat * np.sin(TH_flat)).astype(np.float32)
921
+ z = np.zeros_like(x, dtype=np.float32)
922
+
923
+ # --- 3D tilt: tilt=b/a = cos(inc) -> inc=arccos(tilt)
924
+ inc = float(np.arccos(np.clip(tilt, 0.0, 1.0)))
925
+ ci, si = float(np.cos(inc)), float(np.sin(inc))
926
+
927
+ # Tilt around X: foreshorten Y, push into Z
928
+ y2 = y * ci
929
+ z2 = y * si
930
+
931
+ # Rotate around Z by PA (MATCH refinement/UI)
932
+ a = np.deg2rad(pa_deg)
933
+ ca, sa = float(np.cos(a)), float(np.sin(a))
934
+ x3 = x * ca - y2 * sa
935
+ y3 = x * sa + y2 * ca
936
+ z3 = z2 + 0.002 # tiny lift to reduce z-fighting
937
+
938
+ # --- Vertex colors from polar texture (aligned!)
939
+ # ring_polar01 shape: (n_r, n_theta, 3)
940
+ # _build_ring_grid flattens in meshgrid order (rr rows, th cols) -> matches reshape(-1)
941
+ cols01 = ring_polar01.reshape(-1, 3)
942
+ cols_u8 = np.clip(cols01 * 255.0, 0, 255).astype(np.uint8)
943
+
944
+ # --- validity mask for alpha (do this BEFORE shadowing to avoid alpha=0 for black shadow)
945
+ valid = np.any(cols_u8 > 2, axis=1)
946
+
947
+ # --- Occlude ring where Saturn's disk covers it (true disk silhouette)
948
+ # Planet is unit sphere centered at origin; camera from +Z.
949
+ r2 = x3*x3 + y3*y3
950
+ inside = r2 <= 1.0
951
+ z_front = np.sqrt(np.clip(1.0 - r2, 0.0, 1.0))
952
+ shadow = inside & (z3 < z_front)
953
+
954
+ if np.any(shadow):
955
+ cols_u8 = cols_u8.copy()
956
+ cols_u8[shadow, :3] = 0 # paint ring behind planet black
957
+
958
+ alpha = (valid.astype(np.uint8) * 255)[:, None]
959
+ vcol_ring = np.concatenate([cols_u8, alpha], axis=1)
960
+
961
+ # Double-sided: duplicate tris with reversed winding
962
+ I2 = np.asarray(I2, dtype=np.int32)
963
+ J2 = np.asarray(J2, dtype=np.int32)
964
+ K2 = np.asarray(K2, dtype=np.int32)
965
+ I_all = np.concatenate([I2, I2])
966
+ J_all = np.concatenate([J2, K2])
967
+ K_all = np.concatenate([K2, J2])
968
+
969
+ # Flip Y for Plotly (match IMAGE coords y-down like sphere)
970
+ y3_plot = -y3
971
+
972
+ ring_mesh = go.Mesh3d(
973
+ x=x3, y=y3_plot, z=z3,
974
+ i=I_all, j=J_all, k=K_all,
975
+ vertexcolor=vcol_ring,
976
+ flatshading=False,
977
+ lighting=dict(
978
+ ambient=0.75, diffuse=0.65, specular=0.10,
979
+ roughness=1.0, fresnel=0.05
980
+ ),
981
+ name="Rings",
982
+ hoverinfo="skip",
983
+ showscale=False,
984
+ )
985
+
986
+ data.append(ring_mesh)
987
+
988
+ except Exception:
989
+ # If rings fail, still return the sphere.
990
+ pass
991
+
992
+
993
+ # -----------------------------
994
+ # 7) Figure + layout
995
+ # -----------------------------
996
+ fig = go.Figure(data=data)
997
+
998
+ fig.update_layout(
999
+ title=title,
1000
+ margin=dict(l=0, r=0, b=0, t=40),
1001
+ scene=dict(
1002
+ aspectmode="data",
1003
+ xaxis=dict(visible=False),
1004
+ yaxis=dict(visible=False),
1005
+ zaxis=dict(visible=False),
1006
+ bgcolor="black",
1007
+ camera=dict(
1008
+ eye=dict(x=0.0, y=0.0, z=2.2),
1009
+ center=dict(x=0.0, y=0.0, z=0.0),
1010
+ up=dict(x=0.0, y=-1.0, z=0.0), # image y-down
1011
+ ),
1012
+ ),
1013
+ paper_bgcolor="black",
1014
+ plot_bgcolor="black",
1015
+ showlegend=False,
1016
+ )
1017
+
1018
+ html = fig.to_html(include_plotlyjs="cdn", full_html=True)
1019
+
1020
+ if out_path is None:
1021
+ out_path = os.path.expanduser("~/planet_sphere.html")
1022
+
1023
+ return html, out_path
1024
+
1025
+
1026
+ def export_pseudo_surface_html(
1027
+ rgb: np.ndarray,
1028
+ out_path: str | None = None,
1029
+ *,
1030
+ title: str = "Pseudo Surface (Point Cloud)",
1031
+ max_dim: int = 420,
1032
+ z_scale: float = 0.35,
1033
+ depth_gamma: float = 1.15,
1034
+ blur_sigma: float = 1.2,
1035
+ invert: bool = False,
1036
+ block: int = 10,
1037
+ block_blur_sigma: float = 0.6,
1038
+ max_vertices: int = 250_000,
1039
+ point_size: float = 1.6,
1040
+
1041
+ # height source(s)
1042
+ height_from: str = "brightness", # "brightness" | "color" | "dual"
1043
+
1044
+ # how points are COLORED (from dropdown)
1045
+ color_mode: str = "brightness", # "brightness" | "depth" | "dual"
1046
+ depth_colorscale: str = "Turbo",
1047
+ depth_opacity: float = 0.55,
1048
+ depth_point_size: float | None = None,
1049
+
1050
+ # Dual height controls (saturation-height layer)
1051
+ sat_opacity: float = 0.45,
1052
+ sat_point_size: float | None = None,
1053
+
1054
+ # Optional: reduce saturation-height noise in dark background
1055
+ sat_luma_gate: float = 0.02,
1056
+ sat_luma_soft: float = 0.18,
1057
+ ):
1058
+ """
1059
+ Interactive Plotly Scatter3d point cloud.
1060
+
1061
+ Z (height_from):
1062
+ • "brightness": luminance-derived height
1063
+ • "color": saturation-derived height
1064
+ • "dual": overlays TWO clouds (brightness-height + saturation-height)
1065
+
1066
+ Coloring (color_mode) is ALWAYS honored, even for dual heights:
1067
+ • "brightness": RGB image colors
1068
+ • "depth": colormap by height
1069
+ • "dual": RGB + depth overlay (per height cloud)
1070
+ """
1071
+ import os
1072
+ import numpy as np
1073
+ import cv2
1074
+ import plotly.graph_objects as go
1075
+
1076
+ x = np.asarray(rgb)
1077
+ if x.ndim != 3 or x.shape[2] < 3:
1078
+ raise ValueError("export_pseudo_surface_html expects RGB image (H,W,3).")
1079
+
1080
+ # normalize mode strings
1081
+ cmode = (color_mode or "brightness").strip().lower()
1082
+ if cmode not in ("brightness", "depth", "dual"):
1083
+ cmode = "brightness"
1084
+
1085
+ hmode = (height_from or "brightness").strip().lower()
1086
+ if hmode not in ("brightness", "color", "dual"):
1087
+ hmode = "brightness"
1088
+
1089
+ # ---- float01 ----
1090
+ if x.dtype == np.uint8:
1091
+ img01 = x[..., :3].astype(np.float32) / 255.0
1092
+ elif x.dtype == np.uint16:
1093
+ img01 = x[..., :3].astype(np.float32) / 65535.0
1094
+ else:
1095
+ img01 = np.clip(x[..., :3].astype(np.float32, copy=False), 0.0, 1.0)
1096
+
1097
+ H, W = img01.shape[:2]
1098
+
1099
+ # ---- downsample ----
1100
+ max_dim = int(np.clip(max_dim, 128, 2048))
1101
+ s = float(max_dim) / float(max(H, W))
1102
+ if s < 1.0:
1103
+ newW = max(64, int(round(W * s)))
1104
+ newH = max(64, int(round(H * s)))
1105
+ img01 = cv2.resize(img01, (newW, newH), interpolation=cv2.INTER_AREA)
1106
+
1107
+ hH, hW = img01.shape[:2]
1108
+
1109
+ # ---- vertex cap ----
1110
+ max_vertices = int(max(10_000, max_vertices))
1111
+ if hH * hW > max_vertices:
1112
+ scale = np.sqrt(float(max_vertices) / float(hH * hW))
1113
+ newW = max(64, int(round(hW * scale)))
1114
+ newH = max(64, int(round(hH * scale)))
1115
+ img01 = cv2.resize(img01, (newW, newH), interpolation=cv2.INTER_AREA)
1116
+ hH, hW = img01.shape[:2]
1117
+
1118
+ # ---- helpers ----
1119
+ def _robust01(base: np.ndarray) -> np.ndarray:
1120
+ base = base.astype(np.float32, copy=False)
1121
+ p_lo = float(np.percentile(base, 1.0))
1122
+ p_hi = float(np.percentile(base, 99.5))
1123
+ h = np.clip((base - p_lo) / max(p_hi - p_lo, 1e-9), 0.0, 1.0)
1124
+
1125
+ if invert:
1126
+ h = 1.0 - h
1127
+
1128
+ # coherence smoothing
1129
+ b = max(1, int(block))
1130
+ if b > 1:
1131
+ h = cv2.blur(h, (b, b), borderType=cv2.BORDER_REFLECT101)
1132
+
1133
+ if block_blur_sigma and block_blur_sigma > 0:
1134
+ h = cv2.GaussianBlur(h, (0, 0), float(block_blur_sigma))
1135
+
1136
+ if blur_sigma and blur_sigma > 0:
1137
+ h = cv2.GaussianBlur(h, (0, 0), float(blur_sigma))
1138
+
1139
+ h = np.clip(h, 0.0, 1.0) ** max(1e-3, float(depth_gamma))
1140
+ return h.astype(np.float32)
1141
+
1142
+ # luminance (also used for saturation gating)
1143
+ lum = (0.299 * img01[..., 0] + 0.587 * img01[..., 1] + 0.114 * img01[..., 2]).astype(np.float32)
1144
+ h01_lum = _robust01(lum)
1145
+
1146
+ # saturation
1147
+ hsv = cv2.cvtColor(img01.astype(np.float32), cv2.COLOR_RGB2HSV)
1148
+ sat = hsv[..., 1].astype(np.float32)
1149
+ h01_sat = _robust01(sat)
1150
+
1151
+ # Optional: suppress sat-height in very dark background
1152
+ if sat_luma_soft and sat_luma_soft > 0:
1153
+ gate = float(sat_luma_gate)
1154
+ soft = float(sat_luma_soft)
1155
+ wgt = np.clip((lum - gate) / max(soft, 1e-6), 0.0, 1.0).astype(np.float32)
1156
+ h01_sat = h01_sat * wgt
1157
+
1158
+ # ---- depth scaling ----
1159
+ zmax = 0.5 * float(min(hH, hW)) * float(z_scale)
1160
+ Z_lum = ((h01_lum * 2.0) - 1.0) * zmax
1161
+ Z_sat = ((h01_sat * 2.0) - 1.0) * zmax
1162
+
1163
+ # ---- XY grid ----
1164
+ yy, xx = np.mgrid[0:hH, 0:hW]
1165
+ X = (xx - (hW - 1) * 0.5).reshape(-1)
1166
+ Y = (yy - (hH - 1) * 0.5).reshape(-1)
1167
+
1168
+ Zlum = Z_lum.reshape(-1)
1169
+ Zsat = Z_sat.reshape(-1)
1170
+
1171
+ # ---- RGB strings ----
1172
+ rgb_u8 = np.clip(img01.reshape(-1, 3) * 255.0, 0, 255).astype(np.uint8)
1173
+ rgb_strings = [f"rgb({r},{g},{b})" for r, g, b in rgb_u8]
1174
+
1175
+ traces: list = []
1176
+
1177
+ # sizes / opacities per cloud
1178
+ ps_lum = float(point_size)
1179
+ ps_sat = float(sat_point_size) if sat_point_size is not None else float(point_size) * 1.05
1180
+ op_lum = 0.95
1181
+ op_sat = float(np.clip(sat_opacity, 0.05, 1.0))
1182
+
1183
+ dps = float(depth_point_size) if depth_point_size is not None else float(point_size)
1184
+
1185
+ def _add_cloud(
1186
+ *,
1187
+ name_prefix: str,
1188
+ Z: np.ndarray,
1189
+ size_rgb: float,
1190
+ size_depth: float,
1191
+ op_rgb: float,
1192
+ op_depth: float,
1193
+ ):
1194
+ """
1195
+ Add traces for a single height cloud, honoring cmode.
1196
+ """
1197
+ if cmode in ("brightness", "dual"):
1198
+ traces.append(
1199
+ go.Scatter3d(
1200
+ x=X, y=Y, z=Z,
1201
+ mode="markers",
1202
+ marker=dict(
1203
+ size=float(size_rgb),
1204
+ color=rgb_strings,
1205
+ opacity=1.0 if cmode == "brightness" else float(op_rgb),
1206
+ ),
1207
+ hoverinfo="skip",
1208
+ name=f"{name_prefix} (RGB)" if cmode != "brightness" else f"{name_prefix}",
1209
+ )
1210
+ )
1211
+
1212
+ if cmode in ("depth", "dual"):
1213
+ traces.append(
1214
+ go.Scatter3d(
1215
+ x=X, y=Y, z=Z,
1216
+ mode="markers",
1217
+ marker=dict(
1218
+ size=float(size_depth),
1219
+ color=Z, # numeric -> colorscale
1220
+ colorscale=depth_colorscale,
1221
+ cmin=float(Z.min()),
1222
+ cmax=float(Z.max()),
1223
+ opacity=1.0 if cmode == "depth" else float(op_depth),
1224
+ showscale=(cmode == "depth"),
1225
+ colorbar=dict(
1226
+ title="Depth",
1227
+ thickness=14,
1228
+ len=0.6,
1229
+ ) if cmode == "depth" else None,
1230
+ ),
1231
+ hoverinfo="skip",
1232
+ name=f"{name_prefix} (Depth)" if cmode != "depth" else f"{name_prefix}",
1233
+ )
1234
+ )
1235
+
1236
+ if hmode == "brightness":
1237
+ _add_cloud(
1238
+ name_prefix="Brightness Height",
1239
+ Z=Zlum,
1240
+ size_rgb=ps_lum,
1241
+ size_depth=dps,
1242
+ op_rgb=0.95,
1243
+ op_depth=depth_opacity,
1244
+ )
1245
+ show_legend = (cmode == "dual")
1246
+
1247
+ elif hmode == "color":
1248
+ _add_cloud(
1249
+ name_prefix="Color Height",
1250
+ Z=Zsat,
1251
+ size_rgb=ps_lum, # reuse point_size for single-mode
1252
+ size_depth=dps,
1253
+ op_rgb=0.95,
1254
+ op_depth=depth_opacity,
1255
+ )
1256
+ show_legend = (cmode == "dual")
1257
+
1258
+ else:
1259
+ # dual heights: add BOTH clouds, each honoring cmode
1260
+ _add_cloud(
1261
+ name_prefix="Brightness Height",
1262
+ Z=Zlum,
1263
+ size_rgb=ps_lum,
1264
+ size_depth=dps,
1265
+ op_rgb=op_lum,
1266
+ op_depth=depth_opacity,
1267
+ )
1268
+ _add_cloud(
1269
+ name_prefix="Color Height",
1270
+ Z=Zsat,
1271
+ size_rgb=ps_sat,
1272
+ size_depth=dps,
1273
+ op_rgb=op_sat,
1274
+ op_depth=min(1.0, depth_opacity * 0.85), # slightly softer overlay feels nicer
1275
+ )
1276
+ show_legend = True # dual heights should show what’s what
1277
+
1278
+ # ---- figure ----
1279
+ fig = go.Figure(data=traces)
1280
+ fig.update_layout(
1281
+ title=title,
1282
+ margin=dict(l=0, r=0, b=0, t=40),
1283
+ scene=dict(
1284
+ aspectmode="data",
1285
+ xaxis=dict(visible=False),
1286
+ yaxis=dict(visible=False),
1287
+ zaxis=dict(visible=False),
1288
+ bgcolor="black",
1289
+ camera=dict(
1290
+ eye=dict(x=0.0, y=-1.6, z=1.0),
1291
+ up=dict(x=0.0, y=-1.0, z=0.0),
1292
+ ),
1293
+ ),
1294
+ paper_bgcolor="black",
1295
+ plot_bgcolor="black",
1296
+ showlegend=bool(show_legend),
1297
+ legend=dict(
1298
+ bgcolor="rgba(0,0,0,0)",
1299
+ font=dict(color="white"),
1300
+ ),
1301
+ )
1302
+
1303
+ html = fig.to_html(include_plotlyjs="cdn", full_html=True)
1304
+ if out_path is None:
1305
+ out_path = os.path.expanduser("~/pseudo_surface_pointcloud.html")
1306
+
1307
+ return html, out_path
1308
+
1309
+
1310
+ def deproject_galaxy_topdown_u8(
1311
+ roi01: np.ndarray, # float32 [0..1], (H,W,3)
1312
+ cx0: float, cy0: float,
1313
+ rpx: float,
1314
+ pa_deg: float,
1315
+ tilt: float, # b/a
1316
+ out_size: int = 800
1317
+ ) -> np.ndarray:
1318
+ """
1319
+ Returns RGB uint8 top-down view, outside-disk black.
1320
+ """
1321
+ import numpy as np
1322
+ import cv2
1323
+
1324
+ H, W = roi01.shape[:2]
1325
+ out = int(max(64, out_size))
1326
+
1327
+ # grid in disk plane [-1,1]
1328
+ yy, xx = np.mgrid[0:out, 0:out].astype(np.float32)
1329
+ u = (xx - (out - 1) * 0.5) / ((out - 1) * 0.5)
1330
+ v = (yy - (out - 1) * 0.5) / ((out - 1) * 0.5)
1331
+ rho = np.sqrt(u*u + v*v)
1332
+
1333
+ # ellipse squash (inclination): y compressed by tilt
1334
+ tilt = float(np.clip(tilt, 0.02, 1.0))
1335
+ xe = u
1336
+ ye = v * tilt
1337
+
1338
+ # rotate by PA
1339
+ a = np.deg2rad(pa_deg)
1340
+ ca, sa = float(np.cos(a)), float(np.sin(a))
1341
+ xr = xe * ca - ye * sa
1342
+ yr = xe * sa + ye * ca
1343
+
1344
+ # scale to pixels + translate to ROI coords
1345
+ mapx = (cx0 + xr * rpx).astype(np.float32)
1346
+ mapy = (cy0 + yr * rpx).astype(np.float32)
1347
+
1348
+ # sample
1349
+ img = np.clip(roi01, 0.0, 1.0)
1350
+ top01 = cv2.remap(img, mapx, mapy, interpolation=cv2.INTER_LINEAR,
1351
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
1352
+
1353
+ # mask outside disk-plane circle
1354
+ top01[rho > 1.0] = 0.0
1355
+
1356
+ return np.clip(top01 * 255.0, 0, 255).astype(np.uint8)
1357
+
1358
+
1359
+ # -----------------------------
1360
+ # UI dialog
1361
+ # -----------------------------
1362
+
1363
+ class PlanetProjectionDialog(QDialog):
1364
+ def __init__(self, parent=None, document=None):
1365
+ super().__init__(parent)
1366
+ self.setMinimumSize(520, 520)
1367
+
1368
+ self.resize(560, 640)
1369
+ self.setWindowTitle("3D Projection")
1370
+ self.setModal(False)
1371
+ self.parent = parent
1372
+ self.doc = document
1373
+ self.image = getattr(self.doc, "image", None) if self.doc is not None else None
1374
+ self._bg_img01 = None # float32 [0,1] RGB, resized per ROI
1375
+ self._bg_path = ""
1376
+ self._left = None
1377
+ self._right = None
1378
+ self._wiggle_timer = QTimer(self)
1379
+ self._wiggle_timer.timeout.connect(self._on_wiggle_tick)
1380
+ self._wiggle_state = False
1381
+ self._last_preview_u8 = None # last frame we pushed to preview (H,W,3) uint8
1382
+ self._preview_zoom = 1.0 # kept for compatibility but preview window owns zoom now
1383
+ self._preview_win = None
1384
+ self._wiggle_frames = None # list of RGB uint8 frames
1385
+ self._wiggle_idx = 0
1386
+ self._wiggle_steps = 36 # default smoothness (can make this a UI control later)
1387
+
1388
+ # Persist disk refinement within this dialog session (per image)
1389
+ self._disk_key = None # identifies the current image
1390
+ self._disk_last = None # (cx, cy, r) in FULL IMAGE coords
1391
+ self._disk_last_was_user = False # True once user clicks "Use This Disk"
1392
+
1393
+ self._build_ui()
1394
+ QTimer.singleShot(0, self._apply_initial_layout_fix)
1395
+ self._update_enable()
1396
+
1397
+ def _build_ui(self):
1398
+ outer = QVBoxLayout(self)
1399
+ outer.setContentsMargins(10, 10, 10, 10)
1400
+ outer.setSpacing(8)
1401
+
1402
+ self.lbl_top = QLabel(
1403
+ "Create a synthetic stereo pair from a planet ROI (sphere reprojection).\n"
1404
+ "• Stereo: side-by-side (Parallel or Cross-eye)\n"
1405
+ "• Wiggle: alternates L/R to create depth motion\n"
1406
+ "Optional: add a static starfield background (no parallax)."
1407
+ )
1408
+ self.lbl_top.setWordWrap(True)
1409
+ outer.addWidget(self.lbl_top)
1410
+
1411
+ prev_row = QHBoxLayout()
1412
+ self.btn_preview = QPushButton("Preview")
1413
+ self.btn_save_still = QPushButton("Save Still…")
1414
+ self.btn_save_wiggle = QPushButton("Save Wiggle…")
1415
+
1416
+ prev_row.addWidget(self.btn_preview)
1417
+ prev_row.addWidget(self.btn_save_still)
1418
+ prev_row.addWidget(self.btn_save_wiggle)
1419
+ prev_row.addStretch(1)
1420
+ outer.addLayout(prev_row)
1421
+
1422
+ self.btn_preview.clicked.connect(self._show_preview_window)
1423
+ self.btn_save_still.clicked.connect(self._save_still)
1424
+ self.btn_save_wiggle.clicked.connect(self._save_wiggle)
1425
+ # Controls
1426
+ box = QGroupBox("Parameters")
1427
+ form = QFormLayout(box)
1428
+
1429
+ self.cmb_mode = QComboBox()
1430
+ self.cmb_mode.addItems([
1431
+ "Stereo (Parallel) L | R",
1432
+ "Stereo (Cross-eye) R | L",
1433
+ "Wiggle stereo (toggle L/R)",
1434
+ "Anaglyph (Red/Cyan 3D Glasses)",
1435
+ "Interactive 3D (HTML)",
1436
+ "Galaxy Polar View (Top-Down)",
1437
+ ])
1438
+ form.addRow("Output:", self.cmb_mode)
1439
+
1440
+ self.cmb_planet_type = QComboBox()
1441
+ self.cmb_planet_type.addItems([
1442
+ "Normal (Sphere only)",
1443
+ "Saturn (Sphere + Rings)",
1444
+ "Pseudo surface (Height from brightness)",
1445
+ "Galaxy (Disk deprojection)",
1446
+ ])
1447
+ form.addRow("Planet type:", self.cmb_planet_type)
1448
+
1449
+ # Rings group
1450
+ rings_box = QGroupBox("Saturn rings")
1451
+ rings_form = QFormLayout(rings_box)
1452
+
1453
+ self.chk_rings = QCheckBox("Enable rings")
1454
+ self.chk_rings.setChecked(True)
1455
+ rings_form.addRow("", self.chk_rings)
1456
+
1457
+ self.spin_ring_pa = QDoubleSpinBox()
1458
+ self.spin_ring_pa.setRange(-180.0, 180.0)
1459
+ self.spin_ring_pa.setSingleStep(1.0)
1460
+ self.spin_ring_pa.setValue(0.0)
1461
+ self.spin_ring_pa.setToolTip("Ring position angle in the image (deg). Rotate ellipse.")
1462
+ rings_form.addRow("Ring PA (deg):", self.spin_ring_pa)
1463
+
1464
+ self.spin_ring_tilt = QDoubleSpinBox()
1465
+ self.spin_ring_tilt.setRange(0.05, 1.0)
1466
+ self.spin_ring_tilt.setSingleStep(0.02)
1467
+ self.spin_ring_tilt.setValue(0.35)
1468
+ self.spin_ring_tilt.setToolTip("Ellipse minor/major ratio (0..1). Smaller = more edge-on.")
1469
+ rings_form.addRow("Ring tilt (b/a):", self.spin_ring_tilt)
1470
+
1471
+ self.spin_ring_outer = QDoubleSpinBox()
1472
+ self.spin_ring_outer.setRange(1.0, 4.0)
1473
+ self.spin_ring_outer.setSingleStep(0.05)
1474
+ self.spin_ring_outer.setValue(2.20)
1475
+ self.spin_ring_outer.setToolTip("Outer ring radius factor relative to body radius.")
1476
+ rings_form.addRow("Outer factor:", self.spin_ring_outer)
1477
+
1478
+ self.spin_ring_inner = QDoubleSpinBox()
1479
+ self.spin_ring_inner.setRange(0.2, 3.5)
1480
+ self.spin_ring_inner.setSingleStep(0.05)
1481
+ self.spin_ring_inner.setValue(1.25)
1482
+ self.spin_ring_inner.setToolTip("Inner ring radius factor relative to body radius.")
1483
+ rings_form.addRow("Inner factor:", self.spin_ring_inner)
1484
+
1485
+ form.addRow(rings_box)
1486
+
1487
+ self.spin_theta = QDoubleSpinBox()
1488
+ self.spin_theta.setRange(0.2, 25.0)
1489
+ self.spin_theta.setSingleStep(0.2)
1490
+ self.spin_theta.setValue(6.0)
1491
+ self.spin_theta.setToolTip("Stereo strength in degrees. 6° usually looks best.")
1492
+ form.addRow("Strength (deg):", self.spin_theta)
1493
+
1494
+ # Pseudo surface group
1495
+ ps_box = QGroupBox("Pseudo surface depth")
1496
+ ps_form = QFormLayout(ps_box)
1497
+
1498
+ self.spin_ps_gamma = QDoubleSpinBox()
1499
+ self.spin_ps_gamma.setRange(0.3, 4.0)
1500
+ self.spin_ps_gamma.setSingleStep(0.05)
1501
+ self.spin_ps_gamma.setValue(1.15)
1502
+ self.spin_ps_gamma.setToolTip("Depth gamma. >1 emphasizes bright peaks; <1 broadens depth.")
1503
+ ps_form.addRow("Depth gamma:", self.spin_ps_gamma)
1504
+
1505
+ self.spin_ps_blur = QDoubleSpinBox()
1506
+ self.spin_ps_blur.setRange(0.0, 12.0)
1507
+ self.spin_ps_blur.setSingleStep(0.2)
1508
+ self.spin_ps_blur.setValue(1.2)
1509
+ self.spin_ps_blur.setToolTip("Smooth height map to avoid noisy depth.")
1510
+ ps_form.addRow("Depth blur (px):", self.spin_ps_blur)
1511
+
1512
+ self.chk_ps_invert = QCheckBox("Normal depth (bright = closer), uncheck for inverted")
1513
+ self.chk_ps_invert.setChecked(True)
1514
+ ps_form.addRow("", self.chk_ps_invert)
1515
+
1516
+
1517
+ # Pseudo-surface 3D coloring mode (how points are COLORED)
1518
+ self.cmb_ps_3d_mode = QComboBox(self)
1519
+ self.cmb_ps_3d_mode.addItems([
1520
+ "Brightness (RGB)",
1521
+ "Depth (Height Colormap)",
1522
+ "Dual (RGB + Depth)",
1523
+ ])
1524
+ self.cmb_ps_3d_mode.setToolTip(
1525
+ "3D point cloud coloring:\n"
1526
+ "• Brightness: points colored from the image (RGB)\n"
1527
+ "• Depth: points colored by height (colormap)\n"
1528
+ "• Dual: overlays RGB + depth coloring"
1529
+ )
1530
+ ps_form.addRow("3D Color Mode:", self.cmb_ps_3d_mode)
1531
+
1532
+ # Height-from (what drives HEIGHT / Z)
1533
+ self.cmb_ps_height_from = QComboBox(self)
1534
+ self.cmb_ps_height_from.addItems([
1535
+ "Brightness (Luminance)",
1536
+ "Color Intensity (Saturation)",
1537
+ "Dual (Brightness + Color)",
1538
+ ])
1539
+ self.cmb_ps_height_from.setToolTip(
1540
+ "What drives the HEIGHT (Z) of the 3D point cloud:\n"
1541
+ "• Brightness: luminance-derived height\n"
1542
+ "• Color Intensity: saturation/chroma-derived height\n"
1543
+ "• Dual: overlays TWO clouds (brightness height + saturation height)\n\n"
1544
+ "Tip: Dual gives nebulae \"bulk\" even where brightness is flatter."
1545
+ )
1546
+ ps_form.addRow("Height From:", self.cmb_ps_height_from)
1547
+
1548
+ # Max points (vertex cap)
1549
+ self.spin_ps_max_points = QSpinBox(self)
1550
+ self.spin_ps_max_points.setRange(50_000, 900_000) # tune if you want
1551
+ self.spin_ps_max_points.setSingleStep(50_000)
1552
+ self.spin_ps_max_points.setValue(250_000) # good default
1553
+ self.spin_ps_max_points.setToolTip(
1554
+ "Maximum number of points used in the 3D plot.\n"
1555
+ "Higher = more detail but heavier in the browser."
1556
+ )
1557
+ ps_form.addRow("Max Points:", self.spin_ps_max_points)
1558
+
1559
+ # Dual saturation cloud opacity (only used when Height From == Dual)
1560
+ self.spin_ps_sat_opacity = QDoubleSpinBox(self)
1561
+ self.spin_ps_sat_opacity.setRange(0.05, 1.0)
1562
+ self.spin_ps_sat_opacity.setSingleStep(0.05)
1563
+ self.spin_ps_sat_opacity.setValue(0.45)
1564
+ self.spin_ps_sat_opacity.setToolTip(
1565
+ "Opacity of the saturation-height cloud when Height From is Dual.\n"
1566
+ "Lower = subtle bulk; higher = more pronounced volume."
1567
+ )
1568
+ ps_form.addRow("Dual Sat Opacity:", self.spin_ps_sat_opacity)
1569
+
1570
+ # (Keep your existing)
1571
+ form.addRow(ps_box)
1572
+
1573
+ self.chk_auto_roi = QCheckBox("Auto ROI from planet centroid (green channel)")
1574
+ self.chk_auto_roi.setChecked(True)
1575
+ form.addRow("", self.chk_auto_roi)
1576
+
1577
+ self.spin_pad = QDoubleSpinBox()
1578
+ self.spin_pad.setRange(1.5, 6.0)
1579
+ self.spin_pad.setSingleStep(0.1)
1580
+ self.spin_pad.setValue(3.2)
1581
+ self.spin_pad.setToolTip("ROI size ≈ pad × planet radius")
1582
+ form.addRow("ROI pad (×radius):", self.spin_pad)
1583
+
1584
+ self.spin_min = QSpinBox()
1585
+ self.spin_min.setRange(128, 2000)
1586
+ self.spin_min.setValue(240)
1587
+ form.addRow("ROI min size:", self.spin_min)
1588
+
1589
+ self.spin_max = QSpinBox()
1590
+ self.spin_max.setRange(128, 5000)
1591
+ self.spin_max.setValue(900)
1592
+ form.addRow("ROI max size:", self.spin_max)
1593
+
1594
+ # Disk review + reset row
1595
+ disk_row_w = QWidget()
1596
+ disk_row = QHBoxLayout(disk_row_w)
1597
+ disk_row.setContentsMargins(0, 0, 0, 0)
1598
+
1599
+ self.chk_adjust_disk = QCheckBox("Review / adjust detected disk before generating")
1600
+ self.chk_adjust_disk.setChecked(True)
1601
+ disk_row.addWidget(self.chk_adjust_disk)
1602
+
1603
+ self.btn_reset_disk = themed_toolbtn("edit-undo", "Reset disk detection")
1604
+ ...
1605
+ disk_row.addStretch(1)
1606
+ disk_row.addWidget(self.btn_reset_disk)
1607
+
1608
+ form.addRow("", disk_row_w)
1609
+
1610
+
1611
+ self.chk_starfield = QCheckBox("Add static starfield background (no parallax)")
1612
+ self.chk_starfield.setChecked(True)
1613
+ form.addRow("", self.chk_starfield)
1614
+
1615
+ self.spin_density = QDoubleSpinBox()
1616
+ self.spin_density.setRange(0.0, 0.2)
1617
+ self.spin_density.setSingleStep(0.005)
1618
+ self.spin_density.setValue(0.03)
1619
+ self.spin_density.setToolTip("Star seed density. Try 0.01–0.06 for visible fields.")
1620
+ form.addRow("Star density:", self.spin_density)
1621
+
1622
+ self.spin_seed = QSpinBox()
1623
+ self.spin_seed.setRange(0, 999999)
1624
+ self.spin_seed.setValue(1)
1625
+ form.addRow("Star seed:", self.spin_seed)
1626
+ # Background image row
1627
+ bg_row = QHBoxLayout()
1628
+ self.chk_bg_image = QCheckBox("Use background image")
1629
+ self.chk_bg_image.setChecked(False)
1630
+ bg_row.addWidget(self.chk_bg_image)
1631
+
1632
+ self.bg_path_edit = QLineEdit()
1633
+ self.bg_path_edit.setReadOnly(True)
1634
+ self.bg_path_edit.setPlaceholderText("No background image selected")
1635
+ bg_row.addWidget(self.bg_path_edit, 1)
1636
+
1637
+ self.btn_bg_choose = QPushButton("Choose…")
1638
+ self.btn_bg_choose.clicked.connect(self._choose_bg)
1639
+ bg_row.addWidget(self.btn_bg_choose)
1640
+
1641
+ form.addRow("Background:", bg_row)
1642
+
1643
+ # Background depth (%): UI slider -2..10, internal -2000..10000 (x1000)
1644
+ bg_depth_row = QHBoxLayout()
1645
+
1646
+ self.sld_bg_depth = QSlider(Qt.Orientation.Horizontal)
1647
+ self.sld_bg_depth.setRange(-1000, 1000) # -2.00 .. 10.00 in steps of 0.01
1648
+ self.sld_bg_depth.setValue(300)
1649
+ self.sld_bg_depth.setSingleStep(5) # 0.05
1650
+ self.sld_bg_depth.setPageStep(25) # 0.25
1651
+ bg_depth_row.addWidget(self.sld_bg_depth, 1)
1652
+
1653
+ self.lbl_bg_depth = QLabel("0.00")
1654
+ self.lbl_bg_depth.setMinimumWidth(55)
1655
+ self.lbl_bg_depth.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
1656
+ bg_depth_row.addWidget(self.lbl_bg_depth)
1657
+
1658
+ def _update_bg_depth_label(v: int):
1659
+ self.lbl_bg_depth.setText(f"{v/100.0:.2f}")
1660
+
1661
+ self.sld_bg_depth.valueChanged.connect(_update_bg_depth_label)
1662
+ _update_bg_depth_label(self.sld_bg_depth.value())
1663
+
1664
+ tip = (
1665
+ "Background parallax as a percent of the planet parallax.\n"
1666
+ "0% = no parallax (screen-locked)\n"
1667
+ "25% = far behind (recommended)\n"
1668
+ "100% = same depth as planet (not recommended)\n\n"
1669
+ "UI shows -2..10; internally this is multiplied by 1000."
1670
+ )
1671
+ self.sld_bg_depth.setToolTip(tip)
1672
+ self.lbl_bg_depth.setToolTip(tip)
1673
+
1674
+ form.addRow("Background depth (xR):", bg_depth_row)
1675
+
1676
+
1677
+ self.spin_wiggle_ms = QSpinBox()
1678
+ self.spin_wiggle_ms.setRange(40, 800)
1679
+ self.spin_wiggle_ms.setValue(120)
1680
+ form.addRow("Wiggle period (ms):", self.spin_wiggle_ms)
1681
+
1682
+ outer.addWidget(box)
1683
+
1684
+ # Buttons
1685
+ btns = QHBoxLayout()
1686
+ self.btn_generate = QPushButton("Generate")
1687
+ self.btn_stop = QPushButton("Stop Wiggle")
1688
+ self.btn_stop.setEnabled(False)
1689
+ self.btn_close = QPushButton("Close")
1690
+ btns.addWidget(self.btn_generate)
1691
+ btns.addWidget(self.btn_stop)
1692
+ btns.addStretch(1)
1693
+ btns.addWidget(self.btn_close)
1694
+ outer.addLayout(btns)
1695
+
1696
+ def _set_form_row_visible(form_layout: QFormLayout, field_widget: QWidget, visible: bool):
1697
+ """Hide/show the entire row in a QFormLayout that contains field_widget."""
1698
+ for r in range(form_layout.rowCount()):
1699
+ item = form_layout.itemAt(r, QFormLayout.ItemRole.FieldRole)
1700
+ if item and item.widget() is field_widget:
1701
+ label_item = form_layout.itemAt(r, QFormLayout.ItemRole.LabelRole)
1702
+ if label_item and label_item.widget():
1703
+ label_item.widget().setVisible(visible)
1704
+ field_widget.setVisible(visible)
1705
+ return
1706
+
1707
+
1708
+
1709
+ def _update_type_enable():
1710
+ t = self.cmb_planet_type.currentIndex()
1711
+ is_sat = (t == 1)
1712
+ is_gal = (t == 3)
1713
+ is_pseudo = (t == 2)
1714
+
1715
+ rings_box.setVisible(is_sat or is_gal)
1716
+ ps_box.setVisible(is_pseudo)
1717
+
1718
+ # These are “planet disk” concepts; don’t use for pseudo surface
1719
+ self.chk_auto_roi.setEnabled(not is_pseudo)
1720
+ self.spin_pad.setEnabled(not is_pseudo)
1721
+ self.spin_min.setEnabled(not is_pseudo)
1722
+ self.spin_max.setEnabled(not is_pseudo)
1723
+ if hasattr(self, "chk_adjust_disk"):
1724
+ self.chk_adjust_disk.setEnabled(not is_pseudo)
1725
+
1726
+ # Background/starfield: pseudo surface fills the whole frame anyway
1727
+ self.chk_starfield.setEnabled(not is_pseudo)
1728
+ self.spin_density.setEnabled(not is_pseudo)
1729
+ self.spin_seed.setEnabled(not is_pseudo)
1730
+ self.chk_bg_image.setEnabled(not is_pseudo)
1731
+ self.bg_path_edit.setEnabled(not is_pseudo)
1732
+ self.btn_bg_choose.setEnabled(not is_pseudo)
1733
+ self.sld_bg_depth.setEnabled(not is_pseudo)
1734
+ self.lbl_bg_depth.setEnabled(not is_pseudo)
1735
+
1736
+ # --- Galaxy vs Saturn UI tweaks inside rings_box ---
1737
+ if is_gal:
1738
+ rings_box.setTitle("Galaxy disk")
1739
+ self.chk_rings.setVisible(False) # only meaningful for Saturn
1740
+ _set_form_row_visible(rings_form, self.spin_ring_outer, False)
1741
+ _set_form_row_visible(rings_form, self.spin_ring_inner, False)
1742
+
1743
+ # force output to Galaxy Polar View (optional, but prevents confusion)
1744
+ if self.cmb_mode.currentIndex() != 5:
1745
+ self.cmb_mode.setCurrentIndex(5)
1746
+ else:
1747
+ rings_box.setTitle("Saturn rings")
1748
+ self.chk_rings.setVisible(True)
1749
+ _set_form_row_visible(rings_form, self.spin_ring_outer, True)
1750
+ _set_form_row_visible(rings_form, self.spin_ring_inner, True)
1751
+
1752
+ self.adjustSize() # shrink/grow dialog to fit
1753
+
1754
+
1755
+ self.cmb_planet_type.currentIndexChanged.connect(_update_type_enable)
1756
+ _update_type_enable()
1757
+
1758
+ self.btn_generate.clicked.connect(self._generate)
1759
+ self.btn_stop.clicked.connect(self._stop_wiggle)
1760
+ self.btn_close.clicked.connect(self.close)
1761
+
1762
+ def _apply_initial_layout_fix(self):
1763
+ # Re-run the same logic you already use (rings_box/ps_box visibility + adjustSize)
1764
+ try:
1765
+ # call your existing closure logic by nudging without changing index
1766
+ # simplest: just call adjustSize + clamp to something sane
1767
+ self.adjustSize()
1768
+
1769
+ # Optional: clamp width/height so it doesn't blow out
1770
+ sh = self.sizeHint()
1771
+ w = max(self.minimumWidth(), sh.width())
1772
+ h = max(self.minimumHeight(), sh.height())
1773
+ self.resize(w, h)
1774
+ except Exception:
1775
+ pass
1776
+
1777
+
1778
+ def _reset_disk_cache(self):
1779
+ # Forget disk refinement for the CURRENT image only.
1780
+ self._disk_last = None
1781
+ self._disk_last_was_user = False
1782
+
1783
+ # Disable until we detect/accept again
1784
+ if hasattr(self, "btn_reset_disk") and self.btn_reset_disk is not None:
1785
+ self.btn_reset_disk.setEnabled(False)
1786
+
1787
+ # Optional: small user feedback
1788
+ QMessageBox.information(self, "Planet Projection", "Disk refinement reset. Next Generate will re-detect.")
1789
+
1790
+
1791
+ def _current_image_key(self, img: np.ndarray):
1792
+ """
1793
+ Stable key for the underlying image buffer, even if we take views like img[..., :3].
1794
+ """
1795
+ a = np.asarray(img)
1796
+
1797
+ # Walk to the base ndarray so views/slices map to the same identity
1798
+ base = a
1799
+ while isinstance(getattr(base, "base", None), np.ndarray):
1800
+ base = base.base
1801
+
1802
+ # Use raw data pointer + base dtype/shape (stable across views)
1803
+ ptr = int(base.__array_interface__["data"][0])
1804
+ return (ptr, tuple(base.shape), str(base.dtype))
1805
+
1806
+
1807
+
1808
+ def _set_preview_zoom(self, z: float):
1809
+ """
1810
+ z = 1.0 => Fit-to-window (KeepAspectRatio)
1811
+ z = 0.0 => True 1:1 (no scaling, centered)
1812
+ otherwise => scale relative to Fit (e.g., 0.7 = smaller than fit, 1.4 = bigger than fit)
1813
+ """
1814
+ if z < 0.05 and z != 0.0:
1815
+ z = 0.05
1816
+ if z > 6.0:
1817
+ z = 6.0
1818
+ self._preview_zoom = float(z)
1819
+
1820
+ # re-show last content
1821
+ if self._left is not None and self._right is not None:
1822
+ mode = self.cmb_mode.currentIndex()
1823
+ if mode == 2:
1824
+ # wiggle uses _set_preview_u8 directly; force refresh of current wiggle frame
1825
+ frame = self._right if self._wiggle_state else self._left
1826
+ self._set_preview_u8(frame)
1827
+ else:
1828
+ cross_eye = (mode == 0)
1829
+ self._show_stereo_pair(cross_eye=cross_eye)
1830
+
1831
+ def _fit_scaled_size(self, img_w: int, img_h: int) -> tuple[int, int]:
1832
+ """Compute the fit-to-preview size (KeepAspectRatio)."""
1833
+ pw = max(1, self.preview.width())
1834
+ ph = max(1, self.preview.height())
1835
+ s = min(pw / float(img_w), ph / float(img_h))
1836
+ return int(round(img_w * s)), int(round(img_h * s))
1837
+
1838
+ def _show_preview_window(self):
1839
+ if self._preview_win is None:
1840
+ self._preview_win = PlanetProjectionPreviewDialog(self)
1841
+ try:
1842
+ self._preview_win.resize(980, 600)
1843
+ except Exception:
1844
+ pass
1845
+ self._preview_win.show()
1846
+ self._preview_win.raise_()
1847
+ self._preview_win.activateWindow()
1848
+
1849
+
1850
+ def _open_preview_window(self):
1851
+ if self._preview_win is None:
1852
+ self._preview_win = PlanetProjectionPreviewDialog(self)
1853
+ try:
1854
+ self._preview_win.resize(980, 600)
1855
+ except Exception:
1856
+ pass
1857
+ self._preview_win.show()
1858
+ self._preview_win.raise_()
1859
+ self._preview_win.activateWindow()
1860
+
1861
+ def _raise_preview_window(self):
1862
+ if self._preview_win is None:
1863
+ self._open_preview_window()
1864
+ return
1865
+ self._preview_win.show()
1866
+ self._preview_win.raise_()
1867
+ self._preview_win.activateWindow()
1868
+
1869
+ def _push_preview_u8(self, rgb8: np.ndarray):
1870
+ rgb8 = np.asarray(rgb8)
1871
+ if rgb8.dtype != np.uint8:
1872
+ rgb8 = np.clip(rgb8, 0, 255).astype(np.uint8)
1873
+ if rgb8.ndim == 2:
1874
+ rgb8 = np.stack([rgb8, rgb8, rgb8], axis=2)
1875
+ if rgb8.shape[2] > 3:
1876
+ rgb8 = rgb8[..., :3]
1877
+
1878
+ self._last_preview_u8 = rgb8
1879
+
1880
+ # ensure preview exists
1881
+ if self._preview_win is None or not self._preview_win.isVisible():
1882
+ self._open_preview_window()
1883
+ self._preview_win.set_frame_u8(rgb8)
1884
+
1885
+ def _compose_side_by_side_u8(self, left8: np.ndarray, right8: np.ndarray, *, swap_eyes: bool, gap_px: int) -> np.ndarray:
1886
+ L = np.asarray(left8)
1887
+ R = np.asarray(right8)
1888
+
1889
+ if L.dtype != np.uint8:
1890
+ L = np.clip(L, 0, 255).astype(np.uint8)
1891
+ if R.dtype != np.uint8:
1892
+ R = np.clip(R, 0, 255).astype(np.uint8)
1893
+
1894
+ if L.ndim == 2:
1895
+ L = np.stack([L, L, L], axis=2)
1896
+ if R.ndim == 2:
1897
+ R = np.stack([R, R, R], axis=2)
1898
+
1899
+ if L.shape[2] > 3:
1900
+ L = L[..., :3]
1901
+ if R.shape[2] > 3:
1902
+ R = R[..., :3]
1903
+
1904
+ if swap_eyes:
1905
+ L, R = R, L
1906
+
1907
+ gap = int(max(0, gap_px))
1908
+ H = max(L.shape[0], R.shape[0])
1909
+ W = L.shape[1] + gap + R.shape[1]
1910
+
1911
+ canvas = np.zeros((H, W, 3), dtype=np.uint8)
1912
+ canvas[:L.shape[0], :L.shape[1]] = L
1913
+ canvas[:R.shape[0], L.shape[1] + gap:L.shape[1] + gap + R.shape[1]] = R
1914
+ return canvas
1915
+
1916
+
1917
+ def _bg_depth_internal_signed(self) -> float:
1918
+ """
1919
+ Read background depth from the UI slider, apply Saturn sign flip.
1920
+
1921
+ Slider shows -10.00 .. +10.00 (label uses v/100).
1922
+ We'll interpret slider units as "percent * 100":
1923
+ depth_pct = (slider_value / 100.0)
1924
+ so slider=25 -> 0.25 (25% of planet disparity).
1925
+ """
1926
+ v = float(self.sld_bg_depth.value()) # int
1927
+ # Saturn: invert background direction
1928
+ if self.cmb_planet_type.currentIndex() == 1: # 1 = Saturn
1929
+ v = -v
1930
+ return v
1931
+
1932
+
1933
+ def _set_bg_depth_internal(self, v: float):
1934
+ # internal -2000..10000 -> slider -200..1000
1935
+ self.sld_bg_depth.setValue(int(round(float(v) / 10.0)))
1936
+
1937
+
1938
+ def _update_enable(self):
1939
+ ok = (
1940
+ self.image is not None and isinstance(self.image, np.ndarray)
1941
+ and self.image.ndim == 3 and self.image.shape[2] >= 3
1942
+ )
1943
+ self.btn_generate.setEnabled(bool(ok))
1944
+
1945
+ def _compute_roi(self):
1946
+ img = np.asarray(self.image)
1947
+ H, W = img.shape[:2]
1948
+
1949
+ if self.chk_auto_roi.isChecked():
1950
+ # use green channel for centroid detection
1951
+ c = _planet_centroid_and_area(img[..., 1])
1952
+ if c is not None:
1953
+ cx, cy, area = c
1954
+ return _compute_roi_from_centroid(
1955
+ H, W, cx, cy, area,
1956
+ pad_mul=float(self.spin_pad.value()),
1957
+ min_size=int(self.spin_min.value()),
1958
+ max_size=int(self.spin_max.value()),
1959
+ )
1960
+ # fallback to center if centroid fails
1961
+ # center ROI
1962
+ s = int(np.clip(min(H, W) * 0.45, float(self.spin_min.value()), float(self.spin_max.value())))
1963
+ cx_i, cy_i = W // 2, H // 2
1964
+ x0 = max(0, cx_i - s // 2)
1965
+ y0 = max(0, cy_i - s // 2)
1966
+ x1 = min(W, x0 + s)
1967
+ y1 = min(H, y0 + s)
1968
+ return (x0, y0, x1, y1)
1969
+
1970
+ def _generate(self):
1971
+ self._stop_wiggle()
1972
+ mode = int(self.cmb_mode.currentIndex())
1973
+
1974
+ if self.image is None:
1975
+ QMessageBox.information(self, "Planet Projection", "No image loaded.")
1976
+ return
1977
+
1978
+ img = np.asarray(self.image)
1979
+ if img.ndim != 3 or img.shape[2] < 3:
1980
+ QMessageBox.information(self, "Planet Projection", "Image must be RGB (3 channels).")
1981
+ return
1982
+
1983
+ img = img[..., :3] # ensure exactly RGB
1984
+ Hfull, Wfull = img.shape[:2]
1985
+
1986
+ ptype = int(self.cmb_planet_type.currentIndex())
1987
+ is_pseudo = (ptype == 2)
1988
+
1989
+ # ---- 0) reset cached disk if image changed ----
1990
+ key = self._current_image_key(img)
1991
+ if self._disk_key != key:
1992
+ self._disk_key = key
1993
+ self._disk_last = None
1994
+ self._disk_last_was_user = False
1995
+
1996
+ # ---- 1) initial disk estimate (FULL IMAGE coords) ----
1997
+ if self._disk_last is not None:
1998
+ cx, cy, r = self._disk_last
1999
+ else:
2000
+ c = _planet_centroid_and_area(img[..., 1])
2001
+ if c is not None:
2002
+ cx, cy, area = c
2003
+ r = max(32.0, float(np.sqrt(area / np.pi)))
2004
+ else:
2005
+ cx = 0.5 * (Wfull - 1)
2006
+ cy = 0.5 * (Hfull - 1)
2007
+ r = 0.25 * min(Wfull, Hfull)
2008
+
2009
+ # ---- 2) optional user adjustment (preloads previous) ----
2010
+ if (
2011
+ (not is_pseudo)
2012
+ and self.chk_auto_roi.isChecked()
2013
+ and getattr(self, "chk_adjust_disk", None) is not None
2014
+ and self.chk_adjust_disk.isChecked()
2015
+ ):
2016
+ is_saturn = (ptype == 1)
2017
+ rings_on = bool(
2018
+ is_saturn
2019
+ and getattr(self, "chk_rings", None) is not None
2020
+ and self.chk_rings.isChecked()
2021
+ )
2022
+ is_galaxy = (ptype == 3)
2023
+ overlay_mode = "none"
2024
+ if is_galaxy:
2025
+ overlay_mode = "galaxy"
2026
+ elif is_saturn and rings_on:
2027
+ overlay_mode = "saturn"
2028
+
2029
+ dlg = PlanetDiskAdjustDialog(
2030
+ self, img[..., :3], cx, cy, r,
2031
+ overlay_mode=overlay_mode,
2032
+ ring_pa=float(self.spin_ring_pa.value()),
2033
+ ring_tilt=float(self.spin_ring_tilt.value()),
2034
+ ring_outer=float(self.spin_ring_outer.value()),
2035
+ ring_inner=float(self.spin_ring_inner.value()),
2036
+ )
2037
+
2038
+ if dlg.exec() != QDialog.DialogCode.Accepted:
2039
+ return
2040
+ cx, cy, r = dlg.get_result()
2041
+ self._disk_last = (float(cx), float(cy), float(r))
2042
+ self._disk_last_was_user = True
2043
+
2044
+ if overlay_mode in ("galaxy", "saturn"):
2045
+ pa, tilt, kout, kin = dlg.get_ring_result()
2046
+ self.spin_ring_pa.setValue(pa)
2047
+ self.spin_ring_tilt.setValue(tilt)
2048
+ if overlay_mode == "saturn":
2049
+ self.spin_ring_outer.setValue(kout)
2050
+ self.spin_ring_inner.setValue(kin)
2051
+
2052
+
2053
+ self._disk_last = (float(cx), float(cy), float(r))
2054
+ self._disk_last_was_user = True
2055
+ else:
2056
+ if self._disk_last is None:
2057
+ self._disk_last = (float(cx), float(cy), float(r))
2058
+ self._disk_last_was_user = False
2059
+
2060
+ if hasattr(self, "btn_reset_disk") and self.btn_reset_disk is not None:
2061
+ self.btn_reset_disk.setEnabled(self._disk_last is not None)
2062
+
2063
+ # ---- 3) ROI size from adjusted disk (pad/min/max) ----
2064
+ pad_mul = float(self.spin_pad.value())
2065
+ s = int(np.clip(r * pad_mul, float(self.spin_min.value()), float(self.spin_max.value())))
2066
+
2067
+ # ---- PSEUDO SURFACE MODE: early exit ----
2068
+ if is_pseudo:
2069
+ roi = img # whole image
2070
+ theta = float(self.spin_theta.value())
2071
+
2072
+ left_w, right_w, maskL, maskR = make_pseudo_surface_pair(
2073
+ roi,
2074
+ theta_deg=theta,
2075
+ depth_gamma=float(self.spin_ps_gamma.value()),
2076
+ blur_sigma=float(self.spin_ps_blur.value()),
2077
+ invert=bool(self.chk_ps_invert.isChecked()),
2078
+ )
2079
+
2080
+ Lw01 = left_w.astype(np.float32) / 255.0 if left_w.dtype == np.uint8 else left_w.astype(np.float32, copy=False)
2081
+ Rw01 = right_w.astype(np.float32) / 255.0 if right_w.dtype == np.uint8 else right_w.astype(np.float32, copy=False)
2082
+ Lw01 = np.clip(Lw01, 0.0, 1.0)
2083
+ Rw01 = np.clip(Rw01, 0.0, 1.0)
2084
+
2085
+ self._left = np.clip(Lw01 * 255.0, 0, 255).astype(np.uint8)
2086
+ self._right = np.clip(Rw01 * 255.0, 0, 255).astype(np.uint8)
2087
+
2088
+ # smooth wiggle not implemented for pseudo surface (yet) — keep toggle behavior
2089
+ self._wiggle_frames = None
2090
+ self._wiggle_state = False
2091
+
2092
+ if mode == 4:
2093
+ try:
2094
+ # color mode (how points are colored)
2095
+ idx = int(self.cmb_ps_3d_mode.currentIndex()) if hasattr(self, "cmb_ps_3d_mode") else 0
2096
+ color_mode = ("brightness", "depth", "dual")[max(0, min(2, idx))]
2097
+
2098
+ # height source (what drives Z)
2099
+ hidx = int(self.cmb_ps_height_from.currentIndex()) if hasattr(self, "cmb_ps_height_from") else 0
2100
+ height_from = ("brightness", "color", "dual")[max(0, min(2, hidx))]
2101
+
2102
+ # cap
2103
+ max_pts = int(self.spin_ps_max_points.value()) if hasattr(self, "spin_ps_max_points") else 250_000
2104
+
2105
+ # dual saturation opacity (only used in height_from="dual")
2106
+ sat_opacity = float(self.spin_ps_sat_opacity.value()) if hasattr(self, "spin_ps_sat_opacity") else 0.45
2107
+
2108
+ # title
2109
+ if height_from == "brightness":
2110
+ ht = "Brightness Height"
2111
+ elif height_from == "color":
2112
+ ht = "Color Intensity Height"
2113
+ else:
2114
+ ht = "Dual Height (Brightness + Color)"
2115
+
2116
+ html, default_path = export_pseudo_surface_html(
2117
+ roi,
2118
+ out_path=None,
2119
+ title=f"Pseudo Surface ({ht})",
2120
+ max_dim=2048,
2121
+ z_scale=0.35,
2122
+ depth_gamma=float(self.spin_ps_gamma.value()),
2123
+ blur_sigma=float(self.spin_ps_blur.value()),
2124
+ invert=bool(self.chk_ps_invert.isChecked()),
2125
+ block=10,
2126
+ block_blur_sigma=0.6,
2127
+ max_vertices=max_pts,
2128
+ point_size=1.6,
2129
+ height_from=height_from, # "brightness" | "color" | "dual"
2130
+ color_mode=color_mode, # "brightness" | "depth" | "dual"
2131
+ depth_colorscale="Turbo",
2132
+ depth_opacity=0.55,
2133
+ depth_point_size=1.9,
2134
+ sat_opacity=sat_opacity, # used in height_from="dual"
2135
+ sat_point_size=1.75, # slightly different size looks great
2136
+ sat_luma_gate=0.02, # suppress color-noise in very dark background
2137
+ sat_luma_soft=0.18, # soft knee range
2138
+ )
2139
+
2140
+ fn, _ = QFileDialog.getSaveFileName(
2141
+ self,
2142
+ "Save Pseudo Surface As",
2143
+ default_path,
2144
+ "HTML Files (*.html)"
2145
+ )
2146
+ if fn:
2147
+ if not fn.lower().endswith(".html"):
2148
+ fn += ".html"
2149
+ with open(fn, "w", encoding="utf-8") as f:
2150
+ f.write(html)
2151
+
2152
+ import tempfile, webbrowser
2153
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8")
2154
+ tmp.write(html)
2155
+ tmp.close()
2156
+ webbrowser.open("file://" + tmp.name)
2157
+
2158
+ except Exception as e:
2159
+ QMessageBox.warning(self, "Pseudo Surface", f"Failed to generate 3D pseudo surface:\n{e}")
2160
+ return
2161
+
2162
+ if mode == 2:
2163
+ self._start_wiggle()
2164
+ return
2165
+
2166
+ if mode == 3:
2167
+ try:
2168
+ ana = _make_anaglyph(self._left, self._right, swap_eyes=False)
2169
+ self._push_preview_u8(ana)
2170
+ except Exception as e:
2171
+ QMessageBox.warning(self, "Anaglyph", f"Failed to build anaglyph:\n{e}")
2172
+ return
2173
+
2174
+ cross_eye = (mode == 1)
2175
+ self._show_stereo_pair(cross_eye=cross_eye)
2176
+ return
2177
+
2178
+ # ---- Saturn rings ROI expansion (only increases s) ----
2179
+ is_saturn = (self.cmb_planet_type.currentIndex() == 1)
2180
+ rings_on = bool(is_saturn and getattr(self, "chk_rings", None) is not None and self.chk_rings.isChecked())
2181
+
2182
+ if rings_on:
2183
+ tilt = float(self.spin_ring_tilt.value())
2184
+ pa = float(self.spin_ring_pa.value())
2185
+ k_out = float(self.spin_ring_outer.value())
2186
+
2187
+ outer_boost = 1.05
2188
+ a_out = k_out * float(r) * outer_boost
2189
+ b_out = max(1.0, a_out * tilt)
2190
+
2191
+ th = np.deg2rad(pa)
2192
+ cth, sth = np.cos(th), np.sin(th)
2193
+
2194
+ dx = np.sqrt((a_out * cth) ** 2 + (b_out * sth) ** 2)
2195
+ dy = np.sqrt((a_out * sth) ** 2 + (b_out * cth) ** 2)
2196
+ need_half = float(max(dx, dy))
2197
+
2198
+ margin = 12.0
2199
+ s_need = int(np.ceil(2.0 * (need_half + margin)))
2200
+ s = max(s, s_need)
2201
+
2202
+ s = int(np.clip(s, float(self.spin_min.value()), float(self.spin_max.value())))
2203
+
2204
+ # ---- ROI crop ALWAYS (for normal/saturn) ----
2205
+ cx_i, cy_i = int(round(cx)), int(round(cy))
2206
+ x0 = max(0, cx_i - s // 2)
2207
+ y0 = max(0, cy_i - s // 2)
2208
+ x1 = min(Wfull, x0 + s)
2209
+ y1 = min(Hfull, y0 + s)
2210
+
2211
+ roi = img[y0:y1, x0:x1, :3]
2212
+
2213
+ # ---- disk mask (ROI coords) ----
2214
+ H0, W0 = roi.shape[:2]
2215
+ yy, xx = np.mgrid[0:H0, 0:W0].astype(np.float32)
2216
+ cx0 = float(cx - x0)
2217
+ cy0 = float(cy - y0)
2218
+ disk = ((xx - cx0) ** 2 + (yy - cy0) ** 2) <= (float(r) ** 2)
2219
+
2220
+ def to01(x):
2221
+ if x.dtype == np.uint8:
2222
+ return x.astype(np.float32) / 255.0
2223
+ if x.dtype == np.uint16:
2224
+ return x.astype(np.float32) / 65535.0
2225
+ return x.astype(np.float32, copy=False)
2226
+
2227
+ theta = float(self.spin_theta.value())
2228
+
2229
+ # ---- GALAXY TOP-DOWN (early exit) ----
2230
+ is_galaxy = (ptype == 3) or (mode == 5) # planet_type==Galaxy OR output==Galaxy Polar View
2231
+
2232
+ if is_galaxy:
2233
+ # Galaxy wants the ROI disk params (cx0, cy0, r) + PA/tilt
2234
+ roi01 = to01(roi)
2235
+
2236
+ pa = float(self.spin_ring_pa.value()) # reuse ring PA widget as galaxy PA
2237
+ tilt = float(self.spin_ring_tilt.value()) # reuse ring tilt widget as galaxy b/a
2238
+
2239
+ # choose output size: use ROI size or clamp to something reasonable
2240
+ out_size = int(max(256, min(2000, max(roi.shape[0], roi.shape[1]))))
2241
+
2242
+ try:
2243
+ top8 = deproject_galaxy_topdown_u8(
2244
+ roi01,
2245
+ cx0=float(cx0), cy0=float(cy0),
2246
+ rpx=float(r),
2247
+ pa_deg=pa,
2248
+ tilt=tilt,
2249
+ out_size=out_size,
2250
+ )
2251
+ except Exception as e:
2252
+ QMessageBox.warning(self, "Galaxy Polar View", f"Failed to deproject galaxy:\n{e}")
2253
+ return
2254
+
2255
+ # push single-frame output
2256
+ self._left = None
2257
+ self._right = None
2258
+ self._wiggle_frames = None
2259
+ self._wiggle_state = False
2260
+
2261
+ self._last_preview_u8 = top8
2262
+ self._push_preview_u8(top8)
2263
+ return
2264
+
2265
+ # ---- BODY (sphere reprojection) ----
2266
+ interp = cv2.INTER_LANCZOS4
2267
+ left_w, right_w, maskL, maskR = make_stereo_pair(
2268
+ roi, theta_deg=theta, disk_mask=disk, interp=interp
2269
+ )
2270
+ Lw01 = to01(left_w)
2271
+ Rw01 = to01(right_w)
2272
+
2273
+ # ---- SATURN RINGS (optional) ----
2274
+ ringL01 = ringR01 = None
2275
+ ringL_front = ringL_back = ringR_front = ringR_back = None
2276
+
2277
+ if rings_on:
2278
+ tilt = float(self.spin_ring_tilt.value())
2279
+ pa = float(self.spin_ring_pa.value())
2280
+ k_out = float(self.spin_ring_outer.value())
2281
+ k_in = float(self.spin_ring_inner.value())
2282
+
2283
+ outer_boost = 1.05
2284
+ a_out = k_out * float(r) * outer_boost
2285
+ b_out = max(1.0, a_out * tilt)
2286
+
2287
+ a_in = k_in * float(r)
2288
+ b_in = max(1.0, a_in * tilt)
2289
+
2290
+ ringMask = _ellipse_annulus_mask(H0, W0, cx0, cy0, a_out, b_out, a_in, b_in, pa)
2291
+
2292
+ roi01 = to01(roi)
2293
+ ring_tex01 = roi01.copy()
2294
+ ring_tex01[~ringMask] = 0.0
2295
+
2296
+ mapLx, mapLy, mapRx, mapRy = _yaw_warp_maps(H0, W0, theta, cx0, cy0)
2297
+
2298
+ ringL01 = cv2.remap(ring_tex01, mapLx, mapLy, interpolation=cv2.INTER_LINEAR,
2299
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2300
+ ringR01 = cv2.remap(ring_tex01, mapRx, mapRy, interpolation=cv2.INTER_LINEAR,
2301
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2302
+
2303
+ front0, back0 = _ring_front_back_masks(H0, W0, cx0, cy0, pa, ringMask)
2304
+ f_u8 = (front0.astype(np.uint8) * 255)
2305
+ b_u8 = (back0.astype(np.uint8) * 255)
2306
+
2307
+ ringL_front = cv2.remap(f_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2308
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2309
+ ringL_back = cv2.remap(b_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2310
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2311
+ ringR_front = cv2.remap(f_u8, mapRx, mapRy, interpolation=cv2.INTER_NEAREST,
2312
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2313
+ ringR_back = cv2.remap(b_u8, mapRx, mapRy, interpolation=cv2.INTER_NEAREST,
2314
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2315
+
2316
+ # ---- centroid lock (planet-only) ----
2317
+ cL = _mask_centroid(maskL)
2318
+ cR = _mask_centroid(maskR)
2319
+ if cL is not None and cR is not None:
2320
+ tx = 0.5 * (cL[0] + cR[0])
2321
+ ty = 0.5 * (cL[1] + cR[1])
2322
+
2323
+ dxL, dyL = (tx - cL[0]), (ty - cL[1])
2324
+ dxR, dyR = (tx - cR[0]), (ty - cR[1])
2325
+
2326
+ Lw01 = _shift_image(Lw01, dxL, dyL, border_value=0)
2327
+ Rw01 = _shift_image(Rw01, dxR, dyR, border_value=0)
2328
+ maskL = _shift_mask(maskL, dxL, dyL)
2329
+ maskR = _shift_mask(maskR, dxR, dyR)
2330
+
2331
+ # IMPORTANT for smooth wiggle: ring masks/textures need to be shifted too
2332
+ if ringL01 is not None:
2333
+ ringL01 = _shift_image(ringL01, dxL, dyL, border_value=0)
2334
+ ringR01 = _shift_image(ringR01, dxR, dyR, border_value=0)
2335
+ ringL_front = _shift_mask(ringL_front, dxL, dyL) if ringL_front is not None else None
2336
+ ringL_back = _shift_mask(ringL_back, dxL, dyL) if ringL_back is not None else None
2337
+ ringR_front = _shift_mask(ringR_front, dxR, dyR) if ringR_front is not None else None
2338
+ ringR_back = _shift_mask(ringR_back, dxR, dyR) if ringR_back is not None else None
2339
+
2340
+ # ---- build background (bg01) ----
2341
+ H, W = roi.shape[:2]
2342
+ if self.chk_bg_image.isChecked() and self._bg_img01 is not None:
2343
+ bg = cv2.resize(self._bg_img01, (W, H), interpolation=cv2.INTER_AREA)
2344
+ bg01 = np.clip(bg.astype(np.float32, copy=False), 0.0, 1.0)
2345
+ else:
2346
+ bg01 = np.zeros((H, W, 3), dtype=np.float32)
2347
+
2348
+ if self.chk_starfield.isChecked():
2349
+ bg01 = _add_starfield(
2350
+ bg01,
2351
+ density=float(self.spin_density.value()),
2352
+ seed=int(self.spin_seed.value()),
2353
+ star_sigma=0.8,
2354
+ brightness=0.9,
2355
+ )
2356
+
2357
+ # ---- background parallax (for the still L/R that you already show) ----
2358
+ cL2 = _mask_centroid(maskL)
2359
+ cR2 = _mask_centroid(maskR)
2360
+ planet_disp_px = float(cL2[0] - cR2[0]) if (cL2 is not None and cR2 is not None) else 0.0
2361
+
2362
+ depth_pct = float(self._bg_depth_internal_signed()) / 100.0
2363
+ bg_disp_px = planet_disp_px * depth_pct
2364
+ bg_shift = 0.5 * bg_disp_px
2365
+
2366
+ max_bg_shift = 10.0 * min(H, W)
2367
+ bg_shift = float(np.clip(bg_shift, -max_bg_shift, +max_bg_shift))
2368
+
2369
+ bgL = _shift_image(bg01, +bg_shift, 0.0, border_value=0)
2370
+ bgR = _shift_image(bg01, -bg_shift, 0.0, border_value=0)
2371
+
2372
+ # ---- composite L/R ----
2373
+ Ldisp01 = bgL.copy()
2374
+ Rdisp01 = bgR.copy()
2375
+
2376
+ if ringL01 is not None:
2377
+ Ldisp01[ringL_back & (~maskL)] = ringL01[ringL_back & (~maskL)]
2378
+ Rdisp01[ringR_back & (~maskR)] = ringR01[ringR_back & (~maskR)]
2379
+
2380
+ Ldisp01[maskL] = Lw01[maskL]
2381
+ Rdisp01[maskR] = Rw01[maskR]
2382
+
2383
+ if ringL01 is not None:
2384
+ Ldisp01[ringL_front] = ringL01[ringL_front]
2385
+ Rdisp01[ringR_front] = ringR01[ringR_front]
2386
+
2387
+ self._left = np.clip(Ldisp01 * 255.0, 0, 255).astype(np.uint8)
2388
+ self._right = np.clip(Rdisp01 * 255.0, 0, 255).astype(np.uint8)
2389
+ self._wiggle_state = False
2390
+
2391
+ # ---- mode handling ----
2392
+ if mode == 4:
2393
+ try:
2394
+ rings_kwargs = None
2395
+ if rings_on:
2396
+ rings_kwargs = dict(
2397
+ cx=float(cx0),
2398
+ cy=float(cy0),
2399
+ r=float(r),
2400
+ pa=float(self.spin_ring_pa.value()),
2401
+ tilt=float(self.spin_ring_tilt.value()),
2402
+ k_out=float(self.spin_ring_outer.value()),
2403
+ k_in=float(self.spin_ring_inner.value()),
2404
+ )
2405
+
2406
+ html, default_path = export_planet_sphere_html(
2407
+ roi_rgb=roi,
2408
+ disk_mask=disk,
2409
+ out_path=None,
2410
+ n_lat=140,
2411
+ n_lon=280,
2412
+ title="Saturn" if rings_on else "Planet Sphere",
2413
+ rings=rings_kwargs,
2414
+ )
2415
+
2416
+ fn, _ = QFileDialog.getSaveFileName(
2417
+ self,
2418
+ "Save Planet Sphere As",
2419
+ default_path,
2420
+ "HTML Files (*.html)"
2421
+ )
2422
+ if fn:
2423
+ if not fn.lower().endswith(".html"):
2424
+ fn += ".html"
2425
+ with open(fn, "w", encoding="utf-8") as f:
2426
+ f.write(html)
2427
+
2428
+ import tempfile, webbrowser
2429
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8")
2430
+ tmp.write(html)
2431
+ tmp.close()
2432
+ webbrowser.open("file://" + tmp.name)
2433
+
2434
+ except Exception as e:
2435
+ QMessageBox.warning(self, "Planet Sphere", f"Failed to generate 3D sphere:\n{e}")
2436
+ return
2437
+
2438
+ if mode == 2:
2439
+ self._start_wiggle()
2440
+ return
2441
+
2442
+ if mode == 3:
2443
+ try:
2444
+ ana = _make_anaglyph(self._left, self._right, swap_eyes=False)
2445
+ self._push_preview_u8(ana)
2446
+ except Exception as e:
2447
+ QMessageBox.warning(self, "Anaglyph", f"Failed to build anaglyph:\n{e}")
2448
+ return
2449
+
2450
+ cross_eye = (mode == 0)
2451
+ self._show_stereo_pair(cross_eye=cross_eye)
2452
+ return
2453
+
2454
+ def _render_composited_view_u8(self, theta_deg: float) -> np.ndarray:
2455
+ """
2456
+ Render ONE view (not L/R pair) at a given theta using the real reprojection math.
2457
+ Returns RGB uint8 frame for preview/save.
2458
+ """
2459
+ if not hasattr(self, "_wiggle_ctx") or self._wiggle_ctx is None:
2460
+ return None
2461
+
2462
+ ctx = self._wiggle_ctx
2463
+ roi = ctx["roi"]
2464
+ disk = ctx["disk"]
2465
+ bg01 = ctx["bg01"]
2466
+ H0, W0 = roi.shape[:2]
2467
+ cx0, cy0 = float(ctx["cx0"]), float(ctx["cy0"])
2468
+
2469
+ def to01(x):
2470
+ x = np.asarray(x)
2471
+ if x.dtype == np.uint8:
2472
+ return x.astype(np.float32) / 255.0
2473
+ if x.dtype == np.uint16:
2474
+ return x.astype(np.float32) / 65535.0
2475
+ return x.astype(np.float32, copy=False)
2476
+
2477
+ # --- BODY: we can reuse make_stereo_pair by asking for a symmetric pair
2478
+ # and then choosing the "left" for +theta and "right" for -theta.
2479
+ # Easiest: call make_stereo_pair with theta_deg and take left_w/maskL as view.
2480
+ interp = cv2.INTER_LANCZOS4
2481
+ left_w, right_w, maskL, maskR = make_stereo_pair(roi, theta_deg=float(theta_deg), disk_mask=disk, interp=interp)
2482
+
2483
+ view01 = to01(left_w)
2484
+ mask = maskL
2485
+
2486
+ # --- RINGS (optional): warp ring texture with same theta and composite back/front
2487
+ ring01 = None
2488
+ ring_front = ring_back = None
2489
+ if ctx["rings_on"]:
2490
+ tilt = float(ctx["ring_tilt"])
2491
+ pa = float(ctx["ring_pa"])
2492
+ k_out = float(ctx["ring_outer"])
2493
+ k_in = float(ctx["ring_inner"])
2494
+ r = float(ctx["r"])
2495
+
2496
+ outer_boost = 1.05
2497
+ a_out = k_out * r * outer_boost
2498
+ b_out = max(1.0, a_out * tilt)
2499
+ a_in = k_in * r
2500
+ b_in = max(1.0, a_in * tilt)
2501
+
2502
+ ringMask = _ellipse_annulus_mask(H0, W0, cx0, cy0, a_out, b_out, a_in, b_in, pa)
2503
+
2504
+ roi01 = to01(roi)
2505
+ ring_tex01 = roi01.copy()
2506
+ ring_tex01[~ringMask] = 0.0
2507
+
2508
+ mapLx, mapLy, mapRx, mapRy = _yaw_warp_maps(H0, W0, float(theta_deg), cx0, cy0)
2509
+ ring01 = cv2.remap(ring_tex01, mapLx, mapLy, interpolation=cv2.INTER_LINEAR,
2510
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2511
+
2512
+ front0, back0 = _ring_front_back_masks(H0, W0, cx0, cy0, pa, ringMask)
2513
+ f_u8 = (front0.astype(np.uint8) * 255)
2514
+ b_u8 = (back0.astype(np.uint8) * 255)
2515
+ ring_front = (cv2.remap(f_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2516
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127)
2517
+ ring_back = (cv2.remap(b_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2518
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127)
2519
+
2520
+ # --- BACKGROUND PARALLAX: compute disparity for this theta using centroids in this view vs the opposite view
2521
+ # We already have maskL/maskR from make_stereo_pair.
2522
+ cL = _mask_centroid(maskL)
2523
+ cR = _mask_centroid(maskR)
2524
+ planet_disp_px = float(cL[0] - cR[0]) if (cL is not None and cR is not None) else 0.0
2525
+
2526
+ depth_pct = float(self._bg_depth_internal_signed()) / 100.0
2527
+ bg_disp_px = planet_disp_px * depth_pct
2528
+ bg_shift = 0.5 * bg_disp_px
2529
+ max_bg_shift = 10.0 * min(H0, W0)
2530
+ bg_shift = float(np.clip(bg_shift, -max_bg_shift, +max_bg_shift))
2531
+
2532
+ bg = _shift_image(bg01, +bg_shift, 0.0, border_value=0) # single-view bg
2533
+
2534
+ # --- COMPOSITE
2535
+ out01 = bg.copy()
2536
+
2537
+ if ring01 is not None:
2538
+ out01[ring_back & (~mask)] = ring01[ring_back & (~mask)]
2539
+
2540
+ out01[mask] = view01[mask]
2541
+
2542
+ if ring01 is not None:
2543
+ out01[ring_front] = ring01[ring_front]
2544
+
2545
+ out8 = np.clip(out01 * 255.0, 0, 255).astype(np.uint8)
2546
+ return out8
2547
+
2548
+ def _build_smooth_wiggle_frames(self):
2549
+ if not hasattr(self, "_wiggle_ctx") or self._wiggle_ctx is None:
2550
+ self._wiggle_frames = None
2551
+ return
2552
+
2553
+ theta_max = float(self.spin_theta.value())
2554
+ steps = int(getattr(self, "_wiggle_steps", 36))
2555
+ steps = max(8, min(240, steps))
2556
+
2557
+ frames = []
2558
+ for i in range(steps):
2559
+ phase = (2.0 * np.pi * i) / float(steps)
2560
+ theta_i = theta_max * float(np.sin(phase)) # smooth motion
2561
+ f = self._render_composited_view_u8(theta_i)
2562
+ if f is not None:
2563
+ frames.append(f)
2564
+
2565
+ self._wiggle_frames = frames if frames else None
2566
+
2567
+
2568
+ def _show_stereo_pair(self, cross_eye: bool = False):
2569
+ if self._left is None or self._right is None:
2570
+ return
2571
+
2572
+ # Ensure preview exists (same logic as _push_preview_u8)
2573
+ if self._preview_win is None or not self._preview_win.isVisible():
2574
+ self._open_preview_window()
2575
+
2576
+ # IMPORTANT: pass the RAW L and R (do NOT pre-compose into one canvas)
2577
+ # swap_eyes handles parallel vs cross-eye ordering inside the preview window
2578
+ self._preview_win.set_stereo_u8(
2579
+ self._left,
2580
+ self._right,
2581
+ swap_eyes=bool(cross_eye),
2582
+ gap_px=16
2583
+ )
2584
+
2585
+ # keep "last still" meaningful for Save Still…
2586
+ # If you want Save Still to save the side-by-side, ask preview for its composed canvas,
2587
+ # but for now, we’ll store a simple composed copy here:
2588
+ self._last_preview_u8 = self._compose_side_by_side_u8(
2589
+ self._left, self._right, swap_eyes=bool(cross_eye), gap_px=16
2590
+ )
2591
+
2592
+ def _choose_bg(self):
2593
+ fn, _ = QFileDialog.getOpenFileName(
2594
+ self,
2595
+ "Select background image",
2596
+ "",
2597
+ "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp);;All Files (*)",
2598
+ )
2599
+ if not fn:
2600
+ return
2601
+ self._bg_path = fn
2602
+ self.bg_path_edit.setText(fn)
2603
+
2604
+ try:
2605
+ # load via cv2, convert to RGB float01
2606
+ im = cv2.imread(fn, cv2.IMREAD_UNCHANGED)
2607
+ if im is None:
2608
+ raise RuntimeError("Could not read file.")
2609
+ if im.ndim == 2:
2610
+ im = np.stack([im, im, im], axis=2)
2611
+ if im.shape[2] > 3:
2612
+ im = im[..., :3]
2613
+
2614
+ # BGR->RGB
2615
+ im = im[..., ::-1]
2616
+
2617
+ if im.dtype == np.uint8:
2618
+ im01 = im.astype(np.float32) / 255.0
2619
+ elif im.dtype == np.uint16:
2620
+ im01 = im.astype(np.float32) / 65535.0
2621
+ else:
2622
+ im01 = im.astype(np.float32, copy=False)
2623
+ # best effort clamp
2624
+ im01 = np.clip(im01, 0.0, 1.0)
2625
+
2626
+ self._bg_img01 = im01
2627
+ except Exception as e:
2628
+ self._bg_img01 = None
2629
+ QMessageBox.warning(self, "Background Image", f"Failed to load background:\n{e}")
2630
+
2631
+ def _start_wiggle(self):
2632
+ if self._left is None or self._right is None:
2633
+ QMessageBox.information(self, "Wiggle", "Nothing to wiggle yet. Click Generate first.")
2634
+ return
2635
+
2636
+ self.btn_stop.setEnabled(True)
2637
+ self._wiggle_state = False
2638
+
2639
+ interval = int(self.spin_wiggle_ms.value()) # old meaning: toggle period
2640
+ interval = max(10, interval)
2641
+
2642
+ self._wiggle_timer.start(interval)
2643
+ self._on_wiggle_tick()
2644
+
2645
+
2646
+ def _stop_wiggle(self):
2647
+ if self._wiggle_timer.isActive():
2648
+ self._wiggle_timer.stop()
2649
+ self.btn_stop.setEnabled(False)
2650
+
2651
+
2652
+ def _on_wiggle_tick(self):
2653
+ if self._left is None or self._right is None:
2654
+ return
2655
+
2656
+ frame = self._right if self._wiggle_state else self._left
2657
+ self._wiggle_state = not self._wiggle_state
2658
+ self._push_preview_u8(frame)
2659
+
2660
+ def _save_still(self):
2661
+ if self._last_preview_u8 is None:
2662
+ QMessageBox.information(self, "Save Still", "Nothing to save yet. Click Generate first.")
2663
+ return
2664
+
2665
+ fn, filt = QFileDialog.getSaveFileName(
2666
+ self,
2667
+ "Save Still Image",
2668
+ "",
2669
+ "PNG (*.png);;JPEG (*.jpg *.jpeg);;TIFF (*.tif *.tiff)"
2670
+ )
2671
+ if not fn:
2672
+ return
2673
+
2674
+ img = self._last_preview_u8 # RGB uint8
2675
+
2676
+ # decide format from extension (default to png)
2677
+ ext = os.path.splitext(fn)[1].lower()
2678
+ if ext == "":
2679
+ fn += ".png"
2680
+ ext = ".png"
2681
+
2682
+ try:
2683
+ # use PIL for consistent RGB save
2684
+ from PIL import Image
2685
+ im = Image.fromarray(img, mode="RGB")
2686
+ if ext in (".jpg", ".jpeg"):
2687
+ im.save(fn, quality=95, subsampling=0)
2688
+ else:
2689
+ im.save(fn)
2690
+ except Exception as e:
2691
+ QMessageBox.warning(self, "Save Still", f"Failed to save:\n{e}")
2692
+
2693
+ def _save_wiggle(self):
2694
+ if self._left is None or self._right is None:
2695
+ QMessageBox.information(self, "Save Wiggle", "Nothing to save yet. Click Generate first.")
2696
+ return
2697
+
2698
+ fn, filt = QFileDialog.getSaveFileName(
2699
+ self,
2700
+ "Save Wiggle Animation",
2701
+ "",
2702
+ "Animated GIF (*.gif);;MP4 Video (*.mp4)"
2703
+ )
2704
+ if not fn:
2705
+ return
2706
+
2707
+ want_mp4 = ("*.mp4" in filt) or fn.lower().endswith(".mp4")
2708
+ want_gif = ("*.gif" in filt) or fn.lower().endswith(".gif")
2709
+
2710
+ # add extension if missing
2711
+ if os.path.splitext(fn)[1] == "":
2712
+ fn += ".mp4" if want_mp4 else ".gif"
2713
+ want_mp4 = fn.lower().endswith(".mp4")
2714
+ want_gif = fn.lower().endswith(".gif")
2715
+
2716
+ def _ensure_rgb_u8(x):
2717
+ x = np.asarray(x)
2718
+ if x.dtype != np.uint8:
2719
+ x = np.clip(x, 0, 255).astype(np.uint8)
2720
+ if x.ndim == 2:
2721
+ x = np.stack([x, x, x], axis=2)
2722
+ if x.shape[2] > 3:
2723
+ x = x[..., :3]
2724
+ return x
2725
+
2726
+ L = _ensure_rgb_u8(self._left)
2727
+ R = _ensure_rgb_u8(self._right)
2728
+
2729
+ toggle_ms = int(self.spin_wiggle_ms.value())
2730
+ toggle_ms = max(10, toggle_ms)
2731
+
2732
+ # old behavior: ~2 seconds total, alternating every toggle_ms
2733
+ fps = 1000.0 / float(toggle_ms)
2734
+ n_frames = max(2, int(round(2.0 * fps)))
2735
+ if n_frames % 2 == 1:
2736
+ n_frames += 1
2737
+
2738
+ frames = [R if (i % 2 == 1) else L for i in range(n_frames)]
2739
+
2740
+ if want_gif:
2741
+ try:
2742
+ from PIL import Image
2743
+ pil_frames = [Image.fromarray(f, mode="RGB") for f in frames]
2744
+ pil_frames[0].save(
2745
+ fn,
2746
+ save_all=True,
2747
+ append_images=pil_frames[1:],
2748
+ duration=toggle_ms,
2749
+ loop=0,
2750
+ disposal=2,
2751
+ optimize=False
2752
+ )
2753
+ return
2754
+ except Exception as e:
2755
+ QMessageBox.warning(self, "Save Wiggle", f"Failed to save GIF:\n{e}")
2756
+ return
2757
+
2758
+ # MP4
2759
+ try:
2760
+ import cv2
2761
+ h, w = frames[0].shape[:2]
2762
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
2763
+ vw = cv2.VideoWriter(fn, fourcc, float(fps), (w, h))
2764
+ if not vw.isOpened():
2765
+ raise RuntimeError("Could not open MP4 encoder (mp4v). This system may lack an MP4 codec.")
2766
+
2767
+ for f in frames:
2768
+ vw.write(f[..., ::-1]) # RGB->BGR
2769
+ vw.release()
2770
+ return
2771
+
2772
+ except Exception as e:
2773
+ QMessageBox.warning(
2774
+ self,
2775
+ "Save Wiggle (MP4)",
2776
+ "Failed to save MP4.\n\n"
2777
+ f"{e}\n\n"
2778
+ "Tip: GIF export should always work. If you need MP4 reliably, we can bundle/use ffmpeg."
2779
+ )
2780
+ return
2781
+
2782
+ def closeEvent(self, e):
2783
+ self._stop_wiggle()
2784
+ super().closeEvent(e)
2785
+
2786
+ class PlanetDiskAdjustDialog(QDialog):
2787
+ """
2788
+ Manual override for planet disk center/radius.
2789
+ - Ctrl+drag to move center.
2790
+ - +/- or slider/spin to change radius.
2791
+ - Arrow buttons (and arrow keys) to nudge.
2792
+ Returns cx, cy, r in FULL IMAGE pixel coords.
2793
+ """
2794
+ def __init__(self, parent, img_rgb: np.ndarray, cx: float, cy: float, r: float,
2795
+ *, show_rings: bool = False, overlay_mode: str = "none",
2796
+ ring_pa: float = 0.0, ring_tilt: float = 0.35,
2797
+ ring_outer: float = 2.2, ring_inner: float = 1.25):
2798
+ super().__init__(parent)
2799
+
2800
+ self.setWindowTitle("Adjust Planet Disk")
2801
+ self.setModal(True)
2802
+ self._preview_zoom = 1.0 # 1.0 = Fit
2803
+ self.overlay_mode = str(overlay_mode)
2804
+ self.show_rings = (self.overlay_mode in ("saturn", "galaxy"))
2805
+ self.img = np.asarray(img_rgb)
2806
+ self.H, self.W = self.img.shape[:2]
2807
+
2808
+ self.cx = float(cx)
2809
+ self.cy = float(cy)
2810
+ self.r = float(r)
2811
+
2812
+ # --- rings (optional) ---
2813
+
2814
+ self.ring_pa = float(ring_pa)
2815
+ self.ring_tilt = float(ring_tilt)
2816
+ self.ring_outer = float(ring_outer)
2817
+ self.ring_inner = float(ring_inner)
2818
+
2819
+ self._dragging = False
2820
+ self._drag_offset = (0.0, 0.0)
2821
+
2822
+ # preview state
2823
+ self._disp8 = _to_u8_preview(self.img[..., :3])
2824
+ self._scale = 1.0
2825
+ self._offx = 0.0
2826
+ self._offy = 0.0
2827
+
2828
+ self._build_ui()
2829
+ self._redraw()
2830
+
2831
+ def _build_ui(self):
2832
+ outer = QVBoxLayout(self)
2833
+ outer.setContentsMargins(10, 10, 10, 10)
2834
+ outer.setSpacing(8)
2835
+
2836
+ help_txt = (
2837
+ "Ctrl+Click+Drag to move the circle.\n"
2838
+ "Use Radius controls and arrow nudges for precision."
2839
+ )
2840
+ if self.show_rings:
2841
+ help_txt += "\nAdjust ring PA / tilt / inner / outer to match Saturn's rings."
2842
+
2843
+ self.lbl_help = QLabel(help_txt)
2844
+ self.lbl_help.setWordWrap(True)
2845
+ outer.addWidget(self.lbl_help)
2846
+
2847
+ # Zoom controls
2848
+ zoom_row = QHBoxLayout()
2849
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
2850
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
2851
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
2852
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
2853
+
2854
+ zoom_row.addStretch(1)
2855
+ zoom_row.addWidget(self.btn_zoom_out)
2856
+ zoom_row.addWidget(self.btn_zoom_fit)
2857
+ zoom_row.addWidget(self.btn_zoom_100)
2858
+ zoom_row.addWidget(self.btn_zoom_in)
2859
+ outer.addLayout(zoom_row)
2860
+
2861
+ self.btn_zoom_out.clicked.connect(lambda: self._set_preview_zoom(self._preview_zoom * 0.8))
2862
+ self.btn_zoom_in.clicked.connect(lambda: self._set_preview_zoom(self._preview_zoom * 1.25))
2863
+ self.btn_zoom_fit.clicked.connect(lambda: self._set_preview_zoom(1.0))
2864
+ self.btn_zoom_100.clicked.connect(lambda: self._set_preview_zoom(0.0))
2865
+
2866
+ # preview label
2867
+ self.preview = QLabel(self)
2868
+ self.preview.setMinimumSize(780, 420)
2869
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
2870
+ self.preview.setStyleSheet("background:#111; border:1px solid #333;")
2871
+ self.preview.setMouseTracking(True)
2872
+ self.preview.installEventFilter(self)
2873
+ outer.addWidget(self.preview)
2874
+
2875
+ # --- rings controls (optional) ---
2876
+ if self.overlay_mode in ("saturn", "galaxy"):
2877
+ title = "Galaxy disk alignment" if self.overlay_mode == "galaxy" else "Saturn ring alignment"
2878
+ rings_box = QGroupBox(title)
2879
+ rings_form = QFormLayout(rings_box)
2880
+
2881
+ # PA
2882
+ row, self.sld_ring_pa, self.spin_ring_pa = self._make_slider_spin_row(
2883
+ min_v=-180.0, max_v=180.0, step_v=1.0,
2884
+ value=self.ring_pa, decimals=0,
2885
+ on_change=self._on_ring_widgets_changed
2886
+ )
2887
+ rings_form.addRow("Disk PA (deg):" if self.overlay_mode=="galaxy" else "Ring PA (deg):", row)
2888
+
2889
+ # tilt
2890
+ row, self.sld_ring_tilt, self.spin_ring_tilt = self._make_slider_spin_row(
2891
+ min_v=0.05, max_v=1.0, step_v=0.01,
2892
+ value=self.ring_tilt, decimals=2,
2893
+ on_change=self._on_ring_widgets_changed
2894
+ )
2895
+ rings_form.addRow("Disk tilt (b/a):" if self.overlay_mode=="galaxy" else "Ring tilt (b/a):", row)
2896
+
2897
+ # ONLY Saturn gets inner/outer
2898
+ if self.overlay_mode == "saturn":
2899
+ row, self.sld_ring_outer, self.spin_ring_outer = self._make_slider_spin_row(
2900
+ min_v=1.0, max_v=4.0, step_v=0.05,
2901
+ value=self.ring_outer, decimals=2,
2902
+ on_change=self._on_ring_widgets_changed,
2903
+ )
2904
+ rings_form.addRow("Outer factor:", row)
2905
+
2906
+ row, self.sld_ring_inner, self.spin_ring_inner = self._make_slider_spin_row(
2907
+ min_v=0.2, max_v=3.5, step_v=0.05,
2908
+ value=self.ring_inner, decimals=2,
2909
+ on_change=self._on_ring_widgets_changed,
2910
+ )
2911
+ rings_form.addRow("Inner factor:", row)
2912
+ outer.addWidget(rings_box)
2913
+
2914
+ if self.overlay_mode == "saturn":
2915
+ help_txt += "\nAdjust ring PA / tilt / inner / outer to match Saturn's rings."
2916
+ elif self.overlay_mode == "galaxy":
2917
+ help_txt += "\nAdjust disk PA / tilt to match the galaxy's projected ellipse."
2918
+ self.lbl_help.setText(help_txt)
2919
+
2920
+ # radius row
2921
+ rad_row = QHBoxLayout()
2922
+ self.btn_r_minus = QPushButton("Radius -")
2923
+ self.btn_r_plus = QPushButton("Radius +")
2924
+ self.spin_r = QDoubleSpinBox()
2925
+ self.spin_r.setRange(5.0, float(max(self.W, self.H)))
2926
+ self.spin_r.setDecimals(2)
2927
+ self.spin_r.setSingleStep(1.0)
2928
+ self.spin_r.setValue(self.r)
2929
+ self.spin_r.valueChanged.connect(self._on_radius_spin)
2930
+
2931
+ self.sld_r = QSlider(Qt.Orientation.Horizontal)
2932
+ self.sld_r.setRange(5, int(max(self.W, self.H)))
2933
+ self.sld_r.setValue(int(round(self.r)))
2934
+ self.sld_r.valueChanged.connect(self._on_radius_slider)
2935
+
2936
+ self.btn_r_minus.clicked.connect(lambda: self._bump_radius(-2.0))
2937
+ self.btn_r_plus.clicked.connect(lambda: self._bump_radius(+2.0))
2938
+
2939
+ rad_row.addWidget(self.btn_r_minus)
2940
+ rad_row.addWidget(self.btn_r_plus)
2941
+ rad_row.addWidget(QLabel("R:"))
2942
+ rad_row.addWidget(self.spin_r)
2943
+ rad_row.addWidget(self.sld_r, 1)
2944
+ outer.addLayout(rad_row)
2945
+
2946
+ # nudge row
2947
+ nud_row = QHBoxLayout()
2948
+ self.spin_step = QSpinBox()
2949
+ self.spin_step.setRange(1, 200)
2950
+ self.spin_step.setValue(2)
2951
+ nud_row.addWidget(QLabel("Nudge (px):"))
2952
+ nud_row.addWidget(self.spin_step)
2953
+
2954
+ self.btn_left = QPushButton("◀")
2955
+ self.btn_right = QPushButton("▶")
2956
+ self.btn_up = QPushButton("▲")
2957
+ self.btn_down = QPushButton("▼")
2958
+
2959
+ self.btn_left.clicked.connect(lambda: self._nudge(-1, 0))
2960
+ self.btn_right.clicked.connect(lambda: self._nudge(+1, 0))
2961
+ self.btn_up.clicked.connect(lambda: self._nudge(0, -1))
2962
+ self.btn_down.clicked.connect(lambda: self._nudge(0, +1))
2963
+
2964
+ nud_row.addStretch(1)
2965
+ nud_row.addWidget(self.btn_up)
2966
+ nud_row.addWidget(self.btn_left)
2967
+ nud_row.addWidget(self.btn_right)
2968
+ nud_row.addWidget(self.btn_down)
2969
+ outer.addLayout(nud_row)
2970
+
2971
+ # status
2972
+ self.lbl_status = QLabel("")
2973
+ outer.addWidget(self.lbl_status)
2974
+
2975
+ # ok/cancel
2976
+ btn_row = QHBoxLayout()
2977
+ self.btn_ok = QPushButton("Use This Disk")
2978
+ self.btn_cancel = QPushButton("Cancel")
2979
+ btn_row.addWidget(self.btn_ok)
2980
+ btn_row.addStretch(1)
2981
+ btn_row.addWidget(self.btn_cancel)
2982
+ outer.addLayout(btn_row)
2983
+
2984
+ self.btn_ok.clicked.connect(self.accept)
2985
+ self.btn_cancel.clicked.connect(self.reject)
2986
+
2987
+ # ---------- coordinate helpers ----------
2988
+ def _make_slider_spin_row(self, *,
2989
+ min_v: float, max_v: float, step_v: float,
2990
+ value: float, decimals: int,
2991
+ on_change):
2992
+ """
2993
+ Returns (row_layout, slider, spin).
2994
+ Slider is int-based; spin is float. They stay in sync.
2995
+ """
2996
+ scale = int(round(1.0 / step_v)) # e.g. 0.05 -> 20, 0.02 -> 50
2997
+ if scale <= 0:
2998
+ scale = 1
2999
+
3000
+ sld = QSlider(Qt.Orientation.Horizontal, self)
3001
+ sld.setRange(int(round(min_v * scale)), int(round(max_v * scale)))
3002
+ sld.setSingleStep(1)
3003
+ sld.setPageStep(max(1, int(round(10 * scale * step_v)))) # about 10 steps
3004
+ sld.setValue(int(round(value * scale)))
3005
+
3006
+ spn = QDoubleSpinBox(self)
3007
+ spn.setRange(min_v, max_v)
3008
+ spn.setDecimals(decimals)
3009
+ spn.setSingleStep(step_v)
3010
+ spn.setValue(value)
3011
+ spn.setFixedWidth(100)
3012
+
3013
+ def sld_to_spin(iv: int):
3014
+ fv = iv / float(scale)
3015
+ spn.blockSignals(True)
3016
+ spn.setValue(fv)
3017
+ spn.blockSignals(False)
3018
+ on_change()
3019
+
3020
+ def spin_to_sld(fv: float):
3021
+ iv = int(round(fv * scale))
3022
+ sld.blockSignals(True)
3023
+ sld.setValue(iv)
3024
+ sld.blockSignals(False)
3025
+ on_change()
3026
+
3027
+ sld.valueChanged.connect(sld_to_spin)
3028
+ spn.valueChanged.connect(spin_to_sld)
3029
+
3030
+ row = QHBoxLayout()
3031
+ row.addWidget(sld, 1)
3032
+ row.addWidget(spn)
3033
+
3034
+ return row, sld, spn
3035
+
3036
+
3037
+ def _on_ring_widgets_changed(self):
3038
+ # Always present in saturn+galaxy
3039
+ if hasattr(self, "spin_ring_pa"):
3040
+ self.ring_pa = float(self.spin_ring_pa.value())
3041
+ if hasattr(self, "spin_ring_tilt"):
3042
+ self.ring_tilt = float(self.spin_ring_tilt.value())
3043
+
3044
+ # Only present for saturn
3045
+ if self.overlay_mode == "saturn":
3046
+ if hasattr(self, "spin_ring_outer"):
3047
+ self.ring_outer = float(self.spin_ring_outer.value())
3048
+ if hasattr(self, "spin_ring_inner"):
3049
+ self.ring_inner = float(self.spin_ring_inner.value())
3050
+
3051
+ self._redraw()
3052
+
3053
+ def get_ring_result(self) -> tuple[float, float, float, float]:
3054
+ pa = float(getattr(self, "ring_pa", 0.0))
3055
+ tilt = float(getattr(self, "ring_tilt", 0.35))
3056
+
3057
+ if self.overlay_mode == "saturn":
3058
+ kout = float(getattr(self, "ring_outer", 2.2))
3059
+ kin = float(getattr(self, "ring_inner", 1.25))
3060
+ else:
3061
+ kout = float(getattr(self, "ring_outer", 2.2)) # harmless
3062
+ kin = float(getattr(self, "ring_inner", 1.25))
3063
+
3064
+ return (pa, tilt, kout, kin)
3065
+
3066
+ def _on_ring_changed(self, *_):
3067
+ self.ring_pa = float(self.spin_ring_pa.value())
3068
+ self.ring_tilt = float(self.spin_ring_tilt.value())
3069
+ self.ring_outer = float(self.spin_ring_outer.value())
3070
+ self.ring_inner = float(self.spin_ring_inner.value())
3071
+ self._redraw()
3072
+
3073
+
3074
+ def _compute_fit_transform(self):
3075
+ # label size
3076
+ pw = max(1, self.preview.width())
3077
+ ph = max(1, self.preview.height())
3078
+
3079
+ # scale factor to fit image into label
3080
+ sw = pw / float(self.W)
3081
+ sh = ph / float(self.H)
3082
+ self._scale = float(min(sw, sh))
3083
+
3084
+ # the fitted draw size (in LABEL coords)
3085
+ draw_w = self.W * self._scale
3086
+ draw_h = self.H * self._scale
3087
+
3088
+ # offsets in LABEL coords (letterboxing)
3089
+ self._offx = 0.5 * (pw - draw_w)
3090
+ self._offy = 0.5 * (ph - draw_h)
3091
+
3092
+ # ALSO store pixmap-space scale after scaling
3093
+ # (pixmap is the scaled-to-fit image)
3094
+ self._pix_w = int(round(draw_w))
3095
+ self._pix_h = int(round(draw_h))
3096
+
3097
+ # pixmap space has NO offx/offy; it starts at (0,0)
3098
+ self._pix_scale = self._scale
3099
+
3100
+ def _img_to_label(self, x: float, y: float) -> tuple[float, float]:
3101
+ return (self._offx + x * self._scale, self._offy + y * self._scale)
3102
+
3103
+ def _label_to_img(self, x: float, y: float) -> tuple[float, float]:
3104
+ ix = (x - self._offx) / max(self._scale, 1e-9)
3105
+ iy = (y - self._offy) / max(self._scale, 1e-9)
3106
+ return (ix, iy)
3107
+
3108
+ def _img_to_pix(self, x: float, y: float) -> tuple[float, float]:
3109
+ # pixmap coords (0..pix_w, 0..pix_h)
3110
+ return (x * self._pix_scale, y * self._pix_scale)
3111
+
3112
+ def _set_preview_zoom(self, z: float):
3113
+ if z < 0.05 and z != 0.0:
3114
+ z = 0.05
3115
+ if z > 8.0:
3116
+ z = 8.0
3117
+ self._preview_zoom = float(z)
3118
+ # currently we always draw "fit"; keep behavior consistent by just redrawing
3119
+ self._redraw()
3120
+
3121
+
3122
+ # ---------- drawing ----------
3123
+ def _redraw(self):
3124
+ self._compute_fit_transform()
3125
+
3126
+ # base pixmap (fit into preview)
3127
+ qimg = QImage(
3128
+ self._disp8.data,
3129
+ self.W,
3130
+ self.H,
3131
+ int(self._disp8.strides[0]),
3132
+ QImage.Format.Format_RGB888,
3133
+ )
3134
+ pix = QPixmap.fromImage(qimg).scaled(
3135
+ self.preview.size(),
3136
+ Qt.AspectRatioMode.KeepAspectRatio,
3137
+ Qt.TransformationMode.SmoothTransformation,
3138
+ )
3139
+
3140
+ painter = QPainter(pix)
3141
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
3142
+
3143
+ # Determine overlay mode:
3144
+ # - "saturn": inner+outer ellipses
3145
+ # - "galaxy": single disk ellipse
3146
+ # - otherwise: none
3147
+ overlay_mode = getattr(self, "overlay_mode", None)
3148
+ if overlay_mode is None:
3149
+ # backwards compatibility with old flag
3150
+ overlay_mode = "saturn" if getattr(self, "show_rings", False) else "none"
3151
+ overlay_mode = str(overlay_mode).lower()
3152
+
3153
+ # Map center to pix coords
3154
+ cxp, cyp = self._img_to_pix(self.cx, self.cy)
3155
+
3156
+ # -----------------------------
3157
+ # Main circle + crosshair
3158
+ # -----------------------------
3159
+ # In galaxy mode, the ellipse is the important overlay; circle is optional.
3160
+ DRAW_MAIN_CIRCLE_IN_GALAXY = True # set False if you want ONLY ellipse for galaxy
3161
+
3162
+ if overlay_mode != "galaxy" or DRAW_MAIN_CIRCLE_IN_GALAXY:
3163
+ pen = QPen(QColor(0, 255, 0))
3164
+ pen.setWidth(3)
3165
+ painter.setPen(pen)
3166
+
3167
+ rv = float(self.r) * float(self._pix_scale)
3168
+ painter.drawEllipse(
3169
+ QPoint(int(round(cxp)), int(round(cyp))),
3170
+ int(round(rv)),
3171
+ int(round(rv)),
3172
+ )
3173
+
3174
+ # center crosshair
3175
+ pen2 = QPen(QColor(0, 255, 0))
3176
+ pen2.setWidth(2)
3177
+ painter.setPen(pen2)
3178
+ painter.drawLine(int(round(cxp - 8)), int(round(cyp)), int(round(cxp + 8)), int(round(cyp)))
3179
+ painter.drawLine(int(round(cxp)), int(round(cyp - 8)), int(round(cxp)), int(round(cyp + 8)))
3180
+
3181
+ # -----------------------------
3182
+ # Ellipse overlays
3183
+ # -----------------------------
3184
+ if overlay_mode in ("saturn", "galaxy"):
3185
+ try:
3186
+ pa = float(getattr(self, "ring_pa", 0.0))
3187
+ tilt = float(getattr(self, "ring_tilt", 0.35))
3188
+ tilt = max(0.01, min(1.0, tilt))
3189
+
3190
+ # ellipse semi-axes in SOURCE pixels
3191
+ if overlay_mode == "galaxy":
3192
+ # ONE ellipse: major axis = r, minor = r * tilt
3193
+ a = float(self.r)
3194
+ b = max(1.0, a * tilt)
3195
+
3196
+ # convert to PIX coords
3197
+ a_p = a * float(self._pix_scale)
3198
+ b_p = b * float(self._pix_scale)
3199
+
3200
+ painter.save()
3201
+ painter.translate(cxp, cyp)
3202
+ painter.rotate(pa)
3203
+
3204
+ penr = QPen(QColor(0, 255, 0))
3205
+ penr.setWidth(2)
3206
+ painter.setPen(penr)
3207
+
3208
+ painter.drawEllipse(
3209
+ int(round(-a_p)), int(round(-b_p)),
3210
+ int(round(2 * a_p)), int(round(2 * b_p)),
3211
+ )
3212
+
3213
+ # minor-axis guide
3214
+ pena = QPen(QColor(0, 200, 0))
3215
+ pena.setWidth(2)
3216
+ painter.setPen(pena)
3217
+ painter.drawLine(0, int(round(-b_p)), 0, int(round(b_p)))
3218
+
3219
+ painter.restore()
3220
+
3221
+ else:
3222
+ # SATURN: inner + outer ellipse annulus
3223
+ k_out = float(getattr(self, "ring_outer", 2.2))
3224
+ k_in = float(getattr(self, "ring_inner", 1.25))
3225
+
3226
+ a_out = k_out * float(self.r)
3227
+ b_out = max(1.0, a_out * tilt)
3228
+ a_in = k_in * float(self.r)
3229
+ b_in = max(1.0, a_in * tilt)
3230
+
3231
+ a_out_p = a_out * float(self._pix_scale)
3232
+ b_out_p = b_out * float(self._pix_scale)
3233
+ a_in_p = a_in * float(self._pix_scale)
3234
+ b_in_p = b_in * float(self._pix_scale)
3235
+
3236
+ painter.save()
3237
+ painter.translate(cxp, cyp)
3238
+ painter.rotate(pa)
3239
+
3240
+ penr = QPen(QColor(0, 255, 0))
3241
+ penr.setWidth(2)
3242
+ painter.setPen(penr)
3243
+
3244
+ painter.drawEllipse(
3245
+ int(round(-a_out_p)), int(round(-b_out_p)),
3246
+ int(round(2 * a_out_p)), int(round(2 * b_out_p)),
3247
+ )
3248
+ painter.drawEllipse(
3249
+ int(round(-a_in_p)), int(round(-b_in_p)),
3250
+ int(round(2 * a_in_p)), int(round(2 * b_in_p)),
3251
+ )
3252
+
3253
+ # minor-axis guide
3254
+ pena = QPen(QColor(0, 200, 0))
3255
+ pena.setWidth(2)
3256
+ painter.setPen(pena)
3257
+ painter.drawLine(0, int(round(-b_out_p)), 0, int(round(b_out_p)))
3258
+
3259
+ painter.restore()
3260
+
3261
+ except Exception:
3262
+ # keep UI alive if something weird happens
3263
+ pass
3264
+
3265
+ painter.end()
3266
+
3267
+ self.preview.setPixmap(pix)
3268
+
3269
+ # status label
3270
+ if overlay_mode == "galaxy":
3271
+ pa = float(getattr(self, "ring_pa", 0.0))
3272
+ tilt = float(getattr(self, "ring_tilt", 0.35))
3273
+ self.lbl_status.setText(
3274
+ f"Center: ({self.cx:.1f}, {self.cy:.1f}) Radius: {self.r:.1f}px "
3275
+ f"PA: {pa:.1f}° Tilt(b/a): {tilt:.2f}"
3276
+ )
3277
+ elif overlay_mode == "saturn":
3278
+ pa = float(getattr(self, "ring_pa", 0.0))
3279
+ tilt = float(getattr(self, "ring_tilt", 0.35))
3280
+ kout = float(getattr(self, "ring_outer", 2.2))
3281
+ kin = float(getattr(self, "ring_inner", 1.25))
3282
+ self.lbl_status.setText(
3283
+ f"Center: ({self.cx:.1f}, {self.cy:.1f}) Radius: {self.r:.1f}px "
3284
+ f"PA: {pa:.1f}° Tilt(b/a): {tilt:.2f} Outer: {kout:.2f} Inner: {kin:.2f}"
3285
+ )
3286
+ else:
3287
+ self.lbl_status.setText(f"Center: ({self.cx:.1f}, {self.cy:.1f}) Radius: {self.r:.1f}px")
3288
+
3289
+
3290
+ # ---------- UI callbacks ----------
3291
+ def _clamp(self):
3292
+ self.cx = float(np.clip(self.cx, 0.0, self.W - 1.0))
3293
+ self.cy = float(np.clip(self.cy, 0.0, self.H - 1.0))
3294
+ # radius cannot exceed image bounds too much; keep sane
3295
+ self.r = float(np.clip(self.r, 5.0, 2.0 * max(self.W, self.H)))
3296
+
3297
+ def _bump_radius(self, dr: float):
3298
+ self.r += float(dr)
3299
+ self._clamp()
3300
+ self.spin_r.blockSignals(True)
3301
+ self.sld_r.blockSignals(True)
3302
+ self.spin_r.setValue(self.r)
3303
+ self.sld_r.setValue(int(round(self.r)))
3304
+ self.spin_r.blockSignals(False)
3305
+ self.sld_r.blockSignals(False)
3306
+ self._redraw()
3307
+
3308
+ def _on_radius_spin(self, v: float):
3309
+ self.r = float(v)
3310
+ self._clamp()
3311
+ self.sld_r.blockSignals(True)
3312
+ self.sld_r.setValue(int(round(self.r)))
3313
+ self.sld_r.blockSignals(False)
3314
+ self._redraw()
3315
+
3316
+ def _on_radius_slider(self, v: int):
3317
+ self.r = float(v)
3318
+ self._clamp()
3319
+ self.spin_r.blockSignals(True)
3320
+ self.spin_r.setValue(self.r)
3321
+ self.spin_r.blockSignals(False)
3322
+ self._redraw()
3323
+
3324
+ def _nudge(self, dx: int, dy: int):
3325
+ step = int(self.spin_step.value())
3326
+ self.cx += dx * step
3327
+ self.cy += dy * step
3328
+ self._clamp()
3329
+ self._redraw()
3330
+
3331
+ # ---------- events ----------
3332
+ def keyPressEvent(self, e):
3333
+ key = e.key()
3334
+ if key == Qt.Key.Key_Left:
3335
+ self._nudge(-1, 0); return
3336
+ if key == Qt.Key.Key_Right:
3337
+ self._nudge(+1, 0); return
3338
+ if key == Qt.Key.Key_Up:
3339
+ self._nudge(0, -1); return
3340
+ if key == Qt.Key.Key_Down:
3341
+ self._nudge(0, +1); return
3342
+ super().keyPressEvent(e)
3343
+
3344
+ def resizeEvent(self, e):
3345
+ super().resizeEvent(e)
3346
+ self._redraw()
3347
+
3348
+ def eventFilter(self, obj, ev):
3349
+ if obj is self.preview:
3350
+ if ev.type() == ev.Type.MouseButtonPress:
3351
+ if ev.button() == Qt.MouseButton.LeftButton and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
3352
+ self._compute_fit_transform()
3353
+ mx, my = float(ev.position().x()), float(ev.position().y())
3354
+ ix, iy = self._label_to_img(mx, my)
3355
+ # store drag offset so the center doesn't jump
3356
+ self._dragging = True
3357
+ self._drag_offset = (self.cx - ix, self.cy - iy)
3358
+ return True
3359
+
3360
+ if ev.type() == ev.Type.MouseMove and self._dragging:
3361
+ self._compute_fit_transform()
3362
+ mx, my = float(ev.position().x()), float(ev.position().y())
3363
+ ix, iy = self._label_to_img(mx, my)
3364
+ ox, oy = self._drag_offset
3365
+ self.cx = ix + ox
3366
+ self.cy = iy + oy
3367
+ self._clamp()
3368
+ self._redraw()
3369
+ return True
3370
+
3371
+ if ev.type() == ev.Type.MouseButtonRelease and self._dragging:
3372
+ self._dragging = False
3373
+ return True
3374
+
3375
+ return super().eventFilter(obj, ev)
3376
+
3377
+ def get_result(self) -> tuple[float, float, float]:
3378
+ return (float(self.cx), float(self.cy), float(self.r))
3379
+
3380
+ class PlanetProjectionPreviewDialog(QDialog):
3381
+ """
3382
+ Separate preview window:
3383
+ - Shows the latest output frame (stereo pair or wiggle frame)
3384
+ - Provides Zoom controls: Fit / 100% / +/-.
3385
+ """
3386
+ def __init__(self, parent=None):
3387
+ super().__init__(parent)
3388
+ self.setWindowTitle("3D Projection — Preview")
3389
+ self.setModal(False)
3390
+ self._img_zoom = 1.0 # content zoom (1.0 = full view)
3391
+ self._img_pan_x = 0.0 # in source pixels, relative to center
3392
+ self._img_pan_y = 0.0
3393
+ self._dragging = False
3394
+ self._last_pos = None
3395
+ self._last_left8 = None
3396
+ self._last_right8 = None
3397
+ self._last_swap = False
3398
+ self._gap_px = 16
3399
+ self._last_L8 = None
3400
+ self._last_R8 = None
3401
+ self._last_swap_eyes = False
3402
+ self._last_gap_px = 16
3403
+ self._last_frame_u8 = None
3404
+ # content zoom is RELATIVE TO FIT (1.0 = fit)
3405
+ self._content_zoom = 1.0
3406
+ self._pan_x = 0.0 # pan in SOURCE PIXELS
3407
+ self._pan_y = 0.0
3408
+
3409
+ self._preview_zoom = 1.0 # 1.0 = Fit, 0.0 = 1:1, else relative to Fit
3410
+
3411
+ self._build_ui()
3412
+
3413
+ def _build_ui(self):
3414
+ outer = QVBoxLayout(self)
3415
+ outer.setContentsMargins(10, 10, 10, 10)
3416
+ outer.setSpacing(8)
3417
+
3418
+ # Zoom controls (toolbtn icons like the rest of SASpro)
3419
+ zoom_row = QHBoxLayout()
3420
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
3421
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
3422
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
3423
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
3424
+ self.btn_save_view = themed_toolbtn("document-save", "Save current preview view…")
3425
+ self.btn_push = QPushButton("Push to New Document")
3426
+
3427
+
3428
+
3429
+ self.btn_save_view.clicked.connect(self._save_current_view)
3430
+ self.btn_push.clicked.connect(self._push_to_new_document)
3431
+ zoom_row.addStretch(1)
3432
+ zoom_row.addWidget(self.btn_zoom_out)
3433
+ zoom_row.addWidget(self.btn_zoom_fit)
3434
+ zoom_row.addWidget(self.btn_zoom_100)
3435
+ zoom_row.addWidget(self.btn_zoom_in)
3436
+ zoom_row.addWidget(self.btn_save_view)
3437
+ zoom_row.addWidget(self.btn_push)
3438
+ outer.addLayout(zoom_row)
3439
+
3440
+ self.btn_zoom_out.clicked.connect(lambda: self.set_zoom(self._preview_zoom * 0.8 if self._preview_zoom not in (0.0, 1.0) else 0.8))
3441
+ self.btn_zoom_in.clicked.connect(lambda: self.set_zoom(self._preview_zoom * 1.25 if self._preview_zoom not in (0.0, 1.0) else 1.25))
3442
+ self.btn_zoom_fit.clicked.connect(lambda: self.set_zoom(1.0))
3443
+ self.btn_zoom_100.clicked.connect(lambda: self.set_zoom(0.0))
3444
+
3445
+ imgzoom_row = QHBoxLayout()
3446
+ self.btn_img_reset = themed_toolbtn("edit-undo", "Reset Image Pan/Zoom")
3447
+
3448
+ self.sld_img_zoom = QSlider(Qt.Orientation.Horizontal, self)
3449
+ self.sld_img_zoom.setRange(0, 200) # 0 -> fit, +200 -> zoom in
3450
+ self.sld_img_zoom.setValue(0)
3451
+ self.sld_img_zoom.setToolTip("Zoom into the image content (pan with mouse drag)")
3452
+
3453
+ imgzoom_row.addWidget(QLabel("Image zoom:"))
3454
+ imgzoom_row.addWidget(self.sld_img_zoom, 1)
3455
+ imgzoom_row.addWidget(self.btn_img_reset)
3456
+ outer.addLayout(imgzoom_row)
3457
+
3458
+ self.sld_img_zoom.valueChanged.connect(self._on_img_zoom_changed)
3459
+ self.btn_img_reset.clicked.connect(self._reset_img_view)
3460
+
3461
+ # Preview label
3462
+ self.preview = QLabel(self)
3463
+ self.preview.setMinimumSize(900, 520)
3464
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
3465
+ self.preview.setStyleSheet("background:#111; border:1px solid #333;")
3466
+ self.preview.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
3467
+ outer.addWidget(self.preview)
3468
+ self.preview.setMouseTracking(True)
3469
+ self.preview.installEventFilter(self)
3470
+ self._last_rgb8 = None
3471
+
3472
+ def set_zoom(self, z: float):
3473
+ if z < 0.05 and z != 0.0:
3474
+ z = 0.05
3475
+ if z > 8.0:
3476
+ z = 8.0
3477
+ self._preview_zoom = float(z)
3478
+
3479
+ # IMPORTANT: redraw whichever mode we’re in
3480
+ if self._last_left8 is not None and self._last_right8 is not None:
3481
+ self._redraw() # stereo path uses _preview_zoom inside _render_stereo()
3482
+ elif self._last_rgb8 is not None:
3483
+ self.set_frame_u8(self._last_rgb8)
3484
+
3485
+ def _fit_scaled_size(self, img_w: int, img_h: int) -> tuple[int, int]:
3486
+ pw = max(1, self.preview.width())
3487
+ ph = max(1, self.preview.height())
3488
+ s = min(pw / float(img_w), ph / float(img_h))
3489
+ return int(round(img_w * s)), int(round(img_h * s))
3490
+
3491
+ def set_frame_u8(self, rgb8: np.ndarray):
3492
+ self._last_rgb8 = np.asarray(rgb8)
3493
+
3494
+ # apply content zoom/pan by cropping to viewport size
3495
+ disp8 = self._apply_camera_crop(self._last_rgb8)
3496
+
3497
+ # NEW: cache “what user is looking at” for Push-to-Doc
3498
+ self._last_frame_u8 = np.asarray(disp8)
3499
+
3500
+ # --- sanitize for QImage ---
3501
+ disp8 = np.asarray(disp8, dtype=np.uint8)
3502
+ if disp8.ndim == 2:
3503
+ disp8 = np.stack([disp8, disp8, disp8], axis=2)
3504
+ if disp8.shape[2] > 3:
3505
+ disp8 = disp8[..., :3]
3506
+ if not disp8.flags["C_CONTIGUOUS"]:
3507
+ disp8 = np.ascontiguousarray(disp8)
3508
+
3509
+ # IMPORTANT: keep buffer alive on self for as long as pixmap uses it
3510
+ self._qimg_buf = disp8
3511
+
3512
+ h, w = disp8.shape[:2]
3513
+ bytes_per_line = int(disp8.strides[0])
3514
+
3515
+ ptr = sip.voidptr(disp8.ctypes.data)
3516
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
3517
+ base = QPixmap.fromImage(qimg)
3518
+
3519
+ # NOTE: from this point onward, use disp8 dimensions (not rgb8)
3520
+ if self._preview_zoom == 1.0:
3521
+ self.preview.setPixmap(base) # already fit-with-aspect (letterboxed by QLabel alignment)
3522
+ return
3523
+
3524
+ if self._preview_zoom == 0.0:
3525
+ self.preview.setPixmap(base)
3526
+ return
3527
+
3528
+ fit_w, fit_h = self._fit_scaled_size(w, h)
3529
+ target_w = max(1, int(round(fit_w * self._preview_zoom)))
3530
+ target_h = max(1, int(round(fit_h * self._preview_zoom)))
3531
+
3532
+ pix = base.scaled(
3533
+ QSize(target_w, target_h),
3534
+ Qt.AspectRatioMode.KeepAspectRatio,
3535
+ Qt.TransformationMode.SmoothTransformation,
3536
+ )
3537
+ self.preview.setPixmap(pix)
3538
+
3539
+ def set_stereo_u8(self, left8: np.ndarray, right8: np.ndarray, *, swap_eyes: bool, gap_px: int = 16):
3540
+ self._last_left8 = np.asarray(left8)
3541
+ self._last_right8 = np.asarray(right8)
3542
+ self._last_swap = bool(swap_eyes)
3543
+ self._gap_px = int(max(0, gap_px))
3544
+
3545
+ # keep save-view path in sync
3546
+ self._last_L8 = self._last_left8
3547
+ self._last_R8 = self._last_right8
3548
+ self._last_swap_eyes = self._last_swap
3549
+ self._last_gap_px = self._gap_px
3550
+
3551
+ self._clamp_pan()
3552
+ self._redraw()
3553
+
3554
+ def _redraw(self):
3555
+ if self._last_left8 is None or self._last_right8 is None:
3556
+ # fallback: if you still use set_frame_u8 for non-stereo cases
3557
+ if getattr(self, "_last_rgb8", None) is not None:
3558
+ self.set_frame_u8(self._last_rgb8)
3559
+ return
3560
+ self._render_stereo()
3561
+
3562
+ def _render_stereo(self):
3563
+ L = self._ensure_rgb8(self._last_left8)
3564
+ R = self._ensure_rgb8(self._last_right8)
3565
+
3566
+ Hs, Ws = L.shape[:2]
3567
+
3568
+ pw = max(8, self.preview.width())
3569
+ ph = max(8, self.preview.height())
3570
+ gap = int(self._gap_px)
3571
+
3572
+ view_w = max(8, (pw - gap) // 2)
3573
+ view_h = max(8, ph)
3574
+
3575
+ # --- NEW: fit-rect INSIDE each eye viewport (letterbox/pillarbox) ---
3576
+ rx, ry, rw, rh = self._fit_rect(view_w, view_h, Ws, Hs)
3577
+ rw = max(8, rw); rh = max(8, rh)
3578
+
3579
+ # cache the actual displayed rect for pan math (drag + clamp)
3580
+ self._eye_fit_rect = (rx, ry, rw, rh, view_w, view_h)
3581
+
3582
+ # render each eye ONLY into rw x rh (aspect-safe), then paste into a black canvas
3583
+ Limg = self._crop_and_scale(L, rw, rh)
3584
+ Rimg = self._crop_and_scale(R, rw, rh)
3585
+
3586
+ if self._last_swap:
3587
+ Limg, Rimg = Rimg, Limg
3588
+
3589
+ Lcan = np.zeros((view_h, view_w, 3), dtype=np.uint8)
3590
+ Rcan = np.zeros((view_h, view_w, 3), dtype=np.uint8)
3591
+ Lcan[ry:ry+rh, rx:rx+rw] = Limg
3592
+ Rcan[ry:ry+rh, rx:rx+rw] = Rimg
3593
+
3594
+ canvas_w = view_w + gap + view_w
3595
+ canvas = np.zeros((view_h, canvas_w, 3), dtype=np.uint8)
3596
+ canvas[:, 0:view_w] = Lcan
3597
+ canvas[:, view_w:view_w + gap] = 0
3598
+ canvas[:, view_w + gap:view_w + gap + view_w] = Rcan
3599
+ self._last_frame_u8 = canvas
3600
+
3601
+ self._qimg_buf = canvas
3602
+ h, w = canvas.shape[:2]
3603
+ ptr = sip.voidptr(canvas.ctypes.data)
3604
+ qimg = QImage(ptr, w, h, int(canvas.strides[0]), QImage.Format.Format_RGB888)
3605
+ base = QPixmap.fromImage(qimg)
3606
+
3607
+ # preview zoom (same as before)
3608
+ if self._preview_zoom == 1.0:
3609
+ pix = base.scaled(self.preview.size(), Qt.AspectRatioMode.KeepAspectRatio,
3610
+ Qt.TransformationMode.SmoothTransformation)
3611
+ self.preview.setPixmap(pix)
3612
+ return
3613
+ if self._preview_zoom == 0.0:
3614
+ self.preview.setPixmap(base)
3615
+ return
3616
+
3617
+ fit_w, fit_h = self._fit_scaled_size(w, h)
3618
+ target_w = max(1, int(round(fit_w * self._preview_zoom)))
3619
+ target_h = max(1, int(round(fit_h * self._preview_zoom)))
3620
+ pix = base.scaled(QSize(target_w, target_h), Qt.AspectRatioMode.KeepAspectRatio,
3621
+ Qt.TransformationMode.SmoothTransformation)
3622
+ self.preview.setPixmap(pix)
3623
+
3624
+
3625
+ def _ensure_rgb8(self, img: np.ndarray) -> np.ndarray:
3626
+ x = np.asarray(img)
3627
+ if x.ndim == 2:
3628
+ x = np.stack([x, x, x], axis=2)
3629
+ if x.shape[2] > 3:
3630
+ x = x[..., :3]
3631
+ if x.dtype != np.uint8:
3632
+ x = np.clip(x, 0, 255).astype(np.uint8)
3633
+ if not x.flags["C_CONTIGUOUS"]:
3634
+ x = np.ascontiguousarray(x)
3635
+ return x
3636
+
3637
+ def _crop_and_scale(self, src: np.ndarray, out_w: int, out_h: int) -> np.ndarray:
3638
+ # content zoom is RELATIVE TO FIT
3639
+ H, W = src.shape[:2]
3640
+
3641
+ # fit scale into per-eye viewport
3642
+ s_fit = min(out_w / float(W), out_h / float(H))
3643
+ s_fit = max(1e-9, float(s_fit))
3644
+
3645
+ z = float(max(1e-6, self._content_zoom))
3646
+
3647
+ # visible window in SOURCE pixels:
3648
+ win_w = int(round(out_w / (s_fit * z)))
3649
+ win_h = int(round(out_h / (s_fit * z)))
3650
+ win_w = max(8, min(W, win_w))
3651
+ win_h = max(8, min(H, win_h))
3652
+
3653
+ cx = (W - 1) * 0.5 + float(self._pan_x)
3654
+ cy = (H - 1) * 0.5 + float(self._pan_y)
3655
+
3656
+ x0 = int(round(cx - 0.5 * win_w))
3657
+ y0 = int(round(cy - 0.5 * win_h))
3658
+ x0 = max(0, min(W - win_w, x0))
3659
+ y0 = max(0, min(H - win_h, y0))
3660
+
3661
+ crop = src[y0:y0 + win_h, x0:x0 + win_w]
3662
+
3663
+ if crop.shape[1] != out_w or crop.shape[0] != out_h:
3664
+ crop = cv2.resize(crop, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
3665
+
3666
+ return crop
3667
+
3668
+ def _clamp_pan(self):
3669
+ if self._last_left8 is None:
3670
+ return
3671
+
3672
+ src = self._ensure_rgb8(self._last_left8)
3673
+ H, W = src.shape[:2]
3674
+
3675
+ pw = max(8, self.preview.width())
3676
+ ph = max(8, self.preview.height())
3677
+ gap = int(self._gap_px)
3678
+ view_w = max(8, (pw - gap) // 2)
3679
+ view_h = max(8, ph)
3680
+
3681
+ # use the SAME fit rect used for drawing
3682
+ rx, ry, rw, rh = self._fit_rect(view_w, view_h, W, H)
3683
+ rw = max(8, rw); rh = max(8, rh)
3684
+
3685
+ s_fit = min(rw / float(W), rh / float(H))
3686
+ s_fit = max(1e-9, float(s_fit))
3687
+ z = float(max(1e-6, self._content_zoom))
3688
+
3689
+ win_w = int(round(rw / (s_fit * z)))
3690
+ win_h = int(round(rh / (s_fit * z)))
3691
+
3692
+ max_pan_x = max(0.0, (W - win_w) * 0.5)
3693
+ max_pan_y = max(0.0, (H - win_h) * 0.5)
3694
+
3695
+ self._pan_x = float(np.clip(self._pan_x, -max_pan_x, +max_pan_x))
3696
+ self._pan_y = float(np.clip(self._pan_y, -max_pan_y, +max_pan_y))
3697
+
3698
+
3699
+ def resizeEvent(self, e):
3700
+ super().resizeEvent(e)
3701
+
3702
+ # Clamp whichever pan system you’re using
3703
+ self._clamp_pan()
3704
+ self._clamp_img_view()
3705
+
3706
+ # Redraw correct mode
3707
+ if self._last_left8 is not None and self._last_right8 is not None:
3708
+ self._redraw()
3709
+ elif self._last_rgb8 is not None:
3710
+ self.set_frame_u8(self._last_rgb8)
3711
+
3712
+
3713
+ def _on_img_zoom_changed(self, v: int):
3714
+ # 0 -> 1.0 (fit), +50 -> 2.0, +100 -> 4.0
3715
+ # negative values zoom OUT from fit: -50 -> 0.5, -100 -> 0.25
3716
+ self._content_zoom = float(2.0 ** (v / 50.0))
3717
+ self._clamp_pan()
3718
+ self._redraw()
3719
+
3720
+ def _reset_img_view(self):
3721
+ self._content_zoom = 1.0
3722
+ self._pan_x = 0.0
3723
+ self._pan_y = 0.0
3724
+ self.sld_img_zoom.blockSignals(True)
3725
+ self.sld_img_zoom.setValue(0)
3726
+ self.sld_img_zoom.blockSignals(False)
3727
+ self._redraw()
3728
+
3729
+ def _clamp_img_view(self):
3730
+ if self._last_rgb8 is None:
3731
+ return
3732
+ img = self._last_rgb8
3733
+ H, W = img.shape[:2]
3734
+ z = float(max(1e-6, self._img_zoom))
3735
+
3736
+ # viewport size in *label* pixels (content crop target)
3737
+ vw = max(8, self.preview.width())
3738
+ vh = max(8, self.preview.height())
3739
+
3740
+ # crop window size in source pixels
3741
+ win_w = int(round(vw / z))
3742
+ win_h = int(round(vh / z))
3743
+ win_w = max(8, min(W, win_w))
3744
+ win_h = max(8, min(H, win_h))
3745
+
3746
+ max_pan_x = max(0.0, (W - win_w) * 0.5)
3747
+ max_pan_y = max(0.0, (H - win_h) * 0.5)
3748
+
3749
+ self._img_pan_x = float(np.clip(self._img_pan_x, -max_pan_x, +max_pan_x))
3750
+ self._img_pan_y = float(np.clip(self._img_pan_y, -max_pan_y, +max_pan_y))
3751
+
3752
+ def _fit_rect(self, view_w: int, view_h: int, img_w: int, img_h: int) -> tuple[int,int,int,int]:
3753
+ """Return (x,y,w,h) of the largest rect inside view that matches img aspect."""
3754
+ if img_w <= 0 or img_h <= 0:
3755
+ return (0, 0, view_w, view_h)
3756
+ s = min(view_w / float(img_w), view_h / float(img_h))
3757
+ w = max(1, int(round(img_w * s)))
3758
+ h = max(1, int(round(img_h * s)))
3759
+ x = (view_w - w) // 2
3760
+ y = (view_h - h) // 2
3761
+ return (x, y, w, h)
3762
+
3763
+ def _crop_to_aspect(self, W: int, H: int, target_aspect: float) -> tuple[int,int]:
3764
+ """Return (win_w, win_h) clamped to image bounds with exact target aspect."""
3765
+ win_w = W
3766
+ win_h = int(round(win_w / target_aspect))
3767
+ if win_h > H:
3768
+ win_h = H
3769
+ win_w = int(round(win_h * target_aspect))
3770
+ win_w = max(8, min(W, win_w))
3771
+ win_h = max(8, min(H, win_h))
3772
+ return win_w, win_h
3773
+
3774
+
3775
+ def _apply_camera_crop(self, rgb8: np.ndarray) -> np.ndarray:
3776
+ img = np.asarray(rgb8)
3777
+ H, W = img.shape[:2]
3778
+
3779
+ vw = max(8, self.preview.width())
3780
+ vh = max(8, self.preview.height())
3781
+
3782
+ # IMPORTANT: we fit INSIDE the label, preserving image aspect (letterbox)
3783
+ _, _, out_w, out_h = self._fit_rect(vw, vh, W, H)
3784
+ out_w = max(8, out_w)
3785
+ out_h = max(8, out_h)
3786
+
3787
+ a = out_w / float(out_h) # target aspect matches IMAGE aspect, not label’s
3788
+
3789
+ z = float(max(1e-6, self._img_zoom))
3790
+
3791
+ # aspect-correct window size in source pixels, driven by zoom
3792
+ win_w = int(round(W / z))
3793
+ win_h = int(round(win_w / a))
3794
+ if win_h > H:
3795
+ win_h = int(round(H / z))
3796
+ win_w = int(round(win_h * a))
3797
+
3798
+ win_w, win_h = self._crop_to_aspect(W, H, a)
3799
+
3800
+ cx = (W - 1) * 0.5 + float(self._img_pan_x)
3801
+ cy = (H - 1) * 0.5 + float(self._img_pan_y)
3802
+
3803
+ x0 = int(round(cx - win_w * 0.5))
3804
+ y0 = int(round(cy - win_h * 0.5))
3805
+ x0 = max(0, min(W - win_w, x0))
3806
+ y0 = max(0, min(H - win_h, y0))
3807
+
3808
+ crop = img[y0:y0 + win_h, x0:x0 + win_w]
3809
+
3810
+ # scale to out_w/out_h (aspect matched => no warp)
3811
+ if crop.shape[1] != out_w or crop.shape[0] != out_h:
3812
+ crop = cv2.resize(crop, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
3813
+ return crop
3814
+
3815
+ def eventFilter(self, obj, ev):
3816
+ if obj is self.preview:
3817
+ if ev.type() == ev.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
3818
+ self._dragging = True
3819
+ self._last_pos = ev.position().toPoint()
3820
+ return True
3821
+
3822
+ if ev.type() == ev.Type.MouseMove and self._dragging:
3823
+ p = ev.position().toPoint()
3824
+ d = p - self._last_pos
3825
+ self._last_pos = p
3826
+
3827
+ # compute s_fit for current viewport
3828
+ if self._last_left8 is not None:
3829
+ src = self._ensure_rgb8(self._last_left8)
3830
+ H, W = src.shape[:2]
3831
+
3832
+ pw = max(8, self.preview.width())
3833
+ ph = max(8, self.preview.height())
3834
+ view_w = max(8, (pw - int(self._gap_px)) // 2)
3835
+ view_h = ph
3836
+
3837
+ rx, ry, rw, rh = self._fit_rect(view_w, view_h, W, H)
3838
+ rw = max(8, rw); rh = max(8, rh)
3839
+ s_fit = min(rw / float(W), rh / float(H))
3840
+ s_fit = max(1e-9, float(s_fit))
3841
+
3842
+ z = float(max(1e-6, self._content_zoom))
3843
+ scale = s_fit * z # view_px per source_px
3844
+
3845
+ # drag right should move content right (so we pan LEFT in source coords)
3846
+ self._pan_x -= float(d.x()) / scale
3847
+ self._pan_y -= float(d.y()) / scale
3848
+
3849
+ self._clamp_pan()
3850
+ self._redraw()
3851
+ return True
3852
+
3853
+ if ev.type() == ev.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
3854
+ self._dragging = False
3855
+ self._last_pos = None
3856
+ return True
3857
+
3858
+ if ev.type() == ev.Type.Wheel:
3859
+ # wheel zoom: update slider (keeps everything synced)
3860
+ delta = ev.angleDelta().y()
3861
+ step = 6 if delta > 0 else -6
3862
+ v = int(self.sld_img_zoom.value()) + step
3863
+ v = max(self.sld_img_zoom.minimum(), min(self.sld_img_zoom.maximum(), v))
3864
+ self.sld_img_zoom.setValue(v)
3865
+ return True
3866
+
3867
+ return super().eventFilter(obj, ev)
3868
+
3869
+ def _get_current_view_canvas_u8(self) -> np.ndarray | None:
3870
+ """
3871
+ Return exactly what the preview is displaying as an RGB uint8 canvas:
3872
+ [left_view | gap | right_view], including linked pan/zoom and preview scaling.
3873
+ """
3874
+ if self._last_L8 is None or self._last_R8 is None:
3875
+ return None
3876
+
3877
+ L = np.asarray(self._last_L8)
3878
+ R = np.asarray(self._last_R8)
3879
+
3880
+ # sanitize
3881
+ def _to_rgb_u8(x):
3882
+ x = np.asarray(x)
3883
+ if x.dtype != np.uint8:
3884
+ x = np.clip(x, 0, 255).astype(np.uint8)
3885
+ if x.ndim == 2:
3886
+ x = np.stack([x, x, x], axis=2)
3887
+ if x.shape[2] > 3:
3888
+ x = x[..., :3]
3889
+ if not x.flags["C_CONTIGUOUS"]:
3890
+ x = np.ascontiguousarray(x)
3891
+ return x
3892
+
3893
+ L = _to_rgb_u8(L)
3894
+ R = _to_rgb_u8(R)
3895
+
3896
+ if self._last_swap_eyes:
3897
+ L, R = R, L
3898
+
3899
+ gap = int(max(0, self._last_gap_px))
3900
+
3901
+ # --- per-eye viewport size in label pixels ---
3902
+ pw = max(8, self.preview.width())
3903
+ ph = max(8, self.preview.height())
3904
+
3905
+ # reserve gap inside the label width
3906
+ view_w = max(8, (pw - gap) // 2)
3907
+ view_h = max(8, ph)
3908
+
3909
+ # --- crop+scale each eye independently to its viewport ---
3910
+ Lview = self._crop_and_scale(L, view_w, view_h)
3911
+ Rview = self._crop_and_scale(R, view_w, view_h)
3912
+
3913
+ # compose L|gap|R at label resolution
3914
+ canvas = np.zeros((view_h, view_w + gap + view_w, 3), dtype=np.uint8)
3915
+ canvas[:, :view_w] = Lview
3916
+ canvas[:, view_w + gap:view_w + gap + view_w] = Rview
3917
+
3918
+ # --- now apply the existing "preview zoom" (fit/100%/relative-to-fit)
3919
+ # Fit is already "canvas == label size", so:
3920
+ if self._preview_zoom == 1.0:
3921
+ return canvas
3922
+
3923
+ if self._preview_zoom == 0.0:
3924
+ # 1:1 means: no scaling beyond current composed pixels
3925
+ return canvas
3926
+
3927
+ # relative-to-fit scaling
3928
+ target_w = max(8, int(round(canvas.shape[1] * float(self._preview_zoom))))
3929
+ target_h = max(8, int(round(canvas.shape[0] * float(self._preview_zoom))))
3930
+ canvas = cv2.resize(canvas, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
3931
+ return canvas
3932
+
3933
+ def _apply_camera_crop_to_viewport(self, rgb8: np.ndarray, view_w: int, view_h: int) -> np.ndarray:
3934
+ """
3935
+ Apply linked pan/zoom (_img_zoom/_img_pan_x/_img_pan_y) to ONE eye image,
3936
+ producing exactly (view_h, view_w, 3) uint8.
3937
+ """
3938
+ img = np.asarray(rgb8)
3939
+ H, W = img.shape[:2]
3940
+
3941
+ z = float(max(1e-6, self._img_zoom))
3942
+
3943
+ win_w = int(round(view_w / z))
3944
+ win_h = int(round(view_h / z))
3945
+ win_w = max(8, min(W, win_w))
3946
+ win_h = max(8, min(H, win_h))
3947
+
3948
+ cx = (W - 1) * 0.5 + float(self._img_pan_x)
3949
+ cy = (H - 1) * 0.5 + float(self._img_pan_y)
3950
+
3951
+ x0 = int(round(cx - win_w * 0.5))
3952
+ y0 = int(round(cy - win_h * 0.5))
3953
+
3954
+ x0 = max(0, min(W - win_w, x0))
3955
+ y0 = max(0, min(H - win_h, y0))
3956
+
3957
+ crop = img[y0:y0 + win_h, x0:x0 + win_w]
3958
+
3959
+ if crop.shape[1] != view_w or crop.shape[0] != view_h:
3960
+ crop = cv2.resize(crop, (view_w, view_h), interpolation=cv2.INTER_LINEAR)
3961
+
3962
+ return crop
3963
+
3964
+ def _save_current_view(self):
3965
+ canvas = self._get_current_view_canvas_u8()
3966
+ if canvas is None:
3967
+ QMessageBox.information(self, "Save View", "No preview image to save yet.")
3968
+ return
3969
+
3970
+ fn, filt = QFileDialog.getSaveFileName(
3971
+ self,
3972
+ "Save Preview View",
3973
+ "",
3974
+ "PNG (*.png);;JPEG (*.jpg *.jpeg)"
3975
+ )
3976
+ if not fn:
3977
+ return
3978
+
3979
+ ext = os.path.splitext(fn)[1].lower()
3980
+ if ext == "":
3981
+ fn += ".png"
3982
+ ext = ".png"
3983
+
3984
+ try:
3985
+ from PIL import Image
3986
+ im = Image.fromarray(canvas, mode="RGB")
3987
+ if ext in (".jpg", ".jpeg"):
3988
+ im.save(fn, quality=95, subsampling=0)
3989
+ else:
3990
+ im.save(fn)
3991
+ except Exception as e:
3992
+ QMessageBox.warning(self, "Save View", f"Failed to save:\n{e}")
3993
+
3994
+ def _find_main_window(self):
3995
+ w = self
3996
+ from PyQt6.QtWidgets import QMainWindow, QApplication
3997
+ while w is not None and not isinstance(w, QMainWindow):
3998
+ w = w.parentWidget()
3999
+ if w:
4000
+ return w
4001
+ for tlw in QApplication.topLevelWidgets():
4002
+ if isinstance(tlw, QMainWindow):
4003
+ return tlw
4004
+ return None
4005
+
4006
+
4007
+ def _push_to_new_document(self):
4008
+ img_u8 = None
4009
+
4010
+ # Prefer exact displayed canvas for stereo
4011
+ if self._last_L8 is not None and self._last_R8 is not None:
4012
+ img_u8 = self._get_current_view_canvas_u8()
4013
+ else:
4014
+ img_u8 = getattr(self, "_last_frame_u8", None)
4015
+
4016
+ if img_u8 is None:
4017
+ QMessageBox.warning(self, "Push to New Document", "Nothing to push yet.")
4018
+ return
4019
+
4020
+ # Convert to float32 [0..1] for SASpro docs (matches your other tools)
4021
+
4022
+ arr = np.asarray(img_u8)
4023
+ if arr.ndim == 2:
4024
+ arr01 = arr.astype(np.float32) / 255.0
4025
+ meta = {"is_mono": True, "bit_depth": "8-bit"}
4026
+ else:
4027
+ if arr.shape[2] > 3:
4028
+ arr = arr[..., :3]
4029
+ arr01 = arr.astype(np.float32) / 255.0
4030
+ meta = {"is_mono": False, "bit_depth": "8-bit"}
4031
+
4032
+ mw = self._find_main_window()
4033
+ dm = getattr(mw, "docman", None) if mw else None
4034
+ if not mw or not dm:
4035
+ from PyQt6.QtWidgets import QMessageBox
4036
+ QMessageBox.critical(self, "Push to New Document", "Main window or DocManager not available.")
4037
+ return
4038
+
4039
+ title = "Planet Projection Preview"
4040
+ try:
4041
+ if hasattr(dm, "open_array"):
4042
+ doc = dm.open_array(arr01, metadata=meta, title=title)
4043
+ elif hasattr(dm, "create_document"):
4044
+ doc = dm.create_document(image=arr01, metadata=meta, name=title)
4045
+ else:
4046
+ raise RuntimeError("DocManager lacks open_array/create_document")
4047
+
4048
+ # Spawn a view (same pattern as NBtoRGBStars)
4049
+ if hasattr(mw, "_spawn_subwindow_for"):
4050
+ mw._spawn_subwindow_for(doc)
4051
+ else:
4052
+ from setiastro.saspro.subwindow import ImageSubWindow
4053
+ sw = ImageSubWindow(doc, parent=mw)
4054
+ sw.setWindowTitle(title)
4055
+ sw.show()
4056
+
4057
+ except Exception as e:
4058
+ from PyQt6.QtWidgets import QMessageBox
4059
+ QMessageBox.critical(self, "Push to New Document", f"Failed to open new view:\n{e}")