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

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

Potentially problematic release.


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

Files changed (28) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/saspro/__init__.py +9 -8
  3. setiastro/saspro/__main__.py +326 -285
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/doc_manager.py +4 -1
  6. setiastro/saspro/gui/main_window.py +41 -2
  7. setiastro/saspro/gui/mixins/file_mixin.py +6 -2
  8. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  9. setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -1
  10. setiastro/saspro/imageops/serloader.py +101 -17
  11. setiastro/saspro/layers.py +186 -10
  12. setiastro/saspro/layers_dock.py +198 -5
  13. setiastro/saspro/legacy/image_manager.py +10 -4
  14. setiastro/saspro/planetprojection.py +3854 -0
  15. setiastro/saspro/resources.py +2 -0
  16. setiastro/saspro/save_options.py +45 -13
  17. setiastro/saspro/ser_stack_config.py +21 -1
  18. setiastro/saspro/ser_stacker.py +8 -2
  19. setiastro/saspro/ser_stacker_dialog.py +37 -10
  20. setiastro/saspro/ser_tracking.py +57 -35
  21. setiastro/saspro/serviewer.py +164 -16
  22. setiastro/saspro/subwindow.py +36 -1
  23. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +1 -1
  24. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +28 -26
  25. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  26. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  27. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  28. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,3854 @@
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
+ def export_pseudo_surface_html(
1026
+ rgb: np.ndarray,
1027
+ out_path: str | None = None,
1028
+ *,
1029
+ title: str = "Pseudo Surface (Height from Brightness)",
1030
+ # mesh size control (trade quality vs HTML size)
1031
+ max_dim: int = 420,
1032
+ # height controls
1033
+ z_scale: float = 0.35, # relative to min(W,H) after downsample
1034
+ depth_gamma: float = 1.15,
1035
+ blur_sigma: float = 1.2,
1036
+ invert: bool = False,
1037
+ # NEW: make depth coherent (kills spikes)
1038
+ block: int = 10, # 10 => 10x10 pixel average height
1039
+ block_blur_sigma: float = 0.6, # tiny blur after block for step edges
1040
+ # NEW: safety cap so browsers don't melt if someone cranks max_dim
1041
+ max_vertices: int = 220_000, # ~470x470 grid
1042
+ ):
1043
+ """
1044
+ Interactive Plotly Mesh3d of a displaced heightfield:
1045
+ - XY is image plane
1046
+ - Z is derived from luminance (pseudo surface)
1047
+ - Vertex colors come from RGB
1048
+
1049
+ Uses image-style coordinates: +x right, +y down.
1050
+ """
1051
+ import os
1052
+ import numpy as np
1053
+ import plotly.graph_objects as go
1054
+
1055
+ x = np.asarray(rgb)
1056
+ if x.ndim != 3 or x.shape[2] < 3:
1057
+ raise ValueError("export_pseudo_surface_html expects RGB image (H,W,3).")
1058
+
1059
+ # ---- float01 ----
1060
+ if x.dtype == np.uint8:
1061
+ img01 = x[..., :3].astype(np.float32) / 255.0
1062
+ elif x.dtype == np.uint16:
1063
+ img01 = x[..., :3].astype(np.float32) / 65535.0
1064
+ else:
1065
+ img01 = x[..., :3].astype(np.float32, copy=False)
1066
+ img01 = np.clip(img01, 0.0, 1.0)
1067
+
1068
+ H, W = img01.shape[:2]
1069
+
1070
+ # ---- downsample for mesh size ----
1071
+ # clamp requested max_dim (prevents accidental 2k exports)
1072
+ max_dim = int(np.clip(max_dim, 128, 900))
1073
+
1074
+ s = float(max_dim) / float(max(H, W))
1075
+ if s < 1.0:
1076
+ newW = max(64, int(round(W * s)))
1077
+ newH = max(64, int(round(H * s)))
1078
+ img01_ds = cv2.resize(img01, (newW, newH), interpolation=cv2.INTER_AREA)
1079
+ else:
1080
+ img01_ds = img01
1081
+
1082
+ hH, hW = img01_ds.shape[:2]
1083
+
1084
+ # ---- safety: cap vertex count (triangles scale ~2*(H-1)*(W-1)) ----
1085
+ if hH * hW > max_vertices:
1086
+ scale = np.sqrt(float(max_vertices) / float(hH * hW))
1087
+ newW = max(64, int(round(hW * scale)))
1088
+ newH = max(64, int(round(hH * scale)))
1089
+ img01_ds = cv2.resize(img01_ds, (newW, newH), interpolation=cv2.INTER_AREA)
1090
+ hH, hW = img01_ds.shape[:2]
1091
+
1092
+ # ---- build height map from luminance ----
1093
+ lum = (0.299 * img01_ds[..., 0] + 0.587 * img01_ds[..., 1] + 0.114 * img01_ds[..., 2]).astype(np.float32)
1094
+
1095
+ # Robust normalize (keep your existing behavior)
1096
+ p_lo = float(np.percentile(lum, 1.0))
1097
+ p_hi = float(np.percentile(lum, 99.5))
1098
+ if p_hi <= p_lo + 1e-9:
1099
+ h01 = np.clip(lum, 0.0, 1.0)
1100
+ else:
1101
+ h01 = (lum - p_lo) / (p_hi - p_lo)
1102
+ h01 = np.clip(h01, 0.0, 1.0)
1103
+
1104
+ if invert:
1105
+ h01 = 1.0 - h01
1106
+
1107
+ # ---- NEW: block smoothing to make depth coherent like wiggle ----
1108
+ # each block shares one height value (10x10 average by default)
1109
+ b = int(max(1, block))
1110
+ if b > 1:
1111
+ # cv2.blur is a fast box filter (mean filter)
1112
+ h01 = cv2.blur(h01, (b, b), borderType=cv2.BORDER_REFLECT101)
1113
+
1114
+ # optional tiny blur after blocking to soften terraces
1115
+ if block_blur_sigma and block_blur_sigma > 0:
1116
+ h01 = cv2.GaussianBlur(h01, (0, 0), float(block_blur_sigma))
1117
+
1118
+ # your existing smooth (kept; you can set blur_sigma=0 if you want)
1119
+ if blur_sigma and blur_sigma > 0:
1120
+ h01 = cv2.GaussianBlur(h01, (0, 0), float(blur_sigma))
1121
+
1122
+ # gamma shape
1123
+ g = float(max(1e-3, depth_gamma))
1124
+ h01 = np.clip(h01, 0.0, 1.0) ** g
1125
+
1126
+ # centered displacement [-1,+1]
1127
+ h = (h01 * 2.0 - 1.0).astype(np.float32)
1128
+
1129
+ # Z scale in pixels-ish
1130
+ zmax = float(min(hH, hW)) * float(z_scale)
1131
+ z = h * zmax
1132
+
1133
+ # ---- mesh vertices ----
1134
+ yy, xx = np.mgrid[0:hH, 0:hW].astype(np.float32)
1135
+ X = (xx - (hW - 1) * 0.5).reshape(-1)
1136
+ Y = (yy - (hH - 1) * 0.5).reshape(-1)
1137
+ Z = z.reshape(-1)
1138
+
1139
+ # vertex colors
1140
+ cols = np.clip(img01_ds.reshape(-1, 3) * 255.0, 0, 255).astype(np.uint8)
1141
+ alpha = np.full((cols.shape[0], 1), 255, dtype=np.uint8)
1142
+ vcol = np.concatenate([cols, alpha], axis=1)
1143
+
1144
+ # ---- triangles (vectorized) ----
1145
+ grid = np.arange(hH * hW, dtype=np.int32).reshape(hH, hW)
1146
+ p00 = grid[:-1, :-1].ravel()
1147
+ p01 = grid[:-1, 1:].ravel()
1148
+ p10 = grid[ 1:, :-1].ravel()
1149
+ p11 = grid[ 1:, 1:].ravel()
1150
+
1151
+ I = np.concatenate([p00, p00]).astype(np.int32)
1152
+ J = np.concatenate([p10, p11]).astype(np.int32)
1153
+ K = np.concatenate([p11, p01]).astype(np.int32)
1154
+
1155
+ mesh = go.Mesh3d(
1156
+ x=X, y=Y, z=Z,
1157
+ i=I, j=J, k=K,
1158
+ vertexcolor=vcol,
1159
+ flatshading=False,
1160
+ lighting=dict(
1161
+ ambient=0.55, diffuse=0.85, specular=0.20,
1162
+ roughness=0.95, fresnel=0.10
1163
+ ),
1164
+ lightposition=dict(x=2, y=1, z=3),
1165
+ hoverinfo="skip",
1166
+ name="PseudoSurface",
1167
+ showscale=False,
1168
+ )
1169
+
1170
+ fig = go.Figure(data=[mesh])
1171
+ fig.update_layout(
1172
+ title=title,
1173
+ margin=dict(l=0, r=0, b=0, t=40),
1174
+ scene=dict(
1175
+ aspectmode="data",
1176
+ xaxis=dict(visible=False),
1177
+ yaxis=dict(visible=False),
1178
+ zaxis=dict(visible=False),
1179
+ bgcolor="black",
1180
+ camera=dict(
1181
+ eye=dict(x=0.0, y=-1.6, z=1.2),
1182
+ up=dict(x=0.0, y=-1.0, z=0.0),
1183
+ ),
1184
+ ),
1185
+ paper_bgcolor="black",
1186
+ plot_bgcolor="black",
1187
+ showlegend=False,
1188
+ )
1189
+
1190
+ html = fig.to_html(include_plotlyjs="cdn", full_html=True)
1191
+ if out_path is None:
1192
+ out_path = os.path.expanduser("~/pseudo_surface.html")
1193
+ return html, out_path
1194
+
1195
+ def deproject_galaxy_topdown_u8(
1196
+ roi01: np.ndarray, # float32 [0..1], (H,W,3)
1197
+ cx0: float, cy0: float,
1198
+ rpx: float,
1199
+ pa_deg: float,
1200
+ tilt: float, # b/a
1201
+ out_size: int = 800
1202
+ ) -> np.ndarray:
1203
+ """
1204
+ Returns RGB uint8 top-down view, outside-disk black.
1205
+ """
1206
+ import numpy as np
1207
+ import cv2
1208
+
1209
+ H, W = roi01.shape[:2]
1210
+ out = int(max(64, out_size))
1211
+
1212
+ # grid in disk plane [-1,1]
1213
+ yy, xx = np.mgrid[0:out, 0:out].astype(np.float32)
1214
+ u = (xx - (out - 1) * 0.5) / ((out - 1) * 0.5)
1215
+ v = (yy - (out - 1) * 0.5) / ((out - 1) * 0.5)
1216
+ rho = np.sqrt(u*u + v*v)
1217
+
1218
+ # ellipse squash (inclination): y compressed by tilt
1219
+ tilt = float(np.clip(tilt, 0.02, 1.0))
1220
+ xe = u
1221
+ ye = v * tilt
1222
+
1223
+ # rotate by PA
1224
+ a = np.deg2rad(pa_deg)
1225
+ ca, sa = float(np.cos(a)), float(np.sin(a))
1226
+ xr = xe * ca - ye * sa
1227
+ yr = xe * sa + ye * ca
1228
+
1229
+ # scale to pixels + translate to ROI coords
1230
+ mapx = (cx0 + xr * rpx).astype(np.float32)
1231
+ mapy = (cy0 + yr * rpx).astype(np.float32)
1232
+
1233
+ # sample
1234
+ img = np.clip(roi01, 0.0, 1.0)
1235
+ top01 = cv2.remap(img, mapx, mapy, interpolation=cv2.INTER_LINEAR,
1236
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
1237
+
1238
+ # mask outside disk-plane circle
1239
+ top01[rho > 1.0] = 0.0
1240
+
1241
+ return np.clip(top01 * 255.0, 0, 255).astype(np.uint8)
1242
+
1243
+
1244
+ # -----------------------------
1245
+ # UI dialog
1246
+ # -----------------------------
1247
+
1248
+ class PlanetProjectionDialog(QDialog):
1249
+ def __init__(self, parent=None, document=None):
1250
+ super().__init__(parent)
1251
+ self.setMinimumSize(520, 520)
1252
+
1253
+ self.resize(560, 640)
1254
+ self.setWindowTitle("Planet Projection — Stereo / Wiggle")
1255
+ self.setModal(False)
1256
+ self.parent = parent
1257
+ self.doc = document
1258
+ self.image = getattr(self.doc, "image", None) if self.doc is not None else None
1259
+ self._bg_img01 = None # float32 [0,1] RGB, resized per ROI
1260
+ self._bg_path = ""
1261
+ self._left = None
1262
+ self._right = None
1263
+ self._wiggle_timer = QTimer(self)
1264
+ self._wiggle_timer.timeout.connect(self._on_wiggle_tick)
1265
+ self._wiggle_state = False
1266
+ self._last_preview_u8 = None # last frame we pushed to preview (H,W,3) uint8
1267
+ self._preview_zoom = 1.0 # kept for compatibility but preview window owns zoom now
1268
+ self._preview_win = None
1269
+ self._wiggle_frames = None # list of RGB uint8 frames
1270
+ self._wiggle_idx = 0
1271
+ self._wiggle_steps = 36 # default smoothness (can make this a UI control later)
1272
+
1273
+ # Persist disk refinement within this dialog session (per image)
1274
+ self._disk_key = None # identifies the current image
1275
+ self._disk_last = None # (cx, cy, r) in FULL IMAGE coords
1276
+ self._disk_last_was_user = False # True once user clicks "Use This Disk"
1277
+
1278
+ self._build_ui()
1279
+ QTimer.singleShot(0, self._apply_initial_layout_fix)
1280
+ self._update_enable()
1281
+
1282
+ def _build_ui(self):
1283
+ outer = QVBoxLayout(self)
1284
+ outer.setContentsMargins(10, 10, 10, 10)
1285
+ outer.setSpacing(8)
1286
+
1287
+ self.lbl_top = QLabel(
1288
+ "Create a synthetic stereo pair from a planet ROI (sphere reprojection).\n"
1289
+ "• Stereo: side-by-side (Parallel or Cross-eye)\n"
1290
+ "• Wiggle: alternates L/R to create depth motion\n"
1291
+ "Optional: add a static starfield background (no parallax)."
1292
+ )
1293
+ self.lbl_top.setWordWrap(True)
1294
+ outer.addWidget(self.lbl_top)
1295
+
1296
+ prev_row = QHBoxLayout()
1297
+ self.btn_preview = QPushButton("Preview")
1298
+ self.btn_save_still = QPushButton("Save Still…")
1299
+ self.btn_save_wiggle = QPushButton("Save Wiggle…")
1300
+
1301
+ prev_row.addWidget(self.btn_preview)
1302
+ prev_row.addWidget(self.btn_save_still)
1303
+ prev_row.addWidget(self.btn_save_wiggle)
1304
+ prev_row.addStretch(1)
1305
+ outer.addLayout(prev_row)
1306
+
1307
+ self.btn_preview.clicked.connect(self._show_preview_window)
1308
+ self.btn_save_still.clicked.connect(self._save_still)
1309
+ self.btn_save_wiggle.clicked.connect(self._save_wiggle)
1310
+ # Controls
1311
+ box = QGroupBox("Parameters")
1312
+ form = QFormLayout(box)
1313
+
1314
+ self.cmb_mode = QComboBox()
1315
+ self.cmb_mode.addItems([
1316
+ "Stereo (Parallel) L | R",
1317
+ "Stereo (Cross-eye) R | L",
1318
+ "Wiggle stereo (toggle L/R)",
1319
+ "Anaglyph (Red/Cyan 3D Glasses)",
1320
+ "Interactive 3D Sphere (HTML)",
1321
+ "Galaxy Polar View (Top-Down)",
1322
+ ])
1323
+ form.addRow("Output:", self.cmb_mode)
1324
+
1325
+ self.cmb_planet_type = QComboBox()
1326
+ self.cmb_planet_type.addItems([
1327
+ "Normal (Sphere only)",
1328
+ "Saturn (Sphere + Rings)",
1329
+ "Pseudo surface (Height from brightness)",
1330
+ "Galaxy (Disk deprojection)",
1331
+ ])
1332
+ form.addRow("Planet type:", self.cmb_planet_type)
1333
+
1334
+ # Rings group
1335
+ rings_box = QGroupBox("Saturn rings")
1336
+ rings_form = QFormLayout(rings_box)
1337
+
1338
+ self.chk_rings = QCheckBox("Enable rings")
1339
+ self.chk_rings.setChecked(True)
1340
+ rings_form.addRow("", self.chk_rings)
1341
+
1342
+ self.spin_ring_pa = QDoubleSpinBox()
1343
+ self.spin_ring_pa.setRange(-180.0, 180.0)
1344
+ self.spin_ring_pa.setSingleStep(1.0)
1345
+ self.spin_ring_pa.setValue(0.0)
1346
+ self.spin_ring_pa.setToolTip("Ring position angle in the image (deg). Rotate ellipse.")
1347
+ rings_form.addRow("Ring PA (deg):", self.spin_ring_pa)
1348
+
1349
+ self.spin_ring_tilt = QDoubleSpinBox()
1350
+ self.spin_ring_tilt.setRange(0.05, 1.0)
1351
+ self.spin_ring_tilt.setSingleStep(0.02)
1352
+ self.spin_ring_tilt.setValue(0.35)
1353
+ self.spin_ring_tilt.setToolTip("Ellipse minor/major ratio (0..1). Smaller = more edge-on.")
1354
+ rings_form.addRow("Ring tilt (b/a):", self.spin_ring_tilt)
1355
+
1356
+ self.spin_ring_outer = QDoubleSpinBox()
1357
+ self.spin_ring_outer.setRange(1.0, 4.0)
1358
+ self.spin_ring_outer.setSingleStep(0.05)
1359
+ self.spin_ring_outer.setValue(2.20)
1360
+ self.spin_ring_outer.setToolTip("Outer ring radius factor relative to body radius.")
1361
+ rings_form.addRow("Outer factor:", self.spin_ring_outer)
1362
+
1363
+ self.spin_ring_inner = QDoubleSpinBox()
1364
+ self.spin_ring_inner.setRange(0.2, 3.5)
1365
+ self.spin_ring_inner.setSingleStep(0.05)
1366
+ self.spin_ring_inner.setValue(1.25)
1367
+ self.spin_ring_inner.setToolTip("Inner ring radius factor relative to body radius.")
1368
+ rings_form.addRow("Inner factor:", self.spin_ring_inner)
1369
+
1370
+ form.addRow(rings_box)
1371
+
1372
+ self.spin_theta = QDoubleSpinBox()
1373
+ self.spin_theta.setRange(0.2, 25.0)
1374
+ self.spin_theta.setSingleStep(0.2)
1375
+ self.spin_theta.setValue(6.0)
1376
+ self.spin_theta.setToolTip("Stereo strength in degrees. 6° usually looks best.")
1377
+ form.addRow("Strength (deg):", self.spin_theta)
1378
+
1379
+ # Pseudo surface group
1380
+ ps_box = QGroupBox("Pseudo surface depth")
1381
+ ps_form = QFormLayout(ps_box)
1382
+
1383
+ self.spin_ps_gamma = QDoubleSpinBox()
1384
+ self.spin_ps_gamma.setRange(0.3, 4.0)
1385
+ self.spin_ps_gamma.setSingleStep(0.05)
1386
+ self.spin_ps_gamma.setValue(1.15)
1387
+ self.spin_ps_gamma.setToolTip("Depth gamma. >1 emphasizes bright peaks; <1 broadens depth.")
1388
+ ps_form.addRow("Depth gamma:", self.spin_ps_gamma)
1389
+
1390
+ self.spin_ps_blur = QDoubleSpinBox()
1391
+ self.spin_ps_blur.setRange(0.0, 12.0)
1392
+ self.spin_ps_blur.setSingleStep(0.2)
1393
+ self.spin_ps_blur.setValue(1.2)
1394
+ self.spin_ps_blur.setToolTip("Smooth height map to avoid noisy depth.")
1395
+ ps_form.addRow("Depth blur (px):", self.spin_ps_blur)
1396
+
1397
+ self.chk_ps_invert = QCheckBox("Normal depth (bright = closer), uncheck for inverted")
1398
+ self.chk_ps_invert.setChecked(True)
1399
+ ps_form.addRow("", self.chk_ps_invert)
1400
+
1401
+ form.addRow(ps_box)
1402
+
1403
+ self.chk_auto_roi = QCheckBox("Auto ROI from planet centroid (green channel)")
1404
+ self.chk_auto_roi.setChecked(True)
1405
+ form.addRow("", self.chk_auto_roi)
1406
+
1407
+ self.spin_pad = QDoubleSpinBox()
1408
+ self.spin_pad.setRange(1.5, 6.0)
1409
+ self.spin_pad.setSingleStep(0.1)
1410
+ self.spin_pad.setValue(3.2)
1411
+ self.spin_pad.setToolTip("ROI size ≈ pad × planet radius")
1412
+ form.addRow("ROI pad (×radius):", self.spin_pad)
1413
+
1414
+ self.spin_min = QSpinBox()
1415
+ self.spin_min.setRange(128, 2000)
1416
+ self.spin_min.setValue(240)
1417
+ form.addRow("ROI min size:", self.spin_min)
1418
+
1419
+ self.spin_max = QSpinBox()
1420
+ self.spin_max.setRange(128, 5000)
1421
+ self.spin_max.setValue(900)
1422
+ form.addRow("ROI max size:", self.spin_max)
1423
+
1424
+ # Disk review + reset row
1425
+ disk_row_w = QWidget()
1426
+ disk_row = QHBoxLayout(disk_row_w)
1427
+ disk_row.setContentsMargins(0, 0, 0, 0)
1428
+
1429
+ self.chk_adjust_disk = QCheckBox("Review / adjust detected disk before generating")
1430
+ self.chk_adjust_disk.setChecked(True)
1431
+ disk_row.addWidget(self.chk_adjust_disk)
1432
+
1433
+ self.btn_reset_disk = themed_toolbtn("edit-undo", "Reset disk detection")
1434
+ ...
1435
+ disk_row.addStretch(1)
1436
+ disk_row.addWidget(self.btn_reset_disk)
1437
+
1438
+ form.addRow("", disk_row_w)
1439
+
1440
+
1441
+ self.chk_starfield = QCheckBox("Add static starfield background (no parallax)")
1442
+ self.chk_starfield.setChecked(True)
1443
+ form.addRow("", self.chk_starfield)
1444
+
1445
+ self.spin_density = QDoubleSpinBox()
1446
+ self.spin_density.setRange(0.0, 0.2)
1447
+ self.spin_density.setSingleStep(0.005)
1448
+ self.spin_density.setValue(0.03)
1449
+ self.spin_density.setToolTip("Star seed density. Try 0.01–0.06 for visible fields.")
1450
+ form.addRow("Star density:", self.spin_density)
1451
+
1452
+ self.spin_seed = QSpinBox()
1453
+ self.spin_seed.setRange(0, 999999)
1454
+ self.spin_seed.setValue(1)
1455
+ form.addRow("Star seed:", self.spin_seed)
1456
+ # Background image row
1457
+ bg_row = QHBoxLayout()
1458
+ self.chk_bg_image = QCheckBox("Use background image")
1459
+ self.chk_bg_image.setChecked(False)
1460
+ bg_row.addWidget(self.chk_bg_image)
1461
+
1462
+ self.bg_path_edit = QLineEdit()
1463
+ self.bg_path_edit.setReadOnly(True)
1464
+ self.bg_path_edit.setPlaceholderText("No background image selected")
1465
+ bg_row.addWidget(self.bg_path_edit, 1)
1466
+
1467
+ self.btn_bg_choose = QPushButton("Choose…")
1468
+ self.btn_bg_choose.clicked.connect(self._choose_bg)
1469
+ bg_row.addWidget(self.btn_bg_choose)
1470
+
1471
+ form.addRow("Background:", bg_row)
1472
+
1473
+ # Background depth (%): UI slider -2..10, internal -2000..10000 (x1000)
1474
+ bg_depth_row = QHBoxLayout()
1475
+
1476
+ self.sld_bg_depth = QSlider(Qt.Orientation.Horizontal)
1477
+ self.sld_bg_depth.setRange(-1000, 1000) # -2.00 .. 10.00 in steps of 0.01
1478
+ self.sld_bg_depth.setValue(300)
1479
+ self.sld_bg_depth.setSingleStep(5) # 0.05
1480
+ self.sld_bg_depth.setPageStep(25) # 0.25
1481
+ bg_depth_row.addWidget(self.sld_bg_depth, 1)
1482
+
1483
+ self.lbl_bg_depth = QLabel("0.00")
1484
+ self.lbl_bg_depth.setMinimumWidth(55)
1485
+ self.lbl_bg_depth.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
1486
+ bg_depth_row.addWidget(self.lbl_bg_depth)
1487
+
1488
+ def _update_bg_depth_label(v: int):
1489
+ self.lbl_bg_depth.setText(f"{v/100.0:.2f}")
1490
+
1491
+ self.sld_bg_depth.valueChanged.connect(_update_bg_depth_label)
1492
+ _update_bg_depth_label(self.sld_bg_depth.value())
1493
+
1494
+ tip = (
1495
+ "Background parallax as a percent of the planet parallax.\n"
1496
+ "0% = no parallax (screen-locked)\n"
1497
+ "25% = far behind (recommended)\n"
1498
+ "100% = same depth as planet (not recommended)\n\n"
1499
+ "UI shows -2..10; internally this is multiplied by 1000."
1500
+ )
1501
+ self.sld_bg_depth.setToolTip(tip)
1502
+ self.lbl_bg_depth.setToolTip(tip)
1503
+
1504
+ form.addRow("Background depth (xR):", bg_depth_row)
1505
+
1506
+
1507
+ self.spin_wiggle_ms = QSpinBox()
1508
+ self.spin_wiggle_ms.setRange(40, 800)
1509
+ self.spin_wiggle_ms.setValue(120)
1510
+ form.addRow("Wiggle period (ms):", self.spin_wiggle_ms)
1511
+
1512
+ outer.addWidget(box)
1513
+
1514
+ # Buttons
1515
+ btns = QHBoxLayout()
1516
+ self.btn_generate = QPushButton("Generate")
1517
+ self.btn_stop = QPushButton("Stop Wiggle")
1518
+ self.btn_stop.setEnabled(False)
1519
+ self.btn_close = QPushButton("Close")
1520
+ btns.addWidget(self.btn_generate)
1521
+ btns.addWidget(self.btn_stop)
1522
+ btns.addStretch(1)
1523
+ btns.addWidget(self.btn_close)
1524
+ outer.addLayout(btns)
1525
+
1526
+ def _set_form_row_visible(form_layout: QFormLayout, field_widget: QWidget, visible: bool):
1527
+ """Hide/show the entire row in a QFormLayout that contains field_widget."""
1528
+ for r in range(form_layout.rowCount()):
1529
+ item = form_layout.itemAt(r, QFormLayout.ItemRole.FieldRole)
1530
+ if item and item.widget() is field_widget:
1531
+ label_item = form_layout.itemAt(r, QFormLayout.ItemRole.LabelRole)
1532
+ if label_item and label_item.widget():
1533
+ label_item.widget().setVisible(visible)
1534
+ field_widget.setVisible(visible)
1535
+ return
1536
+
1537
+
1538
+
1539
+ def _update_type_enable():
1540
+ t = self.cmb_planet_type.currentIndex()
1541
+ is_sat = (t == 1)
1542
+ is_gal = (t == 3)
1543
+ is_pseudo = (t == 2)
1544
+
1545
+ rings_box.setVisible(is_sat or is_gal)
1546
+ ps_box.setVisible(is_pseudo)
1547
+
1548
+ # These are “planet disk” concepts; don’t use for pseudo surface
1549
+ self.chk_auto_roi.setEnabled(not is_pseudo)
1550
+ self.spin_pad.setEnabled(not is_pseudo)
1551
+ self.spin_min.setEnabled(not is_pseudo)
1552
+ self.spin_max.setEnabled(not is_pseudo)
1553
+ if hasattr(self, "chk_adjust_disk"):
1554
+ self.chk_adjust_disk.setEnabled(not is_pseudo)
1555
+
1556
+ # Background/starfield: pseudo surface fills the whole frame anyway
1557
+ self.chk_starfield.setEnabled(not is_pseudo)
1558
+ self.spin_density.setEnabled(not is_pseudo)
1559
+ self.spin_seed.setEnabled(not is_pseudo)
1560
+ self.chk_bg_image.setEnabled(not is_pseudo)
1561
+ self.bg_path_edit.setEnabled(not is_pseudo)
1562
+ self.btn_bg_choose.setEnabled(not is_pseudo)
1563
+ self.sld_bg_depth.setEnabled(not is_pseudo)
1564
+ self.lbl_bg_depth.setEnabled(not is_pseudo)
1565
+
1566
+ # --- Galaxy vs Saturn UI tweaks inside rings_box ---
1567
+ if is_gal:
1568
+ rings_box.setTitle("Galaxy disk")
1569
+ self.chk_rings.setVisible(False) # only meaningful for Saturn
1570
+ _set_form_row_visible(rings_form, self.spin_ring_outer, False)
1571
+ _set_form_row_visible(rings_form, self.spin_ring_inner, False)
1572
+
1573
+ # force output to Galaxy Polar View (optional, but prevents confusion)
1574
+ if self.cmb_mode.currentIndex() != 5:
1575
+ self.cmb_mode.setCurrentIndex(5)
1576
+ else:
1577
+ rings_box.setTitle("Saturn rings")
1578
+ self.chk_rings.setVisible(True)
1579
+ _set_form_row_visible(rings_form, self.spin_ring_outer, True)
1580
+ _set_form_row_visible(rings_form, self.spin_ring_inner, True)
1581
+
1582
+ self.adjustSize() # shrink/grow dialog to fit
1583
+
1584
+
1585
+ self.cmb_planet_type.currentIndexChanged.connect(_update_type_enable)
1586
+ _update_type_enable()
1587
+
1588
+ self.btn_generate.clicked.connect(self._generate)
1589
+ self.btn_stop.clicked.connect(self._stop_wiggle)
1590
+ self.btn_close.clicked.connect(self.close)
1591
+
1592
+ def _apply_initial_layout_fix(self):
1593
+ # Re-run the same logic you already use (rings_box/ps_box visibility + adjustSize)
1594
+ try:
1595
+ # call your existing closure logic by nudging without changing index
1596
+ # simplest: just call adjustSize + clamp to something sane
1597
+ self.adjustSize()
1598
+
1599
+ # Optional: clamp width/height so it doesn't blow out
1600
+ sh = self.sizeHint()
1601
+ w = max(self.minimumWidth(), sh.width())
1602
+ h = max(self.minimumHeight(), sh.height())
1603
+ self.resize(w, h)
1604
+ except Exception:
1605
+ pass
1606
+
1607
+
1608
+ def _reset_disk_cache(self):
1609
+ # Forget disk refinement for the CURRENT image only.
1610
+ self._disk_last = None
1611
+ self._disk_last_was_user = False
1612
+
1613
+ # Disable until we detect/accept again
1614
+ if hasattr(self, "btn_reset_disk") and self.btn_reset_disk is not None:
1615
+ self.btn_reset_disk.setEnabled(False)
1616
+
1617
+ # Optional: small user feedback
1618
+ QMessageBox.information(self, "Planet Projection", "Disk refinement reset. Next Generate will re-detect.")
1619
+
1620
+
1621
+ def _current_image_key(self, img: np.ndarray):
1622
+ """
1623
+ Stable key for the underlying image buffer, even if we take views like img[..., :3].
1624
+ """
1625
+ a = np.asarray(img)
1626
+
1627
+ # Walk to the base ndarray so views/slices map to the same identity
1628
+ base = a
1629
+ while isinstance(getattr(base, "base", None), np.ndarray):
1630
+ base = base.base
1631
+
1632
+ # Use raw data pointer + base dtype/shape (stable across views)
1633
+ ptr = int(base.__array_interface__["data"][0])
1634
+ return (ptr, tuple(base.shape), str(base.dtype))
1635
+
1636
+
1637
+
1638
+ def _set_preview_zoom(self, z: float):
1639
+ """
1640
+ z = 1.0 => Fit-to-window (KeepAspectRatio)
1641
+ z = 0.0 => True 1:1 (no scaling, centered)
1642
+ otherwise => scale relative to Fit (e.g., 0.7 = smaller than fit, 1.4 = bigger than fit)
1643
+ """
1644
+ if z < 0.05 and z != 0.0:
1645
+ z = 0.05
1646
+ if z > 6.0:
1647
+ z = 6.0
1648
+ self._preview_zoom = float(z)
1649
+
1650
+ # re-show last content
1651
+ if self._left is not None and self._right is not None:
1652
+ mode = self.cmb_mode.currentIndex()
1653
+ if mode == 2:
1654
+ # wiggle uses _set_preview_u8 directly; force refresh of current wiggle frame
1655
+ frame = self._right if self._wiggle_state else self._left
1656
+ self._set_preview_u8(frame)
1657
+ else:
1658
+ cross_eye = (mode == 0)
1659
+ self._show_stereo_pair(cross_eye=cross_eye)
1660
+
1661
+ def _fit_scaled_size(self, img_w: int, img_h: int) -> tuple[int, int]:
1662
+ """Compute the fit-to-preview size (KeepAspectRatio)."""
1663
+ pw = max(1, self.preview.width())
1664
+ ph = max(1, self.preview.height())
1665
+ s = min(pw / float(img_w), ph / float(img_h))
1666
+ return int(round(img_w * s)), int(round(img_h * s))
1667
+
1668
+ def _show_preview_window(self):
1669
+ if self._preview_win is None:
1670
+ self._preview_win = PlanetProjectionPreviewDialog(self)
1671
+ try:
1672
+ self._preview_win.resize(980, 600)
1673
+ except Exception:
1674
+ pass
1675
+ self._preview_win.show()
1676
+ self._preview_win.raise_()
1677
+ self._preview_win.activateWindow()
1678
+
1679
+
1680
+ def _open_preview_window(self):
1681
+ if self._preview_win is None:
1682
+ self._preview_win = PlanetProjectionPreviewDialog(self)
1683
+ try:
1684
+ self._preview_win.resize(980, 600)
1685
+ except Exception:
1686
+ pass
1687
+ self._preview_win.show()
1688
+ self._preview_win.raise_()
1689
+ self._preview_win.activateWindow()
1690
+
1691
+ def _raise_preview_window(self):
1692
+ if self._preview_win is None:
1693
+ self._open_preview_window()
1694
+ return
1695
+ self._preview_win.show()
1696
+ self._preview_win.raise_()
1697
+ self._preview_win.activateWindow()
1698
+
1699
+ def _push_preview_u8(self, rgb8: np.ndarray):
1700
+ rgb8 = np.asarray(rgb8)
1701
+ if rgb8.dtype != np.uint8:
1702
+ rgb8 = np.clip(rgb8, 0, 255).astype(np.uint8)
1703
+ if rgb8.ndim == 2:
1704
+ rgb8 = np.stack([rgb8, rgb8, rgb8], axis=2)
1705
+ if rgb8.shape[2] > 3:
1706
+ rgb8 = rgb8[..., :3]
1707
+
1708
+ self._last_preview_u8 = rgb8
1709
+
1710
+ # ensure preview exists
1711
+ if self._preview_win is None or not self._preview_win.isVisible():
1712
+ self._open_preview_window()
1713
+ self._preview_win.set_frame_u8(rgb8)
1714
+
1715
+ def _compose_side_by_side_u8(self, left8: np.ndarray, right8: np.ndarray, *, swap_eyes: bool, gap_px: int) -> np.ndarray:
1716
+ L = np.asarray(left8)
1717
+ R = np.asarray(right8)
1718
+
1719
+ if L.dtype != np.uint8:
1720
+ L = np.clip(L, 0, 255).astype(np.uint8)
1721
+ if R.dtype != np.uint8:
1722
+ R = np.clip(R, 0, 255).astype(np.uint8)
1723
+
1724
+ if L.ndim == 2:
1725
+ L = np.stack([L, L, L], axis=2)
1726
+ if R.ndim == 2:
1727
+ R = np.stack([R, R, R], axis=2)
1728
+
1729
+ if L.shape[2] > 3:
1730
+ L = L[..., :3]
1731
+ if R.shape[2] > 3:
1732
+ R = R[..., :3]
1733
+
1734
+ if swap_eyes:
1735
+ L, R = R, L
1736
+
1737
+ gap = int(max(0, gap_px))
1738
+ H = max(L.shape[0], R.shape[0])
1739
+ W = L.shape[1] + gap + R.shape[1]
1740
+
1741
+ canvas = np.zeros((H, W, 3), dtype=np.uint8)
1742
+ canvas[:L.shape[0], :L.shape[1]] = L
1743
+ canvas[:R.shape[0], L.shape[1] + gap:L.shape[1] + gap + R.shape[1]] = R
1744
+ return canvas
1745
+
1746
+
1747
+ def _bg_depth_internal_signed(self) -> float:
1748
+ """
1749
+ Read background depth from the UI slider, apply Saturn sign flip.
1750
+
1751
+ Slider shows -10.00 .. +10.00 (label uses v/100).
1752
+ We'll interpret slider units as "percent * 100":
1753
+ depth_pct = (slider_value / 100.0)
1754
+ so slider=25 -> 0.25 (25% of planet disparity).
1755
+ """
1756
+ v = float(self.sld_bg_depth.value()) # int
1757
+ # Saturn: invert background direction
1758
+ if self.cmb_planet_type.currentIndex() == 1: # 1 = Saturn
1759
+ v = -v
1760
+ return v
1761
+
1762
+
1763
+ def _set_bg_depth_internal(self, v: float):
1764
+ # internal -2000..10000 -> slider -200..1000
1765
+ self.sld_bg_depth.setValue(int(round(float(v) / 10.0)))
1766
+
1767
+
1768
+ def _update_enable(self):
1769
+ ok = (
1770
+ self.image is not None and isinstance(self.image, np.ndarray)
1771
+ and self.image.ndim == 3 and self.image.shape[2] >= 3
1772
+ )
1773
+ self.btn_generate.setEnabled(bool(ok))
1774
+
1775
+ def _compute_roi(self):
1776
+ img = np.asarray(self.image)
1777
+ H, W = img.shape[:2]
1778
+
1779
+ if self.chk_auto_roi.isChecked():
1780
+ # use green channel for centroid detection
1781
+ c = _planet_centroid_and_area(img[..., 1])
1782
+ if c is not None:
1783
+ cx, cy, area = c
1784
+ return _compute_roi_from_centroid(
1785
+ H, W, cx, cy, area,
1786
+ pad_mul=float(self.spin_pad.value()),
1787
+ min_size=int(self.spin_min.value()),
1788
+ max_size=int(self.spin_max.value()),
1789
+ )
1790
+ # fallback to center if centroid fails
1791
+ # center ROI
1792
+ s = int(np.clip(min(H, W) * 0.45, float(self.spin_min.value()), float(self.spin_max.value())))
1793
+ cx_i, cy_i = W // 2, H // 2
1794
+ x0 = max(0, cx_i - s // 2)
1795
+ y0 = max(0, cy_i - s // 2)
1796
+ x1 = min(W, x0 + s)
1797
+ y1 = min(H, y0 + s)
1798
+ return (x0, y0, x1, y1)
1799
+
1800
+ def _generate(self):
1801
+ self._stop_wiggle()
1802
+ mode = int(self.cmb_mode.currentIndex())
1803
+
1804
+ if self.image is None:
1805
+ QMessageBox.information(self, "Planet Projection", "No image loaded.")
1806
+ return
1807
+
1808
+ img = np.asarray(self.image)
1809
+ if img.ndim != 3 or img.shape[2] < 3:
1810
+ QMessageBox.information(self, "Planet Projection", "Image must be RGB (3 channels).")
1811
+ return
1812
+
1813
+ img = img[..., :3] # ensure exactly RGB
1814
+ Hfull, Wfull = img.shape[:2]
1815
+
1816
+ ptype = int(self.cmb_planet_type.currentIndex())
1817
+ is_pseudo = (ptype == 2)
1818
+
1819
+ # ---- 0) reset cached disk if image changed ----
1820
+ key = self._current_image_key(img)
1821
+ if self._disk_key != key:
1822
+ self._disk_key = key
1823
+ self._disk_last = None
1824
+ self._disk_last_was_user = False
1825
+
1826
+ # ---- 1) initial disk estimate (FULL IMAGE coords) ----
1827
+ if self._disk_last is not None:
1828
+ cx, cy, r = self._disk_last
1829
+ else:
1830
+ c = _planet_centroid_and_area(img[..., 1])
1831
+ if c is not None:
1832
+ cx, cy, area = c
1833
+ r = max(32.0, float(np.sqrt(area / np.pi)))
1834
+ else:
1835
+ cx = 0.5 * (Wfull - 1)
1836
+ cy = 0.5 * (Hfull - 1)
1837
+ r = 0.25 * min(Wfull, Hfull)
1838
+
1839
+ # ---- 2) optional user adjustment (preloads previous) ----
1840
+ if (
1841
+ (not is_pseudo)
1842
+ and self.chk_auto_roi.isChecked()
1843
+ and getattr(self, "chk_adjust_disk", None) is not None
1844
+ and self.chk_adjust_disk.isChecked()
1845
+ ):
1846
+ is_saturn = (ptype == 1)
1847
+ rings_on = bool(
1848
+ is_saturn
1849
+ and getattr(self, "chk_rings", None) is not None
1850
+ and self.chk_rings.isChecked()
1851
+ )
1852
+ is_galaxy = (ptype == 3)
1853
+ overlay_mode = "none"
1854
+ if is_galaxy:
1855
+ overlay_mode = "galaxy"
1856
+ elif is_saturn and rings_on:
1857
+ overlay_mode = "saturn"
1858
+
1859
+ dlg = PlanetDiskAdjustDialog(
1860
+ self, img[..., :3], cx, cy, r,
1861
+ overlay_mode=overlay_mode,
1862
+ ring_pa=float(self.spin_ring_pa.value()),
1863
+ ring_tilt=float(self.spin_ring_tilt.value()),
1864
+ ring_outer=float(self.spin_ring_outer.value()),
1865
+ ring_inner=float(self.spin_ring_inner.value()),
1866
+ )
1867
+
1868
+ if dlg.exec() != QDialog.DialogCode.Accepted:
1869
+ return
1870
+ cx, cy, r = dlg.get_result()
1871
+ self._disk_last = (float(cx), float(cy), float(r))
1872
+ self._disk_last_was_user = True
1873
+
1874
+ if overlay_mode in ("galaxy", "saturn"):
1875
+ pa, tilt, kout, kin = dlg.get_ring_result()
1876
+ self.spin_ring_pa.setValue(pa)
1877
+ self.spin_ring_tilt.setValue(tilt)
1878
+ if overlay_mode == "saturn":
1879
+ self.spin_ring_outer.setValue(kout)
1880
+ self.spin_ring_inner.setValue(kin)
1881
+
1882
+
1883
+ self._disk_last = (float(cx), float(cy), float(r))
1884
+ self._disk_last_was_user = True
1885
+ else:
1886
+ if self._disk_last is None:
1887
+ self._disk_last = (float(cx), float(cy), float(r))
1888
+ self._disk_last_was_user = False
1889
+
1890
+ if hasattr(self, "btn_reset_disk") and self.btn_reset_disk is not None:
1891
+ self.btn_reset_disk.setEnabled(self._disk_last is not None)
1892
+
1893
+ # ---- 3) ROI size from adjusted disk (pad/min/max) ----
1894
+ pad_mul = float(self.spin_pad.value())
1895
+ s = int(np.clip(r * pad_mul, float(self.spin_min.value()), float(self.spin_max.value())))
1896
+
1897
+ # ---- PSEUDO SURFACE MODE: early exit ----
1898
+ if is_pseudo:
1899
+ roi = img # whole image
1900
+ theta = float(self.spin_theta.value())
1901
+
1902
+ left_w, right_w, maskL, maskR = make_pseudo_surface_pair(
1903
+ roi,
1904
+ theta_deg=theta,
1905
+ depth_gamma=float(self.spin_ps_gamma.value()),
1906
+ blur_sigma=float(self.spin_ps_blur.value()),
1907
+ invert=bool(self.chk_ps_invert.isChecked()),
1908
+ )
1909
+
1910
+ Lw01 = left_w.astype(np.float32) / 255.0 if left_w.dtype == np.uint8 else left_w.astype(np.float32, copy=False)
1911
+ Rw01 = right_w.astype(np.float32) / 255.0 if right_w.dtype == np.uint8 else right_w.astype(np.float32, copy=False)
1912
+ Lw01 = np.clip(Lw01, 0.0, 1.0)
1913
+ Rw01 = np.clip(Rw01, 0.0, 1.0)
1914
+
1915
+ self._left = np.clip(Lw01 * 255.0, 0, 255).astype(np.uint8)
1916
+ self._right = np.clip(Rw01 * 255.0, 0, 255).astype(np.uint8)
1917
+
1918
+ # smooth wiggle not implemented for pseudo surface (yet) — keep toggle behavior
1919
+ self._wiggle_frames = None
1920
+ self._wiggle_state = False
1921
+
1922
+ if mode == 4:
1923
+ try:
1924
+ html, default_path = export_pseudo_surface_html(
1925
+ roi,
1926
+ out_path=None,
1927
+ title="Pseudo Surface (Height from Brightness)",
1928
+ max_dim=2048,
1929
+ z_scale=0.35,
1930
+ depth_gamma=float(self.spin_ps_gamma.value()),
1931
+ blur_sigma=float(self.spin_ps_blur.value()),
1932
+ invert=not bool(self.chk_ps_invert.isChecked()),
1933
+ )
1934
+
1935
+ fn, _ = QFileDialog.getSaveFileName(
1936
+ self,
1937
+ "Save Pseudo Surface As",
1938
+ default_path,
1939
+ "HTML Files (*.html)"
1940
+ )
1941
+ if fn:
1942
+ if not fn.lower().endswith(".html"):
1943
+ fn += ".html"
1944
+ with open(fn, "w", encoding="utf-8") as f:
1945
+ f.write(html)
1946
+
1947
+ import tempfile, webbrowser
1948
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8")
1949
+ tmp.write(html)
1950
+ tmp.close()
1951
+ webbrowser.open("file://" + tmp.name)
1952
+
1953
+ except Exception as e:
1954
+ QMessageBox.warning(self, "Pseudo Surface", f"Failed to generate 3D pseudo surface:\n{e}")
1955
+ return
1956
+
1957
+ if mode == 2:
1958
+ self._start_wiggle()
1959
+ return
1960
+
1961
+ if mode == 3:
1962
+ try:
1963
+ ana = _make_anaglyph(self._left, self._right, swap_eyes=False)
1964
+ self._push_preview_u8(ana)
1965
+ except Exception as e:
1966
+ QMessageBox.warning(self, "Anaglyph", f"Failed to build anaglyph:\n{e}")
1967
+ return
1968
+
1969
+ cross_eye = (mode == 1)
1970
+ self._show_stereo_pair(cross_eye=cross_eye)
1971
+ return
1972
+
1973
+ # ---- Saturn rings ROI expansion (only increases s) ----
1974
+ is_saturn = (self.cmb_planet_type.currentIndex() == 1)
1975
+ rings_on = bool(is_saturn and getattr(self, "chk_rings", None) is not None and self.chk_rings.isChecked())
1976
+
1977
+ if rings_on:
1978
+ tilt = float(self.spin_ring_tilt.value())
1979
+ pa = float(self.spin_ring_pa.value())
1980
+ k_out = float(self.spin_ring_outer.value())
1981
+
1982
+ outer_boost = 1.05
1983
+ a_out = k_out * float(r) * outer_boost
1984
+ b_out = max(1.0, a_out * tilt)
1985
+
1986
+ th = np.deg2rad(pa)
1987
+ cth, sth = np.cos(th), np.sin(th)
1988
+
1989
+ dx = np.sqrt((a_out * cth) ** 2 + (b_out * sth) ** 2)
1990
+ dy = np.sqrt((a_out * sth) ** 2 + (b_out * cth) ** 2)
1991
+ need_half = float(max(dx, dy))
1992
+
1993
+ margin = 12.0
1994
+ s_need = int(np.ceil(2.0 * (need_half + margin)))
1995
+ s = max(s, s_need)
1996
+
1997
+ s = int(np.clip(s, float(self.spin_min.value()), float(self.spin_max.value())))
1998
+
1999
+ # ---- ROI crop ALWAYS (for normal/saturn) ----
2000
+ cx_i, cy_i = int(round(cx)), int(round(cy))
2001
+ x0 = max(0, cx_i - s // 2)
2002
+ y0 = max(0, cy_i - s // 2)
2003
+ x1 = min(Wfull, x0 + s)
2004
+ y1 = min(Hfull, y0 + s)
2005
+
2006
+ roi = img[y0:y1, x0:x1, :3]
2007
+
2008
+ # ---- disk mask (ROI coords) ----
2009
+ H0, W0 = roi.shape[:2]
2010
+ yy, xx = np.mgrid[0:H0, 0:W0].astype(np.float32)
2011
+ cx0 = float(cx - x0)
2012
+ cy0 = float(cy - y0)
2013
+ disk = ((xx - cx0) ** 2 + (yy - cy0) ** 2) <= (float(r) ** 2)
2014
+
2015
+ def to01(x):
2016
+ if x.dtype == np.uint8:
2017
+ return x.astype(np.float32) / 255.0
2018
+ if x.dtype == np.uint16:
2019
+ return x.astype(np.float32) / 65535.0
2020
+ return x.astype(np.float32, copy=False)
2021
+
2022
+ theta = float(self.spin_theta.value())
2023
+
2024
+ # ---- GALAXY TOP-DOWN (early exit) ----
2025
+ is_galaxy = (ptype == 3) or (mode == 5) # planet_type==Galaxy OR output==Galaxy Polar View
2026
+
2027
+ if is_galaxy:
2028
+ # Galaxy wants the ROI disk params (cx0, cy0, r) + PA/tilt
2029
+ roi01 = to01(roi)
2030
+
2031
+ pa = float(self.spin_ring_pa.value()) # reuse ring PA widget as galaxy PA
2032
+ tilt = float(self.spin_ring_tilt.value()) # reuse ring tilt widget as galaxy b/a
2033
+
2034
+ # choose output size: use ROI size or clamp to something reasonable
2035
+ out_size = int(max(256, min(2000, max(roi.shape[0], roi.shape[1]))))
2036
+
2037
+ try:
2038
+ top8 = deproject_galaxy_topdown_u8(
2039
+ roi01,
2040
+ cx0=float(cx0), cy0=float(cy0),
2041
+ rpx=float(r),
2042
+ pa_deg=pa,
2043
+ tilt=tilt,
2044
+ out_size=out_size,
2045
+ )
2046
+ except Exception as e:
2047
+ QMessageBox.warning(self, "Galaxy Polar View", f"Failed to deproject galaxy:\n{e}")
2048
+ return
2049
+
2050
+ # push single-frame output
2051
+ self._left = None
2052
+ self._right = None
2053
+ self._wiggle_frames = None
2054
+ self._wiggle_state = False
2055
+
2056
+ self._last_preview_u8 = top8
2057
+ self._push_preview_u8(top8)
2058
+ return
2059
+
2060
+ # ---- BODY (sphere reprojection) ----
2061
+ interp = cv2.INTER_LANCZOS4
2062
+ left_w, right_w, maskL, maskR = make_stereo_pair(
2063
+ roi, theta_deg=theta, disk_mask=disk, interp=interp
2064
+ )
2065
+ Lw01 = to01(left_w)
2066
+ Rw01 = to01(right_w)
2067
+
2068
+ # ---- SATURN RINGS (optional) ----
2069
+ ringL01 = ringR01 = None
2070
+ ringL_front = ringL_back = ringR_front = ringR_back = None
2071
+
2072
+ if rings_on:
2073
+ tilt = float(self.spin_ring_tilt.value())
2074
+ pa = float(self.spin_ring_pa.value())
2075
+ k_out = float(self.spin_ring_outer.value())
2076
+ k_in = float(self.spin_ring_inner.value())
2077
+
2078
+ outer_boost = 1.05
2079
+ a_out = k_out * float(r) * outer_boost
2080
+ b_out = max(1.0, a_out * tilt)
2081
+
2082
+ a_in = k_in * float(r)
2083
+ b_in = max(1.0, a_in * tilt)
2084
+
2085
+ ringMask = _ellipse_annulus_mask(H0, W0, cx0, cy0, a_out, b_out, a_in, b_in, pa)
2086
+
2087
+ roi01 = to01(roi)
2088
+ ring_tex01 = roi01.copy()
2089
+ ring_tex01[~ringMask] = 0.0
2090
+
2091
+ mapLx, mapLy, mapRx, mapRy = _yaw_warp_maps(H0, W0, theta, cx0, cy0)
2092
+
2093
+ ringL01 = cv2.remap(ring_tex01, mapLx, mapLy, interpolation=cv2.INTER_LINEAR,
2094
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2095
+ ringR01 = cv2.remap(ring_tex01, mapRx, mapRy, interpolation=cv2.INTER_LINEAR,
2096
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2097
+
2098
+ front0, back0 = _ring_front_back_masks(H0, W0, cx0, cy0, pa, ringMask)
2099
+ f_u8 = (front0.astype(np.uint8) * 255)
2100
+ b_u8 = (back0.astype(np.uint8) * 255)
2101
+
2102
+ ringL_front = cv2.remap(f_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2103
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2104
+ ringL_back = cv2.remap(b_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2105
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2106
+ ringR_front = cv2.remap(f_u8, mapRx, mapRy, interpolation=cv2.INTER_NEAREST,
2107
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2108
+ ringR_back = cv2.remap(b_u8, mapRx, mapRy, interpolation=cv2.INTER_NEAREST,
2109
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127
2110
+
2111
+ # ---- centroid lock (planet-only) ----
2112
+ cL = _mask_centroid(maskL)
2113
+ cR = _mask_centroid(maskR)
2114
+ if cL is not None and cR is not None:
2115
+ tx = 0.5 * (cL[0] + cR[0])
2116
+ ty = 0.5 * (cL[1] + cR[1])
2117
+
2118
+ dxL, dyL = (tx - cL[0]), (ty - cL[1])
2119
+ dxR, dyR = (tx - cR[0]), (ty - cR[1])
2120
+
2121
+ Lw01 = _shift_image(Lw01, dxL, dyL, border_value=0)
2122
+ Rw01 = _shift_image(Rw01, dxR, dyR, border_value=0)
2123
+ maskL = _shift_mask(maskL, dxL, dyL)
2124
+ maskR = _shift_mask(maskR, dxR, dyR)
2125
+
2126
+ # IMPORTANT for smooth wiggle: ring masks/textures need to be shifted too
2127
+ if ringL01 is not None:
2128
+ ringL01 = _shift_image(ringL01, dxL, dyL, border_value=0)
2129
+ ringR01 = _shift_image(ringR01, dxR, dyR, border_value=0)
2130
+ ringL_front = _shift_mask(ringL_front, dxL, dyL) if ringL_front is not None else None
2131
+ ringL_back = _shift_mask(ringL_back, dxL, dyL) if ringL_back is not None else None
2132
+ ringR_front = _shift_mask(ringR_front, dxR, dyR) if ringR_front is not None else None
2133
+ ringR_back = _shift_mask(ringR_back, dxR, dyR) if ringR_back is not None else None
2134
+
2135
+ # ---- build background (bg01) ----
2136
+ H, W = roi.shape[:2]
2137
+ if self.chk_bg_image.isChecked() and self._bg_img01 is not None:
2138
+ bg = cv2.resize(self._bg_img01, (W, H), interpolation=cv2.INTER_AREA)
2139
+ bg01 = np.clip(bg.astype(np.float32, copy=False), 0.0, 1.0)
2140
+ else:
2141
+ bg01 = np.zeros((H, W, 3), dtype=np.float32)
2142
+
2143
+ if self.chk_starfield.isChecked():
2144
+ bg01 = _add_starfield(
2145
+ bg01,
2146
+ density=float(self.spin_density.value()),
2147
+ seed=int(self.spin_seed.value()),
2148
+ star_sigma=0.8,
2149
+ brightness=0.9,
2150
+ )
2151
+
2152
+ # ---- background parallax (for the still L/R that you already show) ----
2153
+ cL2 = _mask_centroid(maskL)
2154
+ cR2 = _mask_centroid(maskR)
2155
+ planet_disp_px = float(cL2[0] - cR2[0]) if (cL2 is not None and cR2 is not None) else 0.0
2156
+
2157
+ depth_pct = float(self._bg_depth_internal_signed()) / 100.0
2158
+ bg_disp_px = planet_disp_px * depth_pct
2159
+ bg_shift = 0.5 * bg_disp_px
2160
+
2161
+ max_bg_shift = 10.0 * min(H, W)
2162
+ bg_shift = float(np.clip(bg_shift, -max_bg_shift, +max_bg_shift))
2163
+
2164
+ bgL = _shift_image(bg01, +bg_shift, 0.0, border_value=0)
2165
+ bgR = _shift_image(bg01, -bg_shift, 0.0, border_value=0)
2166
+
2167
+ # ---- composite L/R ----
2168
+ Ldisp01 = bgL.copy()
2169
+ Rdisp01 = bgR.copy()
2170
+
2171
+ if ringL01 is not None:
2172
+ Ldisp01[ringL_back & (~maskL)] = ringL01[ringL_back & (~maskL)]
2173
+ Rdisp01[ringR_back & (~maskR)] = ringR01[ringR_back & (~maskR)]
2174
+
2175
+ Ldisp01[maskL] = Lw01[maskL]
2176
+ Rdisp01[maskR] = Rw01[maskR]
2177
+
2178
+ if ringL01 is not None:
2179
+ Ldisp01[ringL_front] = ringL01[ringL_front]
2180
+ Rdisp01[ringR_front] = ringR01[ringR_front]
2181
+
2182
+ self._left = np.clip(Ldisp01 * 255.0, 0, 255).astype(np.uint8)
2183
+ self._right = np.clip(Rdisp01 * 255.0, 0, 255).astype(np.uint8)
2184
+ self._wiggle_state = False
2185
+
2186
+ # ---- mode handling ----
2187
+ if mode == 4:
2188
+ try:
2189
+ rings_kwargs = None
2190
+ if rings_on:
2191
+ rings_kwargs = dict(
2192
+ cx=float(cx0),
2193
+ cy=float(cy0),
2194
+ r=float(r),
2195
+ pa=float(self.spin_ring_pa.value()),
2196
+ tilt=float(self.spin_ring_tilt.value()),
2197
+ k_out=float(self.spin_ring_outer.value()),
2198
+ k_in=float(self.spin_ring_inner.value()),
2199
+ )
2200
+
2201
+ html, default_path = export_planet_sphere_html(
2202
+ roi_rgb=roi,
2203
+ disk_mask=disk,
2204
+ out_path=None,
2205
+ n_lat=140,
2206
+ n_lon=280,
2207
+ title="Saturn" if rings_on else "Planet Sphere",
2208
+ rings=rings_kwargs,
2209
+ )
2210
+
2211
+ fn, _ = QFileDialog.getSaveFileName(
2212
+ self,
2213
+ "Save Planet Sphere As",
2214
+ default_path,
2215
+ "HTML Files (*.html)"
2216
+ )
2217
+ if fn:
2218
+ if not fn.lower().endswith(".html"):
2219
+ fn += ".html"
2220
+ with open(fn, "w", encoding="utf-8") as f:
2221
+ f.write(html)
2222
+
2223
+ import tempfile, webbrowser
2224
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8")
2225
+ tmp.write(html)
2226
+ tmp.close()
2227
+ webbrowser.open("file://" + tmp.name)
2228
+
2229
+ except Exception as e:
2230
+ QMessageBox.warning(self, "Planet Sphere", f"Failed to generate 3D sphere:\n{e}")
2231
+ return
2232
+
2233
+ if mode == 2:
2234
+ self._start_wiggle()
2235
+ return
2236
+
2237
+ if mode == 3:
2238
+ try:
2239
+ ana = _make_anaglyph(self._left, self._right, swap_eyes=False)
2240
+ self._push_preview_u8(ana)
2241
+ except Exception as e:
2242
+ QMessageBox.warning(self, "Anaglyph", f"Failed to build anaglyph:\n{e}")
2243
+ return
2244
+
2245
+ cross_eye = (mode == 0)
2246
+ self._show_stereo_pair(cross_eye=cross_eye)
2247
+ return
2248
+
2249
+ def _render_composited_view_u8(self, theta_deg: float) -> np.ndarray:
2250
+ """
2251
+ Render ONE view (not L/R pair) at a given theta using the real reprojection math.
2252
+ Returns RGB uint8 frame for preview/save.
2253
+ """
2254
+ if not hasattr(self, "_wiggle_ctx") or self._wiggle_ctx is None:
2255
+ return None
2256
+
2257
+ ctx = self._wiggle_ctx
2258
+ roi = ctx["roi"]
2259
+ disk = ctx["disk"]
2260
+ bg01 = ctx["bg01"]
2261
+ H0, W0 = roi.shape[:2]
2262
+ cx0, cy0 = float(ctx["cx0"]), float(ctx["cy0"])
2263
+
2264
+ def to01(x):
2265
+ x = np.asarray(x)
2266
+ if x.dtype == np.uint8:
2267
+ return x.astype(np.float32) / 255.0
2268
+ if x.dtype == np.uint16:
2269
+ return x.astype(np.float32) / 65535.0
2270
+ return x.astype(np.float32, copy=False)
2271
+
2272
+ # --- BODY: we can reuse make_stereo_pair by asking for a symmetric pair
2273
+ # and then choosing the "left" for +theta and "right" for -theta.
2274
+ # Easiest: call make_stereo_pair with theta_deg and take left_w/maskL as view.
2275
+ interp = cv2.INTER_LANCZOS4
2276
+ left_w, right_w, maskL, maskR = make_stereo_pair(roi, theta_deg=float(theta_deg), disk_mask=disk, interp=interp)
2277
+
2278
+ view01 = to01(left_w)
2279
+ mask = maskL
2280
+
2281
+ # --- RINGS (optional): warp ring texture with same theta and composite back/front
2282
+ ring01 = None
2283
+ ring_front = ring_back = None
2284
+ if ctx["rings_on"]:
2285
+ tilt = float(ctx["ring_tilt"])
2286
+ pa = float(ctx["ring_pa"])
2287
+ k_out = float(ctx["ring_outer"])
2288
+ k_in = float(ctx["ring_inner"])
2289
+ r = float(ctx["r"])
2290
+
2291
+ outer_boost = 1.05
2292
+ a_out = k_out * r * outer_boost
2293
+ b_out = max(1.0, a_out * tilt)
2294
+ a_in = k_in * r
2295
+ b_in = max(1.0, a_in * tilt)
2296
+
2297
+ ringMask = _ellipse_annulus_mask(H0, W0, cx0, cy0, a_out, b_out, a_in, b_in, pa)
2298
+
2299
+ roi01 = to01(roi)
2300
+ ring_tex01 = roi01.copy()
2301
+ ring_tex01[~ringMask] = 0.0
2302
+
2303
+ mapLx, mapLy, mapRx, mapRy = _yaw_warp_maps(H0, W0, float(theta_deg), cx0, cy0)
2304
+ ring01 = cv2.remap(ring_tex01, mapLx, mapLy, interpolation=cv2.INTER_LINEAR,
2305
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
2306
+
2307
+ front0, back0 = _ring_front_back_masks(H0, W0, cx0, cy0, pa, ringMask)
2308
+ f_u8 = (front0.astype(np.uint8) * 255)
2309
+ b_u8 = (back0.astype(np.uint8) * 255)
2310
+ ring_front = (cv2.remap(f_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2311
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127)
2312
+ ring_back = (cv2.remap(b_u8, mapLx, mapLy, interpolation=cv2.INTER_NEAREST,
2313
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 127)
2314
+
2315
+ # --- BACKGROUND PARALLAX: compute disparity for this theta using centroids in this view vs the opposite view
2316
+ # We already have maskL/maskR from make_stereo_pair.
2317
+ cL = _mask_centroid(maskL)
2318
+ cR = _mask_centroid(maskR)
2319
+ planet_disp_px = float(cL[0] - cR[0]) if (cL is not None and cR is not None) else 0.0
2320
+
2321
+ depth_pct = float(self._bg_depth_internal_signed()) / 100.0
2322
+ bg_disp_px = planet_disp_px * depth_pct
2323
+ bg_shift = 0.5 * bg_disp_px
2324
+ max_bg_shift = 10.0 * min(H0, W0)
2325
+ bg_shift = float(np.clip(bg_shift, -max_bg_shift, +max_bg_shift))
2326
+
2327
+ bg = _shift_image(bg01, +bg_shift, 0.0, border_value=0) # single-view bg
2328
+
2329
+ # --- COMPOSITE
2330
+ out01 = bg.copy()
2331
+
2332
+ if ring01 is not None:
2333
+ out01[ring_back & (~mask)] = ring01[ring_back & (~mask)]
2334
+
2335
+ out01[mask] = view01[mask]
2336
+
2337
+ if ring01 is not None:
2338
+ out01[ring_front] = ring01[ring_front]
2339
+
2340
+ out8 = np.clip(out01 * 255.0, 0, 255).astype(np.uint8)
2341
+ return out8
2342
+
2343
+ def _build_smooth_wiggle_frames(self):
2344
+ if not hasattr(self, "_wiggle_ctx") or self._wiggle_ctx is None:
2345
+ self._wiggle_frames = None
2346
+ return
2347
+
2348
+ theta_max = float(self.spin_theta.value())
2349
+ steps = int(getattr(self, "_wiggle_steps", 36))
2350
+ steps = max(8, min(240, steps))
2351
+
2352
+ frames = []
2353
+ for i in range(steps):
2354
+ phase = (2.0 * np.pi * i) / float(steps)
2355
+ theta_i = theta_max * float(np.sin(phase)) # smooth motion
2356
+ f = self._render_composited_view_u8(theta_i)
2357
+ if f is not None:
2358
+ frames.append(f)
2359
+
2360
+ self._wiggle_frames = frames if frames else None
2361
+
2362
+
2363
+ def _show_stereo_pair(self, cross_eye: bool = False):
2364
+ if self._left is None or self._right is None:
2365
+ return
2366
+
2367
+ # Ensure preview exists (same logic as _push_preview_u8)
2368
+ if self._preview_win is None or not self._preview_win.isVisible():
2369
+ self._open_preview_window()
2370
+
2371
+ # IMPORTANT: pass the RAW L and R (do NOT pre-compose into one canvas)
2372
+ # swap_eyes handles parallel vs cross-eye ordering inside the preview window
2373
+ self._preview_win.set_stereo_u8(
2374
+ self._left,
2375
+ self._right,
2376
+ swap_eyes=bool(cross_eye),
2377
+ gap_px=16
2378
+ )
2379
+
2380
+ # keep "last still" meaningful for Save Still…
2381
+ # If you want Save Still to save the side-by-side, ask preview for its composed canvas,
2382
+ # but for now, we’ll store a simple composed copy here:
2383
+ self._last_preview_u8 = self._compose_side_by_side_u8(
2384
+ self._left, self._right, swap_eyes=bool(cross_eye), gap_px=16
2385
+ )
2386
+
2387
+ def _choose_bg(self):
2388
+ fn, _ = QFileDialog.getOpenFileName(
2389
+ self,
2390
+ "Select background image",
2391
+ "",
2392
+ "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp);;All Files (*)",
2393
+ )
2394
+ if not fn:
2395
+ return
2396
+ self._bg_path = fn
2397
+ self.bg_path_edit.setText(fn)
2398
+
2399
+ try:
2400
+ # load via cv2, convert to RGB float01
2401
+ im = cv2.imread(fn, cv2.IMREAD_UNCHANGED)
2402
+ if im is None:
2403
+ raise RuntimeError("Could not read file.")
2404
+ if im.ndim == 2:
2405
+ im = np.stack([im, im, im], axis=2)
2406
+ if im.shape[2] > 3:
2407
+ im = im[..., :3]
2408
+
2409
+ # BGR->RGB
2410
+ im = im[..., ::-1]
2411
+
2412
+ if im.dtype == np.uint8:
2413
+ im01 = im.astype(np.float32) / 255.0
2414
+ elif im.dtype == np.uint16:
2415
+ im01 = im.astype(np.float32) / 65535.0
2416
+ else:
2417
+ im01 = im.astype(np.float32, copy=False)
2418
+ # best effort clamp
2419
+ im01 = np.clip(im01, 0.0, 1.0)
2420
+
2421
+ self._bg_img01 = im01
2422
+ except Exception as e:
2423
+ self._bg_img01 = None
2424
+ QMessageBox.warning(self, "Background Image", f"Failed to load background:\n{e}")
2425
+
2426
+ def _start_wiggle(self):
2427
+ if self._left is None or self._right is None:
2428
+ QMessageBox.information(self, "Wiggle", "Nothing to wiggle yet. Click Generate first.")
2429
+ return
2430
+
2431
+ self.btn_stop.setEnabled(True)
2432
+ self._wiggle_state = False
2433
+
2434
+ interval = int(self.spin_wiggle_ms.value()) # old meaning: toggle period
2435
+ interval = max(10, interval)
2436
+
2437
+ self._wiggle_timer.start(interval)
2438
+ self._on_wiggle_tick()
2439
+
2440
+
2441
+ def _stop_wiggle(self):
2442
+ if self._wiggle_timer.isActive():
2443
+ self._wiggle_timer.stop()
2444
+ self.btn_stop.setEnabled(False)
2445
+
2446
+
2447
+ def _on_wiggle_tick(self):
2448
+ if self._left is None or self._right is None:
2449
+ return
2450
+
2451
+ frame = self._right if self._wiggle_state else self._left
2452
+ self._wiggle_state = not self._wiggle_state
2453
+ self._push_preview_u8(frame)
2454
+
2455
+ def _save_still(self):
2456
+ if self._last_preview_u8 is None:
2457
+ QMessageBox.information(self, "Save Still", "Nothing to save yet. Click Generate first.")
2458
+ return
2459
+
2460
+ fn, filt = QFileDialog.getSaveFileName(
2461
+ self,
2462
+ "Save Still Image",
2463
+ "",
2464
+ "PNG (*.png);;JPEG (*.jpg *.jpeg);;TIFF (*.tif *.tiff)"
2465
+ )
2466
+ if not fn:
2467
+ return
2468
+
2469
+ img = self._last_preview_u8 # RGB uint8
2470
+
2471
+ # decide format from extension (default to png)
2472
+ ext = os.path.splitext(fn)[1].lower()
2473
+ if ext == "":
2474
+ fn += ".png"
2475
+ ext = ".png"
2476
+
2477
+ try:
2478
+ # use PIL for consistent RGB save
2479
+ from PIL import Image
2480
+ im = Image.fromarray(img, mode="RGB")
2481
+ if ext in (".jpg", ".jpeg"):
2482
+ im.save(fn, quality=95, subsampling=0)
2483
+ else:
2484
+ im.save(fn)
2485
+ except Exception as e:
2486
+ QMessageBox.warning(self, "Save Still", f"Failed to save:\n{e}")
2487
+
2488
+ def _save_wiggle(self):
2489
+ if self._left is None or self._right is None:
2490
+ QMessageBox.information(self, "Save Wiggle", "Nothing to save yet. Click Generate first.")
2491
+ return
2492
+
2493
+ fn, filt = QFileDialog.getSaveFileName(
2494
+ self,
2495
+ "Save Wiggle Animation",
2496
+ "",
2497
+ "Animated GIF (*.gif);;MP4 Video (*.mp4)"
2498
+ )
2499
+ if not fn:
2500
+ return
2501
+
2502
+ want_mp4 = ("*.mp4" in filt) or fn.lower().endswith(".mp4")
2503
+ want_gif = ("*.gif" in filt) or fn.lower().endswith(".gif")
2504
+
2505
+ # add extension if missing
2506
+ if os.path.splitext(fn)[1] == "":
2507
+ fn += ".mp4" if want_mp4 else ".gif"
2508
+ want_mp4 = fn.lower().endswith(".mp4")
2509
+ want_gif = fn.lower().endswith(".gif")
2510
+
2511
+ def _ensure_rgb_u8(x):
2512
+ x = np.asarray(x)
2513
+ if x.dtype != np.uint8:
2514
+ x = np.clip(x, 0, 255).astype(np.uint8)
2515
+ if x.ndim == 2:
2516
+ x = np.stack([x, x, x], axis=2)
2517
+ if x.shape[2] > 3:
2518
+ x = x[..., :3]
2519
+ return x
2520
+
2521
+ L = _ensure_rgb_u8(self._left)
2522
+ R = _ensure_rgb_u8(self._right)
2523
+
2524
+ toggle_ms = int(self.spin_wiggle_ms.value())
2525
+ toggle_ms = max(10, toggle_ms)
2526
+
2527
+ # old behavior: ~2 seconds total, alternating every toggle_ms
2528
+ fps = 1000.0 / float(toggle_ms)
2529
+ n_frames = max(2, int(round(2.0 * fps)))
2530
+ if n_frames % 2 == 1:
2531
+ n_frames += 1
2532
+
2533
+ frames = [R if (i % 2 == 1) else L for i in range(n_frames)]
2534
+
2535
+ if want_gif:
2536
+ try:
2537
+ from PIL import Image
2538
+ pil_frames = [Image.fromarray(f, mode="RGB") for f in frames]
2539
+ pil_frames[0].save(
2540
+ fn,
2541
+ save_all=True,
2542
+ append_images=pil_frames[1:],
2543
+ duration=toggle_ms,
2544
+ loop=0,
2545
+ disposal=2,
2546
+ optimize=False
2547
+ )
2548
+ return
2549
+ except Exception as e:
2550
+ QMessageBox.warning(self, "Save Wiggle", f"Failed to save GIF:\n{e}")
2551
+ return
2552
+
2553
+ # MP4
2554
+ try:
2555
+ import cv2
2556
+ h, w = frames[0].shape[:2]
2557
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
2558
+ vw = cv2.VideoWriter(fn, fourcc, float(fps), (w, h))
2559
+ if not vw.isOpened():
2560
+ raise RuntimeError("Could not open MP4 encoder (mp4v). This system may lack an MP4 codec.")
2561
+
2562
+ for f in frames:
2563
+ vw.write(f[..., ::-1]) # RGB->BGR
2564
+ vw.release()
2565
+ return
2566
+
2567
+ except Exception as e:
2568
+ QMessageBox.warning(
2569
+ self,
2570
+ "Save Wiggle (MP4)",
2571
+ "Failed to save MP4.\n\n"
2572
+ f"{e}\n\n"
2573
+ "Tip: GIF export should always work. If you need MP4 reliably, we can bundle/use ffmpeg."
2574
+ )
2575
+ return
2576
+
2577
+ def closeEvent(self, e):
2578
+ self._stop_wiggle()
2579
+ super().closeEvent(e)
2580
+
2581
+ class PlanetDiskAdjustDialog(QDialog):
2582
+ """
2583
+ Manual override for planet disk center/radius.
2584
+ - Ctrl+drag to move center.
2585
+ - +/- or slider/spin to change radius.
2586
+ - Arrow buttons (and arrow keys) to nudge.
2587
+ Returns cx, cy, r in FULL IMAGE pixel coords.
2588
+ """
2589
+ def __init__(self, parent, img_rgb: np.ndarray, cx: float, cy: float, r: float,
2590
+ *, show_rings: bool = False, overlay_mode: str = "none",
2591
+ ring_pa: float = 0.0, ring_tilt: float = 0.35,
2592
+ ring_outer: float = 2.2, ring_inner: float = 1.25):
2593
+ super().__init__(parent)
2594
+
2595
+ self.setWindowTitle("Adjust Planet Disk")
2596
+ self.setModal(True)
2597
+ self._preview_zoom = 1.0 # 1.0 = Fit
2598
+ self.overlay_mode = str(overlay_mode)
2599
+ self.show_rings = (self.overlay_mode in ("saturn", "galaxy"))
2600
+ self.img = np.asarray(img_rgb)
2601
+ self.H, self.W = self.img.shape[:2]
2602
+
2603
+ self.cx = float(cx)
2604
+ self.cy = float(cy)
2605
+ self.r = float(r)
2606
+
2607
+ # --- rings (optional) ---
2608
+
2609
+ self.ring_pa = float(ring_pa)
2610
+ self.ring_tilt = float(ring_tilt)
2611
+ self.ring_outer = float(ring_outer)
2612
+ self.ring_inner = float(ring_inner)
2613
+
2614
+ self._dragging = False
2615
+ self._drag_offset = (0.0, 0.0)
2616
+
2617
+ # preview state
2618
+ self._disp8 = _to_u8_preview(self.img[..., :3])
2619
+ self._scale = 1.0
2620
+ self._offx = 0.0
2621
+ self._offy = 0.0
2622
+
2623
+ self._build_ui()
2624
+ self._redraw()
2625
+
2626
+ def _build_ui(self):
2627
+ outer = QVBoxLayout(self)
2628
+ outer.setContentsMargins(10, 10, 10, 10)
2629
+ outer.setSpacing(8)
2630
+
2631
+ help_txt = (
2632
+ "Ctrl+Click+Drag to move the circle.\n"
2633
+ "Use Radius controls and arrow nudges for precision."
2634
+ )
2635
+ if self.show_rings:
2636
+ help_txt += "\nAdjust ring PA / tilt / inner / outer to match Saturn's rings."
2637
+
2638
+ self.lbl_help = QLabel(help_txt)
2639
+ self.lbl_help.setWordWrap(True)
2640
+ outer.addWidget(self.lbl_help)
2641
+
2642
+ # Zoom controls
2643
+ zoom_row = QHBoxLayout()
2644
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
2645
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
2646
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
2647
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
2648
+
2649
+ zoom_row.addStretch(1)
2650
+ zoom_row.addWidget(self.btn_zoom_out)
2651
+ zoom_row.addWidget(self.btn_zoom_fit)
2652
+ zoom_row.addWidget(self.btn_zoom_100)
2653
+ zoom_row.addWidget(self.btn_zoom_in)
2654
+ outer.addLayout(zoom_row)
2655
+
2656
+ self.btn_zoom_out.clicked.connect(lambda: self._set_preview_zoom(self._preview_zoom * 0.8))
2657
+ self.btn_zoom_in.clicked.connect(lambda: self._set_preview_zoom(self._preview_zoom * 1.25))
2658
+ self.btn_zoom_fit.clicked.connect(lambda: self._set_preview_zoom(1.0))
2659
+ self.btn_zoom_100.clicked.connect(lambda: self._set_preview_zoom(0.0))
2660
+
2661
+ # preview label
2662
+ self.preview = QLabel(self)
2663
+ self.preview.setMinimumSize(780, 420)
2664
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
2665
+ self.preview.setStyleSheet("background:#111; border:1px solid #333;")
2666
+ self.preview.setMouseTracking(True)
2667
+ self.preview.installEventFilter(self)
2668
+ outer.addWidget(self.preview)
2669
+
2670
+ # --- rings controls (optional) ---
2671
+ if self.overlay_mode in ("saturn", "galaxy"):
2672
+ title = "Galaxy disk alignment" if self.overlay_mode == "galaxy" else "Saturn ring alignment"
2673
+ rings_box = QGroupBox(title)
2674
+ rings_form = QFormLayout(rings_box)
2675
+
2676
+ # PA
2677
+ row, self.sld_ring_pa, self.spin_ring_pa = self._make_slider_spin_row(
2678
+ min_v=-180.0, max_v=180.0, step_v=1.0,
2679
+ value=self.ring_pa, decimals=0,
2680
+ on_change=self._on_ring_widgets_changed
2681
+ )
2682
+ rings_form.addRow("Disk PA (deg):" if self.overlay_mode=="galaxy" else "Ring PA (deg):", row)
2683
+
2684
+ # tilt
2685
+ row, self.sld_ring_tilt, self.spin_ring_tilt = self._make_slider_spin_row(
2686
+ min_v=0.05, max_v=1.0, step_v=0.01,
2687
+ value=self.ring_tilt, decimals=2,
2688
+ on_change=self._on_ring_widgets_changed
2689
+ )
2690
+ rings_form.addRow("Disk tilt (b/a):" if self.overlay_mode=="galaxy" else "Ring tilt (b/a):", row)
2691
+
2692
+ # ONLY Saturn gets inner/outer
2693
+ if self.overlay_mode == "saturn":
2694
+ row, self.sld_ring_outer, self.spin_ring_outer = self._make_slider_spin_row(
2695
+ min_v=1.0, max_v=4.0, step_v=0.05,
2696
+ value=self.ring_outer, decimals=2,
2697
+ on_change=self._on_ring_widgets_changed,
2698
+ )
2699
+ rings_form.addRow("Outer factor:", row)
2700
+
2701
+ row, self.sld_ring_inner, self.spin_ring_inner = self._make_slider_spin_row(
2702
+ min_v=0.2, max_v=3.5, step_v=0.05,
2703
+ value=self.ring_inner, decimals=2,
2704
+ on_change=self._on_ring_widgets_changed,
2705
+ )
2706
+ rings_form.addRow("Inner factor:", row)
2707
+ outer.addWidget(rings_box)
2708
+
2709
+ if self.overlay_mode == "saturn":
2710
+ help_txt += "\nAdjust ring PA / tilt / inner / outer to match Saturn's rings."
2711
+ elif self.overlay_mode == "galaxy":
2712
+ help_txt += "\nAdjust disk PA / tilt to match the galaxy's projected ellipse."
2713
+ self.lbl_help.setText(help_txt)
2714
+
2715
+ # radius row
2716
+ rad_row = QHBoxLayout()
2717
+ self.btn_r_minus = QPushButton("Radius -")
2718
+ self.btn_r_plus = QPushButton("Radius +")
2719
+ self.spin_r = QDoubleSpinBox()
2720
+ self.spin_r.setRange(5.0, float(max(self.W, self.H)))
2721
+ self.spin_r.setDecimals(2)
2722
+ self.spin_r.setSingleStep(1.0)
2723
+ self.spin_r.setValue(self.r)
2724
+ self.spin_r.valueChanged.connect(self._on_radius_spin)
2725
+
2726
+ self.sld_r = QSlider(Qt.Orientation.Horizontal)
2727
+ self.sld_r.setRange(5, int(max(self.W, self.H)))
2728
+ self.sld_r.setValue(int(round(self.r)))
2729
+ self.sld_r.valueChanged.connect(self._on_radius_slider)
2730
+
2731
+ self.btn_r_minus.clicked.connect(lambda: self._bump_radius(-2.0))
2732
+ self.btn_r_plus.clicked.connect(lambda: self._bump_radius(+2.0))
2733
+
2734
+ rad_row.addWidget(self.btn_r_minus)
2735
+ rad_row.addWidget(self.btn_r_plus)
2736
+ rad_row.addWidget(QLabel("R:"))
2737
+ rad_row.addWidget(self.spin_r)
2738
+ rad_row.addWidget(self.sld_r, 1)
2739
+ outer.addLayout(rad_row)
2740
+
2741
+ # nudge row
2742
+ nud_row = QHBoxLayout()
2743
+ self.spin_step = QSpinBox()
2744
+ self.spin_step.setRange(1, 200)
2745
+ self.spin_step.setValue(2)
2746
+ nud_row.addWidget(QLabel("Nudge (px):"))
2747
+ nud_row.addWidget(self.spin_step)
2748
+
2749
+ self.btn_left = QPushButton("◀")
2750
+ self.btn_right = QPushButton("▶")
2751
+ self.btn_up = QPushButton("▲")
2752
+ self.btn_down = QPushButton("▼")
2753
+
2754
+ self.btn_left.clicked.connect(lambda: self._nudge(-1, 0))
2755
+ self.btn_right.clicked.connect(lambda: self._nudge(+1, 0))
2756
+ self.btn_up.clicked.connect(lambda: self._nudge(0, -1))
2757
+ self.btn_down.clicked.connect(lambda: self._nudge(0, +1))
2758
+
2759
+ nud_row.addStretch(1)
2760
+ nud_row.addWidget(self.btn_up)
2761
+ nud_row.addWidget(self.btn_left)
2762
+ nud_row.addWidget(self.btn_right)
2763
+ nud_row.addWidget(self.btn_down)
2764
+ outer.addLayout(nud_row)
2765
+
2766
+ # status
2767
+ self.lbl_status = QLabel("")
2768
+ outer.addWidget(self.lbl_status)
2769
+
2770
+ # ok/cancel
2771
+ btn_row = QHBoxLayout()
2772
+ self.btn_ok = QPushButton("Use This Disk")
2773
+ self.btn_cancel = QPushButton("Cancel")
2774
+ btn_row.addWidget(self.btn_ok)
2775
+ btn_row.addStretch(1)
2776
+ btn_row.addWidget(self.btn_cancel)
2777
+ outer.addLayout(btn_row)
2778
+
2779
+ self.btn_ok.clicked.connect(self.accept)
2780
+ self.btn_cancel.clicked.connect(self.reject)
2781
+
2782
+ # ---------- coordinate helpers ----------
2783
+ def _make_slider_spin_row(self, *,
2784
+ min_v: float, max_v: float, step_v: float,
2785
+ value: float, decimals: int,
2786
+ on_change):
2787
+ """
2788
+ Returns (row_layout, slider, spin).
2789
+ Slider is int-based; spin is float. They stay in sync.
2790
+ """
2791
+ scale = int(round(1.0 / step_v)) # e.g. 0.05 -> 20, 0.02 -> 50
2792
+ if scale <= 0:
2793
+ scale = 1
2794
+
2795
+ sld = QSlider(Qt.Orientation.Horizontal, self)
2796
+ sld.setRange(int(round(min_v * scale)), int(round(max_v * scale)))
2797
+ sld.setSingleStep(1)
2798
+ sld.setPageStep(max(1, int(round(10 * scale * step_v)))) # about 10 steps
2799
+ sld.setValue(int(round(value * scale)))
2800
+
2801
+ spn = QDoubleSpinBox(self)
2802
+ spn.setRange(min_v, max_v)
2803
+ spn.setDecimals(decimals)
2804
+ spn.setSingleStep(step_v)
2805
+ spn.setValue(value)
2806
+ spn.setFixedWidth(100)
2807
+
2808
+ def sld_to_spin(iv: int):
2809
+ fv = iv / float(scale)
2810
+ spn.blockSignals(True)
2811
+ spn.setValue(fv)
2812
+ spn.blockSignals(False)
2813
+ on_change()
2814
+
2815
+ def spin_to_sld(fv: float):
2816
+ iv = int(round(fv * scale))
2817
+ sld.blockSignals(True)
2818
+ sld.setValue(iv)
2819
+ sld.blockSignals(False)
2820
+ on_change()
2821
+
2822
+ sld.valueChanged.connect(sld_to_spin)
2823
+ spn.valueChanged.connect(spin_to_sld)
2824
+
2825
+ row = QHBoxLayout()
2826
+ row.addWidget(sld, 1)
2827
+ row.addWidget(spn)
2828
+
2829
+ return row, sld, spn
2830
+
2831
+
2832
+ def _on_ring_widgets_changed(self):
2833
+ # Always present in saturn+galaxy
2834
+ if hasattr(self, "spin_ring_pa"):
2835
+ self.ring_pa = float(self.spin_ring_pa.value())
2836
+ if hasattr(self, "spin_ring_tilt"):
2837
+ self.ring_tilt = float(self.spin_ring_tilt.value())
2838
+
2839
+ # Only present for saturn
2840
+ if self.overlay_mode == "saturn":
2841
+ if hasattr(self, "spin_ring_outer"):
2842
+ self.ring_outer = float(self.spin_ring_outer.value())
2843
+ if hasattr(self, "spin_ring_inner"):
2844
+ self.ring_inner = float(self.spin_ring_inner.value())
2845
+
2846
+ self._redraw()
2847
+
2848
+ def get_ring_result(self) -> tuple[float, float, float, float]:
2849
+ pa = float(getattr(self, "ring_pa", 0.0))
2850
+ tilt = float(getattr(self, "ring_tilt", 0.35))
2851
+
2852
+ if self.overlay_mode == "saturn":
2853
+ kout = float(getattr(self, "ring_outer", 2.2))
2854
+ kin = float(getattr(self, "ring_inner", 1.25))
2855
+ else:
2856
+ kout = float(getattr(self, "ring_outer", 2.2)) # harmless
2857
+ kin = float(getattr(self, "ring_inner", 1.25))
2858
+
2859
+ return (pa, tilt, kout, kin)
2860
+
2861
+ def _on_ring_changed(self, *_):
2862
+ self.ring_pa = float(self.spin_ring_pa.value())
2863
+ self.ring_tilt = float(self.spin_ring_tilt.value())
2864
+ self.ring_outer = float(self.spin_ring_outer.value())
2865
+ self.ring_inner = float(self.spin_ring_inner.value())
2866
+ self._redraw()
2867
+
2868
+
2869
+ def _compute_fit_transform(self):
2870
+ # label size
2871
+ pw = max(1, self.preview.width())
2872
+ ph = max(1, self.preview.height())
2873
+
2874
+ # scale factor to fit image into label
2875
+ sw = pw / float(self.W)
2876
+ sh = ph / float(self.H)
2877
+ self._scale = float(min(sw, sh))
2878
+
2879
+ # the fitted draw size (in LABEL coords)
2880
+ draw_w = self.W * self._scale
2881
+ draw_h = self.H * self._scale
2882
+
2883
+ # offsets in LABEL coords (letterboxing)
2884
+ self._offx = 0.5 * (pw - draw_w)
2885
+ self._offy = 0.5 * (ph - draw_h)
2886
+
2887
+ # ALSO store pixmap-space scale after scaling
2888
+ # (pixmap is the scaled-to-fit image)
2889
+ self._pix_w = int(round(draw_w))
2890
+ self._pix_h = int(round(draw_h))
2891
+
2892
+ # pixmap space has NO offx/offy; it starts at (0,0)
2893
+ self._pix_scale = self._scale
2894
+
2895
+ def _img_to_label(self, x: float, y: float) -> tuple[float, float]:
2896
+ return (self._offx + x * self._scale, self._offy + y * self._scale)
2897
+
2898
+ def _label_to_img(self, x: float, y: float) -> tuple[float, float]:
2899
+ ix = (x - self._offx) / max(self._scale, 1e-9)
2900
+ iy = (y - self._offy) / max(self._scale, 1e-9)
2901
+ return (ix, iy)
2902
+
2903
+ def _img_to_pix(self, x: float, y: float) -> tuple[float, float]:
2904
+ # pixmap coords (0..pix_w, 0..pix_h)
2905
+ return (x * self._pix_scale, y * self._pix_scale)
2906
+
2907
+ def _set_preview_zoom(self, z: float):
2908
+ if z < 0.05 and z != 0.0:
2909
+ z = 0.05
2910
+ if z > 8.0:
2911
+ z = 8.0
2912
+ self._preview_zoom = float(z)
2913
+ # currently we always draw "fit"; keep behavior consistent by just redrawing
2914
+ self._redraw()
2915
+
2916
+
2917
+ # ---------- drawing ----------
2918
+ def _redraw(self):
2919
+ self._compute_fit_transform()
2920
+
2921
+ # base pixmap (fit into preview)
2922
+ qimg = QImage(
2923
+ self._disp8.data,
2924
+ self.W,
2925
+ self.H,
2926
+ int(self._disp8.strides[0]),
2927
+ QImage.Format.Format_RGB888,
2928
+ )
2929
+ pix = QPixmap.fromImage(qimg).scaled(
2930
+ self.preview.size(),
2931
+ Qt.AspectRatioMode.KeepAspectRatio,
2932
+ Qt.TransformationMode.SmoothTransformation,
2933
+ )
2934
+
2935
+ painter = QPainter(pix)
2936
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
2937
+
2938
+ # Determine overlay mode:
2939
+ # - "saturn": inner+outer ellipses
2940
+ # - "galaxy": single disk ellipse
2941
+ # - otherwise: none
2942
+ overlay_mode = getattr(self, "overlay_mode", None)
2943
+ if overlay_mode is None:
2944
+ # backwards compatibility with old flag
2945
+ overlay_mode = "saturn" if getattr(self, "show_rings", False) else "none"
2946
+ overlay_mode = str(overlay_mode).lower()
2947
+
2948
+ # Map center to pix coords
2949
+ cxp, cyp = self._img_to_pix(self.cx, self.cy)
2950
+
2951
+ # -----------------------------
2952
+ # Main circle + crosshair
2953
+ # -----------------------------
2954
+ # In galaxy mode, the ellipse is the important overlay; circle is optional.
2955
+ DRAW_MAIN_CIRCLE_IN_GALAXY = True # set False if you want ONLY ellipse for galaxy
2956
+
2957
+ if overlay_mode != "galaxy" or DRAW_MAIN_CIRCLE_IN_GALAXY:
2958
+ pen = QPen(QColor(0, 255, 0))
2959
+ pen.setWidth(3)
2960
+ painter.setPen(pen)
2961
+
2962
+ rv = float(self.r) * float(self._pix_scale)
2963
+ painter.drawEllipse(
2964
+ QPoint(int(round(cxp)), int(round(cyp))),
2965
+ int(round(rv)),
2966
+ int(round(rv)),
2967
+ )
2968
+
2969
+ # center crosshair
2970
+ pen2 = QPen(QColor(0, 255, 0))
2971
+ pen2.setWidth(2)
2972
+ painter.setPen(pen2)
2973
+ painter.drawLine(int(round(cxp - 8)), int(round(cyp)), int(round(cxp + 8)), int(round(cyp)))
2974
+ painter.drawLine(int(round(cxp)), int(round(cyp - 8)), int(round(cxp)), int(round(cyp + 8)))
2975
+
2976
+ # -----------------------------
2977
+ # Ellipse overlays
2978
+ # -----------------------------
2979
+ if overlay_mode in ("saturn", "galaxy"):
2980
+ try:
2981
+ pa = float(getattr(self, "ring_pa", 0.0))
2982
+ tilt = float(getattr(self, "ring_tilt", 0.35))
2983
+ tilt = max(0.01, min(1.0, tilt))
2984
+
2985
+ # ellipse semi-axes in SOURCE pixels
2986
+ if overlay_mode == "galaxy":
2987
+ # ONE ellipse: major axis = r, minor = r * tilt
2988
+ a = float(self.r)
2989
+ b = max(1.0, a * tilt)
2990
+
2991
+ # convert to PIX coords
2992
+ a_p = a * float(self._pix_scale)
2993
+ b_p = b * float(self._pix_scale)
2994
+
2995
+ painter.save()
2996
+ painter.translate(cxp, cyp)
2997
+ painter.rotate(pa)
2998
+
2999
+ penr = QPen(QColor(0, 255, 0))
3000
+ penr.setWidth(2)
3001
+ painter.setPen(penr)
3002
+
3003
+ painter.drawEllipse(
3004
+ int(round(-a_p)), int(round(-b_p)),
3005
+ int(round(2 * a_p)), int(round(2 * b_p)),
3006
+ )
3007
+
3008
+ # minor-axis guide
3009
+ pena = QPen(QColor(0, 200, 0))
3010
+ pena.setWidth(2)
3011
+ painter.setPen(pena)
3012
+ painter.drawLine(0, int(round(-b_p)), 0, int(round(b_p)))
3013
+
3014
+ painter.restore()
3015
+
3016
+ else:
3017
+ # SATURN: inner + outer ellipse annulus
3018
+ k_out = float(getattr(self, "ring_outer", 2.2))
3019
+ k_in = float(getattr(self, "ring_inner", 1.25))
3020
+
3021
+ a_out = k_out * float(self.r)
3022
+ b_out = max(1.0, a_out * tilt)
3023
+ a_in = k_in * float(self.r)
3024
+ b_in = max(1.0, a_in * tilt)
3025
+
3026
+ a_out_p = a_out * float(self._pix_scale)
3027
+ b_out_p = b_out * float(self._pix_scale)
3028
+ a_in_p = a_in * float(self._pix_scale)
3029
+ b_in_p = b_in * float(self._pix_scale)
3030
+
3031
+ painter.save()
3032
+ painter.translate(cxp, cyp)
3033
+ painter.rotate(pa)
3034
+
3035
+ penr = QPen(QColor(0, 255, 0))
3036
+ penr.setWidth(2)
3037
+ painter.setPen(penr)
3038
+
3039
+ painter.drawEllipse(
3040
+ int(round(-a_out_p)), int(round(-b_out_p)),
3041
+ int(round(2 * a_out_p)), int(round(2 * b_out_p)),
3042
+ )
3043
+ painter.drawEllipse(
3044
+ int(round(-a_in_p)), int(round(-b_in_p)),
3045
+ int(round(2 * a_in_p)), int(round(2 * b_in_p)),
3046
+ )
3047
+
3048
+ # minor-axis guide
3049
+ pena = QPen(QColor(0, 200, 0))
3050
+ pena.setWidth(2)
3051
+ painter.setPen(pena)
3052
+ painter.drawLine(0, int(round(-b_out_p)), 0, int(round(b_out_p)))
3053
+
3054
+ painter.restore()
3055
+
3056
+ except Exception:
3057
+ # keep UI alive if something weird happens
3058
+ pass
3059
+
3060
+ painter.end()
3061
+
3062
+ self.preview.setPixmap(pix)
3063
+
3064
+ # status label
3065
+ if overlay_mode == "galaxy":
3066
+ pa = float(getattr(self, "ring_pa", 0.0))
3067
+ tilt = float(getattr(self, "ring_tilt", 0.35))
3068
+ self.lbl_status.setText(
3069
+ f"Center: ({self.cx:.1f}, {self.cy:.1f}) Radius: {self.r:.1f}px "
3070
+ f"PA: {pa:.1f}° Tilt(b/a): {tilt:.2f}"
3071
+ )
3072
+ elif overlay_mode == "saturn":
3073
+ pa = float(getattr(self, "ring_pa", 0.0))
3074
+ tilt = float(getattr(self, "ring_tilt", 0.35))
3075
+ kout = float(getattr(self, "ring_outer", 2.2))
3076
+ kin = float(getattr(self, "ring_inner", 1.25))
3077
+ self.lbl_status.setText(
3078
+ f"Center: ({self.cx:.1f}, {self.cy:.1f}) Radius: {self.r:.1f}px "
3079
+ f"PA: {pa:.1f}° Tilt(b/a): {tilt:.2f} Outer: {kout:.2f} Inner: {kin:.2f}"
3080
+ )
3081
+ else:
3082
+ self.lbl_status.setText(f"Center: ({self.cx:.1f}, {self.cy:.1f}) Radius: {self.r:.1f}px")
3083
+
3084
+
3085
+ # ---------- UI callbacks ----------
3086
+ def _clamp(self):
3087
+ self.cx = float(np.clip(self.cx, 0.0, self.W - 1.0))
3088
+ self.cy = float(np.clip(self.cy, 0.0, self.H - 1.0))
3089
+ # radius cannot exceed image bounds too much; keep sane
3090
+ self.r = float(np.clip(self.r, 5.0, 2.0 * max(self.W, self.H)))
3091
+
3092
+ def _bump_radius(self, dr: float):
3093
+ self.r += float(dr)
3094
+ self._clamp()
3095
+ self.spin_r.blockSignals(True)
3096
+ self.sld_r.blockSignals(True)
3097
+ self.spin_r.setValue(self.r)
3098
+ self.sld_r.setValue(int(round(self.r)))
3099
+ self.spin_r.blockSignals(False)
3100
+ self.sld_r.blockSignals(False)
3101
+ self._redraw()
3102
+
3103
+ def _on_radius_spin(self, v: float):
3104
+ self.r = float(v)
3105
+ self._clamp()
3106
+ self.sld_r.blockSignals(True)
3107
+ self.sld_r.setValue(int(round(self.r)))
3108
+ self.sld_r.blockSignals(False)
3109
+ self._redraw()
3110
+
3111
+ def _on_radius_slider(self, v: int):
3112
+ self.r = float(v)
3113
+ self._clamp()
3114
+ self.spin_r.blockSignals(True)
3115
+ self.spin_r.setValue(self.r)
3116
+ self.spin_r.blockSignals(False)
3117
+ self._redraw()
3118
+
3119
+ def _nudge(self, dx: int, dy: int):
3120
+ step = int(self.spin_step.value())
3121
+ self.cx += dx * step
3122
+ self.cy += dy * step
3123
+ self._clamp()
3124
+ self._redraw()
3125
+
3126
+ # ---------- events ----------
3127
+ def keyPressEvent(self, e):
3128
+ key = e.key()
3129
+ if key == Qt.Key.Key_Left:
3130
+ self._nudge(-1, 0); return
3131
+ if key == Qt.Key.Key_Right:
3132
+ self._nudge(+1, 0); return
3133
+ if key == Qt.Key.Key_Up:
3134
+ self._nudge(0, -1); return
3135
+ if key == Qt.Key.Key_Down:
3136
+ self._nudge(0, +1); return
3137
+ super().keyPressEvent(e)
3138
+
3139
+ def resizeEvent(self, e):
3140
+ super().resizeEvent(e)
3141
+ self._redraw()
3142
+
3143
+ def eventFilter(self, obj, ev):
3144
+ if obj is self.preview:
3145
+ if ev.type() == ev.Type.MouseButtonPress:
3146
+ if ev.button() == Qt.MouseButton.LeftButton and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
3147
+ self._compute_fit_transform()
3148
+ mx, my = float(ev.position().x()), float(ev.position().y())
3149
+ ix, iy = self._label_to_img(mx, my)
3150
+ # store drag offset so the center doesn't jump
3151
+ self._dragging = True
3152
+ self._drag_offset = (self.cx - ix, self.cy - iy)
3153
+ return True
3154
+
3155
+ if ev.type() == ev.Type.MouseMove and self._dragging:
3156
+ self._compute_fit_transform()
3157
+ mx, my = float(ev.position().x()), float(ev.position().y())
3158
+ ix, iy = self._label_to_img(mx, my)
3159
+ ox, oy = self._drag_offset
3160
+ self.cx = ix + ox
3161
+ self.cy = iy + oy
3162
+ self._clamp()
3163
+ self._redraw()
3164
+ return True
3165
+
3166
+ if ev.type() == ev.Type.MouseButtonRelease and self._dragging:
3167
+ self._dragging = False
3168
+ return True
3169
+
3170
+ return super().eventFilter(obj, ev)
3171
+
3172
+ def get_result(self) -> tuple[float, float, float]:
3173
+ return (float(self.cx), float(self.cy), float(self.r))
3174
+
3175
+ class PlanetProjectionPreviewDialog(QDialog):
3176
+ """
3177
+ Separate preview window:
3178
+ - Shows the latest output frame (stereo pair or wiggle frame)
3179
+ - Provides Zoom controls: Fit / 100% / +/-.
3180
+ """
3181
+ def __init__(self, parent=None):
3182
+ super().__init__(parent)
3183
+ self.setWindowTitle("Planet Projection — Preview")
3184
+ self.setModal(False)
3185
+ self._img_zoom = 1.0 # content zoom (1.0 = full view)
3186
+ self._img_pan_x = 0.0 # in source pixels, relative to center
3187
+ self._img_pan_y = 0.0
3188
+ self._dragging = False
3189
+ self._last_pos = None
3190
+ self._last_left8 = None
3191
+ self._last_right8 = None
3192
+ self._last_swap = False
3193
+ self._gap_px = 16
3194
+ self._last_L8 = None
3195
+ self._last_R8 = None
3196
+ self._last_swap_eyes = False
3197
+ self._last_gap_px = 16
3198
+ self._last_frame_u8 = None
3199
+ # content zoom is RELATIVE TO FIT (1.0 = fit)
3200
+ self._content_zoom = 1.0
3201
+ self._pan_x = 0.0 # pan in SOURCE PIXELS
3202
+ self._pan_y = 0.0
3203
+
3204
+ self._preview_zoom = 1.0 # 1.0 = Fit, 0.0 = 1:1, else relative to Fit
3205
+
3206
+ self._build_ui()
3207
+
3208
+ def _build_ui(self):
3209
+ outer = QVBoxLayout(self)
3210
+ outer.setContentsMargins(10, 10, 10, 10)
3211
+ outer.setSpacing(8)
3212
+
3213
+ # Zoom controls (toolbtn icons like the rest of SASpro)
3214
+ zoom_row = QHBoxLayout()
3215
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
3216
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
3217
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
3218
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
3219
+ self.btn_save_view = themed_toolbtn("document-save", "Save current preview view…")
3220
+ self.btn_push = QPushButton("Push to New Document")
3221
+
3222
+
3223
+
3224
+ self.btn_save_view.clicked.connect(self._save_current_view)
3225
+ self.btn_push.clicked.connect(self._push_to_new_document)
3226
+ zoom_row.addStretch(1)
3227
+ zoom_row.addWidget(self.btn_zoom_out)
3228
+ zoom_row.addWidget(self.btn_zoom_fit)
3229
+ zoom_row.addWidget(self.btn_zoom_100)
3230
+ zoom_row.addWidget(self.btn_zoom_in)
3231
+ zoom_row.addWidget(self.btn_save_view)
3232
+ zoom_row.addWidget(self.btn_push)
3233
+ outer.addLayout(zoom_row)
3234
+
3235
+ 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))
3236
+ 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))
3237
+ self.btn_zoom_fit.clicked.connect(lambda: self.set_zoom(1.0))
3238
+ self.btn_zoom_100.clicked.connect(lambda: self.set_zoom(0.0))
3239
+
3240
+ imgzoom_row = QHBoxLayout()
3241
+ self.btn_img_reset = themed_toolbtn("edit-undo", "Reset Image Pan/Zoom")
3242
+
3243
+ self.sld_img_zoom = QSlider(Qt.Orientation.Horizontal, self)
3244
+ self.sld_img_zoom.setRange(0, 200) # 0 -> fit, +200 -> zoom in
3245
+ self.sld_img_zoom.setValue(0)
3246
+ self.sld_img_zoom.setToolTip("Zoom into the image content (pan with mouse drag)")
3247
+
3248
+ imgzoom_row.addWidget(QLabel("Image zoom:"))
3249
+ imgzoom_row.addWidget(self.sld_img_zoom, 1)
3250
+ imgzoom_row.addWidget(self.btn_img_reset)
3251
+ outer.addLayout(imgzoom_row)
3252
+
3253
+ self.sld_img_zoom.valueChanged.connect(self._on_img_zoom_changed)
3254
+ self.btn_img_reset.clicked.connect(self._reset_img_view)
3255
+
3256
+ # Preview label
3257
+ self.preview = QLabel(self)
3258
+ self.preview.setMinimumSize(900, 520)
3259
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
3260
+ self.preview.setStyleSheet("background:#111; border:1px solid #333;")
3261
+ self.preview.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
3262
+ outer.addWidget(self.preview)
3263
+ self.preview.setMouseTracking(True)
3264
+ self.preview.installEventFilter(self)
3265
+ self._last_rgb8 = None
3266
+
3267
+ def set_zoom(self, z: float):
3268
+ if z < 0.05 and z != 0.0:
3269
+ z = 0.05
3270
+ if z > 8.0:
3271
+ z = 8.0
3272
+ self._preview_zoom = float(z)
3273
+
3274
+ # IMPORTANT: redraw whichever mode we’re in
3275
+ if self._last_left8 is not None and self._last_right8 is not None:
3276
+ self._redraw() # stereo path uses _preview_zoom inside _render_stereo()
3277
+ elif self._last_rgb8 is not None:
3278
+ self.set_frame_u8(self._last_rgb8)
3279
+
3280
+ def _fit_scaled_size(self, img_w: int, img_h: int) -> tuple[int, int]:
3281
+ pw = max(1, self.preview.width())
3282
+ ph = max(1, self.preview.height())
3283
+ s = min(pw / float(img_w), ph / float(img_h))
3284
+ return int(round(img_w * s)), int(round(img_h * s))
3285
+
3286
+ def set_frame_u8(self, rgb8: np.ndarray):
3287
+ self._last_rgb8 = np.asarray(rgb8)
3288
+
3289
+ # apply content zoom/pan by cropping to viewport size
3290
+ disp8 = self._apply_camera_crop(self._last_rgb8)
3291
+
3292
+ # NEW: cache “what user is looking at” for Push-to-Doc
3293
+ self._last_frame_u8 = np.asarray(disp8)
3294
+
3295
+ # --- sanitize for QImage ---
3296
+ disp8 = np.asarray(disp8, dtype=np.uint8)
3297
+ if disp8.ndim == 2:
3298
+ disp8 = np.stack([disp8, disp8, disp8], axis=2)
3299
+ if disp8.shape[2] > 3:
3300
+ disp8 = disp8[..., :3]
3301
+ if not disp8.flags["C_CONTIGUOUS"]:
3302
+ disp8 = np.ascontiguousarray(disp8)
3303
+
3304
+ # IMPORTANT: keep buffer alive on self for as long as pixmap uses it
3305
+ self._qimg_buf = disp8
3306
+
3307
+ h, w = disp8.shape[:2]
3308
+ bytes_per_line = int(disp8.strides[0])
3309
+
3310
+ ptr = sip.voidptr(disp8.ctypes.data)
3311
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
3312
+ base = QPixmap.fromImage(qimg)
3313
+
3314
+ # NOTE: from this point onward, use disp8 dimensions (not rgb8)
3315
+ if self._preview_zoom == 1.0:
3316
+ self.preview.setPixmap(base) # already fit-with-aspect (letterboxed by QLabel alignment)
3317
+ return
3318
+
3319
+ if self._preview_zoom == 0.0:
3320
+ self.preview.setPixmap(base)
3321
+ return
3322
+
3323
+ fit_w, fit_h = self._fit_scaled_size(w, h)
3324
+ target_w = max(1, int(round(fit_w * self._preview_zoom)))
3325
+ target_h = max(1, int(round(fit_h * self._preview_zoom)))
3326
+
3327
+ pix = base.scaled(
3328
+ QSize(target_w, target_h),
3329
+ Qt.AspectRatioMode.KeepAspectRatio,
3330
+ Qt.TransformationMode.SmoothTransformation,
3331
+ )
3332
+ self.preview.setPixmap(pix)
3333
+
3334
+ def set_stereo_u8(self, left8: np.ndarray, right8: np.ndarray, *, swap_eyes: bool, gap_px: int = 16):
3335
+ self._last_left8 = np.asarray(left8)
3336
+ self._last_right8 = np.asarray(right8)
3337
+ self._last_swap = bool(swap_eyes)
3338
+ self._gap_px = int(max(0, gap_px))
3339
+
3340
+ # keep save-view path in sync
3341
+ self._last_L8 = self._last_left8
3342
+ self._last_R8 = self._last_right8
3343
+ self._last_swap_eyes = self._last_swap
3344
+ self._last_gap_px = self._gap_px
3345
+
3346
+ self._clamp_pan()
3347
+ self._redraw()
3348
+
3349
+ def _redraw(self):
3350
+ if self._last_left8 is None or self._last_right8 is None:
3351
+ # fallback: if you still use set_frame_u8 for non-stereo cases
3352
+ if getattr(self, "_last_rgb8", None) is not None:
3353
+ self.set_frame_u8(self._last_rgb8)
3354
+ return
3355
+ self._render_stereo()
3356
+
3357
+ def _render_stereo(self):
3358
+ L = self._ensure_rgb8(self._last_left8)
3359
+ R = self._ensure_rgb8(self._last_right8)
3360
+
3361
+ Hs, Ws = L.shape[:2]
3362
+
3363
+ pw = max(8, self.preview.width())
3364
+ ph = max(8, self.preview.height())
3365
+ gap = int(self._gap_px)
3366
+
3367
+ view_w = max(8, (pw - gap) // 2)
3368
+ view_h = max(8, ph)
3369
+
3370
+ # --- NEW: fit-rect INSIDE each eye viewport (letterbox/pillarbox) ---
3371
+ rx, ry, rw, rh = self._fit_rect(view_w, view_h, Ws, Hs)
3372
+ rw = max(8, rw); rh = max(8, rh)
3373
+
3374
+ # cache the actual displayed rect for pan math (drag + clamp)
3375
+ self._eye_fit_rect = (rx, ry, rw, rh, view_w, view_h)
3376
+
3377
+ # render each eye ONLY into rw x rh (aspect-safe), then paste into a black canvas
3378
+ Limg = self._crop_and_scale(L, rw, rh)
3379
+ Rimg = self._crop_and_scale(R, rw, rh)
3380
+
3381
+ if self._last_swap:
3382
+ Limg, Rimg = Rimg, Limg
3383
+
3384
+ Lcan = np.zeros((view_h, view_w, 3), dtype=np.uint8)
3385
+ Rcan = np.zeros((view_h, view_w, 3), dtype=np.uint8)
3386
+ Lcan[ry:ry+rh, rx:rx+rw] = Limg
3387
+ Rcan[ry:ry+rh, rx:rx+rw] = Rimg
3388
+
3389
+ canvas_w = view_w + gap + view_w
3390
+ canvas = np.zeros((view_h, canvas_w, 3), dtype=np.uint8)
3391
+ canvas[:, 0:view_w] = Lcan
3392
+ canvas[:, view_w:view_w + gap] = 0
3393
+ canvas[:, view_w + gap:view_w + gap + view_w] = Rcan
3394
+ self._last_frame_u8 = canvas
3395
+
3396
+ self._qimg_buf = canvas
3397
+ h, w = canvas.shape[:2]
3398
+ ptr = sip.voidptr(canvas.ctypes.data)
3399
+ qimg = QImage(ptr, w, h, int(canvas.strides[0]), QImage.Format.Format_RGB888)
3400
+ base = QPixmap.fromImage(qimg)
3401
+
3402
+ # preview zoom (same as before)
3403
+ if self._preview_zoom == 1.0:
3404
+ pix = base.scaled(self.preview.size(), Qt.AspectRatioMode.KeepAspectRatio,
3405
+ Qt.TransformationMode.SmoothTransformation)
3406
+ self.preview.setPixmap(pix)
3407
+ return
3408
+ if self._preview_zoom == 0.0:
3409
+ self.preview.setPixmap(base)
3410
+ return
3411
+
3412
+ fit_w, fit_h = self._fit_scaled_size(w, h)
3413
+ target_w = max(1, int(round(fit_w * self._preview_zoom)))
3414
+ target_h = max(1, int(round(fit_h * self._preview_zoom)))
3415
+ pix = base.scaled(QSize(target_w, target_h), Qt.AspectRatioMode.KeepAspectRatio,
3416
+ Qt.TransformationMode.SmoothTransformation)
3417
+ self.preview.setPixmap(pix)
3418
+
3419
+
3420
+ def _ensure_rgb8(self, img: np.ndarray) -> np.ndarray:
3421
+ x = np.asarray(img)
3422
+ if x.ndim == 2:
3423
+ x = np.stack([x, x, x], axis=2)
3424
+ if x.shape[2] > 3:
3425
+ x = x[..., :3]
3426
+ if x.dtype != np.uint8:
3427
+ x = np.clip(x, 0, 255).astype(np.uint8)
3428
+ if not x.flags["C_CONTIGUOUS"]:
3429
+ x = np.ascontiguousarray(x)
3430
+ return x
3431
+
3432
+ def _crop_and_scale(self, src: np.ndarray, out_w: int, out_h: int) -> np.ndarray:
3433
+ # content zoom is RELATIVE TO FIT
3434
+ H, W = src.shape[:2]
3435
+
3436
+ # fit scale into per-eye viewport
3437
+ s_fit = min(out_w / float(W), out_h / float(H))
3438
+ s_fit = max(1e-9, float(s_fit))
3439
+
3440
+ z = float(max(1e-6, self._content_zoom))
3441
+
3442
+ # visible window in SOURCE pixels:
3443
+ win_w = int(round(out_w / (s_fit * z)))
3444
+ win_h = int(round(out_h / (s_fit * z)))
3445
+ win_w = max(8, min(W, win_w))
3446
+ win_h = max(8, min(H, win_h))
3447
+
3448
+ cx = (W - 1) * 0.5 + float(self._pan_x)
3449
+ cy = (H - 1) * 0.5 + float(self._pan_y)
3450
+
3451
+ x0 = int(round(cx - 0.5 * win_w))
3452
+ y0 = int(round(cy - 0.5 * win_h))
3453
+ x0 = max(0, min(W - win_w, x0))
3454
+ y0 = max(0, min(H - win_h, y0))
3455
+
3456
+ crop = src[y0:y0 + win_h, x0:x0 + win_w]
3457
+
3458
+ if crop.shape[1] != out_w or crop.shape[0] != out_h:
3459
+ crop = cv2.resize(crop, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
3460
+
3461
+ return crop
3462
+
3463
+ def _clamp_pan(self):
3464
+ if self._last_left8 is None:
3465
+ return
3466
+
3467
+ src = self._ensure_rgb8(self._last_left8)
3468
+ H, W = src.shape[:2]
3469
+
3470
+ pw = max(8, self.preview.width())
3471
+ ph = max(8, self.preview.height())
3472
+ gap = int(self._gap_px)
3473
+ view_w = max(8, (pw - gap) // 2)
3474
+ view_h = max(8, ph)
3475
+
3476
+ # use the SAME fit rect used for drawing
3477
+ rx, ry, rw, rh = self._fit_rect(view_w, view_h, W, H)
3478
+ rw = max(8, rw); rh = max(8, rh)
3479
+
3480
+ s_fit = min(rw / float(W), rh / float(H))
3481
+ s_fit = max(1e-9, float(s_fit))
3482
+ z = float(max(1e-6, self._content_zoom))
3483
+
3484
+ win_w = int(round(rw / (s_fit * z)))
3485
+ win_h = int(round(rh / (s_fit * z)))
3486
+
3487
+ max_pan_x = max(0.0, (W - win_w) * 0.5)
3488
+ max_pan_y = max(0.0, (H - win_h) * 0.5)
3489
+
3490
+ self._pan_x = float(np.clip(self._pan_x, -max_pan_x, +max_pan_x))
3491
+ self._pan_y = float(np.clip(self._pan_y, -max_pan_y, +max_pan_y))
3492
+
3493
+
3494
+ def resizeEvent(self, e):
3495
+ super().resizeEvent(e)
3496
+
3497
+ # Clamp whichever pan system you’re using
3498
+ self._clamp_pan()
3499
+ self._clamp_img_view()
3500
+
3501
+ # Redraw correct mode
3502
+ if self._last_left8 is not None and self._last_right8 is not None:
3503
+ self._redraw()
3504
+ elif self._last_rgb8 is not None:
3505
+ self.set_frame_u8(self._last_rgb8)
3506
+
3507
+
3508
+ def _on_img_zoom_changed(self, v: int):
3509
+ # 0 -> 1.0 (fit), +50 -> 2.0, +100 -> 4.0
3510
+ # negative values zoom OUT from fit: -50 -> 0.5, -100 -> 0.25
3511
+ self._content_zoom = float(2.0 ** (v / 50.0))
3512
+ self._clamp_pan()
3513
+ self._redraw()
3514
+
3515
+ def _reset_img_view(self):
3516
+ self._content_zoom = 1.0
3517
+ self._pan_x = 0.0
3518
+ self._pan_y = 0.0
3519
+ self.sld_img_zoom.blockSignals(True)
3520
+ self.sld_img_zoom.setValue(0)
3521
+ self.sld_img_zoom.blockSignals(False)
3522
+ self._redraw()
3523
+
3524
+ def _clamp_img_view(self):
3525
+ if self._last_rgb8 is None:
3526
+ return
3527
+ img = self._last_rgb8
3528
+ H, W = img.shape[:2]
3529
+ z = float(max(1e-6, self._img_zoom))
3530
+
3531
+ # viewport size in *label* pixels (content crop target)
3532
+ vw = max(8, self.preview.width())
3533
+ vh = max(8, self.preview.height())
3534
+
3535
+ # crop window size in source pixels
3536
+ win_w = int(round(vw / z))
3537
+ win_h = int(round(vh / z))
3538
+ win_w = max(8, min(W, win_w))
3539
+ win_h = max(8, min(H, win_h))
3540
+
3541
+ max_pan_x = max(0.0, (W - win_w) * 0.5)
3542
+ max_pan_y = max(0.0, (H - win_h) * 0.5)
3543
+
3544
+ self._img_pan_x = float(np.clip(self._img_pan_x, -max_pan_x, +max_pan_x))
3545
+ self._img_pan_y = float(np.clip(self._img_pan_y, -max_pan_y, +max_pan_y))
3546
+
3547
+ def _fit_rect(self, view_w: int, view_h: int, img_w: int, img_h: int) -> tuple[int,int,int,int]:
3548
+ """Return (x,y,w,h) of the largest rect inside view that matches img aspect."""
3549
+ if img_w <= 0 or img_h <= 0:
3550
+ return (0, 0, view_w, view_h)
3551
+ s = min(view_w / float(img_w), view_h / float(img_h))
3552
+ w = max(1, int(round(img_w * s)))
3553
+ h = max(1, int(round(img_h * s)))
3554
+ x = (view_w - w) // 2
3555
+ y = (view_h - h) // 2
3556
+ return (x, y, w, h)
3557
+
3558
+ def _crop_to_aspect(self, W: int, H: int, target_aspect: float) -> tuple[int,int]:
3559
+ """Return (win_w, win_h) clamped to image bounds with exact target aspect."""
3560
+ win_w = W
3561
+ win_h = int(round(win_w / target_aspect))
3562
+ if win_h > H:
3563
+ win_h = H
3564
+ win_w = int(round(win_h * target_aspect))
3565
+ win_w = max(8, min(W, win_w))
3566
+ win_h = max(8, min(H, win_h))
3567
+ return win_w, win_h
3568
+
3569
+
3570
+ def _apply_camera_crop(self, rgb8: np.ndarray) -> np.ndarray:
3571
+ img = np.asarray(rgb8)
3572
+ H, W = img.shape[:2]
3573
+
3574
+ vw = max(8, self.preview.width())
3575
+ vh = max(8, self.preview.height())
3576
+
3577
+ # IMPORTANT: we fit INSIDE the label, preserving image aspect (letterbox)
3578
+ _, _, out_w, out_h = self._fit_rect(vw, vh, W, H)
3579
+ out_w = max(8, out_w)
3580
+ out_h = max(8, out_h)
3581
+
3582
+ a = out_w / float(out_h) # target aspect matches IMAGE aspect, not label’s
3583
+
3584
+ z = float(max(1e-6, self._img_zoom))
3585
+
3586
+ # aspect-correct window size in source pixels, driven by zoom
3587
+ win_w = int(round(W / z))
3588
+ win_h = int(round(win_w / a))
3589
+ if win_h > H:
3590
+ win_h = int(round(H / z))
3591
+ win_w = int(round(win_h * a))
3592
+
3593
+ win_w, win_h = self._crop_to_aspect(W, H, a)
3594
+
3595
+ cx = (W - 1) * 0.5 + float(self._img_pan_x)
3596
+ cy = (H - 1) * 0.5 + float(self._img_pan_y)
3597
+
3598
+ x0 = int(round(cx - win_w * 0.5))
3599
+ y0 = int(round(cy - win_h * 0.5))
3600
+ x0 = max(0, min(W - win_w, x0))
3601
+ y0 = max(0, min(H - win_h, y0))
3602
+
3603
+ crop = img[y0:y0 + win_h, x0:x0 + win_w]
3604
+
3605
+ # scale to out_w/out_h (aspect matched => no warp)
3606
+ if crop.shape[1] != out_w or crop.shape[0] != out_h:
3607
+ crop = cv2.resize(crop, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
3608
+ return crop
3609
+
3610
+ def eventFilter(self, obj, ev):
3611
+ if obj is self.preview:
3612
+ if ev.type() == ev.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
3613
+ self._dragging = True
3614
+ self._last_pos = ev.position().toPoint()
3615
+ return True
3616
+
3617
+ if ev.type() == ev.Type.MouseMove and self._dragging:
3618
+ p = ev.position().toPoint()
3619
+ d = p - self._last_pos
3620
+ self._last_pos = p
3621
+
3622
+ # compute s_fit for current viewport
3623
+ if self._last_left8 is not None:
3624
+ src = self._ensure_rgb8(self._last_left8)
3625
+ H, W = src.shape[:2]
3626
+
3627
+ pw = max(8, self.preview.width())
3628
+ ph = max(8, self.preview.height())
3629
+ view_w = max(8, (pw - int(self._gap_px)) // 2)
3630
+ view_h = ph
3631
+
3632
+ rx, ry, rw, rh = self._fit_rect(view_w, view_h, W, H)
3633
+ rw = max(8, rw); rh = max(8, rh)
3634
+ s_fit = min(rw / float(W), rh / float(H))
3635
+ s_fit = max(1e-9, float(s_fit))
3636
+
3637
+ z = float(max(1e-6, self._content_zoom))
3638
+ scale = s_fit * z # view_px per source_px
3639
+
3640
+ # drag right should move content right (so we pan LEFT in source coords)
3641
+ self._pan_x -= float(d.x()) / scale
3642
+ self._pan_y -= float(d.y()) / scale
3643
+
3644
+ self._clamp_pan()
3645
+ self._redraw()
3646
+ return True
3647
+
3648
+ if ev.type() == ev.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
3649
+ self._dragging = False
3650
+ self._last_pos = None
3651
+ return True
3652
+
3653
+ if ev.type() == ev.Type.Wheel:
3654
+ # wheel zoom: update slider (keeps everything synced)
3655
+ delta = ev.angleDelta().y()
3656
+ step = 6 if delta > 0 else -6
3657
+ v = int(self.sld_img_zoom.value()) + step
3658
+ v = max(self.sld_img_zoom.minimum(), min(self.sld_img_zoom.maximum(), v))
3659
+ self.sld_img_zoom.setValue(v)
3660
+ return True
3661
+
3662
+ return super().eventFilter(obj, ev)
3663
+
3664
+ def _get_current_view_canvas_u8(self) -> np.ndarray | None:
3665
+ """
3666
+ Return exactly what the preview is displaying as an RGB uint8 canvas:
3667
+ [left_view | gap | right_view], including linked pan/zoom and preview scaling.
3668
+ """
3669
+ if self._last_L8 is None or self._last_R8 is None:
3670
+ return None
3671
+
3672
+ L = np.asarray(self._last_L8)
3673
+ R = np.asarray(self._last_R8)
3674
+
3675
+ # sanitize
3676
+ def _to_rgb_u8(x):
3677
+ x = np.asarray(x)
3678
+ if x.dtype != np.uint8:
3679
+ x = np.clip(x, 0, 255).astype(np.uint8)
3680
+ if x.ndim == 2:
3681
+ x = np.stack([x, x, x], axis=2)
3682
+ if x.shape[2] > 3:
3683
+ x = x[..., :3]
3684
+ if not x.flags["C_CONTIGUOUS"]:
3685
+ x = np.ascontiguousarray(x)
3686
+ return x
3687
+
3688
+ L = _to_rgb_u8(L)
3689
+ R = _to_rgb_u8(R)
3690
+
3691
+ if self._last_swap_eyes:
3692
+ L, R = R, L
3693
+
3694
+ gap = int(max(0, self._last_gap_px))
3695
+
3696
+ # --- per-eye viewport size in label pixels ---
3697
+ pw = max(8, self.preview.width())
3698
+ ph = max(8, self.preview.height())
3699
+
3700
+ # reserve gap inside the label width
3701
+ view_w = max(8, (pw - gap) // 2)
3702
+ view_h = max(8, ph)
3703
+
3704
+ # --- crop+scale each eye independently to its viewport ---
3705
+ Lview = self._crop_and_scale(L, view_w, view_h)
3706
+ Rview = self._crop_and_scale(R, view_w, view_h)
3707
+
3708
+ # compose L|gap|R at label resolution
3709
+ canvas = np.zeros((view_h, view_w + gap + view_w, 3), dtype=np.uint8)
3710
+ canvas[:, :view_w] = Lview
3711
+ canvas[:, view_w + gap:view_w + gap + view_w] = Rview
3712
+
3713
+ # --- now apply the existing "preview zoom" (fit/100%/relative-to-fit)
3714
+ # Fit is already "canvas == label size", so:
3715
+ if self._preview_zoom == 1.0:
3716
+ return canvas
3717
+
3718
+ if self._preview_zoom == 0.0:
3719
+ # 1:1 means: no scaling beyond current composed pixels
3720
+ return canvas
3721
+
3722
+ # relative-to-fit scaling
3723
+ target_w = max(8, int(round(canvas.shape[1] * float(self._preview_zoom))))
3724
+ target_h = max(8, int(round(canvas.shape[0] * float(self._preview_zoom))))
3725
+ canvas = cv2.resize(canvas, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
3726
+ return canvas
3727
+
3728
+ def _apply_camera_crop_to_viewport(self, rgb8: np.ndarray, view_w: int, view_h: int) -> np.ndarray:
3729
+ """
3730
+ Apply linked pan/zoom (_img_zoom/_img_pan_x/_img_pan_y) to ONE eye image,
3731
+ producing exactly (view_h, view_w, 3) uint8.
3732
+ """
3733
+ img = np.asarray(rgb8)
3734
+ H, W = img.shape[:2]
3735
+
3736
+ z = float(max(1e-6, self._img_zoom))
3737
+
3738
+ win_w = int(round(view_w / z))
3739
+ win_h = int(round(view_h / z))
3740
+ win_w = max(8, min(W, win_w))
3741
+ win_h = max(8, min(H, win_h))
3742
+
3743
+ cx = (W - 1) * 0.5 + float(self._img_pan_x)
3744
+ cy = (H - 1) * 0.5 + float(self._img_pan_y)
3745
+
3746
+ x0 = int(round(cx - win_w * 0.5))
3747
+ y0 = int(round(cy - win_h * 0.5))
3748
+
3749
+ x0 = max(0, min(W - win_w, x0))
3750
+ y0 = max(0, min(H - win_h, y0))
3751
+
3752
+ crop = img[y0:y0 + win_h, x0:x0 + win_w]
3753
+
3754
+ if crop.shape[1] != view_w or crop.shape[0] != view_h:
3755
+ crop = cv2.resize(crop, (view_w, view_h), interpolation=cv2.INTER_LINEAR)
3756
+
3757
+ return crop
3758
+
3759
+ def _save_current_view(self):
3760
+ canvas = self._get_current_view_canvas_u8()
3761
+ if canvas is None:
3762
+ QMessageBox.information(self, "Save View", "No preview image to save yet.")
3763
+ return
3764
+
3765
+ fn, filt = QFileDialog.getSaveFileName(
3766
+ self,
3767
+ "Save Preview View",
3768
+ "",
3769
+ "PNG (*.png);;JPEG (*.jpg *.jpeg)"
3770
+ )
3771
+ if not fn:
3772
+ return
3773
+
3774
+ ext = os.path.splitext(fn)[1].lower()
3775
+ if ext == "":
3776
+ fn += ".png"
3777
+ ext = ".png"
3778
+
3779
+ try:
3780
+ from PIL import Image
3781
+ im = Image.fromarray(canvas, mode="RGB")
3782
+ if ext in (".jpg", ".jpeg"):
3783
+ im.save(fn, quality=95, subsampling=0)
3784
+ else:
3785
+ im.save(fn)
3786
+ except Exception as e:
3787
+ QMessageBox.warning(self, "Save View", f"Failed to save:\n{e}")
3788
+
3789
+ def _find_main_window(self):
3790
+ w = self
3791
+ from PyQt6.QtWidgets import QMainWindow, QApplication
3792
+ while w is not None and not isinstance(w, QMainWindow):
3793
+ w = w.parentWidget()
3794
+ if w:
3795
+ return w
3796
+ for tlw in QApplication.topLevelWidgets():
3797
+ if isinstance(tlw, QMainWindow):
3798
+ return tlw
3799
+ return None
3800
+
3801
+
3802
+ def _push_to_new_document(self):
3803
+ img_u8 = None
3804
+
3805
+ # Prefer exact displayed canvas for stereo
3806
+ if self._last_L8 is not None and self._last_R8 is not None:
3807
+ img_u8 = self._get_current_view_canvas_u8()
3808
+ else:
3809
+ img_u8 = getattr(self, "_last_frame_u8", None)
3810
+
3811
+ if img_u8 is None:
3812
+ QMessageBox.warning(self, "Push to New Document", "Nothing to push yet.")
3813
+ return
3814
+
3815
+ # Convert to float32 [0..1] for SASpro docs (matches your other tools)
3816
+
3817
+ arr = np.asarray(img_u8)
3818
+ if arr.ndim == 2:
3819
+ arr01 = arr.astype(np.float32) / 255.0
3820
+ meta = {"is_mono": True, "bit_depth": "8-bit"}
3821
+ else:
3822
+ if arr.shape[2] > 3:
3823
+ arr = arr[..., :3]
3824
+ arr01 = arr.astype(np.float32) / 255.0
3825
+ meta = {"is_mono": False, "bit_depth": "8-bit"}
3826
+
3827
+ mw = self._find_main_window()
3828
+ dm = getattr(mw, "docman", None) if mw else None
3829
+ if not mw or not dm:
3830
+ from PyQt6.QtWidgets import QMessageBox
3831
+ QMessageBox.critical(self, "Push to New Document", "Main window or DocManager not available.")
3832
+ return
3833
+
3834
+ title = "Planet Projection Preview"
3835
+ try:
3836
+ if hasattr(dm, "open_array"):
3837
+ doc = dm.open_array(arr01, metadata=meta, title=title)
3838
+ elif hasattr(dm, "create_document"):
3839
+ doc = dm.create_document(image=arr01, metadata=meta, name=title)
3840
+ else:
3841
+ raise RuntimeError("DocManager lacks open_array/create_document")
3842
+
3843
+ # Spawn a view (same pattern as NBtoRGBStars)
3844
+ if hasattr(mw, "_spawn_subwindow_for"):
3845
+ mw._spawn_subwindow_for(doc)
3846
+ else:
3847
+ from setiastro.saspro.subwindow import ImageSubWindow
3848
+ sw = ImageSubWindow(doc, parent=mw)
3849
+ sw.setWindowTitle(title)
3850
+ sw.show()
3851
+
3852
+ except Exception as e:
3853
+ from PyQt6.QtWidgets import QMessageBox
3854
+ QMessageBox.critical(self, "Push to New Document", f"Failed to open new view:\n{e}")