@@ -163,58 +163,57 @@ def get_cartopy_scale(zoom: int) -> str:
163163 return "110m"
164164
165165
166- def webmercator_to_lonlat (x : float , y : float ) -> Tuple [float , float ]:
167- """
168- Converts Web Mercator projection coordinates (meters) to lon/lat.
169-
170- Parameters
171- ----------
172- x : float
173- X coordinate in meters (Web Mercator).
174- y : float
175- Y coordinate in meters (Web Mercator).
176-
177- Returns
178- -------
179- lon : float
180- Longitude in degrees.
181- lat : float
182- Latitude in degrees.
183- """
184- lon = math .degrees (x / EARTH_RADIUS_M )
185- lat = math .degrees (2 * math .atan (math .exp (y / EARTH_RADIUS_M )) - math .pi / 2 )
186- return lon , lat
187-
188-
189166def plot_usgs_raster_map (
190167 lat_min : float ,
191168 lat_max : float ,
192169 lon_min : float ,
193170 lon_max : float ,
194171 zoom : int = None ,
172+ source : str = "arcgis" ,
195173 verbose : bool = True ,
196174 mask_ocean : bool = False ,
197175 add_features : bool = True ,
198176 display_width_px : int = 1024 ,
199- figsize : Tuple [int , int ] = (12 , 8 ),
200- fig : plt .Figure = None ,
201- ax : plt .Axes = None ,
202177) -> Tuple [plt .Figure , plt .Axes ]:
203178 """
204179 Downloads and displays a USGS raster map for the given bounding box.
205180
206181 Parameters
207182 ----------
208183 lat_min : float
209- Minimum latitude of the region .
184+ Minimum latitude of the bounding box .
210185 lat_max : float
211- Maximum latitude of the region .
186+ Maximum latitude of the bounding box .
212187 lon_min : float
213- Minimum longitude of the region .
188+ Minimum longitude of the bounding box .
214189 lon_max : float
215- Maximum longitude of the region.
190+ Maximum longitude of the bounding box.
191+ zoom : int, optional
192+ Zoom level (default is None, which auto-calculates based on bounding box).
193+ source : str, optional
194+ Source of the map tiles (default is "arcgis"). Options include:
195+ - "arcgis" (Esri World Imagery)
196+ - "google" (Google Maps Satellite)
197+ - "eox" (EOX Sentinel-2 Cloudless)
198+ - "osm" (OpenStreetMap Standard Tiles)
199+ - "amazon" (Amazon Terrarium Elevation Tiles)
200+ - "esri_world" (Esri World Imagery)
201+ - "geoportail_fr" (Geoportail France Orthophotos)
202+ - "ign_spain_pnoa" (IGN Spain PNOA Orthophotos)
203+ verbose : bool, optional
204+ If True, prints additional information about the source and zoom level.
205+ mask_ocean : bool, optional
206+ If True, masks ocean areas in the map.
207+ add_features : bool, optional
208+ If True, adds borders, coastlines, and states to the map.
216209 display_width_px : int, optional
217- Approximate pixel width for display (default is 1024).
210+ Width of the display in pixels (default is 1024).
211+ Returns
212+ -------
213+ fig : plt.Figure
214+ The matplotlib figure containing the map.
215+ ax : plt.Axes
216+ The matplotlib axes with the map projection.
218217 """
219218
220219 tile_size = 256
@@ -229,11 +228,117 @@ def plot_usgs_raster_map(
229228 height = y_end - y_start + 1
230229
231230 map_img = Image .new ("RGB" , (width * tile_size , height * tile_size ))
232- tile_url = "https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}"
231+ if source == "arcgis" :
232+ tile_url = "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
233+ z_max = 19
234+ if verbose :
235+ print (
236+ "Using Esri World Imagery (ArcGIS):\n "
237+ "- Free for public and non-commercial use\n "
238+ "- Commercial use allowed under Esri terms of service\n "
239+ "- Attribution required: 'Tiles © Esri — Sources: Esri, Earthstar Geographics, CNES/Airbus DS, "
240+ "USDA, USGS, AeroGRID, IGN, and the GIS User Community'\n "
241+ "- Max zoom ~19"
242+ )
243+
244+ elif source == "google" :
245+ tile_url = "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
246+ z_max = 21
247+ if verbose :
248+ print (
249+ "Using Google Maps Satellite:\n "
250+ "- NOT license-free\n "
251+ "- Usage outside official Google Maps SDKs/APIs is prohibited\n "
252+ "- Commercial use requires a paid license from Google\n "
253+ "- May be blocked without an API key\n "
254+ "- Max zoom ~21"
255+ )
256+
257+ elif source == "eox" :
258+ tile_url = "https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2024_3857/default/g/{z}/{y}/{x}.jpg"
259+ z_max = 16
260+ if verbose :
261+ print (
262+ "Using EOX Sentinel-2 Cloudless:\n "
263+ "- Based on Copernicus Sentinel-2 data processed by EOX\n "
264+ "- Licensed under Creative Commons BY-NC-SA 4.0 (non-commercial use only)\n "
265+ "- Attribution required: 'Sentinel-2 cloudless – © EOX, based on modified Copernicus Sentinel data 2016–2024'\n "
266+ "- Versions from 2016–2017 are under CC BY 4.0 (commercial use allowed)\n "
267+ "- Max zoom ~16"
268+ )
269+
270+ elif source == "osm" :
271+ tile_url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
272+ z_max = 19
273+ if verbose :
274+ print (
275+ "Using OpenStreetMap Standard Tiles:\n "
276+ "- Free and open-source (ODbL license)\n "
277+ "- Commercial use allowed with attribution\n "
278+ "- Attribution required: '© OpenStreetMap contributors'\n "
279+ "- Max zoom ~19"
280+ )
281+
282+ elif source == "amazon" :
283+ tile_url = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
284+ z_max = 15
285+ if verbose :
286+ print (
287+ "Using Amazon Terrarium Elevation Tiles:\n "
288+ "- Free for public use\n "
289+ "- Attribution recommended: 'Amazon Terrarium'\n "
290+ "- Max zoom ~15"
291+ )
292+
293+ elif source == "esri_world" :
294+ tile_url = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
295+ z_max = 19
296+ if verbose :
297+ print (
298+ "Using Esri World Imagery:\n "
299+ "- High-resolution global imagery\n "
300+ "- Free with attribution under Esri terms\n "
301+ "- Max zoom ~19"
302+ )
303+
304+ elif source == "geoportail_fr" :
305+ tile_url = ("https://data.geopf.fr/wmts?"
306+ "REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0"
307+ "&STYLE=normal&TILEMATRIXSET=PM&FORMAT=image/jpeg"
308+ "&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&TILEMATRIX={z}"
309+ "&TILEROW={y}&TILECOL={x}" )
310+ z_max = 19
311+ if verbose :
312+ print (
313+ "Using Geoportail France (Orthophotos):\n "
314+ "- Aerial orthophotos from the French National Institute (IGN)\n "
315+ "- Free for public use under Etalab license\n "
316+ "- Attribution: Geoportail France / IGN\n "
317+ "- Max zoom ~19"
318+ )
319+ elif source == "ign_spain_pnoa" :
320+ tile_url = ("https://tms-pnoa-ma.idee.es/1.0.0/pnoa-ma/{z}/{x}/{y_inv}.jpeg" )
321+ z_max = 19
322+ if verbose :
323+ print (
324+ "Using IGN Spain PNOA Orthophotos:\n "
325+ "- High-resolution aerial imagery (PNOA program)\n "
326+ "- Provided by IGN/CNIG (Government of Spain)\n "
327+ "- Free to use under Creative Commons BY 4.0 license\n "
328+ "- Attribution required: 'Ortofotografía PNOA – © IGN / CNIG (Gobierno de España) – CC BY 4.0'\n "
329+ "- Max zoom ~19"
330+ )
331+
332+ if zoom > z_max :
333+ zoom = z_max
233334
234335 for x in range (x_start , x_end + 1 ):
235336 for y in range (y_start , y_end + 1 ):
236- url = tile_url .format (z = zoom , x = x , y = y )
337+ if source == "ign_spain_pnoa" :
338+ y_inv = (2 ** zoom - 1 ) - y
339+ url = tile_url .format (z = zoom , x = x , y_inv = y_inv )
340+ else :
341+ url = tile_url .format (z = zoom , x = x , y = y )
237342 try :
238343 response = requests .get (url , timeout = 10 )
239344 tile = Image .open (BytesIO (response .content ))
@@ -244,30 +349,21 @@ def plot_usgs_raster_map(
244349 print (f"Error fetching tile { x } ,{ y } : { e } " )
245350
246351 xmin , ymin , xmax , ymax = tile_bounds_meters (x_start , y_start , x_end , y_end , zoom )
247- lon_min , lat_min = webmercator_to_lonlat (xmin , ymin )
248- lon_max , lat_max = webmercator_to_lonlat (xmax , ymax )
249-
250- scale = get_cartopy_scale (zoom )
251- if verbose :
252- print (f"Using Cartopy scale: { scale } " )
253352
254353 crs_tiles = ccrs .PlateCarree ()
255- if fig is None or ax is None :
256- fig = plt .figure (figsize = figsize )
257- ax = plt .axes (projection = crs_tiles )
258-
354+ fig = plt .figure (figsize = (12 , 8 ))
355+ ax = plt .axes (projection = crs_tiles )
259356 ax .set_extent ([lon_min , lon_max , lat_min , lat_max ], crs = crs_tiles )
260-
261357 ax .imshow (
262- map_img ,
263- origin = "upper" ,
264- extent = [xmin , xmax , ymin , ymax ],
265- transform = ccrs .Mercator .GOOGLE ,
358+ map_img , origin = "upper" , extent = [xmin , xmax , ymin , ymax ], transform = ccrs .Mercator .GOOGLE
266359 )
360+ scale = get_cartopy_scale (zoom )
361+ if verbose :
362+ print (f"Using Cartopy scale: { scale } " )
267363
268364 if add_features :
269365 ax .add_feature (cfeature .BORDERS .with_scale (scale ), linewidth = 0.8 )
270- ax .add_feature (cfeature .COASTLINE .with_scale (scale ), zorder = 1 )
366+ ax .add_feature (cfeature .COASTLINE .with_scale (scale ))
271367 ax .add_feature (cfeature .STATES .with_scale (scale ), linewidth = 0.5 )
272368
273369 if mask_ocean :
0 commit comments