readinsp 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.
readinsp/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Read Insta360 .insp still images into NumPy arrays."""
2
+
3
+ from .core import (
4
+ InspFormatError,
5
+ InspMetadata,
6
+ InspTrailer,
7
+ extract_preview_jpeg,
8
+ primary_jpeg_bytes,
9
+ read,
10
+ read_insp,
11
+ read_metadata,
12
+ read_preview,
13
+ trailer_bytes,
14
+ )
15
+ from .projection import (
16
+ InspCalibration,
17
+ LensCalibration,
18
+ SphericalAngle,
19
+ read_gravity_vector,
20
+ read_rectilinear,
21
+ render_rectilinear,
22
+ )
23
+
24
+ __all__ = [
25
+ "InspFormatError",
26
+ "InspMetadata",
27
+ "InspTrailer",
28
+ "extract_preview_jpeg",
29
+ "primary_jpeg_bytes",
30
+ "read",
31
+ "read_insp",
32
+ "read_metadata",
33
+ "read_preview",
34
+ "read_gravity_vector",
35
+ "read_rectilinear",
36
+ "render_rectilinear",
37
+ "InspCalibration",
38
+ "LensCalibration",
39
+ "SphericalAngle",
40
+ "trailer_bytes",
41
+ ]
readinsp/core.py ADDED
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from io import BytesIO
5
+ from pathlib import Path
6
+ from typing import Any, BinaryIO, Iterable, Optional, Tuple, Union
7
+
8
+ import numpy as np
9
+ from PIL import ExifTags, Image, ImageOps
10
+
11
+ JPEG_SOI = b"\xff\xd8"
12
+ JPEG_EOI = b"\xff\xd9"
13
+
14
+ APP1 = 0xE1
15
+ APP2 = 0xE2
16
+ SOS = 0xDA
17
+
18
+ SOF_MARKERS = {
19
+ 0xC0,
20
+ 0xC1,
21
+ 0xC2,
22
+ 0xC3,
23
+ 0xC5,
24
+ 0xC6,
25
+ 0xC7,
26
+ 0xC9,
27
+ 0xCA,
28
+ 0xCB,
29
+ 0xCD,
30
+ 0xCE,
31
+ 0xCF,
32
+ }
33
+
34
+ TRAILER_FIELD_NAMES = {
35
+ 1: "serial",
36
+ 2: "camera_model",
37
+ 3: "firmware",
38
+ 5: "calibration",
39
+ 9: "jpeg_end_offset_or_size",
40
+ 10: "unknown_10",
41
+ 19: "image_size_message",
42
+ 24: "unknown_24",
43
+ 25: "unknown_25",
44
+ 26: "source_path_message",
45
+ 27: "dimension_message",
46
+ 31: "orientation_or_pose",
47
+ 65: "thumbnail_size_message",
48
+ }
49
+
50
+
51
+ class InspFormatError(ValueError):
52
+ """Raised when a file does not look like the observed .insp format."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class JPEGSegment:
57
+ marker: int
58
+ offset: int
59
+ payload: bytes
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class InspTrailer:
64
+ raw: bytes
65
+ prefix: bytes
66
+ fields: dict[str, Any]
67
+ decoded_until: int
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class InspMetadata:
72
+ path: Path
73
+ width: int
74
+ height: int
75
+ precision: int
76
+ components: int
77
+ preview_size: Optional[Tuple[int, int]]
78
+ preview_bytes: int
79
+ trailer_bytes: int
80
+ exif: dict[str, Any]
81
+ trailer: Optional[InspTrailer]
82
+
83
+
84
+ def read_insp(
85
+ path: Union[str, Path, BinaryIO],
86
+ *,
87
+ mode: Optional[str] = "RGB",
88
+ apply_exif_orientation: bool = False,
89
+ ) -> np.ndarray:
90
+ """Read the primary .insp image as a NumPy array.
91
+
92
+ Parameters
93
+ ----------
94
+ path:
95
+ File path or binary file object.
96
+ mode:
97
+ Optional Pillow mode conversion. The default returns RGB arrays.
98
+ Pass `None` to keep Pillow's decoded mode.
99
+ apply_exif_orientation:
100
+ Apply Pillow's EXIF orientation transform before conversion.
101
+ """
102
+
103
+ return _decode_image(path, mode=mode, apply_exif_orientation=apply_exif_orientation)
104
+
105
+
106
+ read = read_insp
107
+
108
+
109
+ def read_preview(
110
+ path: Union[str, Path],
111
+ *,
112
+ mode: Optional[str] = "RGB",
113
+ apply_exif_orientation: bool = False,
114
+ ) -> np.ndarray:
115
+ """Read the APP2 preview JPEG as a NumPy array."""
116
+
117
+ preview = extract_preview_jpeg(path)
118
+ return _decode_image(
119
+ BytesIO(preview),
120
+ mode=mode,
121
+ apply_exif_orientation=apply_exif_orientation,
122
+ )
123
+
124
+
125
+ def read_metadata(path: Union[str, Path]) -> InspMetadata:
126
+ """Inspect the JPEG structure and best-effort Insta360 trailer metadata."""
127
+
128
+ path = Path(path)
129
+ data = path.read_bytes()
130
+ width, height, precision, components = _jpeg_shape(data)
131
+ preview = _preview_jpeg(data)
132
+ preview_size = None
133
+ if preview is not None:
134
+ preview_width, preview_height, _, _ = _jpeg_shape(preview)
135
+ preview_size = (preview_width, preview_height)
136
+
137
+ raw_trailer = _trailer_bytes(data)
138
+ return InspMetadata(
139
+ path=path,
140
+ width=width,
141
+ height=height,
142
+ precision=precision,
143
+ components=components,
144
+ preview_size=preview_size,
145
+ preview_bytes=len(preview) if preview is not None else 0,
146
+ trailer_bytes=len(raw_trailer),
147
+ exif=_read_exif(path),
148
+ trailer=parse_trailer(raw_trailer) if raw_trailer else None,
149
+ )
150
+
151
+
152
+ def primary_jpeg_bytes(path: Union[str, Path]) -> bytes:
153
+ """Return the primary JPEG stream, excluding the post-EOI trailer."""
154
+
155
+ data = Path(path).read_bytes()
156
+ eoi = data.rfind(JPEG_EOI)
157
+ if eoi < 0:
158
+ raise InspFormatError("missing JPEG EOI marker")
159
+ return data[: eoi + len(JPEG_EOI)]
160
+
161
+
162
+ def trailer_bytes(path: Union[str, Path]) -> bytes:
163
+ """Return the bytes after the final JPEG EOI marker."""
164
+
165
+ return _trailer_bytes(Path(path).read_bytes())
166
+
167
+
168
+ def extract_preview_jpeg(path: Union[str, Path]) -> bytes:
169
+ """Return the JPEG preview stored across APP2 segments."""
170
+
171
+ data = Path(path).read_bytes()
172
+ preview = _preview_jpeg(data)
173
+ if preview is None:
174
+ raise InspFormatError("missing APP2 preview JPEG")
175
+ return preview
176
+
177
+
178
+ def parse_trailer(raw: bytes) -> InspTrailer:
179
+ """Best-effort decoder for the protobuf-like Insta360 trailer."""
180
+
181
+ start, rows = _find_trailer_message(raw)
182
+ fields: dict[str, Any] = {}
183
+ decoded_until = start
184
+
185
+ for field, wire_type, value, _, end in rows:
186
+ decoded_until = end
187
+ name = TRAILER_FIELD_NAMES.get(field, f"field_{field}")
188
+ fields[name] = _decode_trailer_value(value, wire_type)
189
+
190
+ return InspTrailer(
191
+ raw=raw,
192
+ prefix=raw[:start],
193
+ fields=fields,
194
+ decoded_until=decoded_until,
195
+ )
196
+
197
+
198
+ def _decode_image(
199
+ source: Union[str, Path, BinaryIO],
200
+ *,
201
+ mode: Optional[str],
202
+ apply_exif_orientation: bool,
203
+ ) -> np.ndarray:
204
+ with Image.open(source) as image:
205
+ if apply_exif_orientation:
206
+ image = ImageOps.exif_transpose(image)
207
+ if mode is not None:
208
+ image = image.convert(mode)
209
+ return np.array(image)
210
+
211
+
212
+ def _iter_header_segments(data: bytes) -> Iterable[JPEGSegment]:
213
+ if not data.startswith(JPEG_SOI):
214
+ raise InspFormatError("missing JPEG SOI marker")
215
+
216
+ offset = len(JPEG_SOI)
217
+ while offset < len(data):
218
+ if data[offset] != 0xFF:
219
+ raise InspFormatError(f"expected JPEG marker at byte {offset}")
220
+
221
+ marker_offset = offset
222
+ while offset < len(data) and data[offset] == 0xFF:
223
+ offset += 1
224
+ if offset >= len(data):
225
+ raise InspFormatError("truncated JPEG marker")
226
+
227
+ marker = data[offset]
228
+ offset += 1
229
+
230
+ if marker == SOS:
231
+ length = _segment_length(data, offset)
232
+ payload = data[offset + 2 : offset + length]
233
+ yield JPEGSegment(marker=marker, offset=marker_offset, payload=payload)
234
+ return
235
+
236
+ if marker == 0xD9:
237
+ yield JPEGSegment(marker=marker, offset=marker_offset, payload=b"")
238
+ return
239
+
240
+ if marker == 0x01 or 0xD0 <= marker <= 0xD7:
241
+ yield JPEGSegment(marker=marker, offset=marker_offset, payload=b"")
242
+ continue
243
+
244
+ length = _segment_length(data, offset)
245
+ payload = data[offset + 2 : offset + length]
246
+ yield JPEGSegment(marker=marker, offset=marker_offset, payload=payload)
247
+ offset += length
248
+
249
+ raise InspFormatError("truncated JPEG header")
250
+
251
+
252
+ def _segment_length(data: bytes, offset: int) -> int:
253
+ if offset + 2 > len(data):
254
+ raise InspFormatError("truncated JPEG segment length")
255
+ length = int.from_bytes(data[offset : offset + 2], "big")
256
+ if length < 2:
257
+ raise InspFormatError("invalid JPEG segment length")
258
+ if offset + length > len(data):
259
+ raise InspFormatError("truncated JPEG segment payload")
260
+ return length
261
+
262
+
263
+ def _jpeg_shape(data: bytes) -> tuple[int, int, int, int]:
264
+ for segment in _iter_header_segments(data):
265
+ if segment.marker in SOF_MARKERS:
266
+ payload = segment.payload
267
+ if len(payload) < 6:
268
+ raise InspFormatError("truncated JPEG SOF segment")
269
+ precision = payload[0]
270
+ height = int.from_bytes(payload[1:3], "big")
271
+ width = int.from_bytes(payload[3:5], "big")
272
+ components = payload[5]
273
+ return width, height, precision, components
274
+
275
+ if segment.marker == SOS:
276
+ break
277
+
278
+ raise InspFormatError("missing JPEG SOF segment")
279
+
280
+
281
+ def _preview_jpeg(data: bytes) -> Optional[bytes]:
282
+ chunks = [
283
+ segment.payload
284
+ for segment in _iter_header_segments(data)
285
+ if segment.marker == APP2
286
+ ]
287
+ if not chunks:
288
+ return None
289
+
290
+ preview = b"".join(chunks)
291
+ if not preview.startswith(JPEG_SOI) or not preview.endswith(JPEG_EOI):
292
+ raise InspFormatError("APP2 payloads do not form a JPEG preview")
293
+ return preview
294
+
295
+
296
+ def _trailer_bytes(data: bytes) -> bytes:
297
+ eoi = data.rfind(JPEG_EOI)
298
+ if eoi < 0:
299
+ raise InspFormatError("missing JPEG EOI marker")
300
+ return data[eoi + len(JPEG_EOI) :]
301
+
302
+
303
+ def _read_exif(path: Path) -> dict[str, Any]:
304
+ with Image.open(path) as image:
305
+ exif = image.getexif()
306
+ out: dict[str, Any] = {}
307
+ for tag, value in exif.items():
308
+ name = ExifTags.TAGS.get(tag, str(tag))
309
+ out[name] = value
310
+ return out
311
+
312
+
313
+ def _find_trailer_message(raw: bytes) -> tuple[int, list[tuple[int, int, Any, int, int]]]:
314
+ best_start = 0
315
+ best_rows: list[tuple[int, int, Any, int, int]] = []
316
+ best_score = (-1, -1)
317
+
318
+ for start in range(min(len(raw), 32)):
319
+ rows = _parse_protobuf_prefix(raw, start)
320
+ if not rows:
321
+ continue
322
+
323
+ names = {field for field, _, _, _, _ in rows}
324
+ text_fields = sum(
325
+ 1
326
+ for field, wire_type, value, _, _ in rows
327
+ if field in {1, 2, 3, 5} and wire_type == 2 and _looks_like_text(value)
328
+ )
329
+ score = (text_fields + len(names & {1, 2, 3, 5, 19, 26, 27}), len(rows))
330
+ if score > best_score:
331
+ best_start = start
332
+ best_rows = rows
333
+ best_score = score
334
+
335
+ return best_start, best_rows
336
+
337
+
338
+ def _parse_protobuf_prefix(
339
+ raw: bytes,
340
+ start: int,
341
+ ) -> list[tuple[int, int, Any, int, int]]:
342
+ offset = start
343
+ rows: list[tuple[int, int, Any, int, int]] = []
344
+
345
+ while offset < len(raw):
346
+ field_start = offset
347
+ try:
348
+ key, offset = _read_varint(raw, offset)
349
+ field = key >> 3
350
+ wire_type = key & 0x07
351
+
352
+ if field == 0:
353
+ break
354
+
355
+ if wire_type == 0:
356
+ value, offset = _read_varint(raw, offset)
357
+ elif wire_type == 1:
358
+ if offset + 8 > len(raw):
359
+ break
360
+ value = raw[offset : offset + 8]
361
+ offset += 8
362
+ elif wire_type == 2:
363
+ length, offset = _read_varint(raw, offset)
364
+ if offset + length > len(raw):
365
+ break
366
+ value = raw[offset : offset + length]
367
+ offset += length
368
+ elif wire_type == 5:
369
+ if offset + 4 > len(raw):
370
+ break
371
+ value = raw[offset : offset + 4]
372
+ offset += 4
373
+ else:
374
+ break
375
+ except InspFormatError:
376
+ break
377
+
378
+ rows.append((field, wire_type, value, field_start, offset))
379
+
380
+ return rows
381
+
382
+
383
+ def _read_varint(raw: bytes, offset: int) -> tuple[int, int]:
384
+ value = 0
385
+ shift = 0
386
+ while offset < len(raw):
387
+ byte = raw[offset]
388
+ offset += 1
389
+ value |= (byte & 0x7F) << shift
390
+ if byte < 0x80:
391
+ return value, offset
392
+ shift += 7
393
+ if shift > 70:
394
+ break
395
+ raise InspFormatError("invalid varint")
396
+
397
+
398
+ def _decode_trailer_value(value: Any, wire_type: int) -> Any:
399
+ if wire_type == 0:
400
+ return value
401
+ if wire_type == 1 and isinstance(value, bytes):
402
+ return int.from_bytes(value, "little")
403
+ if wire_type == 2 and isinstance(value, bytes):
404
+ if _looks_like_text(value):
405
+ return value.decode("utf-8")
406
+ nested = _parse_protobuf_prefix(value, 0)
407
+ if nested and nested[-1][-1] == len(value):
408
+ return {
409
+ TRAILER_FIELD_NAMES.get(field, f"field_{field}"): _decode_trailer_value(
410
+ nested_value, nested_wire_type
411
+ )
412
+ for field, nested_wire_type, nested_value, _, _ in nested
413
+ }
414
+ return value
415
+ if wire_type == 5 and isinstance(value, bytes):
416
+ return int.from_bytes(value, "little")
417
+ return value
418
+
419
+
420
+ def _looks_like_text(value: bytes) -> bool:
421
+ if not value:
422
+ return False
423
+ try:
424
+ text = value.decode("utf-8")
425
+ except UnicodeDecodeError:
426
+ return False
427
+ return all(32 <= ord(char) < 127 for char in text)
readinsp/projection.py ADDED
@@ -0,0 +1,483 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Optional, Sequence, Tuple, Union
6
+
7
+ import numpy as np
8
+ from PIL import ExifTags, Image
9
+
10
+ from .core import read_insp, read_metadata
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SphericalAngle:
15
+ """Center of a rectilinear view on the stitched sphere, in degrees."""
16
+
17
+ yaw: float
18
+ pitch: float = 0.0
19
+ roll: float = 0.0
20
+ gravity_level: bool = False
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class LensCalibration:
25
+ center_x: float
26
+ center_y: float
27
+ radius: float
28
+ rotation_degrees: Tuple[float, float, float] = (0.0, 0.0, 0.0)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class InspCalibration:
33
+ lenses: Tuple[LensCalibration, LensCalibration]
34
+ image_size: Tuple[int, int]
35
+ raw: Optional[str] = None
36
+
37
+ @classmethod
38
+ def from_string(cls, calibration: str) -> "InspCalibration":
39
+ values = [float(part) for part in calibration.split("_")]
40
+ if len(values) < 16:
41
+ raise ValueError("expected at least 16 values in the calibration string")
42
+
43
+ # Observed format:
44
+ # mode, lens0_y, lens0_x, lens0_r, lens0_rx, lens0_ry, lens0_roll,
45
+ # lens1_y, lens1_x, lens1_r, lens1_rx, lens1_ry, lens1_roll,
46
+ # image_width, image_height, ...
47
+ lens0 = LensCalibration(
48
+ center_x=values[2],
49
+ center_y=values[1],
50
+ radius=values[3],
51
+ rotation_degrees=(values[4], values[5], values[6]),
52
+ )
53
+ lens1 = LensCalibration(
54
+ center_x=values[8],
55
+ center_y=values[7],
56
+ radius=values[9],
57
+ rotation_degrees=(values[10], values[11], values[12]),
58
+ )
59
+ return cls(
60
+ lenses=(lens0, lens1),
61
+ image_size=(int(round(values[13])), int(round(values[14]))),
62
+ raw=calibration,
63
+ )
64
+
65
+ @classmethod
66
+ def from_image_shape(cls, image_shape: Sequence[int]) -> "InspCalibration":
67
+ height = int(image_shape[0])
68
+ width = int(image_shape[1])
69
+ radius = min(width / 4.0, height / 2.0)
70
+ return cls(
71
+ lenses=(
72
+ LensCalibration(width / 4.0, height / 2.0, radius),
73
+ LensCalibration(3.0 * width / 4.0, height / 2.0, radius),
74
+ ),
75
+ image_size=(width, height),
76
+ )
77
+
78
+
79
+ def read_rectilinear(
80
+ path: Union[str, Path],
81
+ spherical_angle: Union[SphericalAngle, Sequence[float]],
82
+ hfov: float,
83
+ vfov: float,
84
+ *,
85
+ width: int = 1024,
86
+ height: Optional[int] = None,
87
+ fisheye_fov: float = 190.0,
88
+ blend_width: float = 8.0,
89
+ use_lens_roll: bool = False,
90
+ gravity_vector: Optional[Sequence[float]] = None,
91
+ ) -> np.ndarray:
92
+ """Read an .insp file and render a regular rectilinear view.
93
+
94
+ `spherical_angle` is `(yaw, pitch)` or `(yaw, pitch, roll)` in degrees.
95
+ Yaw 0 points at the left lens optical axis, yaw 180 points at the right
96
+ lens optical axis, and positive pitch looks upward.
97
+ """
98
+
99
+ path = Path(path)
100
+ angle, gravity_level = _angle_spec(spherical_angle)
101
+ if gravity_level and gravity_vector is None:
102
+ gravity_vector = read_gravity_vector(path)
103
+ if gravity_level and gravity_vector is None:
104
+ raise ValueError("gravity-leveling requested, but no gravity vector was found")
105
+
106
+ image = read_insp(path)
107
+ metadata = read_metadata(path)
108
+ calibration_text = None
109
+ if metadata.trailer is not None:
110
+ calibration_text = metadata.trailer.fields.get("calibration")
111
+
112
+ calibration = (
113
+ InspCalibration.from_string(calibration_text)
114
+ if isinstance(calibration_text, str)
115
+ else InspCalibration.from_image_shape(image.shape)
116
+ )
117
+ return render_rectilinear(
118
+ image,
119
+ calibration,
120
+ SphericalAngle(*angle, gravity_level=gravity_level),
121
+ hfov,
122
+ vfov,
123
+ width=width,
124
+ height=height,
125
+ fisheye_fov=fisheye_fov,
126
+ blend_width=blend_width,
127
+ use_lens_roll=use_lens_roll,
128
+ gravity_vector=gravity_vector,
129
+ )
130
+
131
+
132
+ def render_rectilinear(
133
+ image: np.ndarray,
134
+ calibration: Optional[InspCalibration],
135
+ spherical_angle: Union[SphericalAngle, Sequence[float]],
136
+ hfov: float,
137
+ vfov: float,
138
+ *,
139
+ width: int = 1024,
140
+ height: Optional[int] = None,
141
+ fisheye_fov: float = 190.0,
142
+ blend_width: float = 8.0,
143
+ use_lens_roll: bool = False,
144
+ gravity_vector: Optional[Sequence[float]] = None,
145
+ ) -> np.ndarray:
146
+ """Render a rectilinear view from a dual-fisheye image array."""
147
+
148
+ if calibration is None:
149
+ calibration = InspCalibration.from_image_shape(image.shape)
150
+
151
+ angle, gravity_level = _angle_spec(spherical_angle)
152
+ basis = _raw_basis()
153
+ if gravity_level:
154
+ if gravity_vector is None:
155
+ raise ValueError("gravity_vector is required for gravity-leveled rendering")
156
+ basis = _gravity_leveled_basis(gravity_vector)
157
+
158
+ out_width, out_height = _output_size(width, height, hfov, vfov)
159
+ directions = _view_directions(
160
+ angle,
161
+ hfov,
162
+ vfov,
163
+ out_width,
164
+ out_height,
165
+ basis,
166
+ )
167
+ sampled = _sample_dual_fisheye(
168
+ image,
169
+ calibration,
170
+ directions,
171
+ fisheye_fov=fisheye_fov,
172
+ blend_width=blend_width,
173
+ use_lens_roll=use_lens_roll,
174
+ )
175
+ return _restore_dtype(sampled, image.dtype)
176
+
177
+
178
+ def read_gravity_vector(path: Union[str, Path]) -> Optional[Tuple[float, float, float]]:
179
+ """Return the normalized camera-space gravity/down vector, if present.
180
+
181
+ The inspected .insp files store this in the first three underscore-separated
182
+ numbers of the EXIF MakerNote. The vector is returned in the same raw camera
183
+ coordinate system used by `render_rectilinear`.
184
+ """
185
+
186
+ with Image.open(path) as image:
187
+ maker_note = image.getexif().get_ifd(ExifTags.IFD.Exif).get(0x927C)
188
+ return _gravity_from_maker_note(maker_note)
189
+
190
+
191
+ def _output_size(
192
+ width: int,
193
+ height: Optional[int],
194
+ hfov: float,
195
+ vfov: float,
196
+ ) -> Tuple[int, int]:
197
+ if width <= 0:
198
+ raise ValueError("width must be positive")
199
+ _validate_fov(hfov, "hfov")
200
+ _validate_fov(vfov, "vfov")
201
+
202
+ if height is None:
203
+ ratio = np.tan(np.deg2rad(vfov) / 2.0) / np.tan(np.deg2rad(hfov) / 2.0)
204
+ height = max(1, int(round(width * ratio)))
205
+ if height <= 0:
206
+ raise ValueError("height must be positive")
207
+ return int(width), int(height)
208
+
209
+
210
+ def _validate_fov(value: float, name: str) -> None:
211
+ if not 0.0 < value < 180.0:
212
+ raise ValueError(f"{name} must be between 0 and 180 degrees")
213
+
214
+
215
+ def _angle_spec(
216
+ angle: Union[SphericalAngle, Sequence[float]],
217
+ ) -> Tuple[Tuple[float, float, float], bool]:
218
+ if isinstance(angle, SphericalAngle):
219
+ return (angle.yaw, angle.pitch, angle.roll), angle.gravity_level
220
+ if len(angle) == 2:
221
+ return (float(angle[0]), float(angle[1]), 0.0), False
222
+ if len(angle) == 3:
223
+ return (float(angle[0]), float(angle[1]), float(angle[2])), False
224
+ raise ValueError("spherical_angle must contain yaw/pitch or yaw/pitch/roll")
225
+
226
+
227
+ def _view_directions(
228
+ angle: Tuple[float, float, float],
229
+ hfov: float,
230
+ vfov: float,
231
+ width: int,
232
+ height: int,
233
+ basis: Tuple[np.ndarray, np.ndarray, np.ndarray],
234
+ ) -> np.ndarray:
235
+ yaw, pitch, roll = np.deg2rad(angle)
236
+ basis_right, basis_up, basis_forward = basis
237
+ center = (
238
+ np.sin(yaw) * np.cos(pitch) * basis_right
239
+ + np.sin(pitch) * basis_up
240
+ + np.cos(yaw) * np.cos(pitch) * basis_forward
241
+ )
242
+ center /= np.linalg.norm(center)
243
+
244
+ if abs(float(np.dot(center, basis_up))) > 0.999:
245
+ right = basis_right.copy()
246
+ else:
247
+ right = np.cross(basis_up, center)
248
+ right /= np.linalg.norm(right)
249
+ up = np.cross(center, right)
250
+ up /= np.linalg.norm(up)
251
+
252
+ if roll:
253
+ cos_roll = np.cos(roll)
254
+ sin_roll = np.sin(roll)
255
+ rolled_right = cos_roll * right + sin_roll * up
256
+ rolled_up = -sin_roll * right + cos_roll * up
257
+ right, up = rolled_right, rolled_up
258
+
259
+ xs = (
260
+ (np.arange(width, dtype=np.float32) + 0.5) / float(width) * 2.0 - 1.0
261
+ ) * np.tan(np.deg2rad(hfov) / 2.0)
262
+ ys = (
263
+ 1.0 - (np.arange(height, dtype=np.float32) + 0.5) / float(height) * 2.0
264
+ ) * np.tan(np.deg2rad(vfov) / 2.0)
265
+ xx, yy = np.meshgrid(xs, ys)
266
+ directions = (
267
+ center[None, None, :]
268
+ + xx[:, :, None] * right[None, None, :]
269
+ + yy[:, :, None] * up[None, None, :]
270
+ )
271
+ directions /= np.linalg.norm(directions, axis=2, keepdims=True)
272
+ return directions.astype(np.float32, copy=False)
273
+
274
+
275
+ def _raw_basis() -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
276
+ return (
277
+ np.array([1.0, 0.0, 0.0], dtype=np.float32),
278
+ np.array([0.0, 1.0, 0.0], dtype=np.float32),
279
+ np.array([0.0, 0.0, 1.0], dtype=np.float32),
280
+ )
281
+
282
+
283
+ def _gravity_leveled_basis(
284
+ gravity_vector: Sequence[float],
285
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
286
+ gravity = _normalize_vector(np.asarray(gravity_vector, dtype=np.float32))
287
+ up = -gravity
288
+
289
+ for anchor in _raw_basis()[::-1]:
290
+ forward = anchor - float(np.dot(anchor, up)) * up
291
+ norm = float(np.linalg.norm(forward))
292
+ if norm > 1e-6:
293
+ forward = forward / norm
294
+ right = np.cross(up, forward)
295
+ right /= np.linalg.norm(right)
296
+ forward = np.cross(right, up)
297
+ forward /= np.linalg.norm(forward)
298
+ return (
299
+ right.astype(np.float32),
300
+ up.astype(np.float32),
301
+ forward.astype(np.float32),
302
+ )
303
+
304
+ raise ValueError("could not construct a gravity-leveled basis")
305
+
306
+
307
+ def _normalize_vector(vector: np.ndarray) -> np.ndarray:
308
+ if vector.shape != (3,) or not np.all(np.isfinite(vector)):
309
+ raise ValueError("gravity vector must contain three finite numbers")
310
+ norm = float(np.linalg.norm(vector))
311
+ if norm < 1e-6:
312
+ raise ValueError("gravity vector is too small")
313
+ return vector / norm
314
+
315
+
316
+ def _gravity_from_maker_note(
317
+ maker_note: object,
318
+ ) -> Optional[Tuple[float, float, float]]:
319
+ if not isinstance(maker_note, bytes):
320
+ return None
321
+
322
+ text = maker_note.split(b"\0", 1)[0].decode("ascii", "ignore")
323
+ try:
324
+ values = [float(part) for part in text.split("_")]
325
+ except ValueError:
326
+ return None
327
+ if len(values) < 3:
328
+ return None
329
+
330
+ vector = np.asarray(values[:3], dtype=np.float32)
331
+ if not np.all(np.isfinite(vector)):
332
+ return None
333
+ norm = float(np.linalg.norm(vector))
334
+ if norm < 0.25:
335
+ return None
336
+ vector = vector / norm
337
+ return tuple(float(value) for value in vector)
338
+
339
+
340
+ def _sample_dual_fisheye(
341
+ image: np.ndarray,
342
+ calibration: InspCalibration,
343
+ directions: np.ndarray,
344
+ *,
345
+ fisheye_fov: float,
346
+ blend_width: float,
347
+ use_lens_roll: bool,
348
+ ) -> np.ndarray:
349
+ if image.ndim not in {2, 3}:
350
+ raise ValueError("image must be a 2D or 3D array")
351
+ if len(calibration.lenses) != 2:
352
+ raise ValueError("expected calibration for exactly two lenses")
353
+ if not 0.0 < fisheye_fov <= 360.0:
354
+ raise ValueError("fisheye_fov must be between 0 and 360 degrees")
355
+ if blend_width < 0.0:
356
+ raise ValueError("blend_width must be non-negative")
357
+
358
+ source = image[:, :, None] if image.ndim == 2 else image
359
+ coordinates = [
360
+ _fisheye_coordinates(
361
+ directions,
362
+ calibration.lenses[0],
363
+ lens_index=0,
364
+ fisheye_fov=fisheye_fov,
365
+ use_lens_roll=use_lens_roll,
366
+ ),
367
+ _fisheye_coordinates(
368
+ directions,
369
+ calibration.lenses[1],
370
+ lens_index=1,
371
+ fisheye_fov=fisheye_fov,
372
+ use_lens_roll=use_lens_roll,
373
+ ),
374
+ ]
375
+ samples = [_bilinear_sample(source, x, y, valid) for x, y, valid, _ in coordinates]
376
+
377
+ if blend_width > 0.0:
378
+ weights = []
379
+ blend = np.deg2rad(blend_width)
380
+ for _, _, valid, theta in coordinates:
381
+ half_fov = np.deg2rad(fisheye_fov) / 2.0
382
+ weight = np.clip((half_fov - theta) / blend, 0.0, 1.0)
383
+ weights.append(np.where(valid, np.maximum(weight, 1e-6), 0.0))
384
+ total = weights[0] + weights[1]
385
+ out = np.zeros_like(samples[0], dtype=np.float32)
386
+ has_weight = total > 0.0
387
+ out[has_weight] = (
388
+ samples[0][has_weight] * weights[0][has_weight, None]
389
+ + samples[1][has_weight] * weights[1][has_weight, None]
390
+ ) / total[has_weight, None]
391
+ else:
392
+ valid0 = coordinates[0][2]
393
+ valid1 = coordinates[1][2]
394
+ score0 = np.where(valid0, np.cos(coordinates[0][3]), -np.inf)
395
+ score1 = np.where(valid1, np.cos(coordinates[1][3]), -np.inf)
396
+ use1 = score1 > score0
397
+ out = np.where(use1[:, :, None], samples[1], samples[0])
398
+ out[~(valid0 | valid1)] = 0.0
399
+
400
+ return out[:, :, 0] if image.ndim == 2 else out
401
+
402
+
403
+ def _fisheye_coordinates(
404
+ directions: np.ndarray,
405
+ lens: LensCalibration,
406
+ *,
407
+ lens_index: int,
408
+ fisheye_fov: float,
409
+ use_lens_roll: bool,
410
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
411
+ if lens_index == 0:
412
+ axis = np.array([0.0, 0.0, 1.0], dtype=np.float32)
413
+ x_axis = np.array([1.0, 0.0, 0.0], dtype=np.float32)
414
+ else:
415
+ axis = np.array([0.0, 0.0, -1.0], dtype=np.float32)
416
+ x_axis = np.array([-1.0, 0.0, 0.0], dtype=np.float32)
417
+ y_axis = np.array([0.0, -1.0, 0.0], dtype=np.float32)
418
+
419
+ local_x = np.tensordot(directions, x_axis, axes=([2], [0]))
420
+ local_y = np.tensordot(directions, y_axis, axes=([2], [0]))
421
+ local_z = np.tensordot(directions, axis, axes=([2], [0]))
422
+ local_z = np.clip(local_z, -1.0, 1.0)
423
+
424
+ if use_lens_roll:
425
+ roll = np.deg2rad(lens.rotation_degrees[2])
426
+ cos_roll = np.cos(roll)
427
+ sin_roll = np.sin(roll)
428
+ rolled_x = cos_roll * local_x - sin_roll * local_y
429
+ rolled_y = sin_roll * local_x + cos_roll * local_y
430
+ local_x, local_y = rolled_x, rolled_y
431
+
432
+ theta = np.arccos(local_z)
433
+ half_fov = np.deg2rad(fisheye_fov) / 2.0
434
+ sin_theta = np.sqrt(np.maximum(1.0 - local_z * local_z, 0.0))
435
+ radius = lens.radius * theta / half_fov
436
+ scale = np.divide(
437
+ radius,
438
+ sin_theta,
439
+ out=np.zeros_like(radius, dtype=np.float32),
440
+ where=sin_theta > 1e-7,
441
+ )
442
+
443
+ x = lens.center_x + local_x * scale
444
+ y = lens.center_y + local_y * scale
445
+ valid = theta <= half_fov + 1e-6
446
+ return x, y, valid, theta
447
+
448
+
449
+ def _bilinear_sample(
450
+ image: np.ndarray,
451
+ x: np.ndarray,
452
+ y: np.ndarray,
453
+ valid: np.ndarray,
454
+ ) -> np.ndarray:
455
+ height, width = image.shape[:2]
456
+ in_bounds = (x >= 0.0) & (x <= width - 1) & (y >= 0.0) & (y <= height - 1)
457
+ valid = valid & in_bounds
458
+
459
+ x0 = np.floor(np.clip(x, 0, width - 1)).astype(np.int64)
460
+ y0 = np.floor(np.clip(y, 0, height - 1)).astype(np.int64)
461
+ x1 = np.clip(x0 + 1, 0, width - 1)
462
+ y1 = np.clip(y0 + 1, 0, height - 1)
463
+
464
+ wx = (x - x0)[:, :, None]
465
+ wy = (y - y0)[:, :, None]
466
+
467
+ top = image[y0, x0].astype(np.float32) * (1.0 - wx) + image[y0, x1].astype(
468
+ np.float32
469
+ ) * wx
470
+ bottom = image[y1, x0].astype(np.float32) * (1.0 - wx) + image[y1, x1].astype(
471
+ np.float32
472
+ ) * wx
473
+ out = top * (1.0 - wy) + bottom * wy
474
+ out[~valid] = 0.0
475
+ return out
476
+
477
+
478
+ def _restore_dtype(image: np.ndarray, dtype: np.dtype) -> np.ndarray:
479
+ dtype = np.dtype(dtype)
480
+ if np.issubdtype(dtype, np.integer):
481
+ info = np.iinfo(dtype)
482
+ return np.clip(np.rint(image), info.min, info.max).astype(dtype)
483
+ return image.astype(dtype, copy=False)
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: readinsp
3
+ Version: 0.1.0
4
+ Summary: Read Insta360 .insp still images into NumPy arrays
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: numpy
8
+ Requires-Dist: Pillow
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest; extra == "test"
11
+
12
+ # readinsp
13
+
14
+ `readinsp` reads Insta360 `.insp` still-image files into NumPy arrays.
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from readinsp import read_insp, read_metadata, read_preview
20
+
21
+ image = read_insp("example.insp")
22
+ print(image.shape, image.dtype) # (3040, 6080, 3) uint8
23
+
24
+ preview = read_preview("example.insp")
25
+ print(preview.shape) # (960, 1920, 3)
26
+
27
+ metadata = read_metadata("example.insp")
28
+ print(metadata.width, metadata.height, metadata.preview_size)
29
+ print(metadata.exif.get("Model"))
30
+ print(metadata.trailer.fields.get("camera_model"))
31
+ ```
32
+
33
+ ## Rectilinear views
34
+
35
+ `read_rectilinear` undistorts the two circular fisheye images, combines the two lenses, and renders a regular perspective view:
36
+
37
+ ```python
38
+ from readinsp import read_rectilinear
39
+
40
+ view = read_rectilinear(
41
+ "example.insp",
42
+ spherical_angle=(0, 0), # yaw, pitch in degrees
43
+ hfov=90,
44
+ vfov=60,
45
+ width=1200,
46
+ )
47
+ print(view.shape) # (693, 1200, 3)
48
+ ```
49
+
50
+ Angle convention: yaw `0` points at the left fisheye lens, yaw `180` points at the right fisheye lens, and positive pitch looks up. `spherical_angle` may also include a third roll value: `(yaw, pitch, roll)`. When `height` is omitted, it is computed from the FOVs so the output has approximately square angular sampling near the view center. The default dual-fisheye model assumes a `190` degree fisheye per lens with an `8` degree blend near the stitch boundary. Both values can be overridden.
51
+
52
+ Gravity leveling is available for files with a valid EXIF MakerNote gravity vector:
53
+
54
+ ```python
55
+ from readinsp import SphericalAngle, read_rectilinear
56
+
57
+ floor = read_rectilinear(
58
+ "example.insp",
59
+ spherical_angle=SphericalAngle(yaw=0, pitch=-90, gravity_level=True),
60
+ hfov=90,
61
+ vfov=70,
62
+ width=1000,
63
+ )
64
+ ```
65
+
66
+ With `gravity_level=True`, positive pitch looks opposite gravity and negative pitch looks along gravity. Yaw is still camera-relative because the inspected files do not include a usable magnetic-north heading.
67
+
68
+ Install for local development from the repository root with:
69
+
70
+ ```bash
71
+ python -m pip install -e readinsp
72
+ ```
73
+
74
+ ## Limitations
75
+
76
+ This is a reverse-engineered parser based on samples from the OneR and OneRS cameras and might not work for other camera models. Rectilinear rendering is an approximate open implementation and will not match Insta360 Studio's proprietary stitching exactly, especially near the lens seam.
@@ -0,0 +1,7 @@
1
+ readinsp/__init__.py,sha256=a6CkH31nD5I9E4NxDZRrdD5WiMpF_pYdgREk2Vmr_4A,796
2
+ readinsp/core.py,sha256=3C9aQzpRSGn8ukENWQK9Igxqo1urWeo1yLGqXafU9To,11934
3
+ readinsp/projection.py,sha256=FdLxweclVgfEMjmfGnoRiQ4vlDRCFvJBU-RnEyEehbs,15615
4
+ readinsp-0.1.0.dist-info/METADATA,sha256=5GAXgl_dWvWwKWOTC50OxXEvUqnOLXcIqrnki-Z2SG4,2630
5
+ readinsp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ readinsp-0.1.0.dist-info/top_level.txt,sha256=mEXpNW4_GSc4MIqBwxcolZWTG7fVnfWxy6s-LVid_h8,9
7
+ readinsp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ readinsp