foscat 2025.8.3__py3-none-any.whl → 2025.9.1__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.
- foscat/BkTorch.py +241 -49
- foscat/FoCUS.py +5 -3
- foscat/HOrientedConvol.py +446 -42
- foscat/HealBili.py +305 -0
- foscat/Plot.py +328 -0
- foscat/UNET.py +455 -178
- foscat/healpix_unet_torch.py +717 -0
- foscat/scat_cov.py +42 -30
- {foscat-2025.8.3.dist-info → foscat-2025.9.1.dist-info}/METADATA +1 -1
- {foscat-2025.8.3.dist-info → foscat-2025.9.1.dist-info}/RECORD +13 -10
- {foscat-2025.8.3.dist-info → foscat-2025.9.1.dist-info}/WHEEL +0 -0
- {foscat-2025.8.3.dist-info → foscat-2025.9.1.dist-info}/licenses/LICENSE +0 -0
- {foscat-2025.8.3.dist-info → foscat-2025.9.1.dist-info}/top_level.txt +0 -0
foscat/HealBili.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HealBili: Bilinear weights from a curvilinear (theta, phi) source grid to arbitrary HEALPix targets.
|
|
3
|
+
|
|
4
|
+
This module provides a class `HealBili` that, given a *curvilinear* source grid of angular
|
|
5
|
+
coordinates (theta[y,x], phi[y,x]) on the sphere (e.g., a tangent-plane grid built on an ellipsoid),
|
|
6
|
+
computes **bilinear interpolation weights** to map values from that grid onto arbitrary target
|
|
7
|
+
directions specified by HEALPix angles (heal_theta[n], heal_phi[n]).
|
|
8
|
+
|
|
9
|
+
Key idea
|
|
10
|
+
--------
|
|
11
|
+
Because the source grid is not rectilinear in index-space, we cannot assume a simple affine mapping
|
|
12
|
+
from (i,j) to angles. Instead, for each target direction (theta_h, phi_h), we:
|
|
13
|
+
1) locate a nearby source cell (seed) by nearest neighbor search on the unit sphere;
|
|
14
|
+
2) consider up to 4 candidate quads around the seed: [(i0,j0),(i0+1,j0),(i0,j0+1),(i0+1,j0+1)];
|
|
15
|
+
3) project the 4 corner unit vectors and the target onto a **local tangent plane** built at the
|
|
16
|
+
quad barycenter;
|
|
17
|
+
4) *invert* the bilinear mapping f(s,t) from the quad corners to the plane point using Newton,
|
|
18
|
+
retrieving (s,t) in [0,1]^2;
|
|
19
|
+
5) build the 4 bilinear weights [(1-s)(1-t), s(1-t), (1-s)t, st] and the 4 linear indices
|
|
20
|
+
into the source image (row-major, j*W + i).
|
|
21
|
+
|
|
22
|
+
If no candidate quad cleanly contains the point, we choose the one with the smallest residual in the
|
|
23
|
+
plane and clamp (s,t) to [0,1].
|
|
24
|
+
|
|
25
|
+
The code is NumPy-only by default, but can optionally use `scipy.spatial.cKDTree` for a faster nearest
|
|
26
|
+
neighbor seed search by setting `prefer_kdtree=True` (falls back automatically if SciPy is absent).
|
|
27
|
+
|
|
28
|
+
Usage
|
|
29
|
+
-----
|
|
30
|
+
>>> hb = HealBili(src_theta, src_phi, prefer_kdtree=True)
|
|
31
|
+
>>> I, W = hb.compute_weights(heal_theta, heal_phi)
|
|
32
|
+
>>> # Apply to a source image `img` of shape (H,W):
|
|
33
|
+
>>> vals = hb.apply_weights(img, I, W) # shape (N,)
|
|
34
|
+
|
|
35
|
+
All angles must be in **radians**. theta is colatitude (0 at north pole), phi is longitude in [0, 2*pi).
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from typing import Tuple
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from scipy.spatial import cKDTree # optional
|
|
44
|
+
_HAVE_SCIPY = True
|
|
45
|
+
except Exception: # pragma: no cover
|
|
46
|
+
cKDTree = None
|
|
47
|
+
_HAVE_SCIPY = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HealBili:
|
|
51
|
+
"""Compute bilinear interpolation weights from a curvilinear (theta, phi) grid to HEALPix targets.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
src_theta : np.ndarray, shape (H, W)
|
|
56
|
+
Source **colatitude** (radians) at each grid node.
|
|
57
|
+
src_phi : np.ndarray, shape (H, W)
|
|
58
|
+
Source **longitude** (radians) at each grid node.
|
|
59
|
+
prefer_kdtree : bool, default False
|
|
60
|
+
If True and SciPy is available, use cKDTree on unit vectors for a faster nearest-neighbor seed.
|
|
61
|
+
Falls back to blocked brute-force dot-product search otherwise.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, src_theta: np.ndarray, src_phi: np.ndarray, *, prefer_kdtree: bool = False) -> None:
|
|
65
|
+
if src_theta.shape != src_phi.shape or src_theta.ndim != 2:
|
|
66
|
+
raise ValueError("src_theta and src_phi must have the same 2D shape (H, W)")
|
|
67
|
+
self.src_theta = np.asarray(src_theta, dtype=float)
|
|
68
|
+
self.src_phi = np.asarray(src_phi, dtype=float)
|
|
69
|
+
self.H, self.W = self.src_theta.shape
|
|
70
|
+
# Precompute unit vectors of source grid nodes
|
|
71
|
+
self._Vsrc = self._sph_to_vec(self.src_theta.ravel(), self.src_phi.ravel()) # (H*W, 3)
|
|
72
|
+
self.prefer_kdtree = bool(prefer_kdtree) and _HAVE_SCIPY
|
|
73
|
+
if self.prefer_kdtree: # optional acceleration
|
|
74
|
+
self._kdtree = cKDTree(self._Vsrc)
|
|
75
|
+
else:
|
|
76
|
+
self._kdtree = None
|
|
77
|
+
|
|
78
|
+
# -----------------------------
|
|
79
|
+
# Public API
|
|
80
|
+
# -----------------------------
|
|
81
|
+
def compute_weights(
|
|
82
|
+
self,
|
|
83
|
+
heal_theta: np.ndarray,
|
|
84
|
+
heal_phi: np.ndarray,
|
|
85
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
86
|
+
"""Compute bilinear weights/indices for target HEALPix angles.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
heal_theta, heal_phi : np.ndarray, shape (N,)
|
|
91
|
+
Target **colatitude** and **longitude** in radians.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
I : np.ndarray, shape (4, N), dtype=int64
|
|
96
|
+
Linear indices of the 4 source corners per target (row-major: j*W + i). Invalid corners are -1.
|
|
97
|
+
W : np.ndarray, shape (4, N), dtype=float64
|
|
98
|
+
Bilinear weights aligned with `I`. Weights are set to 0.0 for invalid corners and normalized to sum to 1
|
|
99
|
+
when at least one corner is valid.
|
|
100
|
+
"""
|
|
101
|
+
ht = np.asarray(heal_theta, dtype=float).ravel()
|
|
102
|
+
hp = np.asarray(heal_phi, dtype=float).ravel()
|
|
103
|
+
if ht.shape != hp.shape:
|
|
104
|
+
raise ValueError("heal_theta and heal_phi must have the same 1D shape (N,)")
|
|
105
|
+
N = ht.size
|
|
106
|
+
|
|
107
|
+
# Target unit vectors
|
|
108
|
+
Vtgt = self._sph_to_vec(ht, hp) # (N,3)
|
|
109
|
+
|
|
110
|
+
# 1) Choose a seed node for each target (nearest source grid node on the sphere)
|
|
111
|
+
seed_flat = self._nearest_source_indices(Vtgt)
|
|
112
|
+
seed_j, seed_i = np.divmod(seed_flat, self.W)
|
|
113
|
+
|
|
114
|
+
# 2) For each target, test up to 4 candidate quads around the seed; pick the best
|
|
115
|
+
I = np.full((4, N), -1, dtype=np.int64)
|
|
116
|
+
W = np.zeros((4, N), dtype=float)
|
|
117
|
+
candidates = [(0, 0), (-1, 0), (0, -1), (-1, -1)] # offsets for (i0,j0)
|
|
118
|
+
|
|
119
|
+
for n in range(N):
|
|
120
|
+
v = Vtgt[n]
|
|
121
|
+
best = None # (score_tuple, s, t, i0, j0, (idx00, idx10, idx01, idx11))
|
|
122
|
+
|
|
123
|
+
for di, dj in candidates:
|
|
124
|
+
i0 = seed_i[n] + di
|
|
125
|
+
j0 = seed_j[n] + dj
|
|
126
|
+
if i0 < 0 or j0 < 0 or i0 + 1 >= self.W or j0 + 1 >= self.H:
|
|
127
|
+
continue # out of bounds
|
|
128
|
+
|
|
129
|
+
idx00 = j0 * self.W + i0
|
|
130
|
+
idx10 = j0 * self.W + (i0 + 1)
|
|
131
|
+
idx01 = (j0 + 1) * self.W + i0
|
|
132
|
+
idx11 = (j0 + 1) * self.W + (i0 + 1)
|
|
133
|
+
|
|
134
|
+
v00 = self._Vsrc[idx00]
|
|
135
|
+
v10 = self._Vsrc[idx10]
|
|
136
|
+
v01 = self._Vsrc[idx01]
|
|
137
|
+
v11 = self._Vsrc[idx11]
|
|
138
|
+
|
|
139
|
+
# Local tangent plane at the quad barycenter
|
|
140
|
+
vC = v00 + v10 + v01 + v11
|
|
141
|
+
vC /= np.linalg.norm(vC)
|
|
142
|
+
ex, ey, _ = self._tangent_axes_from_vec(vC)
|
|
143
|
+
|
|
144
|
+
# Project 4 corners + target onto (ex, ey)
|
|
145
|
+
P00 = np.array(self._project_to_plane(v00, ex, ey))
|
|
146
|
+
P10 = np.array(self._project_to_plane(v10, ex, ey))
|
|
147
|
+
P01 = np.array(self._project_to_plane(v01, ex, ey))
|
|
148
|
+
P11 = np.array(self._project_to_plane(v11, ex, ey))
|
|
149
|
+
P = np.array(self._project_to_plane(v, ex, ey))
|
|
150
|
+
|
|
151
|
+
# Invert bilinear mapping f(s,t) = P
|
|
152
|
+
s, t, ok, resid = self._invert_bilinear(P00, P10, P01, P11, P)
|
|
153
|
+
|
|
154
|
+
# Prefer in-bounds solutions; otherwise smallest residual
|
|
155
|
+
score = (0, resid) if ok else (1, resid)
|
|
156
|
+
if (best is None) or (score < best[0]):
|
|
157
|
+
best = (score, s, t, i0, j0, (idx00, idx10, idx01, idx11))
|
|
158
|
+
|
|
159
|
+
if best is None:
|
|
160
|
+
continue # leave weights at 0 and indices at -1
|
|
161
|
+
|
|
162
|
+
_, s, t, i0, j0, (idx00, idx10, idx01, idx11) = best
|
|
163
|
+
|
|
164
|
+
# Bilinear weights
|
|
165
|
+
w00 = (1.0 - s) * (1.0 - t)
|
|
166
|
+
w10 = s * (1.0 - t)
|
|
167
|
+
w01 = (1.0 - s) * t
|
|
168
|
+
w11 = s * t
|
|
169
|
+
|
|
170
|
+
I[:, n] = np.array([idx00, idx10, idx01, idx11], dtype=np.int64)
|
|
171
|
+
W[:, n] = np.array([w00, w10, w01, w11], dtype=float)
|
|
172
|
+
|
|
173
|
+
# Normalize for numerical safety
|
|
174
|
+
sW = W[:, n].sum()
|
|
175
|
+
if sW > 0:
|
|
176
|
+
W[:, n] /= sW
|
|
177
|
+
|
|
178
|
+
return I, W
|
|
179
|
+
|
|
180
|
+
def apply_weights(self, img: np.ndarray, I: np.ndarray, W: np.ndarray) -> np.ndarray:
|
|
181
|
+
"""Apply precomputed (I, W) to a source image to obtain values at the HEALPix targets.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
img : np.ndarray, shape (H, W)
|
|
186
|
+
Source image values defined on the same grid as (src_theta, src_phi).
|
|
187
|
+
I : np.ndarray, shape (4, N), dtype=int64
|
|
188
|
+
Linear indices (row-major) of corner samples; -1 for invalid corners.
|
|
189
|
+
W : np.ndarray, shape (4, N), dtype=float64
|
|
190
|
+
Bilinear weights aligned with I.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
vals : np.ndarray, shape (N,)
|
|
195
|
+
Interpolated values at the target directions.
|
|
196
|
+
"""
|
|
197
|
+
if img.shape != (self.H, self.W):
|
|
198
|
+
raise ValueError(f"img must have shape {(self.H, self.W)}, got {img.shape}")
|
|
199
|
+
img_flat = img.reshape(-1)
|
|
200
|
+
N = I.shape[1]
|
|
201
|
+
vals = np.zeros(N, dtype=float)
|
|
202
|
+
for k in range(4):
|
|
203
|
+
idx = I[k]
|
|
204
|
+
w = W[k]
|
|
205
|
+
m = idx >= 0
|
|
206
|
+
vals[m] += w[m] * img_flat[idx[m]]
|
|
207
|
+
return vals
|
|
208
|
+
|
|
209
|
+
# -----------------------------
|
|
210
|
+
# Internal helpers (geometry)
|
|
211
|
+
# -----------------------------
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _sph_to_vec(theta: np.ndarray, phi: np.ndarray) -> np.ndarray:
|
|
214
|
+
"""(theta, phi) -> unit vectors (x,y,z). theta=colat, phi=lon, radians."""
|
|
215
|
+
st, ct = np.sin(theta), np.cos(theta)
|
|
216
|
+
sp, cp = np.sin(phi), np.cos(phi)
|
|
217
|
+
x = st * cp
|
|
218
|
+
y = st * sp
|
|
219
|
+
z = ct
|
|
220
|
+
return np.stack([x, y, z], axis=-1)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _tangent_axes_from_vec(v: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
224
|
+
"""Return an orthonormal basis (ex, ey, ez=v_hat) for the tangent plane at v."""
|
|
225
|
+
ez = v / np.linalg.norm(v)
|
|
226
|
+
a = np.array([1.0, 0.0, 0.0], dtype=float)
|
|
227
|
+
if abs(np.dot(a, ez)) > 0.9: # avoid near-colinearity
|
|
228
|
+
a = np.array([0.0, 1.0, 0.0], dtype=float)
|
|
229
|
+
ex = a - np.dot(a, ez) * ez
|
|
230
|
+
ex /= np.linalg.norm(ex)
|
|
231
|
+
ey = np.cross(ez, ex)
|
|
232
|
+
return ex, ey, ez
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _project_to_plane(vec: np.ndarray, ex: np.ndarray, ey: np.ndarray) -> Tuple[float, float]:
|
|
236
|
+
"""Project 3D unit vector `vec` onto plane spanned by (ex, ey): returns (x, y)."""
|
|
237
|
+
return float(np.dot(vec, ex)), float(np.dot(vec, ey))
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _bilinear_f(s: float, t: float, P00: np.ndarray, P10: np.ndarray, P01: np.ndarray, P11: np.ndarray) -> np.ndarray:
|
|
241
|
+
"""Bilinear blend of 4 points in R^2."""
|
|
242
|
+
return ((1 - s) * (1 - t)) * P00 + (s * (1 - t)) * P10 + ((1 - s) * t) * P01 + (s * t) * P11
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _bilinear_jacobian(s: float, t: float, P00: np.ndarray, P10: np.ndarray, P01: np.ndarray, P11: np.ndarray) -> np.ndarray:
|
|
246
|
+
"""2x2 Jacobian of the bilinear map at (s,t)."""
|
|
247
|
+
dFds = (-(1 - t)) * P00 + (1 - t) * P10 + (-t) * P01 + t * P11
|
|
248
|
+
dFdt = (-(1 - s)) * P00 + (-s) * P10 + (1 - s) * P01 + s * P11
|
|
249
|
+
return np.stack([dFds, dFdt], axis=-1)
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def _invert_bilinear(cls, P00: np.ndarray, P10: np.ndarray, P01: np.ndarray, P11: np.ndarray, P: np.ndarray,
|
|
253
|
+
max_iter: int = 10, tol: float = 1e-9) -> Tuple[float, float, bool, float]:
|
|
254
|
+
"""Invert the bilinear map f(s,t)=P with a Newton loop; return (s,t, ok, residual)."""
|
|
255
|
+
# Initial guess from parallelogram (ignore cross term)
|
|
256
|
+
A = np.column_stack([P10 - P00, P01 - P00]) # 2x2
|
|
257
|
+
b = P - P00
|
|
258
|
+
try:
|
|
259
|
+
st0 = np.linalg.lstsq(A, b, rcond=None)[0]
|
|
260
|
+
s, t = float(st0[0]), float(st0[1])
|
|
261
|
+
except np.linalg.LinAlgError: # fallback
|
|
262
|
+
s, t = 0.5, 0.5
|
|
263
|
+
|
|
264
|
+
for _ in range(max_iter):
|
|
265
|
+
F = cls._bilinear_f(s, t, P00, P10, P01, P11)
|
|
266
|
+
r = P - F
|
|
267
|
+
if np.linalg.norm(r) < tol:
|
|
268
|
+
break
|
|
269
|
+
J = cls._bilinear_jacobian(s, t, P00, P10, P01, P11)
|
|
270
|
+
try:
|
|
271
|
+
delta = np.linalg.solve(J, r)
|
|
272
|
+
except np.linalg.LinAlgError:
|
|
273
|
+
break
|
|
274
|
+
s += float(delta[0])
|
|
275
|
+
t += float(delta[1])
|
|
276
|
+
|
|
277
|
+
# Clamp to [0,1] softly and compute residual
|
|
278
|
+
s_c = min(max(s, 0.0), 1.0)
|
|
279
|
+
t_c = min(max(t, 0.0), 1.0)
|
|
280
|
+
F_end = cls._bilinear_f(s_c, t_c, P00, P10, P01, P11)
|
|
281
|
+
resid = float(np.linalg.norm(P - F_end))
|
|
282
|
+
ok = (0.0 <= s <= 1.0) and (0.0 <= t <= 1.0)
|
|
283
|
+
return s_c, t_c, ok, resid
|
|
284
|
+
|
|
285
|
+
# -----------------------------
|
|
286
|
+
# Internal helpers (search)
|
|
287
|
+
# -----------------------------
|
|
288
|
+
def _nearest_source_indices(self, Vtgt: np.ndarray) -> np.ndarray:
|
|
289
|
+
"""Return flat indices of nearest source nodes for each target unit vector."""
|
|
290
|
+
if self._kdtree is not None: # fast path
|
|
291
|
+
_, nn = self._kdtree.query(Vtgt, k=1)
|
|
292
|
+
return nn.astype(np.int64)
|
|
293
|
+
# Brute-force in blocks to limit memory
|
|
294
|
+
N = Vtgt.shape[0]
|
|
295
|
+
out = np.empty(N, dtype=np.int64)
|
|
296
|
+
block = 20000
|
|
297
|
+
VsrcT = self._Vsrc.T # (3, H*W)
|
|
298
|
+
for start in range(0, N, block):
|
|
299
|
+
end = min(N, start + block)
|
|
300
|
+
D = Vtgt[start:end] @ VsrcT # cosine similarities
|
|
301
|
+
out[start:end] = np.argmax(D, axis=1)
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = ["HealBili"]
|
foscat/Plot.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import healpy as hp
|
|
4
|
+
|
|
5
|
+
def lgnomproject(
|
|
6
|
+
cell_ids, # array-like (N,), HEALPix pixel indices of your samples
|
|
7
|
+
data, # array-like (N,), values per cell id
|
|
8
|
+
nside: int,
|
|
9
|
+
rot=None, # (lon0_deg, lat0_deg, psi_deg). If None: auto-center from cell_ids (pix centers)
|
|
10
|
+
xsize: int = 800,
|
|
11
|
+
ysize: int = 800,
|
|
12
|
+
reso: float = None, # deg/pixel on tangent plane; if None, use fov_deg
|
|
13
|
+
fov_deg=10.0, # full FoV deg (scalar or (fx,fy))
|
|
14
|
+
nest: bool = True, # True if your cell_ids are NESTED (and ang2pix to be done in NEST)
|
|
15
|
+
reduce: str = "mean", # 'mean'|'median'|'sum'|'first' when duplicates in cell_ids
|
|
16
|
+
mask_outside: bool = True,
|
|
17
|
+
unseen_value=None, # default to hp.UNSEEN
|
|
18
|
+
return_image_only: bool = False,
|
|
19
|
+
title: str = None, cmap: str = "viridis", vmin=None, vmax=None,
|
|
20
|
+
notext: bool = False, # True to avoid tick marks
|
|
21
|
+
hold: bool = True, # create a new figure if True otherwise use sub to split panel
|
|
22
|
+
sub=(1,1,1), # declare sub plot
|
|
23
|
+
cbar: bool = False, # plot colorbar if True
|
|
24
|
+
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Gnomonic projection from *sparse* HEALPix samples (cell_ids, data) to an image (ysize, xsize).
|
|
28
|
+
|
|
29
|
+
For each output image pixel (i,j):
|
|
30
|
+
plane (x,y) --inverse gnomonic--> (theta, phi) --HEALPix--> ipix
|
|
31
|
+
if ipix in `cell_ids`: assign aggregated value, else UNSEEN.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
cell_ids : (N,) int array
|
|
36
|
+
HEALPix pixel indices of your samples. Must correspond to `nside` and `nest`.
|
|
37
|
+
data : (N,) float array
|
|
38
|
+
Sample values for each cell id.
|
|
39
|
+
nside : int
|
|
40
|
+
HEALPix NSIDE used for both `cell_ids` and the image reprojection.
|
|
41
|
+
rot : (lon0_deg, lat0_deg, psi_deg) or None
|
|
42
|
+
Gnomonic center (lon, lat) and in-plane rotation psi (deg).
|
|
43
|
+
If None, we auto-center from the *centers of the provided pixels* (via hp.pix2ang).
|
|
44
|
+
xsize, ysize : int
|
|
45
|
+
Output image size (pixels).
|
|
46
|
+
reso : float or None
|
|
47
|
+
Pixel size (deg/pixel) on the tangent plane. If None, derived from `fov_deg`.
|
|
48
|
+
fov_deg : float or (float,float)
|
|
49
|
+
Full field of view in degrees.
|
|
50
|
+
nest : bool
|
|
51
|
+
Use True if your `cell_ids` correspond to NESTED indexing.
|
|
52
|
+
reduce : str
|
|
53
|
+
How to combine duplicate cell ids: 'mean'|'median'|'sum'|'first'.
|
|
54
|
+
mask_outside : bool
|
|
55
|
+
Mask pixels outside the valid gnomonic hemisphere (cosc <= 0).
|
|
56
|
+
unseen_value : float or None
|
|
57
|
+
Value for invalid pixels (default hp.UNSEEN).
|
|
58
|
+
|
|
59
|
+
return_image_only : bool
|
|
60
|
+
If True, return the 2D array only (no plotting).
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
(fig, ax, img) or img
|
|
65
|
+
If return_image_only=True, returns img (ysize, xsize).
|
|
66
|
+
"""
|
|
67
|
+
if unseen_value is None:
|
|
68
|
+
unseen_value = hp.UNSEEN
|
|
69
|
+
|
|
70
|
+
cell_ids = np.asarray(cell_ids, dtype=np.int64)
|
|
71
|
+
vals_in = np.asarray(data, dtype=float)
|
|
72
|
+
if cell_ids.shape != vals_in.shape:
|
|
73
|
+
raise ValueError("cell_ids and data must have the same shape (N,)")
|
|
74
|
+
|
|
75
|
+
# -------- 1) Aggregate duplicates in cell_ids (if any) --------
|
|
76
|
+
uniq, inv = np.unique(cell_ids, return_inverse=True) # uniq is sorted
|
|
77
|
+
if reduce == "first":
|
|
78
|
+
first_idx = np.full(uniq.size, -1, dtype=np.int64)
|
|
79
|
+
for i, g in enumerate(inv):
|
|
80
|
+
if first_idx[g] < 0:
|
|
81
|
+
first_idx[g] = i
|
|
82
|
+
agg_vals = vals_in[first_idx]
|
|
83
|
+
elif reduce == "sum":
|
|
84
|
+
agg_vals = np.zeros(uniq.size, dtype=float)
|
|
85
|
+
np.add.at(agg_vals, inv, vals_in)
|
|
86
|
+
elif reduce == "median":
|
|
87
|
+
agg_vals = np.empty(uniq.size, dtype=float)
|
|
88
|
+
for k, pix in enumerate(uniq):
|
|
89
|
+
agg_vals[k] = np.median(vals_in[cell_ids == pix])
|
|
90
|
+
elif reduce == "mean":
|
|
91
|
+
sums = np.zeros(uniq.size, dtype=float)
|
|
92
|
+
cnts = np.zeros(uniq.size, dtype=float)
|
|
93
|
+
np.add.at(sums, inv, vals_in)
|
|
94
|
+
np.add.at(cnts, inv, 1.0)
|
|
95
|
+
agg_vals = sums / np.maximum(cnts, 1.0)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError("reduce must be one of {'mean','median','sum','first'}")
|
|
98
|
+
|
|
99
|
+
# -------- 2) Choose gnomonic center (rot) --------
|
|
100
|
+
if rot is None:
|
|
101
|
+
# Center from pixel centers of provided ids
|
|
102
|
+
theta_c, phi_c = hp.pix2ang(nside, uniq, nest=nest) # colat, lon (rad)
|
|
103
|
+
# circular mean for lon, median for colat
|
|
104
|
+
lon0_deg = np.degrees(np.angle(np.mean(np.exp(1j * phi_c))))
|
|
105
|
+
lat0_deg = 90.0 - np.degrees(np.median(theta_c))
|
|
106
|
+
psi_deg = 0.0
|
|
107
|
+
rot = (lon0_deg % 360.0, float(lat0_deg), float(psi_deg))
|
|
108
|
+
|
|
109
|
+
lon0_deg, lat0_deg, psi_deg = rot
|
|
110
|
+
lon0 = np.deg2rad(lon0_deg)
|
|
111
|
+
lat0 = np.deg2rad(lat0_deg)
|
|
112
|
+
psi = np.deg2rad(psi_deg)
|
|
113
|
+
|
|
114
|
+
# -------- 3) Tangent-plane grid (gnomonic) --------
|
|
115
|
+
if reso is not None:
|
|
116
|
+
dx = np.tan(np.deg2rad(reso))
|
|
117
|
+
dy = dx
|
|
118
|
+
half_x = 0.5 * xsize * dx
|
|
119
|
+
half_y = 0.5 * ysize * dy
|
|
120
|
+
else:
|
|
121
|
+
if np.isscalar(fov_deg):
|
|
122
|
+
fx, fy = float(fov_deg), float(fov_deg)
|
|
123
|
+
else:
|
|
124
|
+
fx, fy = float(fov_deg[0]), float(fov_deg[1])
|
|
125
|
+
ax = np.deg2rad(0.5 * fx)
|
|
126
|
+
ay = np.deg2rad(0.5 * fy)
|
|
127
|
+
half_x = np.tan(ax)
|
|
128
|
+
half_y = np.tan(ay)
|
|
129
|
+
|
|
130
|
+
xs = np.linspace(-half_x, +half_x, xsize, endpoint=False) + (half_x / xsize)
|
|
131
|
+
ys = np.linspace(-half_y, +half_y, ysize, endpoint=False) + (half_y / ysize)
|
|
132
|
+
|
|
133
|
+
X, Y = np.meshgrid(xs, ys) # (ysize, xsize)
|
|
134
|
+
|
|
135
|
+
# in-plane rotation psi
|
|
136
|
+
c, s = np.cos(psi), np.sin(psi)
|
|
137
|
+
Xr = c * X + s * Y
|
|
138
|
+
Yr = -s * X + c * Y
|
|
139
|
+
|
|
140
|
+
# -------- 4) Inverse gnomonic → sphere --------
|
|
141
|
+
rho = np.hypot(Xr, Yr)
|
|
142
|
+
cang = np.arctan(rho)
|
|
143
|
+
sinc, cosc = np.sin(cang), np.cos(cang)
|
|
144
|
+
sinlat0, coslat0 = np.sin(lat0), np.cos(lat0)
|
|
145
|
+
|
|
146
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
147
|
+
lat = np.arcsin(cosc * sinlat0 + (Yr * sinc * coslat0) / np.where(rho == 0, 1.0, rho))
|
|
148
|
+
lon = lon0 + np.arctan2(Xr * sinc, rho * coslat0 * cosc - Yr * sinlat0 * sinc)
|
|
149
|
+
|
|
150
|
+
lon = (lon + 2*np.pi) % (2*np.pi)
|
|
151
|
+
theta_img = (np.pi / 2.0) - lat
|
|
152
|
+
outside = (cosc <= 0.0) if mask_outside else np.zeros_like(cosc, dtype=bool)
|
|
153
|
+
|
|
154
|
+
# -------- 5) Map image pixels to HEALPix ids --------
|
|
155
|
+
ip_img = hp.ang2pix(nside, theta_img.ravel(), lon.ravel(), nest=nest).astype(np.int64)
|
|
156
|
+
|
|
157
|
+
# -------- 6) Assign values by matching ip_img ∈ uniq (safe searchsorted) --------
|
|
158
|
+
# uniq is sorted; build insertion pos then check matches only where pos < len(uniq)
|
|
159
|
+
pos = np.searchsorted(uniq, ip_img, side="left")
|
|
160
|
+
valid = pos < uniq.size
|
|
161
|
+
match = np.zeros_like(valid, dtype=bool)
|
|
162
|
+
match[valid] = (uniq[pos[valid]] == ip_img[valid])
|
|
163
|
+
|
|
164
|
+
img_flat = np.full(ip_img.shape, unseen_value, dtype=float)
|
|
165
|
+
img_flat[match] = agg_vals[pos[match]]
|
|
166
|
+
img = img_flat.reshape(ysize, xsize)
|
|
167
|
+
|
|
168
|
+
# Mask out-of-hemisphere gnomonic region
|
|
169
|
+
if mask_outside:
|
|
170
|
+
img[outside] = unseen_value
|
|
171
|
+
|
|
172
|
+
# -------- 7) Return / plot --------
|
|
173
|
+
if return_image_only:
|
|
174
|
+
return img
|
|
175
|
+
|
|
176
|
+
# Axes in approx. "gnomonic degrees" (atan of plane coords)
|
|
177
|
+
x_deg = np.degrees(np.arctan(xs))
|
|
178
|
+
y_deg = np.degrees(np.arctan(ys))
|
|
179
|
+
|
|
180
|
+
longitude_min=x_deg[0]/np.cos(np.deg2rad(lat0_deg))+lon0_deg
|
|
181
|
+
longitude_max=x_deg[-1]/np.cos(np.deg2rad(lat0_deg))+lon0_deg
|
|
182
|
+
|
|
183
|
+
if longitude_min>180:
|
|
184
|
+
longitude_min-=360
|
|
185
|
+
longitude_max-=360
|
|
186
|
+
|
|
187
|
+
extent = (longitude_min,longitude_max,
|
|
188
|
+
y_deg[0]+lat0_deg, y_deg[-1]+lat0_deg)
|
|
189
|
+
|
|
190
|
+
if hold:
|
|
191
|
+
fig, ax = plt.subplots(figsize=(xsize/100, ysize/100), dpi=100)
|
|
192
|
+
else:
|
|
193
|
+
ax=plt.subplot(sub[0],sub[1],sub[2])
|
|
194
|
+
|
|
195
|
+
im = ax.imshow(
|
|
196
|
+
np.where(img == unseen_value, np.nan, img),
|
|
197
|
+
origin="lower",
|
|
198
|
+
extent=extent,
|
|
199
|
+
cmap=cmap,
|
|
200
|
+
vmin=vmin, vmax=vmax,
|
|
201
|
+
interpolation="nearest",
|
|
202
|
+
aspect="equal"
|
|
203
|
+
)
|
|
204
|
+
if not notext:
|
|
205
|
+
ax.set_xlabel("Longitude (deg)")
|
|
206
|
+
ax.set_ylabel("Latitude (deg)")
|
|
207
|
+
else:
|
|
208
|
+
ax.set_xticks([])
|
|
209
|
+
ax.set_yticks([])
|
|
210
|
+
|
|
211
|
+
if title:
|
|
212
|
+
ax.set_title(title)
|
|
213
|
+
|
|
214
|
+
if cbar:
|
|
215
|
+
if hold:
|
|
216
|
+
cb = fig.colorbar(im, ax=ax)
|
|
217
|
+
cb.set_label("value")
|
|
218
|
+
else:
|
|
219
|
+
plt.colorbar(im, ax=ax, orientation="vertical", label="value")
|
|
220
|
+
|
|
221
|
+
plt.tight_layout()
|
|
222
|
+
if hold:
|
|
223
|
+
return fig, ax #, img
|
|
224
|
+
else:
|
|
225
|
+
return ax
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def plot_scat(s1,s2,s3,s4):
|
|
229
|
+
|
|
230
|
+
if not isinstance(s1,np.ndarray): # manage if Torch tensor
|
|
231
|
+
S1=s1.cpu().numpy()
|
|
232
|
+
S2=s2.cpu().numpy()
|
|
233
|
+
S3=s3.cpu().numpy()
|
|
234
|
+
S4=s4.cpu().numpy()
|
|
235
|
+
else:
|
|
236
|
+
S1=s1
|
|
237
|
+
S2=s2
|
|
238
|
+
S3=s3
|
|
239
|
+
S4=s4
|
|
240
|
+
|
|
241
|
+
N_image=s1.shape[0]
|
|
242
|
+
J=s1.shape[1]
|
|
243
|
+
N_orient=s1.shape[2]
|
|
244
|
+
|
|
245
|
+
# compute index j1 and j2 for S3
|
|
246
|
+
j1_s3=np.zeros([s3.shape[1]],dtype='int')
|
|
247
|
+
j2_s3=np.zeros([s3.shape[1]],dtype='int')
|
|
248
|
+
|
|
249
|
+
# compute index j1 and j2 for S4
|
|
250
|
+
j1_s4=np.zeros([s4.shape[1]],dtype='int')
|
|
251
|
+
j2_s4=np.zeros([s4.shape[1]],dtype='int')
|
|
252
|
+
j3_s4=np.zeros([s4.shape[1]],dtype='int')
|
|
253
|
+
|
|
254
|
+
n_s3=0
|
|
255
|
+
n_s4=0
|
|
256
|
+
for j3 in range(0,J):
|
|
257
|
+
for j2 in range(0, j3 + 1):
|
|
258
|
+
j1_s3[n_s3]=j2
|
|
259
|
+
j2_s3[n_s3]=j3
|
|
260
|
+
n_s3+=1
|
|
261
|
+
for j1 in range(0, j2 + 1):
|
|
262
|
+
j1_s4[n_s4]=j1
|
|
263
|
+
j2_s4[n_s4]=j2
|
|
264
|
+
j3_s4[n_s4]=j3
|
|
265
|
+
n_s4+=1
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
color=['b','r','orange','pink']
|
|
269
|
+
symbol=['',':','-','.']
|
|
270
|
+
plt.figure(figsize=(16,12))
|
|
271
|
+
|
|
272
|
+
plt.subplot(2,2,1)
|
|
273
|
+
for k in range(4):
|
|
274
|
+
plt.plot(S1[0,:,k],color=color[k%len(color)],label=r'$\Theta = %d$'%(k))
|
|
275
|
+
plt.legend(frameon=0,ncol=2)
|
|
276
|
+
plt.xlabel(r'$J_1$')
|
|
277
|
+
plt.ylabel(r'$S_1$')
|
|
278
|
+
plt.yscale('log')
|
|
279
|
+
|
|
280
|
+
plt.subplot(2,2,2)
|
|
281
|
+
for k in range(4):
|
|
282
|
+
plt.plot(S2[0,:,k],color=color[k%len(color)],label=r'$\Theta = %d$'%(k))
|
|
283
|
+
plt.xlabel(r'$J_1$')
|
|
284
|
+
plt.ylabel(r'$S_2$')
|
|
285
|
+
plt.yscale('log')
|
|
286
|
+
|
|
287
|
+
plt.subplot(2,2,3)
|
|
288
|
+
nidx=np.concatenate([np.zeros([1]),np.cumsum(np.bincount(j1_s3))],0)
|
|
289
|
+
l_pos=[]
|
|
290
|
+
l_name=[]
|
|
291
|
+
for i in np.unique(j1_s3):
|
|
292
|
+
idx=np.where(j1_s3==i)[0]
|
|
293
|
+
for k in range(4):
|
|
294
|
+
for l in range(4):
|
|
295
|
+
if i==0:
|
|
296
|
+
plt.plot(j2_s3[idx]+nidx[i],S3[0,idx,k,l],symbol[l%len(symbol)],color=color[k%len(color)],label=r'$\Theta = %d,%d$'%(k,l))
|
|
297
|
+
else:
|
|
298
|
+
plt.plot(j2_s3[idx]+nidx[i],S3[0,idx,k,l],symbol[l%len(symbol)],color=color[k%len(color)])
|
|
299
|
+
l_pos=l_pos+list(j2_s3[idx]+nidx[i])
|
|
300
|
+
l_name=l_name+["%d,%d"%(j1_s3[m],j2_s3[m]) for m in idx]
|
|
301
|
+
plt.legend(frameon=0,ncol=2)
|
|
302
|
+
|
|
303
|
+
plt.xticks(l_pos,l_name, fontsize=6)
|
|
304
|
+
plt.xlabel(r"$j_{1},j_{2}$", fontsize=9)
|
|
305
|
+
plt.ylabel(r"$S_{3}$", fontsize=9)
|
|
306
|
+
|
|
307
|
+
plt.subplot(2,2,4)
|
|
308
|
+
nidx=0
|
|
309
|
+
l_pos=[]
|
|
310
|
+
l_name=[]
|
|
311
|
+
for i in np.unique(j1_s4):
|
|
312
|
+
for j in np.unique(j2_s4):
|
|
313
|
+
idx=np.where((j1_s4==i)*(j2_s4==j))[0]
|
|
314
|
+
for k in range(4):
|
|
315
|
+
for l in range(4):
|
|
316
|
+
for m in range(4):
|
|
317
|
+
if i==0 and j==0 and m==0:
|
|
318
|
+
plt.plot(j2_s4[idx]+j3_s4[idx]+nidx,S4[0,idx,k,l,m],symbol[l%len(symbol)],color=color[k%len(color)],label=r'$\Theta = %d,%d,%d$'%(k,l,m))
|
|
319
|
+
else:
|
|
320
|
+
plt.plot(j2_s4[idx]+j3_s4[idx]+nidx,S4[0,idx,k,l,m],symbol[l%len(symbol)],color=color[k%len(color)])
|
|
321
|
+
l_pos=l_pos+list(j2_s4[idx]+j3_s4[idx]+nidx)
|
|
322
|
+
l_name=l_name+["%d,%d,%d"%(j1_s4[m],j2_s4[m],j3_s4[m]) for m in idx]
|
|
323
|
+
nidx+=np.max(j2_s4[j1_s4==i]+j3_s4[j1_s4==i])-np.min(j2_s4[j1_s4==i]+j3_s4[j1_s4==i])+1
|
|
324
|
+
plt.legend(frameon=0,ncol=2)
|
|
325
|
+
|
|
326
|
+
plt.xticks(l_pos,l_name, fontsize=6, rotation=90)
|
|
327
|
+
plt.xlabel(r"$j_{1},j_{2},j_{3}$", fontsize=9)
|
|
328
|
+
plt.ylabel(r"$S_{4}$", fontsize=9)
|