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 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)