Skip to content

Commit 20482b0

Browse files
authored
Merge pull request #790 from mapillary/support-h265
Handle negative ctts sample_offset values in mp4 parsing
2 parents ceca21d + 0f8e800 commit 20482b0

File tree

5 files changed

+71
-2
lines changed

5 files changed

+71
-2
lines changed

mapillary_tools/mp4/mp4_sample_parser.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
from . import construct_mp4_parser as cparser, simple_mp4_parser as sparser
88

99

10+
def _convert_to_signed_int32(unsigned_int32: int) -> int:
11+
"""Interpret an unsigned 32-bit value as negative if high bit is set."""
12+
if (unsigned_int32 & (1 << 31)) == 0:
13+
return unsigned_int32
14+
else:
15+
return unsigned_int32 - (1 << 32)
16+
17+
1018
class RawSample(T.NamedTuple):
1119
# 1-based index
1220
description_idx: int
@@ -192,7 +200,13 @@ def extract_raw_samples_from_stbl_data(
192200
composition_offsets = []
193201
for entry in data["entries"]:
194202
for _ in range(entry["sample_count"]):
195-
composition_offsets.append(entry["sample_offset"])
203+
# Some encodings like H.264 and H.265 support negative offsets.
204+
# We cannot rely on the version field since some encoders incorrectly set
205+
# ctts version to 0 instead of 1 even when using signed offsets.
206+
# Leigitimate positive values are relatively small so we can assume the value is signed.
207+
composition_offsets.append(
208+
_convert_to_signed_int32(entry["sample_offset"])
209+
)
196210
elif box["type"] == b"stss":
197211
syncs = set(data["entries"])
198212

3.83 MB
Binary file not shown.

tests/integration/test_process_and_upload.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,48 @@ def test_video_process_and_upload(
233233
"MAPOrientation": 1,
234234
"filetype": "image",
235235
},
236+
"sample-5s_h265_v_000001.jpg": {
237+
"filename": "sample-5s_h265_v_000001.jpg",
238+
"MAPFilename": "sample-5s_h265_v_000001.jpg",
239+
"MAPAltitude": 94.75,
240+
"MAPCaptureTime": "2025_03_14_07_00_00_000",
241+
"MAPCompassHeading": {
242+
"MagneticHeading": 0.484,
243+
"TrueHeading": 0.484,
244+
},
245+
"MAPLatitude": 37.793585,
246+
"MAPLongitude": -122.461396,
247+
"MAPOrientation": 1,
248+
"filetype": "image",
249+
},
250+
"sample-5s_h265_v_000002.jpg": {
251+
"filename": "sample-5s_h265_v_000002.jpg",
252+
"MAPFilename": "sample-5s_h265_v_000002.jpg",
253+
"MAPAltitude": 93.347,
254+
"MAPCaptureTime": "2025_03_14_07_00_02_000",
255+
"MAPCompassHeading": {
256+
"MagneticHeading": 0.484,
257+
"TrueHeading": 0.484,
258+
},
259+
"MAPLatitude": 37.7937349,
260+
"MAPLongitude": -122.4613944,
261+
"MAPOrientation": 1,
262+
"filetype": "image",
263+
},
264+
"sample-5s_h265_v_000003.jpg": {
265+
"filename": "sample-5s_h265_v_000003.jpg",
266+
"MAPFilename": "sample-5s_h265_v_000003.jpg",
267+
"MAPAltitude": 92.492,
268+
"MAPCaptureTime": "2025_03_14_07_00_04_000",
269+
"MAPCompassHeading": {
270+
"MagneticHeading": 343.286,
271+
"TrueHeading": 343.286,
272+
},
273+
"MAPLatitude": 37.7938825,
274+
"MAPLongitude": -122.4614226,
275+
"MAPOrientation": 1,
276+
"filetype": "image",
277+
},
236278
}
237279
uploaded_descs = sum(extract_all_uploaded_descs(Path(setup_upload)), [])
238280
assert_same_image_descs(uploaded_descs, list(expected.values()))

tests/integration/test_video_process.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def test_video_process(setup_data: py.path.local):
125125
str(video_dir.join("my_samples")),
126126
]
127127
)
128-
assert 3 == len(descs)
128+
assert 6 == len(descs)
129129
assert 0 == len([d for d in descs if "error" in d])
130130

131131

tests/unit/test_mp4_sample_parser.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,16 @@ def test_movie_box_parser():
5252
assert sample.raw_sample.offset == raw_sample.offset
5353
assert sample.raw_sample.is_sync == raw_sample.is_sync
5454
assert sample.raw_sample.size == raw_sample.size
55+
56+
57+
def test_movie_box_parser_negative_composition_offset():
58+
moov_parser = mp4_sample_parser.MovieBoxParser.parse_file(
59+
Path("tests/data/videos/sample-5s_h265.mp4")
60+
)
61+
assert 2 == len(list(moov_parser.extract_tracks()))
62+
video_track = moov_parser.extract_track_at(0)
63+
assert video_track.is_video_track()
64+
raw_samples = list(video_track.extract_raw_samples())
65+
assert 146 == len(raw_samples)
66+
# Make sure the parser can parse negative composition offsets
67+
assert 0 < len([s for s in raw_samples if s.composition_offset < 0])

0 commit comments

Comments
 (0)