From 27d42621117557a5a4057ad4e7db247c0b3c093a Mon Sep 17 00:00:00 2001 From: Caglar Pir Date: Thu, 29 Jan 2026 14:07:49 +0100 Subject: [PATCH] Avoid zigzag patterns in sequences Summary: In some cases we get images from different streets intermingled, causing the infamous spiderweb pattern. This pull request attempts to detect and prevent upload of such sequences. The behavior can be tuned with env variables and turned off with a command line flag. Test Plan: unit tests --- mapillary_tools/commands/process.py | 7 + mapillary_tools/constants.py | 7 + mapillary_tools/exceptions.py | 4 + .../process_sequence_properties.py | 117 +++++++++- tests/unit/test_sequence_processing.py | 213 ++++++++++++++++++ 5 files changed, 345 insertions(+), 3 deletions(-) diff --git a/mapillary_tools/commands/process.py b/mapillary_tools/commands/process.py index 6ed6b4f6..4af4b95b 100644 --- a/mapillary_tools/commands/process.py +++ b/mapillary_tools/commands/process.py @@ -208,6 +208,13 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser): default=constants.DUPLICATE_ANGLE, required=False, ) + group_sequence.add_argument( + "--skip_zigzag_check", + help="Skip the GPS zig-zag pattern detection check.", + action="store_true", + default=False, + required=False, + ) def run(self, vars_args: dict): metadatas = process_geotag_properties( diff --git a/mapillary_tools/constants.py b/mapillary_tools/constants.py index 19e58295..15792226 100644 --- a/mapillary_tools/constants.py +++ b/mapillary_tools/constants.py @@ -141,6 +141,13 @@ def _parse_scaled_integers( MAX_SEQUENCE_PIXELS: int | None = _parse_pixels( os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_PIXELS", "6G") ) +# Zig-zag detection parameters +ZIGZAG_WINDOW_SIZE = int(os.getenv(_ENV_PREFIX + "ZIGZAG_WINDOW_SIZE", 5)) +ZIGZAG_BACKTRACK_THRESHOLD = float( + os.getenv(_ENV_PREFIX + "ZIGZAG_BACKTRACK_THRESHOLD", 0.8) +) +ZIGZAG_MIN_BACKTRACKS = int(os.getenv(_ENV_PREFIX + "ZIGZAG_MIN_BACKTRACKS", 1)) +ZIGZAG_MIN_DISTANCE = float(os.getenv(_ENV_PREFIX + "ZIGZAG_MIN_DISTANCE", 30)) ################## diff --git a/mapillary_tools/exceptions.py b/mapillary_tools/exceptions.py index 3c4c629a..5474fd1b 100644 --- a/mapillary_tools/exceptions.py +++ b/mapillary_tools/exceptions.py @@ -112,6 +112,10 @@ class MapillaryNullIslandError(MapillaryDescriptionError): pass +class MapillaryZigZagError(MapillaryDescriptionError): + pass + + class MapillaryUploadConnectionError(MapillaryUserError): exit_code = 12 diff --git a/mapillary_tools/process_sequence_properties.py b/mapillary_tools/process_sequence_properties.py index 7e53c5f2..7db7899f 100644 --- a/mapillary_tools/process_sequence_properties.py +++ b/mapillary_tools/process_sequence_properties.py @@ -298,9 +298,11 @@ def _check_sequences_by_limits( try: if max_sequence_filesize_in_bytes is not None: sequence_filesize = sum( - utils.get_file_size(image.filename) - if image.filesize is None - else image.filesize + ( + utils.get_file_size(image.filename) + if image.filesize is None + else image.filesize + ) for image in sequence ) if sequence_filesize > max_sequence_filesize_in_bytes: @@ -402,6 +404,102 @@ def _check_sequences_duplication( return output_sequences, output_errors +def _check_sequences_zigzag( + input_sequences: T.Sequence[PointSequence], + window_size: int = 5, + backtrack_threshold: float = 0.8, + min_backtracks: int = 1, + min_distance: float = 50.0, +) -> tuple[list[PointSequence], list[types.ErrorMetadata]]: + """ + Check for zig-zag GPS patterns where images jump back and forth between locations. + + Detects spatial backtracking - when an image returns closer to earlier images + than the previous image was. This catches zig-zag patterns where the sequence + jumps to a different location and then returns. + + Args: + input_sequences: List of image sequences to check + window_size: Number of images to look back when checking for backtracking + backtrack_threshold: Ratio threshold - if dist_curr < dist_prev * threshold, + it's considered backtracking + min_backtracks: Minimum number of backtrack events to fail the sequence + min_distance: Minimum distance (in meters) between consecutive images (prev to curr) + to consider for backtracking. This filters out small-scale + movements like U-turns. + """ + output_sequences: list[PointSequence] = [] + output_errors: list[types.ErrorMetadata] = [] + + for sequence in input_sequences: + if len(sequence) < window_size + 1: + # Sequence too short to detect pattern + output_sequences.append(sequence) + continue + + backtrack_count = 0 + backtrack_locations: list[str] = [] + + for i in range(window_size, len(sequence)): + curr = sequence[i] + prev = sequence[i - 1] + ref = sequence[i - window_size] + + # Distance between consecutive images (prev to curr) + dist_prev_curr = geo.gps_distance( + (prev.lat, prev.lon), (curr.lat, curr.lon) + ) + # Distance from current image back to reference + dist_curr = geo.gps_distance((curr.lat, curr.lon), (ref.lat, ref.lon)) + # Distance from previous image to reference + dist_prev = geo.gps_distance((prev.lat, prev.lon), (ref.lat, ref.lon)) + + # Backtracking: current is closer to reference than previous was + # Only check if the jump between prev and curr is above min_distance + if ( + dist_prev_curr > min_distance + and dist_curr < dist_prev * backtrack_threshold + ): + backtrack_count += 1 + backtrack_locations.append(curr.filename.name) + LOG.debug( + f"Potential zigzag at {curr.filename.name}: " + f"dist_curr={dist_curr:.1f}m < dist_prev={dist_prev:.1f}m * {backtrack_threshold}, " + f"jump={dist_prev_curr:.1f}m" + ) + + if backtrack_count >= min_backtracks: + locations_preview = ", ".join(backtrack_locations[:5]) + if len(backtrack_locations) > 5: + locations_preview += "..." + + ex = exceptions.MapillaryZigZagError( + f"GPS zig-zag pattern detected: {backtrack_count} backtrack events " + f"found (at: {locations_preview})" + ) + LOG.error(f"{_sequence_name(sequence)}: {ex}") + for image in sequence: + output_errors.append( + types.describe_error_metadata( + exc=ex, filename=image.filename, filetype=types.FileType.IMAGE + ) + ) + else: + output_sequences.append(sequence) + + # Assertion to ensure all images accounted for + assert sum(len(s) for s in output_sequences) + len(output_errors) == sum( + len(s) for s in input_sequences + ) + + if output_errors: + LOG.info( + f"Zig-zag check: {len(output_errors)} images rejected due to GPS zig-zag patterns" + ) + + return output_sequences, output_errors + + class SplitState(T.TypedDict, total=False): sequence_images: int sequence_file_size: int @@ -609,6 +707,7 @@ def process_sequence_properties( duplicate_distance: float = constants.DUPLICATE_DISTANCE, duplicate_angle: float = constants.DUPLICATE_ANGLE, max_capture_speed_kmh: float = constants.MAX_CAPTURE_SPEED_KMH, + skip_zigzag_check: bool = False, ) -> list[types.MetadataOrError]: LOG.info("==> Processing sequences...") @@ -688,6 +787,18 @@ def process_sequence_properties( ) error_metadatas.extend(errors) + # Check for zig-zag GPS patterns + # NOTE: This is done after _check_sequences_by_limits to filter missing of zero coordinates + if not skip_zigzag_check: + sequences, errors = _check_sequences_zigzag( + sequences, + window_size=constants.ZIGZAG_WINDOW_SIZE, + backtrack_threshold=constants.ZIGZAG_BACKTRACK_THRESHOLD, + min_backtracks=constants.ZIGZAG_MIN_BACKTRACKS, + min_distance=constants.ZIGZAG_MIN_DISTANCE, + ) + error_metadatas.extend(errors) + # Split sequences by cutoff distance # NOTE: The speed limit check probably rejects most anomalies sequences = _split_sequences_by_limits( diff --git a/tests/unit/test_sequence_processing.py b/tests/unit/test_sequence_processing.py index 4b64eae7..fb3e805f 100644 --- a/tests/unit/test_sequence_processing.py +++ b/tests/unit/test_sequence_processing.py @@ -703,3 +703,216 @@ def test_split_sequence_no_split(tmpdir): metadatas = psp.process_sequence_properties(sequence) assert 1 == len({m.MAPSequenceUUID for m in metadatas}), metadatas # type: ignore + + +def test_zigzag_detection_straight_path(tmpdir: py.path.local): + """Test that a straight path does not trigger zig-zag detection.""" + # Create a sequence moving in a straight line (increasing lat) + sequence = [ + _make_image_metadata( + Path(tmpdir) / Path(f"./img{i}.jpg"), + 1.0, + 1.0 + i * 0.0001, # Moving north in a straight line + i, + filesize=1, + ) + for i in range(10) + ] + + metadatas = psp.process_sequence_properties(sequence) + image_metadatas = [d for d in metadatas if isinstance(d, types.ImageMetadata)] + error_metadatas = [d for d in metadatas if isinstance(d, types.ErrorMetadata)] + + # All images should pass (no zig-zag detected) + assert len(image_metadatas) == 10 + assert len(error_metadatas) == 0 + + +def test_zigzag_detection_backtracking(tmpdir: py.path.local): + """Test that a zig-zag pattern with backtracking is detected.""" + # Create a sequence that moves forward then jumps back + # Images 0-4: moving forward + # Images 5-6: jump to a different location + # Images 7-9: jump back near images 0-4 (backtracking) + sequence = [ + # Moving forward on street 1 + _make_image_metadata( + Path(tmpdir) / Path("./img0.jpg"), 1.0, 1.0000, 0, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img1.jpg"), 1.0, 1.0001, 1, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img2.jpg"), 1.0, 1.0002, 2, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img3.jpg"), 1.0, 1.0003, 3, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img4.jpg"), 1.0, 1.0004, 4, filesize=1 + ), + # Jump to parallel street 2 (far away) + _make_image_metadata( + Path(tmpdir) / Path("./img5.jpg"), 1.001, 1.0005, 5, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img6.jpg"), 1.001, 1.0006, 6, filesize=1 + ), + # Jump back near street 1 (backtracking) + _make_image_metadata( + Path(tmpdir) / Path("./img7.jpg"), 1.0, 1.0007, 7, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img8.jpg"), 1.0, 1.0008, 8, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img9.jpg"), 1.0, 1.0009, 9, filesize=1 + ), + ] + + metadatas = psp.process_sequence_properties(sequence) + error_metadatas = [d for d in metadatas if isinstance(d, types.ErrorMetadata)] + + # Zig-zag should be detected + assert len(error_metadatas) == 10 # All images in sequence should be rejected + assert all( + isinstance(d.error, exceptions.MapillaryZigZagError) for d in error_metadatas + ) + + +def test_zigzag_detection_skip_flag(tmpdir: py.path.local): + """Test that skip_zigzag_check=True bypasses the check.""" + # Same zig-zag pattern as above + sequence = [ + _make_image_metadata( + Path(tmpdir) / Path("./img0.jpg"), 1.0, 1.0000, 0, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img1.jpg"), 1.0, 1.0001, 1, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img2.jpg"), 1.0, 1.0002, 2, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img3.jpg"), 1.0, 1.0003, 3, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img4.jpg"), 1.0, 1.0004, 4, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img5.jpg"), 1.001, 1.0005, 5, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img6.jpg"), 1.001, 1.0006, 6, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img7.jpg"), 1.0, 1.0007, 7, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img8.jpg"), 1.0, 1.0008, 8, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img9.jpg"), 1.0, 1.0009, 9, filesize=1 + ), + ] + + metadatas = psp.process_sequence_properties(sequence, skip_zigzag_check=True) + image_metadatas = [d for d in metadatas if isinstance(d, types.ImageMetadata)] + zigzag_errors = [ + d + for d in metadatas + if isinstance(d, types.ErrorMetadata) + and isinstance(d.error, exceptions.MapillaryZigZagError) + ] + + # With skip flag, no zig-zag errors should be raised + assert len(zigzag_errors) == 0 + # Note: duplication check may remove some images + assert len(image_metadatas) > 0 + + +def test_zigzag_detection_short_sequence(tmpdir: py.path.local): + """Test that sequences shorter than window_size+1 are skipped.""" + # Create a short sequence (less than window_size + 1 = 6 images) + sequence = [ + _make_image_metadata( + Path(tmpdir) / Path(f"./img{i}.jpg"), + 1.0, + 1.0 + i * 0.0001, + i, + filesize=1, + ) + for i in range(5) + ] + + metadatas = psp.process_sequence_properties(sequence) + image_metadatas = [d for d in metadatas if isinstance(d, types.ImageMetadata)] + zigzag_errors = [ + d + for d in metadatas + if isinstance(d, types.ErrorMetadata) + and isinstance(d.error, exceptions.MapillaryZigZagError) + ] + + # Short sequence should not be checked for zig-zag + assert len(zigzag_errors) == 0 + assert len(image_metadatas) == 5 + + +def test_zigzag_detection_uturn_not_triggered(tmpdir: py.path.local): + """Test that a U-turn (small distance < 50m) does not trigger zig-zag detection. + + U-turns are legitimate captures where the camera goes down a street, + turns around, and comes back. The min_distance threshold (50m) should + filter these out since the backtrack distances are small. + + At the equator: 0.0001 degrees ≈ 11 meters + So the distances in this test are ~11-33m, below the 50m threshold. + """ + sequence = [ + # Going forward (each step ~11m) + _make_image_metadata( + Path(tmpdir) / Path("./img0.jpg"), 1.0, 1.0000, 0, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img1.jpg"), 1.0, 1.0001, 1, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img2.jpg"), 1.0, 1.0002, 2, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img3.jpg"), 1.0, 1.0003, 3, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img4.jpg"), 1.0, 1.0004, 4, filesize=1 + ), + # U-turn - coming back (each step ~11m, total backtrack ~33m < 50m threshold) + _make_image_metadata( + Path(tmpdir) / Path("./img5.jpg"), 1.0, 1.0003, 5, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img6.jpg"), 1.0, 1.0002, 6, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img7.jpg"), 1.0, 1.0001, 7, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img8.jpg"), 1.0, 1.0000, 8, filesize=1 + ), + _make_image_metadata( + Path(tmpdir) / Path("./img9.jpg"), 1.0, 0.9999, 9, filesize=1 + ), + ] + + metadatas = psp.process_sequence_properties(sequence) + image_metadatas = [d for d in metadatas if isinstance(d, types.ImageMetadata)] + zigzag_errors = [ + d + for d in metadatas + if isinstance(d, types.ErrorMetadata) + and isinstance(d.error, exceptions.MapillaryZigZagError) + ] + + # U-turn should NOT trigger zig-zag detection (distances < 50m threshold) + assert len(zigzag_errors) == 0 + assert len(image_metadatas) > 0