KSvend Claude Opus 4.6 (1M context) commited on
Commit
ccd0638
·
1 Parent(s): b0128ec

fix: anchor all maps on AOI bbox to stop squashed hotspot images

Browse files

The hotspot map was using the product raster's bounds for set_xlim/ylim,
while render_raster_map was using the true-color raster's bounds. When
CDSE returned a product raster with tile-boundary-cropped bounds (e.g. a
0.03° × 0.08° strip), the hotspot came out several times flatter than
the top product map.

Fix:
- new _figsize_for_extent() sizes the matplotlib figure to match the
data aspect (cos-lat corrected) so tight_layout + bbox_inches never
produce a stretched PNG
- render_raster_map, render_hotspot_map, and render_overview_map all
now anchor xlim/ylim on the AOI bbox instead of whichever raster
happened to be opened first
- imshow() calls no longer pass the aspect kwarg — set_aspect() on the
axes is the single source of truth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. app/outputs/maps.py +86 -36
app/outputs/maps.py CHANGED
@@ -243,49 +243,53 @@ def render_raster_map(
243
  """
244
  import rasterio
245
 
246
- fig, ax = plt.subplots(figsize=(6, 5), dpi=200, facecolor=SHELL)
247
- ax.set_facecolor(SHELL)
 
 
 
 
248
 
249
- extent = None
 
 
 
 
 
250
 
251
- # Render true-color base layer
 
252
  if true_color_path is not None:
253
  with rasterio.open(true_color_path) as src:
254
  rgb = src.read([1, 2, 3]).astype(np.float32)
255
- extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
256
- # Sentinel-2 reflectance scaling (values typically 0-10000)
257
  rgb_max = max(rgb.max(), 1.0)
258
  scale = 3000.0 if rgb_max > 255 else 255.0
259
  rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
260
- geo_aspect = _geographic_aspect(extent)
261
- ax.imshow(rgb_normalized, extent=extent, aspect=geo_aspect, zorder=0)
262
 
263
- # Render indicator raster overlay
264
  if indicator_path is not None:
265
  with rasterio.open(indicator_path) as src:
266
  data = src.read(indicator_band).astype(np.float32)
267
  nodata = src.nodata
268
  ind_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
269
- if extent is None:
270
- extent = ind_extent
271
- geo_aspect = _geographic_aspect(extent)
272
  masked = np.ma.masked_where(
273
  (data == nodata) if nodata is not None else np.zeros_like(data, dtype=bool),
274
  data,
275
  )
276
  im = ax.imshow(
277
  masked, extent=ind_extent, cmap=cmap, alpha=alpha,
278
- vmin=vmin, vmax=vmax, aspect=geo_aspect, zorder=1,
279
  )
280
  cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
281
  cbar.set_label(label, fontsize=7, color=INK_MUTED)
282
  cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
283
 
284
- # AOI outline
285
- if extent is not None:
286
- ax.set_xlim(extent[0], extent[1])
287
- ax.set_ylim(extent[2], extent[3])
288
- ax.set_aspect(_geographic_aspect(extent))
289
  color = STATUS_COLORS[status]
290
  _draw_aoi_rect(ax, aoi, color)
291
 
@@ -319,6 +323,33 @@ def _geographic_aspect(extent: list[float]) -> float:
319
  return 1.0 / cos_lat
320
 
321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  def render_hotspot_map(
323
  *,
324
  true_color_path: str | None,
@@ -335,17 +366,25 @@ def render_hotspot_map(
335
  Only pixels where |z-score| > threshold are shown; non-significant
336
  pixels are transparent, letting the true-color base show through.
337
 
338
- Uses a geographic (cos-lat-corrected) axis aspect so the image is
339
- never stretched when it's later placed into the PDF.
 
 
340
  """
341
  import rasterio
342
 
343
- fig, ax = plt.subplots(figsize=(6, 5), dpi=200, facecolor=SHELL)
344
- ax.set_facecolor(SHELL)
 
345
 
346
- geo_aspect = _geographic_aspect(extent)
 
 
 
 
 
347
 
348
- # True-color base layer
349
  if true_color_path is not None:
350
  with rasterio.open(true_color_path) as src:
351
  rgb = src.read([1, 2, 3]).astype(np.float32)
@@ -353,22 +392,22 @@ def render_hotspot_map(
353
  rgb_max = max(rgb.max(), 1.0)
354
  scale = 3000.0 if rgb_max > 255 else 255.0
355
  rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
356
- ax.imshow(rgb_normalized, extent=tc_extent, aspect=geo_aspect, zorder=0)
357
 
358
- # Hotspot overlay — only significant pixels, masked elsewhere
359
  masked_z = np.ma.masked_where(~hotspot_mask, zscore_raster)
360
  vmax = min(float(np.nanmax(np.abs(zscore_raster))), 5.0)
361
  im = ax.imshow(
362
  masked_z, extent=extent, cmap="RdBu_r", alpha=0.8,
363
- vmin=-vmax, vmax=vmax, aspect=geo_aspect, zorder=1,
364
  )
365
  cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
366
  cbar.set_label(f"{label} (decline \u2190 \u2192 increase)", fontsize=7, color=INK_MUTED)
367
  cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
368
 
369
- # AOI outline and axis limits
370
- ax.set_xlim(extent[0], extent[1])
371
- ax.set_ylim(extent[2], extent[3])
372
  ax.set_aspect(geo_aspect)
373
  color = STATUS_COLORS[status]
374
  _draw_aoi_rect(ax, aoi, color)
@@ -411,19 +450,30 @@ def render_overview_map(
411
  """
412
  import rasterio
413
 
414
- fig, ax = plt.subplots(figsize=(8, 6), dpi=200, facecolor=SHELL)
 
 
 
 
 
 
 
 
 
 
 
 
415
  ax.set_facecolor(SHELL)
416
 
417
  with rasterio.open(true_color_path) as src:
418
  rgb = src.read([1, 2, 3]).astype(np.float32)
419
- extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
420
 
421
  # Sentinel-2 reflectance scaling
422
  rgb_max = max(rgb.max(), 1.0)
423
  scale = 3000.0 if rgb_max > 255 else 255.0
424
  rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
425
- geo_aspect = _geographic_aspect(extent)
426
- ax.imshow(rgb_normalized, extent=extent, aspect=geo_aspect)
427
  ax.set_aspect(geo_aspect)
428
 
429
  # AOI outline
@@ -439,8 +489,8 @@ def render_overview_map(
439
  elif date_range:
440
  ax.set_title(date_range, fontsize=8, color=INK_MUTED, pad=6)
441
 
442
- ax.set_xlim(extent[0], extent[1])
443
- ax.set_ylim(extent[2], extent[3])
444
  ax.tick_params(labelsize=6, colors=INK_MUTED)
445
  ax.set_xlabel("Longitude", fontsize=7, color=INK_MUTED)
446
  ax.set_ylabel("Latitude", fontsize=7, color=INK_MUTED)
 
243
  """
244
  import rasterio
245
 
246
+ # Anchor the plot on the AOI bbox so every map in the report uses the
247
+ # same frame — prevents "pushed flat" artifacts caused by CDSE tile
248
+ # rasters coming back with bounds that don't match the AOI aspect.
249
+ min_lon, min_lat, max_lon, max_lat = aoi.bbox
250
+ plot_extent = [min_lon, max_lon, min_lat, max_lat]
251
+ geo_aspect = _geographic_aspect(plot_extent)
252
 
253
+ fig, ax = plt.subplots(
254
+ figsize=_figsize_for_extent(plot_extent),
255
+ dpi=200,
256
+ facecolor=SHELL,
257
+ )
258
+ ax.set_facecolor(SHELL)
259
 
260
+ # Render true-color base layer at its own bounds; anything outside the
261
+ # AOI is clipped by set_xlim/ylim below.
262
  if true_color_path is not None:
263
  with rasterio.open(true_color_path) as src:
264
  rgb = src.read([1, 2, 3]).astype(np.float32)
265
+ tc_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
 
266
  rgb_max = max(rgb.max(), 1.0)
267
  scale = 3000.0 if rgb_max > 255 else 255.0
268
  rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
269
+ ax.imshow(rgb_normalized, extent=tc_extent, zorder=0)
 
270
 
271
+ # Render indicator raster overlay at its own bounds.
272
  if indicator_path is not None:
273
  with rasterio.open(indicator_path) as src:
274
  data = src.read(indicator_band).astype(np.float32)
275
  nodata = src.nodata
276
  ind_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
 
 
 
277
  masked = np.ma.masked_where(
278
  (data == nodata) if nodata is not None else np.zeros_like(data, dtype=bool),
279
  data,
280
  )
281
  im = ax.imshow(
282
  masked, extent=ind_extent, cmap=cmap, alpha=alpha,
283
+ vmin=vmin, vmax=vmax, zorder=1,
284
  )
285
  cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
286
  cbar.set_label(label, fontsize=7, color=INK_MUTED)
287
  cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
288
 
289
+ # Anchor view + aspect on AOI bbox.
290
+ ax.set_xlim(plot_extent[0], plot_extent[1])
291
+ ax.set_ylim(plot_extent[2], plot_extent[3])
292
+ ax.set_aspect(geo_aspect)
 
293
  color = STATUS_COLORS[status]
294
  _draw_aoi_rect(ax, aoi, color)
295
 
 
323
  return 1.0 / cos_lat
324
 
325
 
326
+ def _figsize_for_extent(
327
+ extent: list[float],
328
+ *,
329
+ base_height_in: float = 5.0,
330
+ colorbar_allowance_in: float = 1.0,
331
+ min_width_in: float = 3.5,
332
+ max_width_in: float = 10.0,
333
+ ) -> tuple[float, float]:
334
+ """Return (width, height) in inches that match the data aspect ratio.
335
+
336
+ Without this, matplotlib's default figsize can force the axes into a
337
+ shape that doesn't match the data, and `bbox_inches="tight"` then
338
+ crops a distorted frame. By sizing the figure to the data first, the
339
+ rendered PNG matches the physical aspect of the AOI, and the image
340
+ no longer looks "pushed flat" (or stretched) in the PDF.
341
+ """
342
+ west, east, south, north = extent
343
+ data_w = max(east - west, 1e-9)
344
+ data_h = max(north - south, 1e-9)
345
+ geo_aspect = _geographic_aspect(extent)
346
+ # Display ratio (width / height) once cos-lat correction is applied.
347
+ display_ratio = data_w / (data_h * geo_aspect)
348
+ fig_w = base_height_in * display_ratio + colorbar_allowance_in
349
+ fig_w = min(max(fig_w, min_width_in), max_width_in)
350
+ return fig_w, base_height_in
351
+
352
+
353
  def render_hotspot_map(
354
  *,
355
  true_color_path: str | None,
 
366
  Only pixels where |z-score| > threshold are shown; non-significant
367
  pixels are transparent, letting the true-color base show through.
368
 
369
+ The plot is anchored on the AOI bbox (not the product raster bounds)
370
+ so that it matches the other maps in the report, and the figure is
371
+ sized to match the data aspect so the PNG never comes out "pushed
372
+ flat" after bbox_inches="tight" cropping.
373
  """
374
  import rasterio
375
 
376
+ min_lon, min_lat, max_lon, max_lat = aoi.bbox
377
+ plot_extent = [min_lon, max_lon, min_lat, max_lat]
378
+ geo_aspect = _geographic_aspect(plot_extent)
379
 
380
+ fig, ax = plt.subplots(
381
+ figsize=_figsize_for_extent(plot_extent),
382
+ dpi=200,
383
+ facecolor=SHELL,
384
+ )
385
+ ax.set_facecolor(SHELL)
386
 
387
+ # True-color base layer — drawn at its own extent but clipped to AOI.
388
  if true_color_path is not None:
389
  with rasterio.open(true_color_path) as src:
390
  rgb = src.read([1, 2, 3]).astype(np.float32)
 
392
  rgb_max = max(rgb.max(), 1.0)
393
  scale = 3000.0 if rgb_max > 255 else 255.0
394
  rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
395
+ ax.imshow(rgb_normalized, extent=tc_extent, zorder=0)
396
 
397
+ # Hotspot overlay — drawn at the product raster's extent.
398
  masked_z = np.ma.masked_where(~hotspot_mask, zscore_raster)
399
  vmax = min(float(np.nanmax(np.abs(zscore_raster))), 5.0)
400
  im = ax.imshow(
401
  masked_z, extent=extent, cmap="RdBu_r", alpha=0.8,
402
+ vmin=-vmax, vmax=vmax, zorder=1,
403
  )
404
  cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
405
  cbar.set_label(f"{label} (decline \u2190 \u2192 increase)", fontsize=7, color=INK_MUTED)
406
  cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
407
 
408
+ # Anchor view on AOI so this map lines up with the product map.
409
+ ax.set_xlim(plot_extent[0], plot_extent[1])
410
+ ax.set_ylim(plot_extent[2], plot_extent[3])
411
  ax.set_aspect(geo_aspect)
412
  color = STATUS_COLORS[status]
413
  _draw_aoi_rect(ax, aoi, color)
 
450
  """
451
  import rasterio
452
 
453
+ # Anchor overview on the AOI bbox — same reasoning as the indicator
454
+ # and hotspot maps: keep geometry consistent across the report.
455
+ min_lon, min_lat, max_lon, max_lat = aoi.bbox
456
+ plot_extent = [min_lon, max_lon, min_lat, max_lat]
457
+ geo_aspect = _geographic_aspect(plot_extent)
458
+
459
+ fig, ax = plt.subplots(
460
+ figsize=_figsize_for_extent(
461
+ plot_extent, base_height_in=6.0, max_width_in=11.0,
462
+ ),
463
+ dpi=200,
464
+ facecolor=SHELL,
465
+ )
466
  ax.set_facecolor(SHELL)
467
 
468
  with rasterio.open(true_color_path) as src:
469
  rgb = src.read([1, 2, 3]).astype(np.float32)
470
+ tc_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
471
 
472
  # Sentinel-2 reflectance scaling
473
  rgb_max = max(rgb.max(), 1.0)
474
  scale = 3000.0 if rgb_max > 255 else 255.0
475
  rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
476
+ ax.imshow(rgb_normalized, extent=tc_extent)
 
477
  ax.set_aspect(geo_aspect)
478
 
479
  # AOI outline
 
489
  elif date_range:
490
  ax.set_title(date_range, fontsize=8, color=INK_MUTED, pad=6)
491
 
492
+ ax.set_xlim(plot_extent[0], plot_extent[1])
493
+ ax.set_ylim(plot_extent[2], plot_extent[3])
494
  ax.tick_params(labelsize=6, colors=INK_MUTED)
495
  ax.set_xlabel("Longitude", fontsize=7, color=INK_MUTED)
496
  ax.set_ylabel("Latitude", fontsize=7, color=INK_MUTED)