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 +41 -0
- readinsp/core.py +427 -0
- readinsp/projection.py +483 -0
- readinsp-0.1.0.dist-info/METADATA +76 -0
- readinsp-0.1.0.dist-info/RECORD +7 -0
- readinsp-0.1.0.dist-info/WHEEL +5 -0
- readinsp-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
readinsp
|