|
26 | 26 | from geotessera.registry import ( |
27 | 27 | EMBEDDINGS_DIR_NAME, |
28 | 28 | LANDMASKS_DIR_NAME, |
| 29 | + tile_from_world, |
29 | 30 | tile_to_landmask_filename, |
30 | 31 | tile_to_embedding_paths, |
31 | 32 | ) |
@@ -118,6 +119,23 @@ def format_bbox(bbox_coords) -> str: |
118 | 119 | return f"[{min_lon_str}, {min_lat_str}] - [{max_lon_str}, {max_lat_str}]" |
119 | 120 |
|
120 | 121 |
|
| 122 | +def point_to_tile_bbox(lon: float, lat: float) -> tuple: |
| 123 | + """Convert a point to the bounding box of its containing tile. |
| 124 | +
|
| 125 | + Tiles are 0.1x0.1 degree squares centered at 0.05-degree offsets. |
| 126 | +
|
| 127 | + Args: |
| 128 | + lon: Longitude in decimal degrees |
| 129 | + lat: Latitude in decimal degrees |
| 130 | +
|
| 131 | + Returns: |
| 132 | + Tuple of (min_lon, min_lat, max_lon, max_lat) for the containing tile |
| 133 | + """ |
| 134 | + tile_lon, tile_lat = tile_from_world(lon, lat) |
| 135 | + # Tile center ± 0.05 degrees |
| 136 | + return (tile_lon - 0.05, tile_lat - 0.05, tile_lon + 0.05, tile_lat + 0.05) |
| 137 | + |
| 138 | + |
121 | 139 | app = typer.Typer( |
122 | 140 | name="geotessera", |
123 | 141 | help=f"GeoTessera v{__version__}: Download satellite embedding tiles as GeoTIFFs", |
@@ -533,6 +551,17 @@ def coverage( |
533 | 551 | help="Country name to focus coverage map on (e.g., 'United Kingdom', 'UK', 'GB')", |
534 | 552 | ), |
535 | 553 | ] = None, |
| 554 | + bbox: Annotated[ |
| 555 | + Optional[str], |
| 556 | + typer.Option( |
| 557 | + "--bbox", |
| 558 | + help="Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'", |
| 559 | + ), |
| 560 | + ] = None, |
| 561 | + tile: Annotated[ |
| 562 | + Optional[str], |
| 563 | + typer.Option("--tile", help="Single tile by any point within it: 'lon,lat'"), |
| 564 | + ] = None, |
536 | 565 | tile_color: Annotated[ |
537 | 566 | str, |
538 | 567 | typer.Option( |
@@ -611,13 +640,60 @@ def coverage( |
611 | 640 | country_geojson_file = None |
612 | 641 | region_file_temp = None # Track if we created a temporary file |
613 | 642 |
|
614 | | - if region_file and country: |
| 643 | + # Check mutual exclusivity of region options |
| 644 | + region_sources = sum(1 for x in [bbox, tile, region_file, country] if x) |
| 645 | + if region_sources > 1: |
615 | 646 | rprint( |
616 | | - "[red]Error: Cannot specify both --region-file and --country. Choose one.[/red]" |
| 647 | + "[red]Error: Cannot specify multiple region options. " |
| 648 | + "Choose one of: --bbox, --tile, --region-file, --country[/red]" |
617 | 649 | ) |
618 | 650 | raise typer.Exit(1) |
619 | 651 |
|
620 | | - if region_file: |
| 652 | + if tile: |
| 653 | + try: |
| 654 | + tile_coords = tuple(map(float, tile.split(","))) |
| 655 | + if len(tile_coords) != 2: |
| 656 | + rprint("[red]Error: --tile must be 'lon,lat'[/red]") |
| 657 | + raise typer.Exit(1) |
| 658 | + lon, lat = tile_coords |
| 659 | + tile_center = tile_from_world(lon, lat) |
| 660 | + region_bbox = point_to_tile_bbox(lon, lat) |
| 661 | + rprint( |
| 662 | + f"[green]Point ({lon}, {lat}) → tile " |
| 663 | + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" |
| 664 | + ) |
| 665 | + rprint(f"[green]Region bounding box:[/green] {format_bbox(region_bbox)}") |
| 666 | + except ValueError as e: |
| 667 | + rprint(f"[red]Error: Invalid --tile format. Use 'lon,lat': {e}[/red]") |
| 668 | + raise typer.Exit(1) |
| 669 | + elif bbox: |
| 670 | + try: |
| 671 | + bbox_coords = tuple(map(float, bbox.split(","))) |
| 672 | + if len(bbox_coords) == 2: |
| 673 | + # Two coordinates = single tile |
| 674 | + lon, lat = bbox_coords |
| 675 | + tile_center = tile_from_world(lon, lat) |
| 676 | + region_bbox = point_to_tile_bbox(lon, lat) |
| 677 | + rprint( |
| 678 | + f"[green]Point ({lon}, {lat}) → tile " |
| 679 | + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" |
| 680 | + ) |
| 681 | + elif len(bbox_coords) == 4: |
| 682 | + region_bbox = bbox_coords |
| 683 | + else: |
| 684 | + rprint( |
| 685 | + "[red]Error: bbox must be 'lon,lat' (single tile) " |
| 686 | + "or 'min_lon,min_lat,max_lon,max_lat'[/red]" |
| 687 | + ) |
| 688 | + raise typer.Exit(1) |
| 689 | + rprint(f"[green]Region bounding box:[/green] {format_bbox(region_bbox)}") |
| 690 | + except ValueError: |
| 691 | + rprint( |
| 692 | + "[red]Error: Invalid bbox format. Use: 'lon,lat' or " |
| 693 | + "'min_lon,min_lat,max_lon,max_lat'[/red]" |
| 694 | + ) |
| 695 | + raise typer.Exit(1) |
| 696 | + elif region_file: |
621 | 697 | try: |
622 | 698 | from .visualization import calculate_bbox_from_file |
623 | 699 |
|
@@ -892,7 +968,14 @@ def download( |
892 | 968 | ] = None, |
893 | 969 | bbox: Annotated[ |
894 | 970 | Optional[str], |
895 | | - typer.Option("--bbox", help="Bounding box: 'min_lon,min_lat,max_lon,max_lat'"), |
| 971 | + typer.Option( |
| 972 | + "--bbox", |
| 973 | + help="Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'", |
| 974 | + ), |
| 975 | + ] = None, |
| 976 | + tile: Annotated[ |
| 977 | + Optional[str], |
| 978 | + typer.Option("--tile", help="Single tile by any point within it: 'lon,lat'"), |
896 | 979 | ] = None, |
897 | 980 | region_file: Annotated[ |
898 | 981 | Optional[str], |
@@ -1006,19 +1089,58 @@ def download( |
1006 | 1089 | verify_hashes=not skip_hash, |
1007 | 1090 | ) |
1008 | 1091 |
|
1009 | | - # Parse bounding box |
1010 | | - if bbox: |
| 1092 | + # Check mutual exclusivity of region options |
| 1093 | + region_sources = sum(1 for x in [bbox, tile, region_file, country] if x) |
| 1094 | + if region_sources > 1: |
| 1095 | + rprint( |
| 1096 | + "[red]Error: Cannot specify multiple region options. " |
| 1097 | + "Choose one of: --bbox, --tile, --region-file, --country[/red]" |
| 1098 | + ) |
| 1099 | + raise typer.Exit(1) |
| 1100 | + |
| 1101 | + # Parse bounding box or tile |
| 1102 | + if tile: |
| 1103 | + try: |
| 1104 | + tile_coords = tuple(map(float, tile.split(","))) |
| 1105 | + if len(tile_coords) != 2: |
| 1106 | + rprint("[red]Error: --tile must be 'lon,lat'[/red]") |
| 1107 | + raise typer.Exit(1) |
| 1108 | + lon, lat = tile_coords |
| 1109 | + tile_center = tile_from_world(lon, lat) |
| 1110 | + bbox_coords = point_to_tile_bbox(lon, lat) |
| 1111 | + rprint( |
| 1112 | + f"[green]Point ({lon}, {lat}) → tile " |
| 1113 | + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" |
| 1114 | + ) |
| 1115 | + rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") |
| 1116 | + except ValueError as e: |
| 1117 | + rprint(f"[red]Error: Invalid --tile format. Use 'lon,lat': {e}[/red]") |
| 1118 | + raise typer.Exit(1) |
| 1119 | + elif bbox: |
1011 | 1120 | try: |
1012 | 1121 | bbox_coords = tuple(map(float, bbox.split(","))) |
1013 | | - if len(bbox_coords) != 4: |
| 1122 | + if len(bbox_coords) == 2: |
| 1123 | + # Two coordinates = single tile |
| 1124 | + lon, lat = bbox_coords |
| 1125 | + tile_center = tile_from_world(lon, lat) |
| 1126 | + bbox_coords = point_to_tile_bbox(lon, lat) |
1014 | 1127 | rprint( |
1015 | | - "[red]Error: bbox must be 'min_lon,min_lat,max_lon,max_lat'[/red]" |
| 1128 | + f"[green]Point ({lon}, {lat}) → tile " |
| 1129 | + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" |
| 1130 | + ) |
| 1131 | + rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") |
| 1132 | + elif len(bbox_coords) == 4: |
| 1133 | + rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") |
| 1134 | + else: |
| 1135 | + rprint( |
| 1136 | + "[red]Error: bbox must be 'lon,lat' (single tile) " |
| 1137 | + "or 'min_lon,min_lat,max_lon,max_lat'[/red]" |
1016 | 1138 | ) |
1017 | 1139 | raise typer.Exit(1) |
1018 | | - rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") |
1019 | 1140 | except ValueError: |
1020 | 1141 | rprint( |
1021 | | - "[red]Error: Invalid bbox format. Use: 'min_lon,min_lat,max_lon,max_lat'[/red]" |
| 1142 | + "[red]Error: Invalid bbox format. Use: 'lon,lat' or " |
| 1143 | + "'min_lon,min_lat,max_lon,max_lat'[/red]" |
1022 | 1144 | ) |
1023 | 1145 | raise typer.Exit(1) |
1024 | 1146 | elif region_file: |
@@ -1098,10 +1220,14 @@ def country_progress_callback(current: int, total: int, status: str = None): |
1098 | 1220 | rprint(f"[green]Using country '{country}':[/green] {format_bbox(bbox_coords)}") |
1099 | 1221 | else: |
1100 | 1222 | rprint( |
1101 | | - "[red]Error: Must specify either --bbox, --region-file, or --country[/red]" |
| 1223 | + "[red]Error: Must specify either --bbox, --tile, --region-file, or --country[/red]" |
1102 | 1224 | ) |
1103 | 1225 | rprint("Examples:") |
1104 | | - rprint(" --bbox '-0.2,51.4,0.1,51.6' # London area") |
| 1226 | + rprint(" --tile '0.17,52.23' # Single tile containing point") |
| 1227 | + rprint( |
| 1228 | + " --bbox '0.17,52.23' # Same as above (2 coords = single tile)" |
| 1229 | + ) |
| 1230 | + rprint(" --bbox '-0.2,51.4,0.1,51.6' # London area (4 coords = region)") |
1105 | 1231 | rprint(" --region-file london.geojson # From GeoJSON file") |
1106 | 1232 | rprint(" --country 'United Kingdom' # Country by name") |
1107 | 1233 | raise typer.Exit(1) |
|
0 commit comments