Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,10 @@ geotessera download [OPTIONS]

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

Single tile examples:
```bash
# Download a single tile containing a specific point
geotessera download --tile "0.17,52.23" --year 2024 -o ./single_tile

# Same result using --bbox with 2 coordinates
geotessera download --bbox "0.17,52.23" --year 2024 -o ./single_tile
```

Output formats:
- **tiff**: Georeferenced GeoTIFF files with UTM projection
- **npy**: Raw numpy arrays with metadata.json file
Expand Down Expand Up @@ -366,12 +377,14 @@ geotessera coverage [OPTIONS]
Options:
-o, --output PATH Output PNG file (default: tessera_coverage.png)
--year INT Specific year to visualize
--bbox TEXT Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'
--tile TEXT Single tile by any point within it: 'lon,lat'
--region-file PATH GeoJSON/Shapefile to focus on specific region
--country TEXT Country name to focus on (e.g., 'United Kingdom')
--tile-color TEXT Color for tiles (default: red)
--tile-alpha FLOAT Transparency 0-1 (default: 0.6)
--tile-size FLOAT Size multiplier (default: 1.0)
--dpi INT Output resolution (default: 100)
--width INT Figure width in inches (default: 20)
--height INT Figure height in inches (default: 10)
--width INT Output image width in pixels (default: 2000)
--no-countries Don't show country boundaries
```

Expand Down
153 changes: 141 additions & 12 deletions geotessera/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from geotessera.registry import (
EMBEDDINGS_DIR_NAME,
LANDMASKS_DIR_NAME,
tile_from_world,
tile_to_landmask_filename,
tile_to_embedding_paths,
)
Expand Down Expand Up @@ -118,6 +119,26 @@ def format_bbox(bbox_coords) -> str:
return f"[{min_lon_str}, {min_lat_str}] - [{max_lon_str}, {max_lat_str}]"


def point_to_tile_bbox(lon: float, lat: float) -> tuple:
"""Convert a point to the bounding box of its containing tile.

Tiles are 0.1x0.1 degree squares centered at 0.05-degree offsets.
Returns a point bbox at the tile center, which the registry query
will expand by 0.05 degrees to match exactly one tile.

Args:
lon: Longitude in decimal degrees
lat: Latitude in decimal degrees

Returns:
Tuple of (min_lon, min_lat, max_lon, max_lat) for the containing tile
"""
tile_lon, tile_lat = tile_from_world(lon, lat)
# Return point bbox at tile center - registry expands by 0.05 degrees
# which will match exactly this one tile
return (tile_lon, tile_lat, tile_lon, tile_lat)


app = typer.Typer(
name="geotessera",
help=f"GeoTessera v{__version__}: Download satellite embedding tiles as GeoTIFFs",
Expand Down Expand Up @@ -533,6 +554,17 @@ def coverage(
help="Country name to focus coverage map on (e.g., 'United Kingdom', 'UK', 'GB')",
),
] = None,
bbox: Annotated[
Optional[str],
typer.Option(
"--bbox",
help="Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'",
),
] = None,
tile: Annotated[
Optional[str],
typer.Option("--tile", help="Single tile by any point within it: 'lon,lat'"),
] = None,
tile_color: Annotated[
str,
typer.Option(
Expand Down Expand Up @@ -611,13 +643,60 @@ def coverage(
country_geojson_file = None
region_file_temp = None # Track if we created a temporary file

if region_file and country:
# Check mutual exclusivity of region options
region_sources = sum(1 for x in [bbox, tile, region_file, country] if x)
if region_sources > 1:
rprint(
"[red]Error: Cannot specify both --region-file and --country. Choose one.[/red]"
"[red]Error: Cannot specify multiple region options. "
"Choose one of: --bbox, --tile, --region-file, --country[/red]"
)
raise typer.Exit(1)

if region_file:
if tile:
try:
tile_coords = tuple(map(float, tile.split(",")))
if len(tile_coords) != 2:
rprint("[red]Error: --tile must be 'lon,lat'[/red]")
raise typer.Exit(1)
lon, lat = tile_coords
tile_center = tile_from_world(lon, lat)
region_bbox = point_to_tile_bbox(lon, lat)
rprint(
f"[green]Point ({lon}, {lat}) -> tile "
f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]"
)
rprint(f"[green]Region bounding box:[/green] {format_bbox(region_bbox)}")
except ValueError as e:
rprint(f"[red]Error: Invalid --tile format. Use 'lon,lat': {e}[/red]")
raise typer.Exit(1)
elif bbox:
try:
bbox_coords = tuple(map(float, bbox.split(",")))
if len(bbox_coords) == 2:
# Two coordinates = single tile
lon, lat = bbox_coords
tile_center = tile_from_world(lon, lat)
region_bbox = point_to_tile_bbox(lon, lat)
rprint(
f"[green]Point ({lon}, {lat}) -> tile "
f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]"
)
elif len(bbox_coords) == 4:
region_bbox = bbox_coords
else:
rprint(
"[red]Error: bbox must be 'lon,lat' (single tile) "
"or 'min_lon,min_lat,max_lon,max_lat'[/red]"
)
raise typer.Exit(1)
rprint(f"[green]Region bounding box:[/green] {format_bbox(region_bbox)}")
except ValueError:
rprint(
"[red]Error: Invalid bbox format. Use: 'lon,lat' or "
"'min_lon,min_lat,max_lon,max_lat'[/red]"
)
raise typer.Exit(1)
elif region_file:
try:
from .visualization import calculate_bbox_from_file

Expand Down Expand Up @@ -892,7 +971,14 @@ def download(
] = None,
bbox: Annotated[
Optional[str],
typer.Option("--bbox", help="Bounding box: 'min_lon,min_lat,max_lon,max_lat'"),
typer.Option(
"--bbox",
help="Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'",
),
] = None,
tile: Annotated[
Optional[str],
typer.Option("--tile", help="Single tile by any point within it: 'lon,lat'"),
] = None,
region_file: Annotated[
Optional[str],
Expand Down Expand Up @@ -1006,19 +1092,58 @@ def download(
verify_hashes=not skip_hash,
)

# Parse bounding box
if bbox:
# Check mutual exclusivity of region options
region_sources = sum(1 for x in [bbox, tile, region_file, country] if x)
if region_sources > 1:
rprint(
"[red]Error: Cannot specify multiple region options. "
"Choose one of: --bbox, --tile, --region-file, --country[/red]"
)
raise typer.Exit(1)

# Parse bounding box or tile
if tile:
try:
tile_coords = tuple(map(float, tile.split(",")))
if len(tile_coords) != 2:
rprint("[red]Error: --tile must be 'lon,lat'[/red]")
raise typer.Exit(1)
lon, lat = tile_coords
tile_center = tile_from_world(lon, lat)
bbox_coords = point_to_tile_bbox(lon, lat)
rprint(
f"[green]Point ({lon}, {lat}) -> tile "
f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]"
)
rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}")
except ValueError as e:
rprint(f"[red]Error: Invalid --tile format. Use 'lon,lat': {e}[/red]")
raise typer.Exit(1)
elif bbox:
try:
bbox_coords = tuple(map(float, bbox.split(",")))
if len(bbox_coords) != 4:
if len(bbox_coords) == 2:
# Two coordinates = single tile
lon, lat = bbox_coords
tile_center = tile_from_world(lon, lat)
bbox_coords = point_to_tile_bbox(lon, lat)
rprint(
"[red]Error: bbox must be 'min_lon,min_lat,max_lon,max_lat'[/red]"
f"[green]Point ({lon}, {lat}) -> tile "
f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]"
)
rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}")
elif len(bbox_coords) == 4:
rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}")
else:
rprint(
"[red]Error: bbox must be 'lon,lat' (single tile) "
"or 'min_lon,min_lat,max_lon,max_lat'[/red]"
)
raise typer.Exit(1)
rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}")
except ValueError:
rprint(
"[red]Error: Invalid bbox format. Use: 'min_lon,min_lat,max_lon,max_lat'[/red]"
"[red]Error: Invalid bbox format. Use: 'lon,lat' or "
"'min_lon,min_lat,max_lon,max_lat'[/red]"
)
raise typer.Exit(1)
elif region_file:
Expand Down Expand Up @@ -1098,10 +1223,14 @@ def country_progress_callback(current: int, total: int, status: str = None):
rprint(f"[green]Using country '{country}':[/green] {format_bbox(bbox_coords)}")
else:
rprint(
"[red]Error: Must specify either --bbox, --region-file, or --country[/red]"
"[red]Error: Must specify either --bbox, --tile, --region-file, or --country[/red]"
)
rprint("Examples:")
rprint(" --bbox '-0.2,51.4,0.1,51.6' # London area")
rprint(" --tile '0.17,52.23' # Single tile containing point")
rprint(
" --bbox '0.17,52.23' # Same as above (2 coords = single tile)"
)
rprint(" --bbox '-0.2,51.4,0.1,51.6' # London area (4 coords = region)")
rprint(" --region-file london.geojson # From GeoJSON file")
rprint(" --country 'United Kingdom' # Country by name")
raise typer.Exit(1)
Expand Down
18 changes: 14 additions & 4 deletions geotessera/registry_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,7 @@ def progress_callback(current, total, status):

# Group warnings by reason for better readability
from collections import defaultdict

warnings_by_reason = defaultdict(list)
for reason, path in warnings:
warnings_by_reason[reason].append(path)
Expand All @@ -780,7 +781,9 @@ def progress_callback(current, total, status):
with open(missing_embeddings_file, "w") as f:
for path in sorted(missing_embeddings):
f.write(f"{path}\n")
console.print(f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]")
console.print(
f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]"
)

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

return True

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

# Group warnings by reason for better readability
from collections import defaultdict

warnings_by_reason = defaultdict(list)
for reason, path in warnings:
warnings_by_reason[reason].append(path)
Expand All @@ -920,7 +926,9 @@ def progress_callback(current, total, status):
with open(missing_embeddings_file, "w") as f:
for path in sorted(missing_embeddings):
f.write(f"{path}\n")
console.print(f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]")
console.print(
f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]"
)

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

return 0

Expand Down
2 changes: 1 addition & 1 deletion tests/cli.t
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Test the info command without arguments to see library information.
We just verify key information is present, ignoring formatting:

$ geotessera info --dataset-version v1 2>&1 | grep -E 'Available years'
Available years: 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025
Available years: 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025

Test: Download Dry Run for UK Tile
-----------------------------------
Expand Down
Loading
Loading