ray360 0.1.0__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.
- ray360/__init__.py +27 -0
- ray360/annotations.py +304 -0
- ray360/cubemap.py +149 -0
- ray360/experimental.py +55 -0
- ray360/geometry.py +78 -0
- ray360/image.py +81 -0
- ray360/panorama.py +163 -0
- ray360/projections.py +176 -0
- ray360/py.typed +0 -0
- ray360/remap.py +172 -0
- ray360/transforms.py +181 -0
- ray360-0.1.0.dist-info/METADATA +186 -0
- ray360-0.1.0.dist-info/RECORD +15 -0
- ray360-0.1.0.dist-info/WHEEL +4 -0
- ray360-0.1.0.dist-info/licenses/LICENSE +21 -0
ray360/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Perspective views, cubemaps and prediction mapping for stitched panoramas."""
|
|
2
|
+
|
|
3
|
+
from .annotations import Box2D, Polygon2D, SphericalRegion
|
|
4
|
+
from .cubemap import cube_projections, from_cubemap, to_cubemap
|
|
5
|
+
from .panorama import Panorama, View
|
|
6
|
+
from .projections import Equirectangular, Perspective, coverage_views
|
|
7
|
+
from .remap import MapCache, ProjectionMap
|
|
8
|
+
from .transforms import ViewTransform
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Box2D",
|
|
14
|
+
"Equirectangular",
|
|
15
|
+
"MapCache",
|
|
16
|
+
"Panorama",
|
|
17
|
+
"Perspective",
|
|
18
|
+
"Polygon2D",
|
|
19
|
+
"ProjectionMap",
|
|
20
|
+
"SphericalRegion",
|
|
21
|
+
"View",
|
|
22
|
+
"ViewTransform",
|
|
23
|
+
"coverage_views",
|
|
24
|
+
"cube_projections",
|
|
25
|
+
"from_cubemap",
|
|
26
|
+
"to_cubemap",
|
|
27
|
+
]
|
ray360/annotations.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Boxes and their spherical interiors. Polygon output may have seam pieces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
|
|
11
|
+
from .geometry import normalize, positive_int
|
|
12
|
+
from .projections import (
|
|
13
|
+
Equirectangular,
|
|
14
|
+
Perspective,
|
|
15
|
+
Projection,
|
|
16
|
+
projection_from_dict,
|
|
17
|
+
projection_to_dict,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, kw_only=True)
|
|
22
|
+
class _Metadata:
|
|
23
|
+
label: str | int | None = None
|
|
24
|
+
score: float | None = None
|
|
25
|
+
track_id: str | int | None = None
|
|
26
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self.score is not None:
|
|
30
|
+
if not 0 <= self.score <= 1:
|
|
31
|
+
raise ValueError("score must be between 0 and 1.")
|
|
32
|
+
object.__setattr__(self, "score", float(self.score))
|
|
33
|
+
object.__setattr__(self, "attributes", dict(self.attributes))
|
|
34
|
+
|
|
35
|
+
def metadata(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"label": self.label,
|
|
38
|
+
"score": self.score,
|
|
39
|
+
"track_id": self.track_id,
|
|
40
|
+
"attributes": dict(self.attributes),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Box2D(_Metadata):
|
|
46
|
+
"""Positive-area pixel-edge coordinates, with optional prediction metadata."""
|
|
47
|
+
|
|
48
|
+
x1: float
|
|
49
|
+
y1: float
|
|
50
|
+
x2: float
|
|
51
|
+
y2: float
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
super().__post_init__()
|
|
55
|
+
if not np.isfinite(self.xyxy).all() or self.x2 <= self.x1 or self.y2 <= self.y1:
|
|
56
|
+
raise ValueError("Boxes require finite coordinates and positive width and height.")
|
|
57
|
+
for name in ("x1", "y1", "x2", "y2"):
|
|
58
|
+
object.__setattr__(self, name, float(getattr(self, name)))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def xyxy(self) -> NDArray:
|
|
62
|
+
return np.array([self.x1, self.y1, self.x2, self.y2], dtype=np.float64)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def area(self) -> float:
|
|
66
|
+
return (self.x2 - self.x1) * (self.y2 - self.y1)
|
|
67
|
+
|
|
68
|
+
def polygon(self) -> Polygon2D:
|
|
69
|
+
return Polygon2D(
|
|
70
|
+
np.array(
|
|
71
|
+
[[self.x1, self.y1], [self.x2, self.y1], [self.x2, self.y2], [self.x1, self.y2]]
|
|
72
|
+
),
|
|
73
|
+
**self.metadata(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
return {"xyxy": self.xyxy.tolist(), **self.metadata()}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, value: dict[str, Any]) -> Box2D:
|
|
81
|
+
return cls(
|
|
82
|
+
*value["xyxy"],
|
|
83
|
+
**{k: value[k] for k in ("label", "score", "track_id", "attributes") if k in value},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Polygon2D(_Metadata):
|
|
89
|
+
"""A rendered boundary. Combine rings with even-odd fill; is_hole marks holes."""
|
|
90
|
+
|
|
91
|
+
vertices: NDArray
|
|
92
|
+
is_hole: bool = False
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
super().__post_init__()
|
|
96
|
+
vertices = np.array(self.vertices, dtype=np.float64, copy=True)
|
|
97
|
+
if (
|
|
98
|
+
vertices.ndim != 2
|
|
99
|
+
or vertices.shape[1] != 2
|
|
100
|
+
or len(vertices) < 3
|
|
101
|
+
or not np.isfinite(vertices).all()
|
|
102
|
+
):
|
|
103
|
+
raise ValueError("A polygon needs at least three finite xy vertices.")
|
|
104
|
+
vertices.flags.writeable = False
|
|
105
|
+
object.__setattr__(self, "vertices", vertices)
|
|
106
|
+
|
|
107
|
+
def bbox(self) -> Box2D:
|
|
108
|
+
x1, y1 = self.vertices.min(0)
|
|
109
|
+
x2, y2 = self.vertices.max(0)
|
|
110
|
+
return Box2D(x1, y1, x2, y2, **self.metadata())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class SphericalRegion:
|
|
115
|
+
"""One object defined by a box and its projection, including its intended interior.
|
|
116
|
+
|
|
117
|
+
Equirectangular boxes may cross the seam using unwrapped x, such as 350..370
|
|
118
|
+
in a 360-pixel panorama. Their width cannot exceed one revolution.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
box: Box2D
|
|
122
|
+
projection: Projection
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
if not isinstance(self.box, Box2D) or not isinstance(
|
|
126
|
+
self.projection, (Equirectangular, Perspective)
|
|
127
|
+
):
|
|
128
|
+
raise TypeError(
|
|
129
|
+
"A region requires a Box2D and an Equirectangular or Perspective projection."
|
|
130
|
+
)
|
|
131
|
+
b, p = self.box, self.projection
|
|
132
|
+
if b.y1 < 0 or b.y2 > p.height:
|
|
133
|
+
raise ValueError("Box y coordinates must lie within the projection.")
|
|
134
|
+
if isinstance(p, Equirectangular):
|
|
135
|
+
if b.x2 - b.x1 > p.width:
|
|
136
|
+
raise ValueError("A panorama box cannot span more than one revolution.")
|
|
137
|
+
elif b.x1 < 0 or b.x2 > p.width:
|
|
138
|
+
raise ValueError("Box x coordinates must lie within the perspective view.")
|
|
139
|
+
|
|
140
|
+
def contains(self, rays: ArrayLike) -> NDArray:
|
|
141
|
+
xy, visible = self.projection.project(normalize(rays))
|
|
142
|
+
b = self.box
|
|
143
|
+
x = xy[..., 0]
|
|
144
|
+
if isinstance(self.projection, Equirectangular):
|
|
145
|
+
distance = (x - b.x1) % self.projection.width
|
|
146
|
+
inside_x = (distance <= b.x2 - b.x1 + 1e-8) | (distance >= self.projection.width - 1e-8)
|
|
147
|
+
else:
|
|
148
|
+
inside_x = (x >= b.x1 - 1e-8) & (x <= b.x2 + 1e-8)
|
|
149
|
+
return visible & inside_x & (xy[..., 1] >= b.y1 - 1e-8) & (xy[..., 1] <= b.y2 + 1e-8)
|
|
150
|
+
|
|
151
|
+
def boundary(self, samples_per_edge: int = 32) -> NDArray:
|
|
152
|
+
"""Sample all four source edges and return world-space unit rays."""
|
|
153
|
+
positive_int(samples_per_edge, "samples_per_edge")
|
|
154
|
+
return self.projection.unproject(_densify(self.box.polygon().vertices, samples_per_edge))[0]
|
|
155
|
+
|
|
156
|
+
def rasterize(self, target: Projection) -> NDArray:
|
|
157
|
+
"""Authoritative membership at target pixel centers."""
|
|
158
|
+
mask = np.empty((target.height, target.width), bool)
|
|
159
|
+
for start in range(0, target.height, 128):
|
|
160
|
+
stop = min(start + 128, target.height)
|
|
161
|
+
rays, valid = target.unproject(target.pixel_grid(start, stop))
|
|
162
|
+
mask[start:stop] = valid & self.contains(rays)
|
|
163
|
+
return mask
|
|
164
|
+
|
|
165
|
+
def polygons(self, target: Projection, *, samples_per_edge: int = 32) -> list[Polygon2D]:
|
|
166
|
+
"""Render boundaries in the target image.
|
|
167
|
+
|
|
168
|
+
Perspective-source edges are sampled approximations. Panorama-source
|
|
169
|
+
regions use pixel-edge contours, preserving wide interiors and both poles
|
|
170
|
+
under rotation. Subpixel regions may disappear in that raster fallback.
|
|
171
|
+
"""
|
|
172
|
+
positive_int(samples_per_edge, "samples_per_edge")
|
|
173
|
+
if not isinstance(target, (Equirectangular, Perspective)):
|
|
174
|
+
raise TypeError("Unsupported target projection.")
|
|
175
|
+
if isinstance(self.projection, Equirectangular):
|
|
176
|
+
return [
|
|
177
|
+
replace(p, **self.box.metadata()) for p in _mask_to_polygons(self.rasterize(target))
|
|
178
|
+
]
|
|
179
|
+
rays = self.boundary(samples_per_edge)
|
|
180
|
+
if isinstance(target, Perspective):
|
|
181
|
+
local = rays @ target.rotation
|
|
182
|
+
for normal in (
|
|
183
|
+
[0, 0, 1],
|
|
184
|
+
[1, 0, target.width / (2 * target.focal)],
|
|
185
|
+
[-1, 0, target.width / (2 * target.focal)],
|
|
186
|
+
[0, 1, target.height / (2 * target.focal)],
|
|
187
|
+
[0, -1, target.height / (2 * target.focal)],
|
|
188
|
+
):
|
|
189
|
+
local = _clip(local, np.asarray(normal))
|
|
190
|
+
if len(local) < 3:
|
|
191
|
+
return []
|
|
192
|
+
xy, _ = target.project(normalize(local) @ target.rotation.T)
|
|
193
|
+
return self._polygons([_clip_image(xy, target.width, target.height)])
|
|
194
|
+
xy, _ = target.project(np.concatenate((rays, rays[:1])))
|
|
195
|
+
x = np.unwrap(xy[:, 0] / target.width * (2 * np.pi)) / (2 * np.pi) * target.width
|
|
196
|
+
boundary = np.column_stack((x, xy[:, 1]))
|
|
197
|
+
if abs(x[-1] - x[0]) > target.width * 0.5:
|
|
198
|
+
north = self.contains(np.array([0, 1, 0]) @ target.rotation.T)
|
|
199
|
+
pole_y = 0 if north else target.height
|
|
200
|
+
boundary = np.concatenate((boundary, [[x[-1], pole_y], [x[0], pole_y]]))
|
|
201
|
+
lo, hi = int(np.floor(np.min(x) / target.width)), int(np.floor(np.max(x) / target.width))
|
|
202
|
+
return self._polygons(
|
|
203
|
+
[
|
|
204
|
+
_clip_image(boundary - [shift * target.width, 0], target.width, target.height)
|
|
205
|
+
for shift in range(lo, hi + 1)
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _polygons(self, pieces: list[NDArray]) -> list[Polygon2D]:
|
|
210
|
+
return [
|
|
211
|
+
Polygon2D(xy, **self.box.metadata())
|
|
212
|
+
for xy in pieces
|
|
213
|
+
if len(xy) >= 3 and abs(_signed_area(xy)) > 1e-8
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
def boxes(self, target: Projection) -> list[Box2D]:
|
|
217
|
+
"""Approximate enclosing rectangles for outer components; holes are excluded."""
|
|
218
|
+
return [p.bbox() for p in self.polygons(target) if not p.is_hole]
|
|
219
|
+
|
|
220
|
+
def to_dict(self) -> dict[str, Any]:
|
|
221
|
+
return {
|
|
222
|
+
"schema_version": 1,
|
|
223
|
+
"box": self.box.to_dict(),
|
|
224
|
+
"projection": projection_to_dict(self.projection),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def from_dict(cls, value: dict[str, Any]) -> SphericalRegion:
|
|
229
|
+
if value.get("schema_version") != 1:
|
|
230
|
+
raise ValueError("Unsupported region schema_version.")
|
|
231
|
+
return cls(Box2D.from_dict(value["box"]), projection_from_dict(value["projection"]))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _signed_area(xy: NDArray) -> float:
|
|
235
|
+
return float(np.sum(xy[:, 0] * np.roll(xy[:, 1], -1) - xy[:, 1] * np.roll(xy[:, 0], -1)) / 2)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _clip(vertices: NDArray, normal: NDArray, offset: float = 0) -> NDArray:
|
|
239
|
+
"""Sutherland-Hodgman against dot(vertex, normal) + offset >= 0."""
|
|
240
|
+
if len(vertices) == 0:
|
|
241
|
+
return vertices
|
|
242
|
+
output = []
|
|
243
|
+
a = vertices[-1]
|
|
244
|
+
da = float(a @ normal + offset)
|
|
245
|
+
for b in vertices:
|
|
246
|
+
db = float(b @ normal + offset)
|
|
247
|
+
if (da >= 0) != (db >= 0):
|
|
248
|
+
output.append(a + da / (da - db) * (b - a))
|
|
249
|
+
if db >= 0:
|
|
250
|
+
output.append(b)
|
|
251
|
+
a, da = b, db
|
|
252
|
+
return np.asarray(output).reshape(-1, vertices.shape[-1])
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _clip_image(vertices: NDArray, width: float, height: float) -> NDArray:
|
|
256
|
+
for normal, offset in (([1, 0], 0), ([-1, 0], width), ([0, 1], 0), ([0, -1], height)):
|
|
257
|
+
vertices = _clip(vertices, np.asarray(normal), offset)
|
|
258
|
+
return vertices
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _densify(vertices: NDArray, samples_per_edge: int) -> NDArray:
|
|
262
|
+
t = np.arange(samples_per_edge) / samples_per_edge
|
|
263
|
+
return np.concatenate(
|
|
264
|
+
[
|
|
265
|
+
a[None] * (1 - t[:, None]) + b[None] * t[:, None]
|
|
266
|
+
for a, b in zip(vertices, np.roll(vertices, -1, axis=0), strict=True)
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _mask_to_polygons(mask: NDArray) -> list[Polygon2D]:
|
|
272
|
+
"""Trace pixel-edge contours, including hole rings (opposite winding)."""
|
|
273
|
+
mask = np.asarray(mask, bool)
|
|
274
|
+
if mask.ndim != 2:
|
|
275
|
+
raise ValueError("mask must be HW.")
|
|
276
|
+
padded = np.pad(mask, 1)
|
|
277
|
+
edges: dict[tuple[int, int], list[tuple[int, int]]] = {}
|
|
278
|
+
for dy, dx, a, b in (
|
|
279
|
+
(-1, 0, (0, 0), (1, 0)),
|
|
280
|
+
(0, 1, (1, 0), (1, 1)),
|
|
281
|
+
(1, 0, (1, 1), (0, 1)),
|
|
282
|
+
(0, -1, (0, 1), (0, 0)),
|
|
283
|
+
):
|
|
284
|
+
neighbor = padded[1 + dy : 1 + dy + mask.shape[0], 1 + dx : 1 + dx + mask.shape[1]]
|
|
285
|
+
ys, xs = np.nonzero(mask & ~neighbor)
|
|
286
|
+
for x, y in zip(xs.tolist(), ys.tolist(), strict=True):
|
|
287
|
+
edges.setdefault((x + a[0], y + a[1]), []).append((x + b[0], y + b[1]))
|
|
288
|
+
polygons = []
|
|
289
|
+
while edges:
|
|
290
|
+
first = next(iter(edges))
|
|
291
|
+
point = first
|
|
292
|
+
ring = []
|
|
293
|
+
while True:
|
|
294
|
+
ring.append(point)
|
|
295
|
+
following = edges[point].pop()
|
|
296
|
+
if not edges[point]:
|
|
297
|
+
del edges[point]
|
|
298
|
+
point = following
|
|
299
|
+
if point == first:
|
|
300
|
+
break
|
|
301
|
+
if len(ring) >= 3:
|
|
302
|
+
vertices = np.asarray(ring)
|
|
303
|
+
polygons.append(Polygon2D(vertices, is_hole=_signed_area(vertices) < 0))
|
|
304
|
+
return polygons
|
ray360/cubemap.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Six named 90-degree cube faces, with filtering across shared edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from .geometry import positive_int
|
|
11
|
+
from .panorama import Panorama
|
|
12
|
+
from .projections import Equirectangular, Perspective
|
|
13
|
+
from .remap import DEFAULT_CACHE, Interpolation, MapCache, sample, validate_image
|
|
14
|
+
|
|
15
|
+
_ORIENTATIONS = {
|
|
16
|
+
"front": (0, 0),
|
|
17
|
+
"right": (90, 0),
|
|
18
|
+
"back": (180, 0),
|
|
19
|
+
"left": (-90, 0),
|
|
20
|
+
"up": (0, 90),
|
|
21
|
+
"down": (0, -90),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cube_projections(size: int) -> dict[str, Perspective]:
|
|
26
|
+
"""World-aligned square faces: front +Z, right +X, back -Z, left -X, up +Y, down -Y.
|
|
27
|
+
|
|
28
|
+
All faces have zero roll. The top of the up face points toward -Z;
|
|
29
|
+
the top of the down face points toward +Z. No atlas layout is assumed.
|
|
30
|
+
"""
|
|
31
|
+
positive_int(size, "size")
|
|
32
|
+
return {
|
|
33
|
+
name: Perspective(size, size, yaw=yaw, pitch=pitch, hfov=90)
|
|
34
|
+
for name, (yaw, pitch) in _ORIENTATIONS.items()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_cubemap(
|
|
39
|
+
panorama: Panorama,
|
|
40
|
+
size: int = 512,
|
|
41
|
+
*,
|
|
42
|
+
interpolation: Interpolation = "bilinear",
|
|
43
|
+
cache: MapCache | None = DEFAULT_CACHE,
|
|
44
|
+
) -> dict[str, NDArray]:
|
|
45
|
+
"""Convert a declared panorama into six named, world-aligned arrays.
|
|
46
|
+
|
|
47
|
+
To retain prediction transforms, use panorama.view on cube_projections instead.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(panorama, Panorama):
|
|
50
|
+
raise TypeError("to_cubemap requires a Panorama.")
|
|
51
|
+
return {
|
|
52
|
+
name: panorama.view(projection, interpolation=interpolation, cache=cache).image
|
|
53
|
+
for name, projection in cube_projections(size).items()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _owners(rays: NDArray) -> NDArray:
|
|
58
|
+
# Ties prefer X, then Y, then Z. Each ray belongs to exactly one face.
|
|
59
|
+
axis = np.argmax(np.abs(rays), axis=-1)
|
|
60
|
+
positive = np.take_along_axis(rays, axis[..., None], axis=-1)[..., 0] >= 0
|
|
61
|
+
return np.where(positive, np.array([1, 4, 0])[axis], np.array([3, 5, 2])[axis])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _nearest(
|
|
65
|
+
faces: Mapping[str, NDArray], projections: Mapping[str, Perspective], rays: NDArray
|
|
66
|
+
) -> NDArray:
|
|
67
|
+
first = faces["front"]
|
|
68
|
+
output = np.empty((*rays.shape[:-1], *first.shape[2:]), dtype=first.dtype)
|
|
69
|
+
owners = _owners(rays)
|
|
70
|
+
for index, (name, projection) in enumerate(projections.items()):
|
|
71
|
+
chosen = owners == index
|
|
72
|
+
if chosen.any():
|
|
73
|
+
xy, _ = projection.project(rays[chosen])
|
|
74
|
+
output[chosen] = sample(faces[name], xy, interpolation="nearest")
|
|
75
|
+
return output
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _face_sample(
|
|
79
|
+
faces: Mapping[str, NDArray],
|
|
80
|
+
projections: Mapping[str, Perspective],
|
|
81
|
+
name: str,
|
|
82
|
+
xy: NDArray,
|
|
83
|
+
interpolation: Interpolation,
|
|
84
|
+
) -> NDArray:
|
|
85
|
+
face = faces[name]
|
|
86
|
+
output = sample(face, xy, interpolation=interpolation)
|
|
87
|
+
if interpolation == "nearest":
|
|
88
|
+
return output
|
|
89
|
+
size = face.shape[0]
|
|
90
|
+
border = ((xy < 0.5) | (xy > size - 0.5)).any(-1)
|
|
91
|
+
if not border.any():
|
|
92
|
+
return output
|
|
93
|
+
# Only the edge samples need cross-face taps. Interior pixels use the common sampler.
|
|
94
|
+
centers = xy[border] - 0.5
|
|
95
|
+
lower = np.floor(centers)
|
|
96
|
+
fraction = centers - lower
|
|
97
|
+
dtype = np.result_type(face.dtype, np.float32)
|
|
98
|
+
filtered = np.zeros((len(centers), *face.shape[2:]), dtype=dtype)
|
|
99
|
+
for dx, dy in ((0, 0), (1, 0), (0, 1), (1, 1)):
|
|
100
|
+
taps = lower + [dx + 0.5, dy + 0.5]
|
|
101
|
+
rays, _ = projections[name].unproject(taps)
|
|
102
|
+
values = _nearest(faces, projections, rays).astype(dtype)
|
|
103
|
+
weight = (fraction[:, 0] if dx else 1 - fraction[:, 0]) * (
|
|
104
|
+
fraction[:, 1] if dy else 1 - fraction[:, 1]
|
|
105
|
+
)
|
|
106
|
+
if face.ndim == 3:
|
|
107
|
+
weight = weight[:, None]
|
|
108
|
+
filtered += values * weight.astype(dtype)
|
|
109
|
+
if face.dtype.kind in "ui":
|
|
110
|
+
limits = np.iinfo(face.dtype)
|
|
111
|
+
filtered = np.clip(np.rint(filtered), limits.min, limits.max)
|
|
112
|
+
output[border] = filtered.astype(face.dtype)
|
|
113
|
+
return output
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def from_cubemap(
|
|
117
|
+
faces: Mapping[str, NDArray],
|
|
118
|
+
target: Equirectangular,
|
|
119
|
+
*,
|
|
120
|
+
interpolation: Interpolation = "bilinear",
|
|
121
|
+
chunk_rows: int = 128,
|
|
122
|
+
) -> NDArray:
|
|
123
|
+
"""Convert six matching square faces to a full equirectangular array.
|
|
124
|
+
|
|
125
|
+
Bilinear taps crossing a face edge sample the adjacent face. Nearest sampling
|
|
126
|
+
preserves class IDs. Face orientation follows cube_projections, including poles.
|
|
127
|
+
"""
|
|
128
|
+
if not isinstance(target, Equirectangular):
|
|
129
|
+
raise TypeError("from_cubemap requires an Equirectangular target.")
|
|
130
|
+
positive_int(chunk_rows, "chunk_rows")
|
|
131
|
+
if set(faces) != set(_ORIENTATIONS):
|
|
132
|
+
raise ValueError("Expected exactly front, right, back, left, up, and down faces.")
|
|
133
|
+
arrays = {name: validate_image(array) for name, array in faces.items()}
|
|
134
|
+
first = arrays["front"]
|
|
135
|
+
if first.shape[0] != first.shape[1]:
|
|
136
|
+
raise ValueError("Cubemap faces must be square.")
|
|
137
|
+
if any(array.shape != first.shape or array.dtype != first.dtype for array in arrays.values()):
|
|
138
|
+
raise ValueError("Cubemap faces must have matching shapes and dtypes.")
|
|
139
|
+
projections = cube_projections(first.shape[0])
|
|
140
|
+
output = np.empty((target.height, target.width, *first.shape[2:]), dtype=first.dtype)
|
|
141
|
+
for start in range(0, target.height, chunk_rows):
|
|
142
|
+
stop = min(target.height, start + chunk_rows)
|
|
143
|
+
rays, _ = target.unproject(target.pixel_grid(start, stop))
|
|
144
|
+
owners = _owners(rays)
|
|
145
|
+
for index, (name, projection) in enumerate(projections.items()):
|
|
146
|
+
chosen = owners == index
|
|
147
|
+
xy, _ = projection.project(rays[chosen])
|
|
148
|
+
output[start:stop][chosen] = _face_sample(arrays, projections, name, xy, interpolation)
|
|
149
|
+
return output
|
ray360/experimental.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Experimental sampled overlap and NMS; excluded from the stable root API.
|
|
2
|
+
|
|
3
|
+
Small regions can miss all samples. These methods neither fuse partial detections
|
|
4
|
+
nor establish detector accuracy. Increase samples and validate on the target data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
from .annotations import SphericalRegion
|
|
13
|
+
from .geometry import fibonacci_sphere
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def spherical_iou(a: SphericalRegion, b: SphericalRegion, *, samples: int = 65536) -> float:
|
|
17
|
+
"""Approximate solid-angle IoU using equal-area samples, not panorama pixel area.
|
|
18
|
+
|
|
19
|
+
Increase samples for small objects; a union missed by all samples yields 0.
|
|
20
|
+
"""
|
|
21
|
+
rays = fibonacci_sphere(samples)
|
|
22
|
+
aa, bb = a.contains(rays), b.contains(rays)
|
|
23
|
+
union = int(np.count_nonzero(aa | bb))
|
|
24
|
+
return int(np.count_nonzero(aa & bb)) / union if union else 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def spherical_nms(
|
|
28
|
+
regions: list[SphericalRegion],
|
|
29
|
+
threshold: float = 0.5,
|
|
30
|
+
*,
|
|
31
|
+
samples: int = 65536,
|
|
32
|
+
class_agnostic: bool = False,
|
|
33
|
+
) -> list[SphericalRegion]:
|
|
34
|
+
if not 0 <= threshold <= 1:
|
|
35
|
+
raise ValueError("threshold must be between 0 and 1.")
|
|
36
|
+
rays = fibonacci_sphere(samples)
|
|
37
|
+
# Evaluate each region once, rather than resampling every pair.
|
|
38
|
+
ordered = sorted(
|
|
39
|
+
regions, key=lambda r: r.box.score if r.box.score is not None else 1.0, reverse=True
|
|
40
|
+
)
|
|
41
|
+
kept: list[tuple[SphericalRegion, NDArray]] = []
|
|
42
|
+
for region in ordered:
|
|
43
|
+
mask = region.contains(rays)
|
|
44
|
+
suppressed = False
|
|
45
|
+
for other, other_mask in kept:
|
|
46
|
+
if not class_agnostic and region.box.label != other.box.label:
|
|
47
|
+
continue
|
|
48
|
+
union = int(np.count_nonzero(mask | other_mask))
|
|
49
|
+
overlap = int(np.count_nonzero(mask & other_mask)) / union if union else 0
|
|
50
|
+
if overlap > threshold:
|
|
51
|
+
suppressed = True
|
|
52
|
+
break
|
|
53
|
+
if not suppressed:
|
|
54
|
+
kept.append((region, mask))
|
|
55
|
+
return [region for region, _ in kept]
|
ray360/geometry.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Spherical coordinates. Rays use x=right, y=up, z=forward."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike, NDArray
|
|
7
|
+
|
|
8
|
+
FloatArray = NDArray[np.float64]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize(vectors: ArrayLike) -> FloatArray:
|
|
12
|
+
values = np.asarray(vectors, dtype=np.float64)
|
|
13
|
+
if values.ndim < 1 or values.shape[-1] != 3:
|
|
14
|
+
raise ValueError("Expected vectors with shape (..., 3).")
|
|
15
|
+
scale = np.max(np.abs(values), axis=-1, keepdims=True)
|
|
16
|
+
if not np.all(np.isfinite(values)) or np.any(scale == 0):
|
|
17
|
+
raise ValueError("Rays must be finite and nonzero.")
|
|
18
|
+
scaled = values / scale
|
|
19
|
+
return scaled / np.linalg.norm(scaled, axis=-1, keepdims=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def lonlat_to_rays(lonlat: ArrayLike) -> FloatArray:
|
|
23
|
+
"""Convert (..., 2) longitude/latitude in degrees to unit rays."""
|
|
24
|
+
ll = np.asarray(lonlat, dtype=np.float64)
|
|
25
|
+
if ll.ndim < 1 or ll.shape[-1] != 2 or not np.all(np.isfinite(ll)):
|
|
26
|
+
raise ValueError("Expected finite longitude/latitude pairs.")
|
|
27
|
+
if np.any(np.abs(ll[..., 1]) > 90):
|
|
28
|
+
raise ValueError("Latitude must be between -90 and 90 degrees.")
|
|
29
|
+
lon, lat = np.moveaxis(np.deg2rad(ll), -1, 0)
|
|
30
|
+
return np.stack((np.sin(lon) * np.cos(lat), np.sin(lat), np.cos(lon) * np.cos(lat)), -1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rays_to_lonlat(rays: ArrayLike) -> FloatArray:
|
|
34
|
+
rays = normalize(rays)
|
|
35
|
+
lon = np.rad2deg(np.arctan2(rays[..., 0], rays[..., 2]))
|
|
36
|
+
lon = (lon + 180) % 360 - 180
|
|
37
|
+
lat = np.rad2deg(np.arctan2(rays[..., 1], np.hypot(rays[..., 0], rays[..., 2])))
|
|
38
|
+
return np.stack((lon, lat), -1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rotation_matrix(yaw: float = 0, pitch: float = 0, roll: float = 0) -> FloatArray:
|
|
42
|
+
"""Camera-to-world rotation: yaw right, pitch up, roll camera-right toward down."""
|
|
43
|
+
if not np.all(np.isfinite([yaw, pitch, roll])):
|
|
44
|
+
raise ValueError("Rotation angles must be finite.")
|
|
45
|
+
y, p, r = np.deg2rad([yaw, pitch, roll])
|
|
46
|
+
cy, sy, cp, sp, cr, sr = np.cos(y), np.sin(y), np.cos(p), np.sin(p), np.cos(r), np.sin(r)
|
|
47
|
+
ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
|
|
48
|
+
rx = np.array([[1, 0, 0], [0, cp, sp], [0, -sp, cp]])
|
|
49
|
+
rz = np.array([[cr, sr, 0], [-sr, cr, 0], [0, 0, 1]])
|
|
50
|
+
return ry @ rx @ rz
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def angular_distance(a: ArrayLike, b: ArrayLike) -> FloatArray:
|
|
54
|
+
"""Great-circle angular distance in degrees, with NumPy broadcasting."""
|
|
55
|
+
a, b = normalize(a), normalize(b)
|
|
56
|
+
return np.rad2deg(np.arctan2(np.linalg.norm(np.cross(a, b), axis=-1), np.sum(a * b, -1)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fibonacci_sphere(samples: int = 65536) -> FloatArray:
|
|
60
|
+
"""Deterministic approximately equal-area samples, useful for spherical IoU."""
|
|
61
|
+
positive_int(samples, "samples")
|
|
62
|
+
index = np.arange(samples, dtype=np.float64)
|
|
63
|
+
y = 1 - 2 * (index + 0.5) / samples
|
|
64
|
+
lon = index * (np.pi * (3 - np.sqrt(5)))
|
|
65
|
+
r = np.sqrt(1 - y * y)
|
|
66
|
+
return np.stack((r * np.sin(lon), y, r * np.cos(lon)), -1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def positive_int(value: int, name: str) -> None:
|
|
70
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
71
|
+
raise ValueError(f"{name} must be a positive integer.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def points2d(points: ArrayLike) -> FloatArray:
|
|
75
|
+
xy = np.asarray(points, dtype=np.float64)
|
|
76
|
+
if xy.ndim < 1 or xy.shape[-1] != 2 or not np.isfinite(xy).all():
|
|
77
|
+
raise ValueError("Expected finite coordinates with shape (..., 2).")
|
|
78
|
+
return xy
|
ray360/image.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Optional Pillow loading, saving and outline overlays for stitched images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
from .annotations import Box2D, Polygon2D, SphericalRegion
|
|
13
|
+
from .panorama import Panorama
|
|
14
|
+
from .projections import Equirectangular, Projection
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pillow() -> Any:
|
|
18
|
+
try:
|
|
19
|
+
from PIL import Image
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise ImportError("Image helpers require Pillow; install 'ray360[image]'.") from exc
|
|
22
|
+
return Image
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_panorama(path: str | Path, *, projection: Equirectangular | None = None) -> Panorama:
|
|
26
|
+
"""Read uint8 RGB pixels. Calling this explicitly declares the input stitched.
|
|
27
|
+
|
|
28
|
+
EXIF orientation is not applied: the supplied projection refers to stored pixels.
|
|
29
|
+
Use arrays directly for label images or high-bit-depth data.
|
|
30
|
+
"""
|
|
31
|
+
with _pillow().open(path) as image:
|
|
32
|
+
array = np.array(image.convert("RGB"))
|
|
33
|
+
return Panorama(array, projection, source_id=str(path))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_image(path: str | Path, image: NDArray, *, overwrite: bool = False) -> None:
|
|
37
|
+
"""Write an array to the exact filename, refusing existing files by default."""
|
|
38
|
+
encoded = _pillow().fromarray(np.asarray(image))
|
|
39
|
+
path = Path(path)
|
|
40
|
+
extension = path.suffix.lower()
|
|
41
|
+
formats = _pillow().registered_extensions()
|
|
42
|
+
if extension not in formats:
|
|
43
|
+
raise ValueError(f"Unknown image extension {extension!r}.")
|
|
44
|
+
with path.open("wb" if overwrite else "xb") as stream:
|
|
45
|
+
encoded.save(stream, format=formats[extension])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def overlay(
|
|
49
|
+
image: NDArray,
|
|
50
|
+
annotations: Iterable[Box2D | Polygon2D | SphericalRegion],
|
|
51
|
+
*,
|
|
52
|
+
projection: Projection | None = None,
|
|
53
|
+
color: tuple[int, int, int] = (255, 60, 40),
|
|
54
|
+
width: int = 2,
|
|
55
|
+
) -> NDArray:
|
|
56
|
+
"""Return RGB pixels with outlines. Spherical regions require a target projection."""
|
|
57
|
+
from .geometry import positive_int
|
|
58
|
+
|
|
59
|
+
positive_int(width, "width")
|
|
60
|
+
Image = _pillow()
|
|
61
|
+
from PIL import ImageDraw
|
|
62
|
+
|
|
63
|
+
base = Image.fromarray(np.asarray(image)).convert("RGB")
|
|
64
|
+
if projection is not None and projection.size != base.size:
|
|
65
|
+
raise ValueError("Overlay projection must match image dimensions.")
|
|
66
|
+
draw = ImageDraw.Draw(base)
|
|
67
|
+
for annotation in annotations:
|
|
68
|
+
if isinstance(annotation, SphericalRegion):
|
|
69
|
+
if projection is None:
|
|
70
|
+
raise ValueError("Spherical overlays require a target projection.")
|
|
71
|
+
polygons = annotation.polygons(projection)
|
|
72
|
+
elif isinstance(annotation, Box2D):
|
|
73
|
+
polygons = [annotation.polygon()]
|
|
74
|
+
elif isinstance(annotation, Polygon2D):
|
|
75
|
+
polygons = [annotation]
|
|
76
|
+
else:
|
|
77
|
+
raise TypeError("Expected a box, rendered polygon or spherical region.")
|
|
78
|
+
for polygon in polygons:
|
|
79
|
+
points = [tuple(p) for p in polygon.vertices.tolist()]
|
|
80
|
+
draw.line(points + points[:1], fill=color, width=width)
|
|
81
|
+
return np.array(base)
|