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

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

Potentially problematic release.


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

Files changed (47) hide show
  1. setiastro/saspro/__init__.py +15 -4
  2. setiastro/saspro/__main__.py +23 -5
  3. setiastro/saspro/_generated/build_info.py +2 -2
  4. setiastro/saspro/abe.py +4 -4
  5. setiastro/saspro/autostretch.py +29 -18
  6. setiastro/saspro/gui/main_window.py +5 -5
  7. setiastro/saspro/gui/mixins/toolbar_mixin.py +2 -2
  8. setiastro/saspro/legacy/numba_utils.py +301 -119
  9. setiastro/saspro/numba_utils.py +998 -270
  10. setiastro/saspro/ops/settings.py +6 -6
  11. setiastro/saspro/pixelmath.py +1 -1
  12. setiastro/saspro/planetprojection.py +310 -105
  13. setiastro/saspro/sfcc.py +14 -8
  14. setiastro/saspro/stacking_suite.py +292 -111
  15. setiastro/saspro/subwindow.py +28 -35
  16. setiastro/saspro/translations/all_source_strings.json +2 -2
  17. setiastro/saspro/translations/ar_translations.py +3 -3
  18. setiastro/saspro/translations/de_translations.py +2 -2
  19. setiastro/saspro/translations/es_translations.py +2 -2
  20. setiastro/saspro/translations/fr_translations.py +2 -2
  21. setiastro/saspro/translations/hi_translations.py +2 -2
  22. setiastro/saspro/translations/it_translations.py +2 -2
  23. setiastro/saspro/translations/ja_translations.py +2 -2
  24. setiastro/saspro/translations/pt_translations.py +2 -2
  25. setiastro/saspro/translations/ru_translations.py +2 -2
  26. setiastro/saspro/translations/saspro_ar.ts +2 -2
  27. setiastro/saspro/translations/saspro_de.ts +4 -4
  28. setiastro/saspro/translations/saspro_es.ts +2 -2
  29. setiastro/saspro/translations/saspro_fr.ts +2 -2
  30. setiastro/saspro/translations/saspro_hi.ts +2 -2
  31. setiastro/saspro/translations/saspro_it.ts +4 -4
  32. setiastro/saspro/translations/saspro_ja.ts +2 -2
  33. setiastro/saspro/translations/saspro_pt.ts +2 -2
  34. setiastro/saspro/translations/saspro_ru.ts +2 -2
  35. setiastro/saspro/translations/saspro_sw.ts +2 -2
  36. setiastro/saspro/translations/saspro_uk.ts +2 -2
  37. setiastro/saspro/translations/saspro_zh.ts +2 -2
  38. setiastro/saspro/translations/sw_translations.py +2 -2
  39. setiastro/saspro/translations/uk_translations.py +2 -2
  40. setiastro/saspro/translations/zh_translations.py +2 -2
  41. setiastro/saspro/window_shelf.py +62 -1
  42. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.4.dist-info}/METADATA +1 -1
  43. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.4.dist-info}/RECORD +47 -47
  44. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.4.dist-info}/entry_points.txt +1 -1
  45. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.4.dist-info}/WHEEL +0 -0
  46. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/LICENSE +0 -0
  47. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/license.txt +0 -0
@@ -96,8 +96,8 @@ class SettingsDialog(QDialog):
96
96
  self.sp_min_alt = QDoubleSpinBox(); self.sp_min_alt.setRange(0.0, 90.0); self.sp_min_alt.setDecimals(1)
97
97
  self.sp_obj_limit = QSpinBox(); self.sp_obj_limit.setRange(1, 1000)
98
98
 
99
- self.chk_autostretch_16bit = QCheckBox(self.tr("High-quality autostretch (16-bit; better gradients)"))
100
- self.chk_autostretch_16bit.setToolTip(self.tr("Compute autostretch on a 16-bit histogram (smoother gradients)."))
99
+ self.chk_autostretch_24bit = QCheckBox(self.tr("High-quality autostretch (24-bit; better gradients)"))
100
+ self.chk_autostretch_24bit.setToolTip(self.tr("Compute autostretch on a 24-bit histogram (smoother gradients)."))
101
101
 
102
102
  self.slider_bg_opacity = QSlider(Qt.Orientation.Horizontal)
103
103
  self.slider_bg_opacity.setRange(0, 100)
@@ -178,7 +178,7 @@ class SettingsDialog(QDialog):
178
178
 
179
179
  # ---- Display (moved under Theme) ----
180
180
  left_col.addRow(QLabel(self.tr("<b>Display</b>")))
181
- left_col.addRow(self.chk_autostretch_16bit)
181
+ left_col.addRow(self.chk_autostretch_24bit)
182
182
  left_col.addRow(self.tr("Background Opacity:"), w_bg_opacity)
183
183
  left_col.addRow(self.tr("Background Image:"), w_bg_image)
184
184
 
@@ -296,8 +296,8 @@ class SettingsDialog(QDialog):
296
296
  self.sp_obj_limit.setValue(self.settings.value("object_limit", 100, type=int))
297
297
 
298
298
  # Display
299
- self.chk_autostretch_16bit.setChecked(
300
- self.settings.value("display/autostretch_16bit", True, type=bool)
299
+ self.chk_autostretch_24bit.setChecked(
300
+ self.settings.value("display/autostretch_24bit", True, type=bool)
301
301
  )
302
302
 
303
303
  current_opacity = self.settings.value("display/bg_opacity", 50, type=int)
@@ -464,7 +464,7 @@ class SettingsDialog(QDialog):
464
464
  # Updates + Display
465
465
  self.settings.setValue("updates/check_on_startup", self.chk_updates_startup.isChecked())
466
466
  self.settings.setValue("updates/url", self.le_updates_url.text().strip())
467
- self.settings.setValue("display/autostretch_16bit", self.chk_autostretch_16bit.isChecked())
467
+ self.settings.setValue("display/autostretch_24bit", self.chk_autostretch_24bit.isChecked())
468
468
 
469
469
  # Custom background: persist the chosen path (empty -> remove)
470
470
  bg_path = (self.le_bg_path.text() or "").strip()
@@ -1179,7 +1179,7 @@ class PixelMathDialogPro(QDialog):
1179
1179
  target_median=float(getattr(self, "_as_target", 0.25)),
1180
1180
  linked=bool(getattr(self, "_as_linked", True)),
1181
1181
  sigma=float(getattr(self, "_as_sigma", 3.0)),
1182
- use_16bit=bool(getattr(self, "_as_16bit", True)),
1182
+ use_24bit=bool(getattr(self, "_as_24bit", True)),
1183
1183
  )
1184
1184
 
1185
1185
  self._set_preview_image(out)
@@ -1022,152 +1022,261 @@ def export_planet_sphere_html(
1022
1022
 
1023
1023
  return html, out_path
1024
1024
 
1025
+
1025
1026
  def export_pseudo_surface_html(
1026
1027
  rgb: np.ndarray,
1027
1028
  out_path: str | None = None,
1028
1029
  *,
1029
- title: str = "Pseudo Surface (Height from Brightness)",
1030
- # mesh size control (trade quality vs HTML size)
1030
+ title: str = "Pseudo Surface (Point Cloud)",
1031
1031
  max_dim: int = 420,
1032
- # height controls
1033
- z_scale: float = 0.35, # relative to min(W,H) after downsample
1032
+ z_scale: float = 0.35,
1034
1033
  depth_gamma: float = 1.15,
1035
1034
  blur_sigma: float = 1.2,
1036
1035
  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
1036
+ block: int = 10,
1037
+ block_blur_sigma: float = 0.6,
1038
+ max_vertices: int = 250_000,
1039
+ point_size: float = 1.6,
1040
+
1041
+ # height source(s)
1042
+ height_from: str = "brightness", # "brightness" | "color" | "dual"
1043
+
1044
+ # how points are COLORED (from dropdown)
1045
+ color_mode: str = "brightness", # "brightness" | "depth" | "dual"
1046
+ depth_colorscale: str = "Turbo",
1047
+ depth_opacity: float = 0.55,
1048
+ depth_point_size: float | None = None,
1049
+
1050
+ # Dual height controls (saturation-height layer)
1051
+ sat_opacity: float = 0.45,
1052
+ sat_point_size: float | None = None,
1053
+
1054
+ # Optional: reduce saturation-height noise in dark background
1055
+ sat_luma_gate: float = 0.02,
1056
+ sat_luma_soft: float = 0.18,
1042
1057
  ):
1043
1058
  """
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
1059
+ Interactive Plotly Scatter3d point cloud.
1060
+
1061
+ Z (height_from):
1062
+ "brightness": luminance-derived height
1063
+ • "color": saturation-derived height
1064
+ • "dual": overlays TWO clouds (brightness-height + saturation-height)
1048
1065
 
1049
- Uses image-style coordinates: +x right, +y down.
1066
+ Coloring (color_mode) is ALWAYS honored, even for dual heights:
1067
+ • "brightness": RGB image colors
1068
+ • "depth": colormap by height
1069
+ • "dual": RGB + depth overlay (per height cloud)
1050
1070
  """
1051
1071
  import os
1052
1072
  import numpy as np
1073
+ import cv2
1053
1074
  import plotly.graph_objects as go
1054
1075
 
1055
1076
  x = np.asarray(rgb)
1056
1077
  if x.ndim != 3 or x.shape[2] < 3:
1057
1078
  raise ValueError("export_pseudo_surface_html expects RGB image (H,W,3).")
1058
1079
 
1080
+ # normalize mode strings
1081
+ cmode = (color_mode or "brightness").strip().lower()
1082
+ if cmode not in ("brightness", "depth", "dual"):
1083
+ cmode = "brightness"
1084
+
1085
+ hmode = (height_from or "brightness").strip().lower()
1086
+ if hmode not in ("brightness", "color", "dual"):
1087
+ hmode = "brightness"
1088
+
1059
1089
  # ---- float01 ----
1060
1090
  if x.dtype == np.uint8:
1061
1091
  img01 = x[..., :3].astype(np.float32) / 255.0
1062
1092
  elif x.dtype == np.uint16:
1063
1093
  img01 = x[..., :3].astype(np.float32) / 65535.0
1064
1094
  else:
1065
- img01 = x[..., :3].astype(np.float32, copy=False)
1066
- img01 = np.clip(img01, 0.0, 1.0)
1095
+ img01 = np.clip(x[..., :3].astype(np.float32, copy=False), 0.0, 1.0)
1067
1096
 
1068
1097
  H, W = img01.shape[:2]
1069
1098
 
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
-
1099
+ # ---- downsample ----
1100
+ max_dim = int(np.clip(max_dim, 128, 2048))
1074
1101
  s = float(max_dim) / float(max(H, W))
1075
1102
  if s < 1.0:
1076
1103
  newW = max(64, int(round(W * s)))
1077
1104
  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
1105
+ img01 = cv2.resize(img01, (newW, newH), interpolation=cv2.INTER_AREA)
1081
1106
 
1082
- hH, hW = img01_ds.shape[:2]
1107
+ hH, hW = img01.shape[:2]
1083
1108
 
1084
- # ---- safety: cap vertex count (triangles scale ~2*(H-1)*(W-1)) ----
1109
+ # ---- vertex cap ----
1110
+ max_vertices = int(max(10_000, max_vertices))
1085
1111
  if hH * hW > max_vertices:
1086
1112
  scale = np.sqrt(float(max_vertices) / float(hH * hW))
1087
1113
  newW = max(64, int(round(hW * scale)))
1088
1114
  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)
1115
+ img01 = cv2.resize(img01, (newW, newH), interpolation=cv2.INTER_AREA)
1116
+ hH, hW = img01.shape[:2]
1117
+
1118
+ # ---- helpers ----
1119
+ def _robust01(base: np.ndarray) -> np.ndarray:
1120
+ base = base.astype(np.float32, copy=False)
1121
+ p_lo = float(np.percentile(base, 1.0))
1122
+ p_hi = float(np.percentile(base, 99.5))
1123
+ h = np.clip((base - p_lo) / max(p_hi - p_lo, 1e-9), 0.0, 1.0)
1124
+
1125
+ if invert:
1126
+ h = 1.0 - h
1127
+
1128
+ # coherence smoothing
1129
+ b = max(1, int(block))
1130
+ if b > 1:
1131
+ h = cv2.blur(h, (b, b), borderType=cv2.BORDER_REFLECT101)
1132
+
1133
+ if block_blur_sigma and block_blur_sigma > 0:
1134
+ h = cv2.GaussianBlur(h, (0, 0), float(block_blur_sigma))
1135
+
1136
+ if blur_sigma and blur_sigma > 0:
1137
+ h = cv2.GaussianBlur(h, (0, 0), float(blur_sigma))
1138
+
1139
+ h = np.clip(h, 0.0, 1.0) ** max(1e-3, float(depth_gamma))
1140
+ return h.astype(np.float32)
1141
+
1142
+ # luminance (also used for saturation gating)
1143
+ lum = (0.299 * img01[..., 0] + 0.587 * img01[..., 1] + 0.114 * img01[..., 2]).astype(np.float32)
1144
+ h01_lum = _robust01(lum)
1145
+
1146
+ # saturation
1147
+ hsv = cv2.cvtColor(img01.astype(np.float32), cv2.COLOR_RGB2HSV)
1148
+ sat = hsv[..., 1].astype(np.float32)
1149
+ h01_sat = _robust01(sat)
1150
+
1151
+ # Optional: suppress sat-height in very dark background
1152
+ if sat_luma_soft and sat_luma_soft > 0:
1153
+ gate = float(sat_luma_gate)
1154
+ soft = float(sat_luma_soft)
1155
+ wgt = np.clip((lum - gate) / max(soft, 1e-6), 0.0, 1.0).astype(np.float32)
1156
+ h01_sat = h01_sat * wgt
1157
+
1158
+ # ---- depth scaling ----
1159
+ zmax = 0.5 * float(min(hH, hW)) * float(z_scale)
1160
+ Z_lum = ((h01_lum * 2.0) - 1.0) * zmax
1161
+ Z_sat = ((h01_sat * 2.0) - 1.0) * zmax
1162
+
1163
+ # ---- XY grid ----
1164
+ yy, xx = np.mgrid[0:hH, 0:hW]
1165
+ X = (xx - (hW - 1) * 0.5).reshape(-1)
1166
+ Y = (yy - (hH - 1) * 0.5).reshape(-1)
1094
1167
 
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)
1168
+ Zlum = Z_lum.reshape(-1)
1169
+ Zsat = Z_sat.reshape(-1)
1103
1170
 
1104
- if invert:
1105
- h01 = 1.0 - h01
1171
+ # ---- RGB strings ----
1172
+ rgb_u8 = np.clip(img01.reshape(-1, 3) * 255.0, 0, 255).astype(np.uint8)
1173
+ rgb_strings = [f"rgb({r},{g},{b})" for r, g, b in rgb_u8]
1106
1174
 
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)
1175
+ traces: list = []
1113
1176
 
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))
1177
+ # sizes / opacities per cloud
1178
+ ps_lum = float(point_size)
1179
+ ps_sat = float(sat_point_size) if sat_point_size is not None else float(point_size) * 1.05
1180
+ op_lum = 0.95
1181
+ op_sat = float(np.clip(sat_opacity, 0.05, 1.0))
1117
1182
 
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))
1183
+ dps = float(depth_point_size) if depth_point_size is not None else float(point_size)
1121
1184
 
1122
- # gamma shape
1123
- g = float(max(1e-3, depth_gamma))
1124
- h01 = np.clip(h01, 0.0, 1.0) ** g
1185
+ def _add_cloud(
1186
+ *,
1187
+ name_prefix: str,
1188
+ Z: np.ndarray,
1189
+ size_rgb: float,
1190
+ size_depth: float,
1191
+ op_rgb: float,
1192
+ op_depth: float,
1193
+ ):
1194
+ """
1195
+ Add traces for a single height cloud, honoring cmode.
1196
+ """
1197
+ if cmode in ("brightness", "dual"):
1198
+ traces.append(
1199
+ go.Scatter3d(
1200
+ x=X, y=Y, z=Z,
1201
+ mode="markers",
1202
+ marker=dict(
1203
+ size=float(size_rgb),
1204
+ color=rgb_strings,
1205
+ opacity=1.0 if cmode == "brightness" else float(op_rgb),
1206
+ ),
1207
+ hoverinfo="skip",
1208
+ name=f"{name_prefix} (RGB)" if cmode != "brightness" else f"{name_prefix}",
1209
+ )
1210
+ )
1125
1211
 
1126
- # centered displacement [-1,+1]
1127
- h = (h01 * 2.0 - 1.0).astype(np.float32)
1212
+ if cmode in ("depth", "dual"):
1213
+ traces.append(
1214
+ go.Scatter3d(
1215
+ x=X, y=Y, z=Z,
1216
+ mode="markers",
1217
+ marker=dict(
1218
+ size=float(size_depth),
1219
+ color=Z, # numeric -> colorscale
1220
+ colorscale=depth_colorscale,
1221
+ cmin=float(Z.min()),
1222
+ cmax=float(Z.max()),
1223
+ opacity=1.0 if cmode == "depth" else float(op_depth),
1224
+ showscale=(cmode == "depth"),
1225
+ colorbar=dict(
1226
+ title="Depth",
1227
+ thickness=14,
1228
+ len=0.6,
1229
+ ) if cmode == "depth" else None,
1230
+ ),
1231
+ hoverinfo="skip",
1232
+ name=f"{name_prefix} (Depth)" if cmode != "depth" else f"{name_prefix}",
1233
+ )
1234
+ )
1128
1235
 
1129
- # Z scale in pixels-ish
1130
- zmax = float(min(hH, hW)) * float(z_scale)
1131
- z = h * zmax
1236
+ if hmode == "brightness":
1237
+ _add_cloud(
1238
+ name_prefix="Brightness Height",
1239
+ Z=Zlum,
1240
+ size_rgb=ps_lum,
1241
+ size_depth=dps,
1242
+ op_rgb=0.95,
1243
+ op_depth=depth_opacity,
1244
+ )
1245
+ show_legend = (cmode == "dual")
1246
+
1247
+ elif hmode == "color":
1248
+ _add_cloud(
1249
+ name_prefix="Color Height",
1250
+ Z=Zsat,
1251
+ size_rgb=ps_lum, # reuse point_size for single-mode
1252
+ size_depth=dps,
1253
+ op_rgb=0.95,
1254
+ op_depth=depth_opacity,
1255
+ )
1256
+ show_legend = (cmode == "dual")
1132
1257
 
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
- )
1258
+ else:
1259
+ # dual heights: add BOTH clouds, each honoring cmode
1260
+ _add_cloud(
1261
+ name_prefix="Brightness Height",
1262
+ Z=Zlum,
1263
+ size_rgb=ps_lum,
1264
+ size_depth=dps,
1265
+ op_rgb=op_lum,
1266
+ op_depth=depth_opacity,
1267
+ )
1268
+ _add_cloud(
1269
+ name_prefix="Color Height",
1270
+ Z=Zsat,
1271
+ size_rgb=ps_sat,
1272
+ size_depth=dps,
1273
+ op_rgb=op_sat,
1274
+ op_depth=min(1.0, depth_opacity * 0.85), # slightly softer overlay feels nicer
1275
+ )
1276
+ show_legend = True # dual heights should show what’s what
1169
1277
 
1170
- fig = go.Figure(data=[mesh])
1278
+ # ---- figure ----
1279
+ fig = go.Figure(data=traces)
1171
1280
  fig.update_layout(
1172
1281
  title=title,
1173
1282
  margin=dict(l=0, r=0, b=0, t=40),
@@ -1178,20 +1287,26 @@ def export_pseudo_surface_html(
1178
1287
  zaxis=dict(visible=False),
1179
1288
  bgcolor="black",
1180
1289
  camera=dict(
1181
- eye=dict(x=0.0, y=-1.6, z=1.2),
1290
+ eye=dict(x=0.0, y=-1.6, z=1.0),
1182
1291
  up=dict(x=0.0, y=-1.0, z=0.0),
1183
1292
  ),
1184
1293
  ),
1185
1294
  paper_bgcolor="black",
1186
1295
  plot_bgcolor="black",
1187
- showlegend=False,
1296
+ showlegend=bool(show_legend),
1297
+ legend=dict(
1298
+ bgcolor="rgba(0,0,0,0)",
1299
+ font=dict(color="white"),
1300
+ ),
1188
1301
  )
1189
1302
 
1190
1303
  html = fig.to_html(include_plotlyjs="cdn", full_html=True)
1191
1304
  if out_path is None:
1192
- out_path = os.path.expanduser("~/pseudo_surface.html")
1305
+ out_path = os.path.expanduser("~/pseudo_surface_pointcloud.html")
1306
+
1193
1307
  return html, out_path
1194
1308
 
1309
+
1195
1310
  def deproject_galaxy_topdown_u8(
1196
1311
  roi01: np.ndarray, # float32 [0..1], (H,W,3)
1197
1312
  cx0: float, cy0: float,
@@ -1251,7 +1366,7 @@ class PlanetProjectionDialog(QDialog):
1251
1366
  self.setMinimumSize(520, 520)
1252
1367
 
1253
1368
  self.resize(560, 640)
1254
- self.setWindowTitle("Planet Projection — Stereo / Wiggle")
1369
+ self.setWindowTitle("3D Projection")
1255
1370
  self.setModal(False)
1256
1371
  self.parent = parent
1257
1372
  self.doc = document
@@ -1317,7 +1432,7 @@ class PlanetProjectionDialog(QDialog):
1317
1432
  "Stereo (Cross-eye) R | L",
1318
1433
  "Wiggle stereo (toggle L/R)",
1319
1434
  "Anaglyph (Red/Cyan 3D Glasses)",
1320
- "Interactive 3D Sphere (HTML)",
1435
+ "Interactive 3D (HTML)",
1321
1436
  "Galaxy Polar View (Top-Down)",
1322
1437
  ])
1323
1438
  form.addRow("Output:", self.cmb_mode)
@@ -1398,6 +1513,61 @@ class PlanetProjectionDialog(QDialog):
1398
1513
  self.chk_ps_invert.setChecked(True)
1399
1514
  ps_form.addRow("", self.chk_ps_invert)
1400
1515
 
1516
+
1517
+ # Pseudo-surface 3D coloring mode (how points are COLORED)
1518
+ self.cmb_ps_3d_mode = QComboBox(self)
1519
+ self.cmb_ps_3d_mode.addItems([
1520
+ "Brightness (RGB)",
1521
+ "Depth (Height Colormap)",
1522
+ "Dual (RGB + Depth)",
1523
+ ])
1524
+ self.cmb_ps_3d_mode.setToolTip(
1525
+ "3D point cloud coloring:\n"
1526
+ "• Brightness: points colored from the image (RGB)\n"
1527
+ "• Depth: points colored by height (colormap)\n"
1528
+ "• Dual: overlays RGB + depth coloring"
1529
+ )
1530
+ ps_form.addRow("3D Color Mode:", self.cmb_ps_3d_mode)
1531
+
1532
+ # Height-from (what drives HEIGHT / Z)
1533
+ self.cmb_ps_height_from = QComboBox(self)
1534
+ self.cmb_ps_height_from.addItems([
1535
+ "Brightness (Luminance)",
1536
+ "Color Intensity (Saturation)",
1537
+ "Dual (Brightness + Color)",
1538
+ ])
1539
+ self.cmb_ps_height_from.setToolTip(
1540
+ "What drives the HEIGHT (Z) of the 3D point cloud:\n"
1541
+ "• Brightness: luminance-derived height\n"
1542
+ "• Color Intensity: saturation/chroma-derived height\n"
1543
+ "• Dual: overlays TWO clouds (brightness height + saturation height)\n\n"
1544
+ "Tip: Dual gives nebulae \"bulk\" even where brightness is flatter."
1545
+ )
1546
+ ps_form.addRow("Height From:", self.cmb_ps_height_from)
1547
+
1548
+ # Max points (vertex cap)
1549
+ self.spin_ps_max_points = QSpinBox(self)
1550
+ self.spin_ps_max_points.setRange(50_000, 900_000) # tune if you want
1551
+ self.spin_ps_max_points.setSingleStep(50_000)
1552
+ self.spin_ps_max_points.setValue(250_000) # good default
1553
+ self.spin_ps_max_points.setToolTip(
1554
+ "Maximum number of points used in the 3D plot.\n"
1555
+ "Higher = more detail but heavier in the browser."
1556
+ )
1557
+ ps_form.addRow("Max Points:", self.spin_ps_max_points)
1558
+
1559
+ # Dual saturation cloud opacity (only used when Height From == Dual)
1560
+ self.spin_ps_sat_opacity = QDoubleSpinBox(self)
1561
+ self.spin_ps_sat_opacity.setRange(0.05, 1.0)
1562
+ self.spin_ps_sat_opacity.setSingleStep(0.05)
1563
+ self.spin_ps_sat_opacity.setValue(0.45)
1564
+ self.spin_ps_sat_opacity.setToolTip(
1565
+ "Opacity of the saturation-height cloud when Height From is Dual.\n"
1566
+ "Lower = subtle bulk; higher = more pronounced volume."
1567
+ )
1568
+ ps_form.addRow("Dual Sat Opacity:", self.spin_ps_sat_opacity)
1569
+
1570
+ # (Keep your existing)
1401
1571
  form.addRow(ps_box)
1402
1572
 
1403
1573
  self.chk_auto_roi = QCheckBox("Auto ROI from planet centroid (green channel)")
@@ -1921,15 +2091,50 @@ class PlanetProjectionDialog(QDialog):
1921
2091
 
1922
2092
  if mode == 4:
1923
2093
  try:
2094
+ # color mode (how points are colored)
2095
+ idx = int(self.cmb_ps_3d_mode.currentIndex()) if hasattr(self, "cmb_ps_3d_mode") else 0
2096
+ color_mode = ("brightness", "depth", "dual")[max(0, min(2, idx))]
2097
+
2098
+ # height source (what drives Z)
2099
+ hidx = int(self.cmb_ps_height_from.currentIndex()) if hasattr(self, "cmb_ps_height_from") else 0
2100
+ height_from = ("brightness", "color", "dual")[max(0, min(2, hidx))]
2101
+
2102
+ # cap
2103
+ max_pts = int(self.spin_ps_max_points.value()) if hasattr(self, "spin_ps_max_points") else 250_000
2104
+
2105
+ # dual saturation opacity (only used in height_from="dual")
2106
+ sat_opacity = float(self.spin_ps_sat_opacity.value()) if hasattr(self, "spin_ps_sat_opacity") else 0.45
2107
+
2108
+ # title
2109
+ if height_from == "brightness":
2110
+ ht = "Brightness Height"
2111
+ elif height_from == "color":
2112
+ ht = "Color Intensity Height"
2113
+ else:
2114
+ ht = "Dual Height (Brightness + Color)"
2115
+
1924
2116
  html, default_path = export_pseudo_surface_html(
1925
2117
  roi,
1926
2118
  out_path=None,
1927
- title="Pseudo Surface (Height from Brightness)",
2119
+ title=f"Pseudo Surface ({ht})",
1928
2120
  max_dim=2048,
1929
2121
  z_scale=0.35,
1930
2122
  depth_gamma=float(self.spin_ps_gamma.value()),
1931
2123
  blur_sigma=float(self.spin_ps_blur.value()),
1932
- invert=not bool(self.chk_ps_invert.isChecked()),
2124
+ invert=bool(self.chk_ps_invert.isChecked()),
2125
+ block=10,
2126
+ block_blur_sigma=0.6,
2127
+ max_vertices=max_pts,
2128
+ point_size=1.6,
2129
+ height_from=height_from, # "brightness" | "color" | "dual"
2130
+ color_mode=color_mode, # "brightness" | "depth" | "dual"
2131
+ depth_colorscale="Turbo",
2132
+ depth_opacity=0.55,
2133
+ depth_point_size=1.9,
2134
+ sat_opacity=sat_opacity, # used in height_from="dual"
2135
+ sat_point_size=1.75, # slightly different size looks great
2136
+ sat_luma_gate=0.02, # suppress color-noise in very dark background
2137
+ sat_luma_soft=0.18, # soft knee range
1933
2138
  )
1934
2139
 
1935
2140
  fn, _ = QFileDialog.getSaveFileName(
@@ -3180,7 +3385,7 @@ class PlanetProjectionPreviewDialog(QDialog):
3180
3385
  """
3181
3386
  def __init__(self, parent=None):
3182
3387
  super().__init__(parent)
3183
- self.setWindowTitle("Planet Projection — Preview")
3388
+ self.setWindowTitle("3D Projection — Preview")
3184
3389
  self.setModal(False)
3185
3390
  self._img_zoom = 1.0 # content zoom (1.0 = full view)
3186
3391
  self._img_pan_x = 0.0 # in source pixels, relative to center
setiastro/saspro/sfcc.py CHANGED
@@ -20,6 +20,12 @@ from datetime import datetime
20
20
  from typing import List, Tuple, Optional
21
21
 
22
22
  import numpy as np
23
+
24
+ try:
25
+ _trapz = np.trapezoid
26
+ except AttributeError:
27
+ _trapz = np.trapz
28
+
23
29
  import numpy.ma as ma
24
30
  import pandas as pd
25
31
 
@@ -313,8 +319,8 @@ class SaspViewer(QMainWindow):
313
319
  for color in ("red","green","blue"):
314
320
  data = rgb_data[color]
315
321
  if data is not None:
316
- S_star = np.trapezoid(data["response"], x=common_wl)
317
- S_veg = np.trapezoid(fl_veg_c * data["T_sys"], x=common_wl)
322
+ S_star = _trapz(data["response"], x=common_wl)
323
+ S_veg = _trapz(fl_veg_c * data["T_sys"], x=common_wl)
318
324
  if S_veg>0 and S_star>0:
319
325
  mag = -2.5 * np.log10(S_star / S_veg)
320
326
  mag_texts.append(f"{color[0].upper()}→{data['filter_name']}: {mag:.2f}")
@@ -1485,9 +1491,9 @@ class SFCCDialog(QDialog):
1485
1491
  wl_ref, fl_ref = load_sed(ref_sed_name)
1486
1492
  fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0.0, right=0.0)
1487
1493
 
1488
- S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1489
- S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1490
- S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
1494
+ S_ref_R = _trapz(fr_i * T_sys_R, x=wl_grid)
1495
+ S_ref_G = _trapz(fr_i * T_sys_G, x=wl_grid)
1496
+ S_ref_B = _trapz(fr_i * T_sys_B, x=wl_grid)
1491
1497
 
1492
1498
  diag_meas_RG, diag_exp_RG = [], []
1493
1499
  diag_meas_BG, diag_exp_BG = [], []
@@ -1510,9 +1516,9 @@ class SFCCDialog(QDialog):
1510
1516
  try:
1511
1517
  wl_s, fl_s = load_sed(pname)
1512
1518
  fs_i = np.interp(wl_grid, wl_s, fl_s, left=0.0, right=0.0)
1513
- S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1514
- S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1515
- S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1519
+ S_sr = _trapz(fs_i * T_sys_R, x=wl_grid)
1520
+ S_sg = _trapz(fs_i * T_sys_G, x=wl_grid)
1521
+ S_sb = _trapz(fs_i * T_sys_B, x=wl_grid)
1516
1522
  template_integrals[pname] = (S_sr, S_sg, S_sb)
1517
1523
  except Exception as e:
1518
1524
  print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")