From 5fb87e21f8343c2e18b9adcfa2493cf825f2e76d Mon Sep 17 00:00:00 2001 From: Tao Peng Date: Wed, 24 Jun 2026 12:04:46 -0700 Subject: [PATCH] Accept ISO 8601 (with timezone) for --video_start_time The --video_start_time option only accepted the proprietary YYYY_MM_DD_HH_MM_SS_sss format, which is always interpreted as UTC. This left no clean way to correct timestamps for cameras whose RTC has no timezone and records local time (e.g. GoPro MAX), as reported in #819. Add a _parse_video_start_time() helper that also accepts ISO 8601: - an explicit UTC offset (or Z) is honored, and - a naive value is interpreted in the system local timezone. The legacy format is unchanged and still treated as UTC. Documented via doctests and updated the CLI help text. --- mapillary_tools/commands/sample_video.py | 2 +- mapillary_tools/sample_video.py | 56 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/mapillary_tools/commands/sample_video.py b/mapillary_tools/commands/sample_video.py index 2573f800..fa048700 100644 --- a/mapillary_tools/commands/sample_video.py +++ b/mapillary_tools/commands/sample_video.py @@ -41,7 +41,7 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser): ) group.add_argument( "--video_start_time", - help="Video start time specified in YYYY_MM_DD_HH_MM_SS_sss in UTC. For example 2020_12_28_12_36_36_508 represents 2020-12-28T12:36:36.508Z.", + help="Video start time, either as YYYY_MM_DD_HH_MM_SS_sss in UTC (for example 2020_12_28_12_36_36_508 represents 2020-12-28T12:36:36.508Z) or as ISO 8601, which may include a UTC offset (for example 2020-12-28T13:36:36.508+01:00). Use the ISO 8601 form for cameras that record local time, such as GoPro MAX. An ISO 8601 value without an offset is interpreted in the system local timezone.", default=None, required=False, ) diff --git a/mapillary_tools/sample_video.py b/mapillary_tools/sample_video.py index 12978718..85752672 100644 --- a/mapillary_tools/sample_video.py +++ b/mapillary_tools/sample_video.py @@ -23,6 +23,60 @@ LOG = logging.getLogger(__name__) +def _parse_video_start_time(value: str) -> datetime.datetime: + """ + Parse a ``--video_start_time`` value into a timezone-aware UTC datetime. + + Two formats are accepted: + + - The legacy proprietary format ``YYYY_MM_DD_HH_MM_SS_sss``, which is + always interpreted as UTC. + - ISO 8601, which may carry a UTC offset (e.g. + ``2020-12-28T12:36:36.508+01:00`` or ``...Z``). This lets users of + cameras that write local time (e.g. GoPro MAX, whose RTC has no + timezone) correct the offset. A naive ISO 8601 value (no offset) is + interpreted in the system's local timezone, so a wall-clock time + copied from such a camera lands at the right instant. + + Legacy format is always UTC: + + >>> _parse_video_start_time("2020_12_28_12_36_36_508").isoformat() + '2020-12-28T12:36:36.508000+00:00' + + ISO 8601 with an explicit offset (or ``Z``) keeps that offset: + + >>> _parse_video_start_time("2020-12-28T12:36:36.508+01:00").isoformat() + '2020-12-28T11:36:36.508000+00:00' + >>> _parse_video_start_time("2020-12-28T12:36:36.508Z").isoformat() + '2020-12-28T12:36:36.508000+00:00' + + Naive ISO 8601 is interpreted in the system local timezone (the result + below is shown relative to local time so the doctest is tz-independent): + + >>> naive = "2020-12-28T13:36:36.508" + >>> expected = datetime.datetime( + ... 2020, 12, 28, 13, 36, 36, 508000 + ... ).astimezone(datetime.timezone.utc) + >>> _parse_video_start_time(naive) == expected + True + + >>> _parse_video_start_time("not-a-timestamp") + Traceback (most recent call last): + ... + ValueError: Invalid isoformat string: 'not-a-timestamp' + """ + try: + return parse_capture_time(value) + except ValueError: + pass + + # datetime.fromisoformat does not accept a trailing "Z" before Python 3.11 + dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + # A naive value is assumed to be in the system local timezone; astimezone() + # treats naive datetimes as local and converts aware ones by their offset. + return dt.astimezone(datetime.timezone.utc) + + def _normalize_path( video_import_path: Path, skip_subfolders: bool ) -> tuple[Path, list[Path]]: @@ -71,7 +125,7 @@ def sample_video( video_start_time_dt: datetime.datetime | None = None if video_start_time is not None: try: - video_start_time_dt = parse_capture_time(video_start_time) + video_start_time_dt = _parse_video_start_time(video_start_time) except ValueError as ex: raise exceptions.MapillaryBadParameterError(str(ex))