Skip to content

Commit cabfe82

Browse files
committed
add a --tile option to the cli to make it easier to query single points
1 parent 8cdbc8a commit cabfe82

File tree

4 files changed

+303
-20
lines changed

4 files changed

+303
-20
lines changed

README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,10 @@ geotessera download [OPTIONS]
325325

326326
Options:
327327
-o, --output PATH Output directory [required]
328-
--bbox TEXT Bounding box: 'min_lon,min_lat,max_lon,max_lat'
328+
--bbox TEXT Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'
329+
--tile TEXT Single tile by any point within it: 'lon,lat'
329330
--region-file PATH GeoJSON/Shapefile to define region
331+
--country TEXT Country name (e.g., 'United Kingdom', 'UK', 'GB')
330332
-f, --format TEXT Output format: 'tiff' or 'npy' (default: tiff)
331333
--year INT Year of embeddings (default: 2024)
332334
--bands TEXT Comma-separated band indices (default: all 128)
@@ -335,6 +337,15 @@ Options:
335337
-v, --verbose Verbose output
336338
```
337339

340+
Single tile examples:
341+
```bash
342+
# Download a single tile containing a specific point
343+
geotessera download --tile "0.17,52.23" --year 2024 -o ./single_tile
344+
345+
# Same result using --bbox with 2 coordinates
346+
geotessera download --bbox "0.17,52.23" --year 2024 -o ./single_tile
347+
```
348+
338349
Output formats:
339350
- **tiff**: Georeferenced GeoTIFF files with UTM projection
340351
- **npy**: Raw numpy arrays with metadata.json file
@@ -366,12 +377,14 @@ geotessera coverage [OPTIONS]
366377
Options:
367378
-o, --output PATH Output PNG file (default: tessera_coverage.png)
368379
--year INT Specific year to visualize
380+
--bbox TEXT Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'
381+
--tile TEXT Single tile by any point within it: 'lon,lat'
382+
--region-file PATH GeoJSON/Shapefile to focus on specific region
383+
--country TEXT Country name to focus on (e.g., 'United Kingdom')
369384
--tile-color TEXT Color for tiles (default: red)
370385
--tile-alpha FLOAT Transparency 0-1 (default: 0.6)
371386
--tile-size FLOAT Size multiplier (default: 1.0)
372-
--dpi INT Output resolution (default: 100)
373-
--width INT Figure width in inches (default: 20)
374-
--height INT Figure height in inches (default: 10)
387+
--width INT Output image width in pixels (default: 2000)
375388
--no-countries Don't show country boundaries
376389
```
377390

geotessera/cli.py

Lines changed: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from geotessera.registry import (
2727
EMBEDDINGS_DIR_NAME,
2828
LANDMASKS_DIR_NAME,
29+
tile_from_world,
2930
tile_to_landmask_filename,
3031
tile_to_embedding_paths,
3132
)
@@ -118,6 +119,23 @@ def format_bbox(bbox_coords) -> str:
118119
return f"[{min_lon_str}, {min_lat_str}] - [{max_lon_str}, {max_lat_str}]"
119120

120121

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+
121139
app = typer.Typer(
122140
name="geotessera",
123141
help=f"GeoTessera v{__version__}: Download satellite embedding tiles as GeoTIFFs",
@@ -533,6 +551,17 @@ def coverage(
533551
help="Country name to focus coverage map on (e.g., 'United Kingdom', 'UK', 'GB')",
534552
),
535553
] = 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,
536565
tile_color: Annotated[
537566
str,
538567
typer.Option(
@@ -611,13 +640,60 @@ def coverage(
611640
country_geojson_file = None
612641
region_file_temp = None # Track if we created a temporary file
613642

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:
615646
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]"
617649
)
618650
raise typer.Exit(1)
619651

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:
621697
try:
622698
from .visualization import calculate_bbox_from_file
623699

@@ -892,7 +968,14 @@ def download(
892968
] = None,
893969
bbox: Annotated[
894970
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'"),
896979
] = None,
897980
region_file: Annotated[
898981
Optional[str],
@@ -1006,19 +1089,58 @@ def download(
10061089
verify_hashes=not skip_hash,
10071090
)
10081091

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:
10111120
try:
10121121
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)
10141127
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]"
10161138
)
10171139
raise typer.Exit(1)
1018-
rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}")
10191140
except ValueError:
10201141
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]"
10221144
)
10231145
raise typer.Exit(1)
10241146
elif region_file:
@@ -1098,10 +1220,14 @@ def country_progress_callback(current: int, total: int, status: str = None):
10981220
rprint(f"[green]Using country '{country}':[/green] {format_bbox(bbox_coords)}")
10991221
else:
11001222
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]"
11021224
)
11031225
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)")
11051231
rprint(" --region-file london.geojson # From GeoJSON file")
11061232
rprint(" --country 'United Kingdom' # Country by name")
11071233
raise typer.Exit(1)

geotessera/registry_cli.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ def progress_callback(current, total, status):
761761

762762
# Group warnings by reason for better readability
763763
from collections import defaultdict
764+
764765
warnings_by_reason = defaultdict(list)
765766
for reason, path in warnings:
766767
warnings_by_reason[reason].append(path)
@@ -780,7 +781,9 @@ def progress_callback(current, total, status):
780781
with open(missing_embeddings_file, "w") as f:
781782
for path in sorted(missing_embeddings):
782783
f.write(f"{path}\n")
783-
console.print(f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]")
784+
console.print(
785+
f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]"
786+
)
784787

785788
# Write missing scales
786789
missing_scales = warnings_by_reason.get("missing scales", [])
@@ -789,7 +792,9 @@ def progress_callback(current, total, status):
789792
with open(missing_scales_file, "w") as f:
790793
for path in sorted(missing_scales):
791794
f.write(f"{path}\n")
792-
console.print(f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]")
795+
console.print(
796+
f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]"
797+
)
793798

794799
return True
795800

@@ -901,6 +906,7 @@ def progress_callback(current, total, status):
901906

902907
# Group warnings by reason for better readability
903908
from collections import defaultdict
909+
904910
warnings_by_reason = defaultdict(list)
905911
for reason, path in warnings:
906912
warnings_by_reason[reason].append(path)
@@ -920,7 +926,9 @@ def progress_callback(current, total, status):
920926
with open(missing_embeddings_file, "w") as f:
921927
for path in sorted(missing_embeddings):
922928
f.write(f"{path}\n")
923-
console.print(f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]")
929+
console.print(
930+
f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]"
931+
)
924932

925933
# Write missing scales
926934
missing_scales = warnings_by_reason.get("missing scales", [])
@@ -929,7 +937,9 @@ def progress_callback(current, total, status):
929937
with open(missing_scales_file, "w") as f:
930938
for path in sorted(missing_scales):
931939
f.write(f"{path}\n")
932-
console.print(f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]")
940+
console.print(
941+
f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]"
942+
)
933943

934944
return 0
935945

0 commit comments

Comments
 (0)