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.
- setiastro/images/3dplanet.png +0 -0
- setiastro/saspro/__init__.py +9 -8
- setiastro/saspro/__main__.py +326 -285
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/doc_manager.py +4 -1
- setiastro/saspro/gui/main_window.py +41 -2
- setiastro/saspro/gui/mixins/file_mixin.py +6 -2
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -1
- setiastro/saspro/imageops/serloader.py +101 -17
- setiastro/saspro/layers.py +186 -10
- setiastro/saspro/layers_dock.py +198 -5
- setiastro/saspro/legacy/image_manager.py +10 -4
- setiastro/saspro/planetprojection.py +3854 -0
- setiastro/saspro/resources.py +2 -0
- setiastro/saspro/save_options.py +45 -13
- setiastro/saspro/ser_stack_config.py +21 -1
- setiastro/saspro/ser_stacker.py +8 -2
- setiastro/saspro/ser_stacker_dialog.py +37 -10
- setiastro/saspro/ser_tracking.py +57 -35
- setiastro/saspro/serviewer.py +164 -16
- setiastro/saspro/subwindow.py +36 -1
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +28 -26
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
- {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}")
|