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