moos-map 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moos_map/__init__.py +3 -0
- moos_map/__main__.py +5 -0
- moos_map/acquisition.py +216 -0
- moos_map/cache.py +52 -0
- moos_map/cli.py +352 -0
- moos_map/errors.py +14 -0
- moos_map/geometry.py +204 -0
- moos_map/models.py +255 -0
- moos_map/moos_compat.py +162 -0
- moos_map/moos_files.py +161 -0
- moos_map/raster.py +67 -0
- moos_map/service.py +322 -0
- moos_map/sources.py +189 -0
- moos_map/static/app.js +559 -0
- moos_map/static/index.html +195 -0
- moos_map/static/styles.css +328 -0
- moos_map/verification.py +115 -0
- moos_map/web.py +139 -0
- moos_map-1.0.1.dist-info/METADATA +138 -0
- moos_map-1.0.1.dist-info/RECORD +24 -0
- moos_map-1.0.1.dist-info/WHEEL +5 -0
- moos_map-1.0.1.dist-info/entry_points.txt +2 -0
- moos_map-1.0.1.dist-info/licenses/LICENSE +674 -0
- moos_map-1.0.1.dist-info/top_level.txt +1 -0
moos_map/__init__.py
ADDED
moos_map/__main__.py
ADDED
moos_map/acquisition.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import Callable, Protocol
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from PIL import Image, UnidentifiedImageError
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .cache import TileCache
|
|
15
|
+
from .errors import FetchError
|
|
16
|
+
from .models import TileRange
|
|
17
|
+
from .sources import MapSource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ProgressCallback = Callable[[int, int, int, int], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TileProvider(Protocol):
|
|
24
|
+
cache_hits: int
|
|
25
|
+
downloaded_tiles: int
|
|
26
|
+
|
|
27
|
+
def fetch(self, zoom: int, x: int, y: int, *, force: bool = False) -> bytes: ...
|
|
28
|
+
|
|
29
|
+
def close(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_tile_bytes(data: bytes, tile_size: int, description: str) -> None:
|
|
33
|
+
if not data:
|
|
34
|
+
raise FetchError(f"Empty tile response for {description}")
|
|
35
|
+
try:
|
|
36
|
+
with Image.open(BytesIO(data)) as image:
|
|
37
|
+
image.load()
|
|
38
|
+
if image.size != (tile_size, tile_size):
|
|
39
|
+
raise FetchError(
|
|
40
|
+
f"Unexpected tile dimensions for {description}: "
|
|
41
|
+
f"{image.width}x{image.height}, expected {tile_size}x{tile_size}"
|
|
42
|
+
)
|
|
43
|
+
except (UnidentifiedImageError, OSError) as exc:
|
|
44
|
+
raise FetchError(
|
|
45
|
+
f"Response was not a readable image for {description}"
|
|
46
|
+
) from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HttpTileProvider:
|
|
50
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
source: MapSource,
|
|
55
|
+
*,
|
|
56
|
+
cache: TileCache | None = None,
|
|
57
|
+
timeout_seconds: float = 20.0,
|
|
58
|
+
retries: int = 3,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.source = source
|
|
61
|
+
self.cache = cache or TileCache(source)
|
|
62
|
+
self.retries = retries
|
|
63
|
+
self.cache_hits = 0
|
|
64
|
+
self.downloaded_tiles = 0
|
|
65
|
+
self._stats_lock = threading.Lock()
|
|
66
|
+
self._client = httpx.Client(
|
|
67
|
+
timeout=httpx.Timeout(timeout_seconds),
|
|
68
|
+
follow_redirects=True,
|
|
69
|
+
headers={
|
|
70
|
+
"User-Agent": f"moos-map/{__version__} (+local MOOS-IvP map builder)",
|
|
71
|
+
"Accept": "image/*",
|
|
72
|
+
},
|
|
73
|
+
limits=httpx.Limits(max_connections=8, max_keepalive_connections=8),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def fetch(self, zoom: int, x: int, y: int, *, force: bool = False) -> bytes:
|
|
77
|
+
description = f"{self.source.id} z={zoom} x={x} y={y}"
|
|
78
|
+
if not force:
|
|
79
|
+
cached = self.cache.read(zoom, x, y)
|
|
80
|
+
if cached is not None:
|
|
81
|
+
try:
|
|
82
|
+
validate_tile_bytes(cached, self.source.tile_size, description)
|
|
83
|
+
except FetchError:
|
|
84
|
+
self.cache.path_for(zoom, x, y).unlink(missing_ok=True)
|
|
85
|
+
else:
|
|
86
|
+
with self._stats_lock:
|
|
87
|
+
self.cache_hits += 1
|
|
88
|
+
return cached
|
|
89
|
+
|
|
90
|
+
url = self.source.tile_url(zoom, x, y)
|
|
91
|
+
last_error: Exception | None = None
|
|
92
|
+
for attempt in range(self.retries + 1):
|
|
93
|
+
try:
|
|
94
|
+
response = self._client.get(url)
|
|
95
|
+
if response.status_code == 200:
|
|
96
|
+
data = response.content
|
|
97
|
+
if len(data) > 10 * 1024 * 1024:
|
|
98
|
+
raise FetchError(
|
|
99
|
+
f"Tile response exceeded 10 MB for {description}"
|
|
100
|
+
)
|
|
101
|
+
validate_tile_bytes(data, self.source.tile_size, description)
|
|
102
|
+
self.cache.write(zoom, x, y, data)
|
|
103
|
+
with self._stats_lock:
|
|
104
|
+
self.downloaded_tiles += 1
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
message = f"HTTP {response.status_code} fetching {description}"
|
|
108
|
+
if response.status_code not in self.RETRYABLE_STATUS_CODES:
|
|
109
|
+
raise FetchError(message)
|
|
110
|
+
|
|
111
|
+
retry_after = response.headers.get("Retry-After", "")
|
|
112
|
+
try:
|
|
113
|
+
delay = min(float(retry_after), 10.0)
|
|
114
|
+
except ValueError:
|
|
115
|
+
delay = min(0.5 * (2**attempt), 8.0)
|
|
116
|
+
last_error = FetchError(message)
|
|
117
|
+
except (httpx.HTTPError, FetchError) as exc:
|
|
118
|
+
last_error = exc
|
|
119
|
+
if isinstance(exc, FetchError) and "HTTP " in str(exc):
|
|
120
|
+
status_text = str(exc).split("HTTP ", 1)[1].split(" ", 1)[0]
|
|
121
|
+
if (
|
|
122
|
+
status_text.isdigit()
|
|
123
|
+
and int(status_text) not in self.RETRYABLE_STATUS_CODES
|
|
124
|
+
):
|
|
125
|
+
raise
|
|
126
|
+
delay = min(0.5 * (2**attempt), 8.0)
|
|
127
|
+
|
|
128
|
+
if attempt < self.retries:
|
|
129
|
+
time.sleep(delay)
|
|
130
|
+
|
|
131
|
+
raise FetchError(
|
|
132
|
+
f"Failed after {self.retries + 1} attempts: {description}"
|
|
133
|
+
) from last_error
|
|
134
|
+
|
|
135
|
+
def close(self) -> None:
|
|
136
|
+
self._client.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class MBTilesProvider:
|
|
140
|
+
def __init__(self, source: MapSource) -> None:
|
|
141
|
+
if not source.mbtiles_path:
|
|
142
|
+
raise FetchError("MBTiles source has no archive path")
|
|
143
|
+
self.source = source
|
|
144
|
+
self.path = source.mbtiles_path
|
|
145
|
+
self.cache_hits = 0
|
|
146
|
+
self.downloaded_tiles = 0
|
|
147
|
+
self._lock = threading.Lock()
|
|
148
|
+
try:
|
|
149
|
+
self._connection = sqlite3.connect(self.path, check_same_thread=False)
|
|
150
|
+
self._connection.execute("SELECT 1 FROM tiles LIMIT 1")
|
|
151
|
+
except sqlite3.Error as exc:
|
|
152
|
+
raise FetchError(f"Invalid MBTiles archive: {self.path}") from exc
|
|
153
|
+
|
|
154
|
+
def fetch(self, zoom: int, x: int, y: int, *, force: bool = False) -> bytes:
|
|
155
|
+
del force
|
|
156
|
+
tms_y = (1 << zoom) - 1 - y
|
|
157
|
+
with self._lock:
|
|
158
|
+
row = self._connection.execute(
|
|
159
|
+
"SELECT tile_data FROM tiles "
|
|
160
|
+
"WHERE zoom_level=? AND tile_column=? AND tile_row=?",
|
|
161
|
+
(zoom, x, tms_y),
|
|
162
|
+
).fetchone()
|
|
163
|
+
if row is None:
|
|
164
|
+
raise FetchError(
|
|
165
|
+
f"Tile is absent from {self.path.name}: z={zoom} x={x} y={y}"
|
|
166
|
+
)
|
|
167
|
+
data = bytes(row[0])
|
|
168
|
+
validate_tile_bytes(
|
|
169
|
+
data, self.source.tile_size, f"{self.path.name} z={zoom} x={x} y={y}"
|
|
170
|
+
)
|
|
171
|
+
with self._lock:
|
|
172
|
+
self.cache_hits += 1
|
|
173
|
+
return data
|
|
174
|
+
|
|
175
|
+
def close(self) -> None:
|
|
176
|
+
self._connection.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def create_provider(source: MapSource) -> TileProvider:
|
|
180
|
+
if source.kind == "xyz":
|
|
181
|
+
return HttpTileProvider(source)
|
|
182
|
+
if source.kind == "mbtiles":
|
|
183
|
+
return MBTilesProvider(source)
|
|
184
|
+
raise FetchError(f"Unsupported source kind: {source.kind}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def fetch_tile_range(
|
|
188
|
+
provider: TileProvider,
|
|
189
|
+
tiles: TileRange,
|
|
190
|
+
*,
|
|
191
|
+
force: bool = False,
|
|
192
|
+
workers: int = 4,
|
|
193
|
+
progress: ProgressCallback | None = None,
|
|
194
|
+
) -> dict[tuple[int, int], bytes]:
|
|
195
|
+
coordinates = list(tiles.coordinates())
|
|
196
|
+
results: dict[tuple[int, int], bytes] = {}
|
|
197
|
+
completed = 0
|
|
198
|
+
|
|
199
|
+
with ThreadPoolExecutor(max_workers=max(1, min(workers, 16))) as executor:
|
|
200
|
+
futures: dict[Future[bytes], tuple[int, int]] = {
|
|
201
|
+
executor.submit(provider.fetch, tiles.zoom, x, y, force=force): (x, y)
|
|
202
|
+
for x, y in coordinates
|
|
203
|
+
}
|
|
204
|
+
try:
|
|
205
|
+
for future in as_completed(futures):
|
|
206
|
+
x, y = futures[future]
|
|
207
|
+
results[(x, y)] = future.result()
|
|
208
|
+
completed += 1
|
|
209
|
+
if progress:
|
|
210
|
+
progress(completed, len(coordinates), x, y)
|
|
211
|
+
except Exception:
|
|
212
|
+
for future in futures:
|
|
213
|
+
future.cancel()
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
return results
|
moos_map/cache.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .sources import MapSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_cache_dir() -> Path:
|
|
13
|
+
root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
14
|
+
return root / "moos-map" / "tiles"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def source_cache_namespace(source: MapSource) -> str:
|
|
18
|
+
identity = source.url_template or str(source.mbtiles_path or source.id)
|
|
19
|
+
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12]
|
|
20
|
+
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", source.id)
|
|
21
|
+
return f"{safe_id}-{digest}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TileCache:
|
|
25
|
+
def __init__(self, source: MapSource, root: Path | None = None) -> None:
|
|
26
|
+
self.root = (root or default_cache_dir()).expanduser()
|
|
27
|
+
self.namespace = source_cache_namespace(source)
|
|
28
|
+
|
|
29
|
+
def path_for(self, zoom: int, x: int, y: int) -> Path:
|
|
30
|
+
return self.root / self.namespace / str(zoom) / str(x) / f"{y}.tile"
|
|
31
|
+
|
|
32
|
+
def read(self, zoom: int, x: int, y: int) -> bytes | None:
|
|
33
|
+
path = self.path_for(zoom, x, y)
|
|
34
|
+
try:
|
|
35
|
+
return path.read_bytes()
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def write(self, zoom: int, x: int, y: int, data: bytes) -> Path:
|
|
40
|
+
path = self.path_for(zoom, x, y)
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
handle, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
43
|
+
temp_path = Path(temp_name)
|
|
44
|
+
try:
|
|
45
|
+
with os.fdopen(handle, "wb") as stream:
|
|
46
|
+
stream.write(data)
|
|
47
|
+
stream.flush()
|
|
48
|
+
os.fsync(stream.fileno())
|
|
49
|
+
os.replace(temp_path, path)
|
|
50
|
+
finally:
|
|
51
|
+
temp_path.unlink(missing_ok=True)
|
|
52
|
+
return path
|
moos_map/cli.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Sequence
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .errors import MoosMapError
|
|
11
|
+
from .models import Bounds, MapRequest, Origin
|
|
12
|
+
from .service import build_map, plan_map
|
|
13
|
+
from .sources import list_sources
|
|
14
|
+
from .verification import verify_bundle
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _json_print(value: Any) -> None:
|
|
18
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _add_machine_output(parser: argparse.ArgumentParser) -> None:
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--json", action="store_true", help="Emit machine-readable JSON"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_map_arguments(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--corners",
|
|
30
|
+
nargs=4,
|
|
31
|
+
type=float,
|
|
32
|
+
metavar=("LAT1", "LON1", "LAT2", "LON2"),
|
|
33
|
+
required=True,
|
|
34
|
+
help=(
|
|
35
|
+
"Two diagonally opposite WGS84 corners as latitude/longitude pairs; "
|
|
36
|
+
"either corner order is accepted"
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--origin",
|
|
41
|
+
nargs=2,
|
|
42
|
+
type=float,
|
|
43
|
+
metavar=("LAT", "LON"),
|
|
44
|
+
help="MOOS LatOrigin and LongOrigin (default: map center)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--zoom", type=int, default=17, help="XYZ zoom level (default: 17)"
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--source",
|
|
51
|
+
default="esri-world-imagery",
|
|
52
|
+
help="Built-in source ID (default: esri-world-imagery)",
|
|
53
|
+
)
|
|
54
|
+
source_group = parser.add_mutually_exclusive_group()
|
|
55
|
+
source_group.add_argument(
|
|
56
|
+
"--url-template",
|
|
57
|
+
help="Custom XYZ URL containing {z}, {x}, and {y}",
|
|
58
|
+
)
|
|
59
|
+
source_group.add_argument(
|
|
60
|
+
"--mbtiles",
|
|
61
|
+
type=Path,
|
|
62
|
+
help="Read tiles from a local MBTiles archive",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--accept-source-terms",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Confirm that a custom source permits static/offline export",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument("--max-tiles", type=int, default=1024)
|
|
70
|
+
parser.add_argument("--max-pixels", type=int, default=67_108_864)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _request_from_args(args: argparse.Namespace) -> MapRequest:
|
|
74
|
+
latitude_1, longitude_1, latitude_2, longitude_2 = args.corners
|
|
75
|
+
bounds = Bounds(
|
|
76
|
+
west=min(longitude_1, longitude_2),
|
|
77
|
+
south=min(latitude_1, latitude_2),
|
|
78
|
+
east=max(longitude_1, longitude_2),
|
|
79
|
+
north=max(latitude_1, latitude_2),
|
|
80
|
+
)
|
|
81
|
+
if args.origin is None:
|
|
82
|
+
origin = Origin(
|
|
83
|
+
latitude=(bounds.south + bounds.north) / 2,
|
|
84
|
+
longitude=(bounds.west + bounds.east) / 2,
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
latitude, longitude = args.origin
|
|
88
|
+
origin = Origin(latitude=latitude, longitude=longitude)
|
|
89
|
+
return MapRequest(
|
|
90
|
+
bounds=bounds,
|
|
91
|
+
origin=origin,
|
|
92
|
+
zoom=args.zoom,
|
|
93
|
+
source_id=args.source,
|
|
94
|
+
name=getattr(args, "name", "moos_map"),
|
|
95
|
+
output_dir=getattr(args, "output_dir", Path.home() / "moos-maps"),
|
|
96
|
+
emit_moos=getattr(args, "emit_moos", True),
|
|
97
|
+
force=getattr(args, "force", False),
|
|
98
|
+
overwrite=getattr(args, "overwrite", True),
|
|
99
|
+
refresh_tiles=getattr(args, "refresh_tiles", False),
|
|
100
|
+
custom_url_template=args.url_template,
|
|
101
|
+
accept_custom_source_terms=args.accept_source_terms,
|
|
102
|
+
mbtiles_path=args.mbtiles,
|
|
103
|
+
max_tiles=args.max_tiles,
|
|
104
|
+
max_pixels=args.max_pixels,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _format_file_size(size_bytes: int, *, precise: bool = False) -> str:
|
|
109
|
+
if size_bytes < 1_000:
|
|
110
|
+
return f"{size_bytes:,} bytes"
|
|
111
|
+
if size_bytes < 1_000_000:
|
|
112
|
+
return f"{size_bytes / 1_000:.{1 if precise else 0}f} KB"
|
|
113
|
+
return f"{size_bytes / 1_000_000:.{2 if precise else 1}f} MB"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _print_plan_human(
|
|
117
|
+
plan: dict[str, Any], *, actual_tiff_size_bytes: int | None = None
|
|
118
|
+
) -> None:
|
|
119
|
+
source = plan["source"]
|
|
120
|
+
tiles = plan["tiles"]
|
|
121
|
+
actual = plan["actual_bounds"]
|
|
122
|
+
origin = plan["origin"]
|
|
123
|
+
print(f"Source: {source['name']} ({source['id']})")
|
|
124
|
+
print(
|
|
125
|
+
f"Source tiles: {tiles['count']} "
|
|
126
|
+
f"({tiles['columns']} x {tiles['rows']}), zoom {tiles['zoom']}"
|
|
127
|
+
)
|
|
128
|
+
print(f"TIFF dimensions: {plan['pixel_width']} x {plan['pixel_height']} pixels")
|
|
129
|
+
if actual_tiff_size_bytes is None:
|
|
130
|
+
print(
|
|
131
|
+
"Estimated TIFF size: "
|
|
132
|
+
f"~{_format_file_size(plan['estimated_tiff_size_bytes'])} "
|
|
133
|
+
"before content-dependent LZW compression"
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
print(
|
|
137
|
+
"TIFF file size: "
|
|
138
|
+
f"{_format_file_size(actual_tiff_size_bytes, precise=True)} "
|
|
139
|
+
f"({actual_tiff_size_bytes:,} bytes)"
|
|
140
|
+
)
|
|
141
|
+
print(
|
|
142
|
+
"Exact bounds: "
|
|
143
|
+
f"W {actual['west']:.10f}, S {actual['south']:.10f}, "
|
|
144
|
+
f"E {actual['east']:.10f}, N {actual['north']:.10f}"
|
|
145
|
+
)
|
|
146
|
+
print(
|
|
147
|
+
"Mission origin: "
|
|
148
|
+
f"LatOrigin {origin['latitude']:.10f}, "
|
|
149
|
+
f"LongOrigin {origin['longitude']:.10f}"
|
|
150
|
+
)
|
|
151
|
+
print(
|
|
152
|
+
"Ground area: "
|
|
153
|
+
f"{plan['approximate_ground_width_m']:.1f} x "
|
|
154
|
+
f"{plan['approximate_ground_height_m']:.1f} m"
|
|
155
|
+
)
|
|
156
|
+
print(f"Source resolution: {plan['approximate_meters_per_pixel']:.3f} m/pixel")
|
|
157
|
+
print(
|
|
158
|
+
"Display alignment: "
|
|
159
|
+
f"{plan['estimated_max_requested_area_position_error_m']:.1f} m model max "
|
|
160
|
+
"(theoretical, not measured)"
|
|
161
|
+
)
|
|
162
|
+
print("Output crop: exact requested bounds")
|
|
163
|
+
for warning in plan["warnings"]:
|
|
164
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
168
|
+
parser = argparse.ArgumentParser(
|
|
169
|
+
prog="moos-map",
|
|
170
|
+
description="Build exact-crop TIFF background maps for MOOS-IvP",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
174
|
+
)
|
|
175
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
176
|
+
|
|
177
|
+
sources_parser = subparsers.add_parser("sources", help="List available map sources")
|
|
178
|
+
_add_machine_output(sources_parser)
|
|
179
|
+
|
|
180
|
+
plan_parser = subparsers.add_parser(
|
|
181
|
+
"plan", help="Plan an export without downloading"
|
|
182
|
+
)
|
|
183
|
+
_add_map_arguments(plan_parser)
|
|
184
|
+
_add_machine_output(plan_parser)
|
|
185
|
+
|
|
186
|
+
build_command = subparsers.add_parser("build", help="Download and build a MOOS map")
|
|
187
|
+
_add_map_arguments(build_command)
|
|
188
|
+
build_command.add_argument(
|
|
189
|
+
"--name", required=True, help="Output basename without suffix"
|
|
190
|
+
)
|
|
191
|
+
build_command.add_argument(
|
|
192
|
+
"--output-dir",
|
|
193
|
+
type=Path,
|
|
194
|
+
default=Path.home() / "moos-maps",
|
|
195
|
+
help="Output directory (default: ~/moos-maps)",
|
|
196
|
+
)
|
|
197
|
+
build_command.add_argument(
|
|
198
|
+
"--no-moos",
|
|
199
|
+
dest="emit_moos",
|
|
200
|
+
action="store_false",
|
|
201
|
+
default=True,
|
|
202
|
+
help="Do not write the default .moos mission snippet",
|
|
203
|
+
)
|
|
204
|
+
build_command.add_argument(
|
|
205
|
+
"--emit-moos",
|
|
206
|
+
dest="emit_moos",
|
|
207
|
+
action="store_true",
|
|
208
|
+
help=argparse.SUPPRESS,
|
|
209
|
+
)
|
|
210
|
+
build_command.add_argument(
|
|
211
|
+
"--no-overwrite",
|
|
212
|
+
dest="overwrite",
|
|
213
|
+
action="store_false",
|
|
214
|
+
default=True,
|
|
215
|
+
help="Refuse to replace existing output files with the same map name",
|
|
216
|
+
)
|
|
217
|
+
build_command.add_argument(
|
|
218
|
+
"--overwrite",
|
|
219
|
+
dest="overwrite",
|
|
220
|
+
action="store_true",
|
|
221
|
+
help=argparse.SUPPRESS,
|
|
222
|
+
)
|
|
223
|
+
build_command.add_argument(
|
|
224
|
+
"--refresh-tiles",
|
|
225
|
+
action="store_true",
|
|
226
|
+
help="Redownload source tiles instead of reusing the local cache",
|
|
227
|
+
)
|
|
228
|
+
build_command.add_argument(
|
|
229
|
+
"--force",
|
|
230
|
+
action="store_true",
|
|
231
|
+
help=argparse.SUPPRESS,
|
|
232
|
+
)
|
|
233
|
+
_add_machine_output(build_command)
|
|
234
|
+
|
|
235
|
+
verify_parser = subparsers.add_parser("verify", help="Verify a TIFF/.info bundle")
|
|
236
|
+
verify_parser.add_argument("tiff", type=Path)
|
|
237
|
+
_add_machine_output(verify_parser)
|
|
238
|
+
|
|
239
|
+
ui_parser = subparsers.add_parser("ui", help="Launch the local browser UI")
|
|
240
|
+
ui_parser.add_argument(
|
|
241
|
+
"--host", default="127.0.0.1", help="Local bind address (default: 127.0.0.1)"
|
|
242
|
+
)
|
|
243
|
+
ui_parser.add_argument("--port", type=int, default=8765, help="Port (default: 8765)")
|
|
244
|
+
ui_parser.add_argument(
|
|
245
|
+
"--no-browser",
|
|
246
|
+
action="store_true",
|
|
247
|
+
help="Start the server without opening a browser window",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return parser
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run(args: argparse.Namespace) -> int:
|
|
254
|
+
if args.command == "sources":
|
|
255
|
+
sources = [source.as_dict() for source in list_sources()]
|
|
256
|
+
if args.json:
|
|
257
|
+
_json_print({"sources": sources})
|
|
258
|
+
else:
|
|
259
|
+
print("Sources:")
|
|
260
|
+
for source in sources:
|
|
261
|
+
capability = "export" if source["export_allowed"] else "preview only"
|
|
262
|
+
print(
|
|
263
|
+
f"{source['id']:<16} {capability:<12} "
|
|
264
|
+
f"zoom {source['min_zoom']}-{source['max_zoom']} {source['name']}"
|
|
265
|
+
)
|
|
266
|
+
if source["note"]:
|
|
267
|
+
print(f" {source['note']}")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
if args.command == "plan":
|
|
271
|
+
plan = plan_map(_request_from_args(args)).as_dict()
|
|
272
|
+
if args.json:
|
|
273
|
+
_json_print(plan)
|
|
274
|
+
else:
|
|
275
|
+
_print_plan_human(plan)
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
if args.command == "build":
|
|
279
|
+
request = _request_from_args(args)
|
|
280
|
+
|
|
281
|
+
def progress(completed: int, total: int, x: int, y: int) -> None:
|
|
282
|
+
print(
|
|
283
|
+
f"Fetched {completed}/{total} tiles (latest x={x}, y={y})",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
result = build_map(request, progress=progress)
|
|
288
|
+
payload = result.as_dict()
|
|
289
|
+
if args.json:
|
|
290
|
+
_json_print(payload)
|
|
291
|
+
else:
|
|
292
|
+
_print_plan_human(
|
|
293
|
+
payload["plan"],
|
|
294
|
+
actual_tiff_size_bytes=payload["verification"]["details"][
|
|
295
|
+
"file_size_bytes"
|
|
296
|
+
],
|
|
297
|
+
)
|
|
298
|
+
print(f"TIFF: {payload['tiff_path']}")
|
|
299
|
+
print(f"Info: {payload['info_path']}")
|
|
300
|
+
if payload["moos_path"]:
|
|
301
|
+
print(f"MOOS snippet: {payload['moos_path']}")
|
|
302
|
+
print(
|
|
303
|
+
f"Tiles downloaded: {payload['downloaded_tiles']}; "
|
|
304
|
+
f"cache hits: {payload['cache_hits']}"
|
|
305
|
+
)
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
if args.command == "verify":
|
|
309
|
+
report = verify_bundle(args.tiff)
|
|
310
|
+
payload = report.as_dict()
|
|
311
|
+
if args.json:
|
|
312
|
+
_json_print(payload)
|
|
313
|
+
else:
|
|
314
|
+
print("PASS" if report.ok else "FAIL")
|
|
315
|
+
print(f"TIFF: {report.tiff_path}")
|
|
316
|
+
print(f"Info: {report.info_path}")
|
|
317
|
+
if "pixel_width" in report.details:
|
|
318
|
+
print(
|
|
319
|
+
"Image: "
|
|
320
|
+
f"{report.details['pixel_width']} x "
|
|
321
|
+
f"{report.details['pixel_height']} pixels, "
|
|
322
|
+
f"{report.details['mode']} {report.details['format']}"
|
|
323
|
+
)
|
|
324
|
+
size_bytes = report.details["file_size_bytes"]
|
|
325
|
+
print(
|
|
326
|
+
"TIFF file size: "
|
|
327
|
+
f"{_format_file_size(size_bytes, precise=True)} "
|
|
328
|
+
f"({size_bytes:,} bytes)"
|
|
329
|
+
)
|
|
330
|
+
for warning in report.warnings:
|
|
331
|
+
print(f"Warning: {warning}")
|
|
332
|
+
for error in report.errors:
|
|
333
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
334
|
+
return 0 if report.ok else 1
|
|
335
|
+
|
|
336
|
+
if args.command == "ui":
|
|
337
|
+
from .web import run_ui
|
|
338
|
+
|
|
339
|
+
run_ui(host=args.host, port=args.port, open_browser=not args.no_browser)
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
raise AssertionError(f"Unhandled command: {args.command}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
346
|
+
parser = build_parser()
|
|
347
|
+
args = parser.parse_args(argv)
|
|
348
|
+
try:
|
|
349
|
+
raise SystemExit(run(args))
|
|
350
|
+
except MoosMapError as exc:
|
|
351
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
352
|
+
raise SystemExit(2) from exc
|
moos_map/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class MoosMapError(Exception):
|
|
2
|
+
"""Base exception for expected, user-facing failures."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ValidationError(MoosMapError):
|
|
6
|
+
"""Input or output failed validation."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SourcePolicyError(MoosMapError):
|
|
10
|
+
"""The selected provider does not permit this operation."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FetchError(MoosMapError):
|
|
14
|
+
"""A source tile could not be acquired or decoded."""
|