Source code for geeViz.outputLib.thumbs

"""
Generate Earth Engine thumbnails with automatic visualization handling.

``geeViz.outputLib.thumbs`` provides functions that mirror the auto-visualization
logic in ``geeView`` and ``geeViz.outputLib.charts`` — detecting thematic vs. continuous
data, reading ``*_class_values`` / ``*_class_palette`` image properties, and
building appropriate viz params — so you can get publication-ready thumbnail
URLs and embeddable HTML ``<img>`` tags without manual configuration.

Supports ``ee.Image`` (PNG) and ``ee.ImageCollection`` (animated GIF or
filmstrip), with optional per-feature clipping for ``ee.FeatureCollection``
geometries.

Animated GIFs
-------------
For ``ee.ImageCollection`` inputs, :func:`generate_gif` creates properly
mosaicked per-time-step frames, with optional date burn-in using
``system:time_start`` metadata.

Example::

    import geeViz.geeView as gv
    from geeViz.outputLib import thumbs as tl

    ee = gv.ee
    lcms = ee.ImageCollection("USFS/GTAC/LCMS/v2024-10")
    area = ee.Geometry.Point([-111.8, 40.7]).buffer(10000)

    url = tl.get_thumb_url(lcms.select(["Land_Cover"]).first(), area)
    html = tl.embed_thumb(url, title="LCMS Land Cover")

    # Animated GIF with date labels
    gif_html = tl.generate_gif(
        lcms.select(["Land_Cover"]),
        area,
        burn_in_date=True,
        date_format="YYYY",
    )
"""

"""
   Copyright 2026 Ian Housman

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
"""

import base64
import concurrent.futures
import io
import math
import urllib.request

import geeViz.geeView as gv

ee = gv.ee

from geeViz.outputLib import charts as cl

# ---------------------------------------------------------------------------
#  Constants
# ---------------------------------------------------------------------------
_DEFAULT_DIMENSIONS = 640
_MAX_GIF_FRAMES = 50
_DEFAULT_FPS = 2
_DEFAULT_MARGIN = 16

# Common continuous viz defaults when no viz_params provided and data is not thematic
_CONTINUOUS_DEFAULTS = {
    "min": 0,
    "max": 1,
    "palette": ["000000", "00FF00"],
}

# Default font sizes (pixels) for consistent typography across all outputs
_DEFAULT_TITLE_FONT_SIZE = 18
_DEFAULT_LABEL_FONT_SIZE = 12

# Default CRS for thumbnail/GIF generation
_DEFAULT_CRS = "EPSG:3857"

# Default single-band palette (grayscale fallback)
_CONTINUOUS_SINGLE_BAND_PALETTE = ["000000", "ffffff"]

# Band-name → palette lookup using geeViz.geePalettes (ee-palettes)
# Keys are lower-cased band names; values are resolved palette lists.
def _build_band_palette_lookup():
    """Build a dict mapping common band names to ee-palettes colour lists."""
    try:
        import geeViz.geePalettes as gp
    except ImportError:
        return {}

    # Helper to strip leading '#' from matplotlib palettes
    def _clean(pal):
        return [c.lstrip("#") for c in pal]

    lookup = {}

    # Vegetation indices — green ramps
    _rdylgn9 = _clean(gp.colorbrewer["RdYlGn"][9])
    for name in ("ndvi", "evi", "evi2", "savi", "msavi", "nirv", "gcc"):
        lookup[name] = _rdylgn9

    # Water / moisture indices — blue ramps
    _pubu9 = _clean(gp.colorbrewer["PuBu"][9])
    for name in ("ndmi", "ndwi", "lswi", "ndsi", "mndwi"):
        lookup[name] = _pubu9

    # Burn / fire indices
    _ylorrd9 = _clean(gp.colorbrewer["YlOrRd"][9])
    for name in ("nbr", "dnbr", "dndvi", "rbr"):
        lookup[name] = _ylorrd9

    # Temperature
    _thermal = _clean(gp.cmocean["Thermal"][7])
    for name in ("temp", "lst", "tmmn", "tmmx", "temperature",
                 "thermal", "brightness_temperature"):
        lookup[name] = _thermal

    # Elevation / terrain
    _terrain = _clean(gp.cmocean["Deep"][7])[::-1]  # reverse: dark=deep, light=high
    for name in ("elevation", "dem", "height", "slope", "aspect", "hillshade"):
        lookup[name] = _terrain

    # Precipitation / rainfall — teal blues
    _tempo = _clean(gp.cmocean["Tempo"][7])
    for name in ("precipitation", "pr", "precip", "rainfall", "rain"):
        lookup[name] = _tempo

    # Generic spectral bands — viridis
    _viridis = _clean(gp.matplotlib["viridis"][7])
    for name in ("blue", "green", "red", "nir", "swir1", "swir2",
                 "nir2", "re1", "re2", "re3", "cb"):
        lookup[name] = _viridis

    # Speed / wind
    _speed = _clean(gp.cmocean["Speed"][7])
    for name in ("speed", "wind", "vs"):
        lookup[name] = _speed

    return lookup


_BAND_PALETTE_LOOKUP = _build_band_palette_lookup()


def _get_palette_for_band(band_name):
    """Return a palette list for a band name, or the grayscale fallback."""
    if band_name:
        return _BAND_PALETTE_LOOKUP.get(
            band_name.lower(), list(_CONTINUOUS_SINGLE_BAND_PALETTE)
        )
    return list(_CONTINUOUS_SINGLE_BAND_PALETTE)

# ---------------------------------------------------------------------------
#  Theme — delegated to outputLib.themes for consistency across geeViz
# ---------------------------------------------------------------------------
from geeViz.outputLib.themes import get_theme as _get_theme_obj
from geeViz.outputLib._colors import resolve_color as _resolve_color
from geeViz.outputLib._basemaps import (
    fetch_basemap as _fetch_basemap,
    build_bottom_strip as _build_bottom_strip,
    build_inset_image as _build_inset_image,
    build_title_strip as _build_title_strip,
)


def _get_theme(bg_color=None, font_color=None):
    """Return a Theme object for the given background and/or font colors."""
    return _get_theme_obj(bg_color=bg_color, font_color=font_color)


def _resolve_font_colors(bg_color=None, font_color=None, font_outline_color=None):
    """Resolve font, outline, and background colors into a consistent theme.

    Determines the full color scheme from whichever colors are provided,
    filling in missing values using the geeViz theme system.  The four
    resolution cases are:

    1. Only ``font_color`` -- derive background from font luminance
       (dark font implies light background).
    2. Only ``bg_color`` -- derive font from background via theme.
    3. Both ``font_color`` and ``bg_color`` -- use as-is; accent colors
       derived from the font color.
    4. Neither -- default dark theme (black background, light text).

    Args:
        bg_color (str or tuple, optional): Background color as a CSS name,
            hex string, or ``(R, G, B)`` tuple.  Defaults to ``None``
            (resolved by theme system).
        font_color (str or tuple, optional): Text color as a CSS name,
            hex string, or ``(R, G, B)`` tuple.  Defaults to ``None``
            (resolved by theme system).
        font_outline_color (str or tuple, optional): Outline / halo color
            for text readability.  Auto-generated to contrast with
            ``font_color`` when ``None``.  Defaults to ``None``.

    Returns:
        tuple: A 4-element tuple of
        ``(font_color_rgb, font_outline_color_rgb, theme, bg_color_str)``
        where *font_color_rgb* and *font_outline_color_rgb* are
        ``(R, G, B)`` tuples, *theme* is a ``Theme`` object, and
        *bg_color_str* is a hex string.

    Example:
        >>> fc, oc, theme, bg = _resolve_font_colors(bg_color="white")
        >>> fc   # e.g. (0, 0, 0) -- dark text on light bg
    """
    from geeViz.outputLib.themes import luminance
    from geeViz.outputLib._colors import to_hex

    # Resolve string inputs to RGB tuples
    if font_color is not None:
        font_color = _resolve_color(font_color) if isinstance(font_color, str) else tuple(font_color)

    # Pass through to theme system — it handles all None combos
    theme = _get_theme(bg_color, font_color=font_color)

    # Pull resolved values from theme
    if font_color is None:
        font_color = theme.text
    bg_color = to_hex(theme.bg)

    # Auto-generate outline: contrasts with font color
    if font_outline_color is None:
        if luminance(font_color) > 128:
            font_outline_color = (0, 0, 0)
        else:
            font_outline_color = (255, 255, 255)
    else:
        font_outline_color = _resolve_color(font_outline_color) if isinstance(font_outline_color, str) else tuple(font_outline_color)

    return font_color, font_outline_color, theme, bg_color


# Date format mapping: chartingLib-style format -> Python strftime
_DATE_FORMAT_MAP = {
    "YYYY": "%Y",
    "YYYY-MM": "%Y-%m",
    "YYYY-MM-dd": "%Y-%m-%d",
    "YYYYMMdd": "%Y%m%d",
    "MMM YYYY": "%b %Y",
    "MMMM YYYY": "%B %Y",
    "MM/YYYY": "%m/%Y",
    "MM/dd/YYYY": "%m/%d/%Y",
}


# ---------------------------------------------------------------------------
#  Projection helpers
# ---------------------------------------------------------------------------
def _validate_projection_params(crs, transform, scale):
    """Validate the crs / transform / scale combination.

    Rules:
        - All three can be None (no projection override).
        - ``crs`` alone is allowed.
        - ``crs`` + ``transform`` is allowed.
        - ``crs`` + ``scale`` is allowed.
        - ``crs`` + ``transform`` + ``scale`` is allowed.
        - ``transform`` or ``scale`` without ``crs`` is an error.

    Raises:
        ValueError: If ``transform`` or ``scale`` is provided without ``crs``.
    """
    if crs is None and (transform is not None or scale is not None):
        raise ValueError(
            "When transform or scale is provided, crs must also be "
            "provided.  Either supply crs together with transform/scale, "
            "or omit all three."
        )


def _apply_projection(ee_img, crs=None, transform=None, scale=None):
    """Apply ``setDefaultProjection`` to an ``ee.Image`` if projection params are given.

    Args:
        ee_img: ``ee.Image``.
        crs (str, optional): CRS code, e.g. ``"EPSG:4326"``.
        transform (list, optional): Affine transform as a 6-element list.
        scale (float, optional): Nominal scale in meters.

    Returns:
        ee.Image: The image with default projection set (or unchanged).
    """
    if crs is None:
        return ee_img
    kwargs = {"crs": crs}
    if transform is not None:
        kwargs["crsTransform"] = transform
    if scale is not None:
        kwargs["scale"] = scale
    elif transform is None:
        # When only crs is given (no scale or transform), derive the scale
        # from the image's native projection.  Without an explicit scale,
        # setDefaultProjection uses a [1,0,0,0,1,0] transform whose
        # positive y-scale flips the image in projected CRSes like UTM.
        kwargs["scale"] = ee_img.projection().nominalScale()
    return ee_img.setDefaultProjection(**kwargs)


def _apply_projection_to_collection(ee_col, crs=None, transform=None, scale=None):
    """Apply ``setDefaultProjection`` to every image in an ``ee.ImageCollection``.

    Args:
        ee_col: ``ee.ImageCollection``.
        crs (str, optional): CRS code.
        transform (list, optional): Affine transform.
        scale (float, optional): Nominal scale in meters.

    Returns:
        ee.ImageCollection: Collection with projection set on each image.
    """
    if crs is None:
        return ee_col
    def _set_proj(img):
        return _apply_projection(img, crs, transform, scale).copyProperties(
            img, ["system:time_start"]
        )
    return ee_col.map(_set_proj)


# ---------------------------------------------------------------------------
#  Basemap compositing helpers
def _hex_fill_to_rgba(hex_str):
    """Convert a hex fill color string (RRGGBB or RRGGBBAA) to an RGBA tuple."""
    if not hex_str or len(hex_str) < 6:
        return None
    hex_str = hex_str.lstrip("#")
    try:
        r, g, b = int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
        a = int(hex_str[6:8], 16) if len(hex_str) >= 8 else 255
        return (r, g, b, a)
    except (ValueError, IndexError):
        return None


# ---------------------------------------------------------------------------
def _get_bounds_4326(geom):
    """Extract ``(xmin, ymin, xmax, ymax)`` from an ``ee.Geometry``.

    Returns:
        tuple or None: Bounds in EPSG:4326, or None on failure.
    """
    try:
        coords = geom.bounds(10,"EPSG:4326").coordinates().get(0).getInfo()
        lons = [c[0] for c in coords]
        lats = [c[1] for c in coords]
        return (min(lons), min(lats), max(lons), max(lats))
    except Exception:
        return None


_THUMB_PADDING = 8  # pixel border around thumbnails showing basemap


def _expand_bounds_for_padding(bounds_4326, dimensions, pad=_THUMB_PADDING):
    """Expand geographic bounds by the padding ratio so EE data fills to the basemap edges.

    The basemap is fetched at ``dimensions + 2*pad`` pixels for the same
    geographic extent, creating a border.  This function expands the bounds
    proportionally so the EE thumbnail request covers the same geographic
    area as the basemap (including the padding margin).

    Returns:
        ee.Geometry.Rectangle with expanded bounds, or None if bounds is None.
    """
    if bounds_4326 is None:
        return None
    xmin, ymin, xmax, ymax = bounds_4326
    dx = xmax - xmin
    dy = ymax - ymin
    # Fraction of the image that is padding on each side
    frac = pad / dimensions if dimensions > 0 else 0
    buf_x = dx * frac
    buf_y = dy * frac
    return (xmin - buf_x, ymin - buf_y, xmax + buf_x, ymax + buf_y)


def _composite_with_basemap(frame, basemap_img, overlay_opacity=1.0):
    """Composite an Earth Engine thumbnail frame over a basemap image.

    Both images should cover the same geographic extent (the EE frame
    was requested with an expanded region matching the basemap).  The
    basemap is resized to match the frame dimensions and composited
    underneath.

    Args:
        frame (PIL.Image.Image): RGBA Earth Engine thumbnail frame
            (requested at padded dimensions with expanded region).
        basemap_img (PIL.Image.Image): RGBA basemap tile image.
        overlay_opacity (float, optional): Opacity multiplier (0.0–1.0).

    Returns:
        PIL.Image.Image: RGBA composite.
    """
    from PIL import Image
    fw, fh = frame.size

    bg = basemap_img.resize((fw, fh), Image.LANCZOS).convert("RGBA")
    overlay = frame.convert("RGBA")
    if overlay_opacity < 1.0:
        r, g, b, a = overlay.split()
        a = a.point(lambda x: int(x * max(0.0, min(1.0, overlay_opacity))))
        overlay = Image.merge("RGBA", (r, g, b, a))
    bg.paste(overlay, (0, 0), overlay)
    return bg


def _add_thumb_padding(frame, bg_color="black"):
    """Add ``_THUMB_PADDING`` pixels of blank space around a frame.

    Used when no basemap is present, to keep frame sizes consistent
    with the basemap-composited path.

    Args:
        frame (PIL.Image.Image): RGBA frame.
        bg_color: Background colour for the padding.

    Returns:
        PIL.Image.Image: Padded RGBA image.
    """
    from PIL import Image
    pad = _THUMB_PADDING
    fw, fh = frame.size
    bg_rgba = _resolve_color(bg_color) + (255,)
    out = Image.new("RGBA", (fw + 2 * pad, fh + 2 * pad), bg_rgba)
    out.paste(frame, (pad, pad), frame if frame.mode == "RGBA" else None)
    return out


def _auto_geometry_color(basemap_img):
    """Pick a contrasting outline color based on the basemap's average brightness.

    Returns a color that contrasts well with both the basemap AND
    a dark chart background.  Uses medium-bright tones that are visible
    on both light and dark surfaces.

    Args:
        basemap_img (PIL.Image.Image): Basemap tile image (RGBA).

    Returns:
        tuple: ``(R, G, B)`` colour tuple.
    """
    if basemap_img is None:
        return (220, 220, 220)
    try:
        small = basemap_img.resize((16, 16)).convert("RGB")
        pixels = list(small.getdata())
        avg_r = sum(p[0] for p in pixels) // len(pixels)
        avg_g = sum(p[1] for p in pixels) // len(pixels)
        avg_b = sum(p[2] for p in pixels) // len(pixels)
        luminance = 0.299 * avg_r + 0.587 * avg_g + 0.114 * avg_b
        if luminance < 100:
            # Very dark basemap → bright white
            return (255, 255, 255)
        elif luminance < 170:
            # Medium basemap (hillshade) → bright color visible on both
            return (220, 220, 220)
        else:
            # Light basemap → dark but not too dark (visible on dark chart bg)
            return (80, 80, 80)
    except Exception:
        return (220, 220, 220)


def _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds):
    """Resolve the geometry outline colour.

    Priority: explicit color > auto from basemap > font_color.
    """
    if geometry_outline_color is not None:
        if isinstance(geometry_outline_color, str):
            return _resolve_color(geometry_outline_color)
        return geometry_outline_color
    if basemap is not None and bounds is not None:
        try:
            bm = _fetch_basemap(bounds, 64, 64, basemap)
            return _auto_geometry_color(bm)
        except Exception:
            pass
    return font_color or (255, 255, 255)


def _paint_boundary(ee_obj, geometry, color, viz_params=None, fill_color=None, width=2, crs=None):
    """Paint a geometry boundary outline onto an ee.Image or ee.ImageCollection.

    Uses ``FeatureCollection.style()`` to render the boundary as a styled
    RGB image, then ``.blend()`` it onto each visualized frame.  The data
    image is first visualized with *viz_params* (producing an RGB image),
    then the styled outline is blended on top.

    Args:
        ee_obj: ``ee.Image`` or ``ee.ImageCollection``.
        geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection``.
        color (tuple or list): ``(R, G, B)`` colour for the outline (0–255).
        viz_params (dict, optional): Visualization parameters to apply
            before blending.  When ``None`` the image is assumed to be
            already visualized or single-band.
        fill_color (str, optional): CSS fill colour for the geometry interior
            (e.g. ``"33333388"`` for semi-transparent). Default ``None`` (no fill).

    Returns:
        Same type as *ee_obj* with boundary painted on and pre-visualized
        as 3-band ``vis-red/vis-green/vis-blue``.
    """
    geom = _to_geometry(geometry)

    # Build FeatureCollection for styling (accepts geometry, Feature, or FC)
    if isinstance(geometry, ee.FeatureCollection):
        boundary_fc = geometry
    elif isinstance(geometry, (ee.Feature, ee.element.Element)):
        boundary_fc = ee.FeatureCollection([ee.Feature(geometry)])
    else:
        boundary_fc = ee.FeatureCollection([ee.Feature(geom)])

    # Convert colour to hex string for .style()
    if isinstance(color, str):
        gc = list(_resolve_color(color))
    else:
        gc = list(color)
    hex_color = "{:02x}{:02x}{:02x}".format(int(gc[0]), int(gc[1]), int(gc[2]))

    # Render styled boundary and set projection so it aligns with the data CRS
    _fill = fill_color if fill_color is not None else "00000000"
    styled_boundary = boundary_fc.style(
        color=hex_color, fillColor=_fill, width=width,
    )
    if crs is not None:
        styled_boundary = styled_boundary.setDefaultProjection(crs, None, 1)

    def _paint_img(img):
        img = ee.Image(img)
        # If image has no bands (geometry-only), return just the styled boundary
        has_bands = viz_params and any(k in viz_params for k in ("bands", "min", "max", "palette"))
        if has_bands:
            vis = img.visualize(**{k: v for k, v in viz_params.items()
                                   if k not in ("dimensions", "format", "region")})
        else:
            # No data to visualize — use styled boundary as the image
            return ee.Image(styled_boundary).copyProperties(img, ["system:time_start"])
        # Blend styled boundary on top — cast back to ee.Image
        return ee.Image(vis.blend(styled_boundary)).copyProperties(img, ["system:time_start"])

    if isinstance(ee_obj, ee.ImageCollection):
        return ee_obj.map(_paint_img)
    return ee.Image(_paint_img(ee.Image(ee_obj)))

    if isinstance(ee_obj, ee.ImageCollection):
        return ee_obj.map(_paint_img)
    return _paint_img(ee.Image(ee_obj))


def _assemble_with_cartography(frame, bounds_4326, bg_color="black",
                                font_color=None, font_outline_color=None,
                                title=None, scalebar=True,
                                scalebar_units="metric", north_arrow=True,
                                north_arrow_style="solid",
                                inset_map=True, inset_basemap=None, inset_scale=0.3,
                                inset_on_map=True,
                                inset_rect_color=None, inset_rect_fill_color=None,
                                legend_panel=None, inset_below_legend=True,
                                margin=_DEFAULT_MARGIN, crs=None):
    """Assemble a map frame with cartographic elements into a final image.

    Combines the raw EE thumbnail frame with optional cartographic
    decorations: scalebar, north arrow, inset overview map, legend panel,
    and title strip.  The layout follows these rules:

    * **Scalebar and north arrow** are drawn directly on the map image
      (lower-left and upper-right, respectively).
    * **Inset map** is placed on the map (lower-right) when
      ``inset_on_map`` is True; otherwise it is positioned below the
      legend or below the main frame.
    * **Legend panel** is appended as a right-side column.
    * **Title strip** is stacked above the entire composition.

    Args:
        frame (PIL.Image.Image): The map thumbnail frame (RGBA).
        bounds_4326 (tuple or None): Geographic bounds as
            ``(xmin, ymin, xmax, ymax)`` in EPSG:4326, used for
            scalebar distance calculation and inset generation.  Pass
            ``None`` to skip all geographic decorations.
        bg_color (str, optional): Background color name or hex string.
            Defaults to ``"black"``.
        font_color (str or tuple, optional): Text color for labels.
            Resolved via theme when ``None``.  Defaults to ``None``.
        font_outline_color (str or tuple, optional): Outline color for
            text readability.  Auto-derived when ``None``.
            Defaults to ``None``.
        title (str, optional): Title text rendered as a strip above the
            frame.  Defaults to ``None`` (no title).
        scalebar (bool, optional): Draw a scalebar on the lower-left of
            the map.  Defaults to ``True``.
        scalebar_units (str, optional): Unit system for the scalebar,
            either ``"metric"`` or ``"imperial"``.
            Defaults to ``"metric"``.
        north_arrow (bool, optional): Draw a north arrow on the
            upper-right of the map.  Defaults to ``True``.
        north_arrow_style (str, optional): Arrow style -- ``"solid"``,
            ``"classic"``, or ``"outline"``.  Defaults to ``"solid"``.
        inset_map (bool, optional): Generate and include an inset
            overview map.  Defaults to ``True``.
        inset_basemap (str or dict, optional): Basemap for the inset.
            Falls back to the main basemap when ``None``.
            Defaults to ``None``.
        inset_scale (float, optional): Relative height of the inset
            compared to the frame height.  Defaults to ``0.3``.
        inset_on_map (bool, optional): Place the inset directly on the
            map rather than below it.  Defaults to ``True``.
        legend_panel (PIL.Image.Image, optional): Pre-built legend panel
            image to attach as a right-side column.
            Defaults to ``None``.
        inset_below_legend (bool, optional): When the inset is not on
            the map and a legend is present, place the inset below the
            legend in the right column.  Defaults to ``True``.
        margin (int, optional): Internal padding in pixels used for
            spacing calculations.  Defaults to ``16``.

    Returns:
        tuple: A 2-element tuple of ``(PIL.Image.Image, bool)`` where
        the image is the assembled result and the boolean indicates
        whether a below-frame element was added (used by callers to
        adjust outer margin).

    Example:
        >>> assembled, has_bottom = _assemble_with_cartography(
        ...     frame, bounds_4326=(-111.5, 40.0, -110.5, 41.0),
        ...     title="LCMS Land Cover 2023", scalebar=True,
        ... )
    """
    from PIL import Image, ImageDraw

    bg_rgba = _resolve_color(bg_color) + (255,)
    font_color, font_outline_color, theme, bg_color = _resolve_font_colors(
        bg_color, font_color, font_outline_color)
    contrast = font_outline_color

    try:
        # --- Build inset image ---
        inset_img = None
        if inset_map and bounds_4326 is not None:
            # Compute final display size so we fetch at that resolution
            _inset_display_h = int(frame.size[1] * inset_scale)
            _inset_kw = {}
            if inset_rect_color is not None:
                _inset_kw["rect_color"] = inset_rect_color
            if inset_rect_fill_color is not None:
                _inset_kw["rect_fill_color"] = inset_rect_fill_color
            inset_img = _build_inset_image(
                bounds_4326, size=_inset_display_h,
                inset_basemap=inset_basemap, crs=crs, **_inset_kw,
            )

        # --- Draw scalebar + north arrow ON the frame (lower-left) ---
        if bounds_4326 is not None and (scalebar or north_arrow):
            _draw_scalebar_and_arrow_on_frame(
                frame, bounds_4326, scalebar=scalebar,
                scalebar_units=scalebar_units, north_arrow=north_arrow,
                north_arrow_style=north_arrow_style,
                font_color=font_color, contrast=contrast,
                accent=theme.accent, crs=crs,
            )

        # --- Place inset ON the map (lower-right) if requested ---
        if inset_on_map and inset_img is not None:
            target_h = int(frame.size[1] * inset_scale)
            src_w, src_h = inset_img.size
            aspect = src_w / src_h if src_h > 0 else 1.0
            iw = int(target_h * aspect)
            ih = target_h
            if iw > frame.size[0] // 3:
                iw = frame.size[0] // 3
                ih = int(iw / aspect)
            inset_resized = inset_img.resize((iw, ih), Image.LANCZOS)
            padding = max(6, margin // 2)
            px = frame.size[0] - iw - padding
            py = frame.size[1] - ih - padding
            frame.paste(inset_resized, (px, py),
                       inset_resized if inset_resized.mode == "RGBA" else None)
            inset_img = None  # consumed

        # --- Attach legend + inset in right column ---
        if legend_panel is not None:
            col_w = legend_panel.size[0]
            col_h = frame.size[1]

            if inset_img is not None and inset_below_legend:
                # Inset below legend in right column
                inset_pad_left = max(4, margin // 3)
                inset_target_h = int(col_h * inset_scale)
                src_w, src_h = inset_img.size
                aspect = src_w / src_h if src_h > 0 else 1.0
                max_inset_w = col_w - inset_pad_left
                iw = int(inset_target_h * aspect)
                ih = inset_target_h
                if iw > max_inset_w:
                    iw = max_inset_w
                    ih = int(max_inset_w / aspect)
                inset_resized = inset_img.resize((iw, ih), Image.LANCZOS)
                inset_img = None

                legend_h = col_h - ih
                legend_resized = legend_panel.resize((col_w, legend_h), Image.LANCZOS)

                right_col = Image.new("RGBA", (col_w, col_h), bg_rgba)
                right_col.paste(legend_resized, (0, 0),
                               legend_resized if legend_resized.mode == "RGBA" else None)
                right_col.paste(inset_resized, (inset_pad_left, col_h - ih),
                               inset_resized if inset_resized.mode == "RGBA" else None)
            else:
                right_col = Image.new("RGBA", (col_w, col_h), bg_rgba)
                right_col.paste(legend_panel, (0, 0),
                               legend_panel if legend_panel.mode == "RGBA" else None)

            combined_w = frame.size[0] + col_w
            combined = Image.new("RGBA", (combined_w, col_h), bg_rgba)
            combined.paste(frame, (0, 0))
            combined.paste(right_col, (frame.size[0], 0),
                          right_col if right_col.mode == "RGBA" else None)
            frame = combined

        # --- Place inset below frame if not consumed ---
        has_below = False
        if inset_img is not None:
            target_h = int(frame.size[1] * inset_scale)
            src_w, src_h = inset_img.size
            aspect = src_w / src_h if src_h > 0 else 1.0
            iw = int(target_h * aspect)
            ih = target_h
            if iw > frame.size[0] // 3:
                iw = frame.size[0] // 3
                ih = int(iw / aspect)
            inset_resized = inset_img.resize((iw, ih), Image.LANCZOS)

            gap_above = max(4, margin // 3)
            inset_row = Image.new("RGBA", (frame.size[0], ih), bg_rgba)
            inset_row.paste(inset_resized, (0, 0),
                           inset_resized if inset_resized.mode == "RGBA" else None)

            combined_h = frame.size[1] + gap_above + ih
            combined = Image.new("RGBA", (frame.size[0], combined_h), bg_rgba)
            combined.paste(frame, (0, 0))
            combined.paste(inset_row, (0, frame.size[1] + gap_above),
                          inset_row if inset_row.mode == "RGBA" else None)
            frame = combined
            has_below = True

        # --- Title strip ---
        title_strip = None
        if title:
            title_strip = _build_title_strip(
                frame.size[0], title, bg_color=bg_rgba,
                text_color=font_color, margin=margin,
            )

        # --- Stack: title + frame ---
        if title_strip is not None:
            total_w = max(frame.size[0], title_strip.size[0])
            total_h = frame.size[1] + title_strip.size[1]
            final = Image.new("RGBA", (total_w, total_h), bg_rgba)
            final.paste(title_strip, (0, 0),
                       title_strip if title_strip.mode == "RGBA" else None)
            final.paste(frame, (0, title_strip.size[1]),
                       frame if frame.mode == "RGBA" else None)
            return final, has_below

        return frame, has_below

    except Exception:
        return frame, False  # Never let decoration failures break thumbnail generation


def _compute_convergence(bounds_4326, crs=None):
    """Compute the grid convergence angle (degrees) at the center of bounds.

    Grid convergence is the angle between true north and grid north.
    For EPSG:4326 this is 0. For projected CRS like Albers, it varies
    with longitude relative to the central meridian.

    Uses Earth Engine to project two points (center and center+0.1 lat)
    into the target CRS and computes the angle of the projected line
    relative to the y-axis.

    Returns 0 if CRS is None, "EPSG:4326", or if computation fails.
    """
    if crs is None or crs in ("EPSG:4326", "epsg:4326"):
        return 0.0
    try:
        xmin, ymin, xmax, ymax = bounds_4326
        cx = (xmin + xmax) / 2.0
        cy = (ymin + ymax) / 2.0
        # Project center and a point slightly north
        p1 = ee.Geometry.Point([cx, cy]).transform(crs, 1).coordinates().getInfo()
        p2 = ee.Geometry.Point([cx, cy + 0.1]).transform(crs, 1).coordinates().getInfo()
        dx = p2[0] - p1[0]
        dy = p2[1] - p1[1]
        angle_rad = math.atan2(dx, dy)  # angle from y-axis
        return math.degrees(angle_rad)
    except Exception:
        return 0.0


def _draw_scalebar_and_arrow_on_frame(frame, bounds_4326, scalebar=True,
                                       scalebar_units="metric", north_arrow=True,
                                       north_arrow_style="solid",
                                       font_color=(255, 255, 255),
                                       contrast=(0, 0, 0), accent=(180, 180, 180),
                                       label_font_size=_DEFAULT_LABEL_FONT_SIZE,
                                       crs=None):
    """Draw a scalebar and north arrow directly onto a map frame in-place.

    The scalebar is rendered in the lower-left corner with a
    semi-transparent background and alternating color segments.  The
    north arrow is placed in the upper-right corner.  Both elements are
    sized relative to the frame dimensions and the geographic extent.

    Args:
        frame (PIL.Image.Image): RGBA map frame to draw on.  Modified
            in-place.
        bounds_4326 (tuple): Geographic bounds as
            ``(xmin, ymin, xmax, ymax)`` in EPSG:4326.  Used to
            calculate the real-world distance for the scalebar.
        scalebar (bool, optional): Whether to draw the scalebar.
            Defaults to ``True``.
        scalebar_units (str, optional): Unit system -- ``"metric"``
            (meters / km) or ``"imperial"`` (feet / miles).
            Defaults to ``"metric"``.
        north_arrow (bool, optional): Whether to draw the north arrow.
            Defaults to ``True``.
        north_arrow_style (str, optional): Arrow visual style --
            ``"solid"``, ``"classic"``, or ``"outline"``.
            Defaults to ``"solid"``.
        font_color (tuple, optional): ``(R, G, B)`` tuple for scalebar
            text and primary bar segments.
            Defaults to ``(255, 255, 255)``.
        contrast (tuple, optional): ``(R, G, B)`` tuple for the bar
            outline and alternating segments.
            Defaults to ``(0, 0, 0)``.
        accent (tuple, optional): ``(R, G, B)`` tuple for secondary bar
            segments and arrow accents.
            Defaults to ``(180, 180, 180)``.

    Returns:
        None: The frame is modified in-place.

    Example:
        >>> _draw_scalebar_and_arrow_on_frame(
            label_font_size=label_font_size,
        ...     frame, (-111.5, 40.0, -110.5, 41.0),
        ...     scalebar_units="imperial", north_arrow_style="classic",
        ... )
    """
    from PIL import Image, ImageDraw
    from geeViz.outputLib._basemaps import (
        _pick_nice_distance, _format_metric, _format_imperial,
        _get_font as _bm_get_font,
        _M_PER_DEG_LAT, _METRIC_STEPS, _IMPERIAL_STEPS_FT,
        render_north_arrow,
    )

    w, h = frame.size
    padding = max(8, w // 40)  # margin from frame edge

    xmin, ymin, xmax, ymax = bounds_4326
    mid_lat = (ymin + ymax) / 2.0
    lon_span_m = (xmax - xmin) * _M_PER_DEG_LAT * math.cos(math.radians(mid_lat))
    if lon_span_m <= 0:
        return

    draw = ImageDraw.Draw(frame)

    # --- Scalebar (lower-left) ---
    if scalebar:
        # Scale font to frame size but cap at label_font_size
        fs = min(label_font_size, max(8, w // 35))
        sfont = _get_font(fs)
        bar_h = max(3, fs // 3)

        if scalebar_units == "imperial":
            total_ft = lon_span_m * 3.28084
            bar_val = _pick_nice_distance(total_ft, _IMPERIAL_STEPS_FT)
            bar_frac = bar_val / total_ft
            label = _format_imperial(bar_val)
        else:
            bar_val = _pick_nice_distance(lon_span_m, _METRIC_STEPS)
            bar_frac = bar_val / lon_span_m
            label = _format_metric(bar_val)

        bar_px = max(20, int(w * bar_frac))
        bar_px = min(bar_px, w // 3)

        # Determine display value and unit label
        if scalebar_units == "imperial":
            if bar_val >= 5_280:
                display_val = bar_val / 5_280
                unit_str = "Miles"
            else:
                display_val = bar_val
                unit_str = "ft"
        else:
            if bar_val >= 1_000:
                display_val = bar_val / 1_000
                unit_str = "km"
            else:
                display_val = bar_val
                unit_str = "m"

        # Number of bar segments from the display value
        seg = max(2, min(int(display_val), 10))

        # Tick labels: 0, midpoint, end + unit at bar end
        tick_font_size = fs  # same as scalebar font
        tick_font = _get_font(tick_font_size)
        mid_seg = seg // 2
        mid_val = display_val * mid_seg / seg

        def _fmt_tick(v):
            return f"{v:g}"

        tick_0 = "0"
        tick_mid = _fmt_tick(mid_val)
        tick_end = _fmt_tick(display_val)

        # Measure tick height for layout
        tb = draw.textbbox((0, 0), tick_0, font=tick_font)
        tick_h = tb[3] - tb[1]
        tick_gap = 2  # gap between bar and tick labels
        unit_bb = draw.textbbox((0, 0), unit_str, font=tick_font)
        unit_w = unit_bb[2] - unit_bb[0]

        tick_mark_h = max(2, bar_h // 2)
        elem_h = bar_h + tick_gap + tick_mark_h + 1 + tick_h
        bx = padding
        by = h - padding - elem_h

        # Semi-transparent background (compact)
        bg_pad = 4
        bg_x0 = max(0, bx - bg_pad)
        bg_y0 = max(0, by - bg_pad)
        bg_x1 = min(w, bx + bar_px + bg_pad + unit_w + 6)
        bg_y1 = min(h, by + elem_h + bg_pad)
        bg_w = bg_x1 - bg_x0
        bg_h = bg_y1 - bg_y0
        if bg_w > 0 and bg_h > 0:
            bg_layer = Image.new("RGBA", (bg_w, bg_h), (0, 0, 0, 0))
            ImageDraw.Draw(bg_layer).rounded_rectangle(
                (0, 0, bg_w - 1, bg_h - 1), radius=4, fill=(0, 0, 0, 120))
            frame.paste(bg_layer, (bg_x0, bg_y0), bg_layer)
        draw = ImageDraw.Draw(frame)

        # Draw bar segments
        draw.rectangle((bx - 1, by - 1, bx + bar_px + 1, by + bar_h + 1),
                       fill=contrast)
        sw = bar_px // seg
        for si in range(seg):
            sx = bx + si * sw
            ex = bx + (si + 1) * sw if si < seg - 1 else bx + bar_px
            c = font_color if si % 2 == 0 else accent
            draw.rectangle((sx, by, ex, by + bar_h), fill=c)

        # Small vertical tick marks + labels below bar
        bar_left = bx
        bar_right = bx + bar_px
        bar_mid = bx + mid_seg * sw
        tick_top = by + bar_h + 1
        ty = tick_top + tick_mark_h + 1

        # Measure average char width for half-char offset
        _cbb = draw.textbbox((0, 0), "0", font=tick_font)
        half_char = (_cbb[2] - _cbb[0]) // 2

        tick_positions = [
            (bar_left, tick_0, "left"),
            (bar_mid, tick_mid, "center"),
            (bar_right, tick_end, "right"),
        ]
        for tx_pos, txt, align in tick_positions:
            # Vertical tick mark from bar bottom edge down
            draw.line([(tx_pos, tick_top), (tx_pos, tick_top + tick_mark_h)],
                      fill=font_color, width=1)
            # Label — offset by half a char width so the glyph edge
            # aligns with the bar edge, not the glyph center
            tbb = draw.textbbox((0, 0), txt, font=tick_font)
            ttw = tbb[2] - tbb[0]
            # Center label on tick, nudge left/right labels slightly
            lx = tx_pos - ttw // 2
            if align == "left":
                lx += 1
            elif align == "right":
                lx -= 1
            draw.text((lx, ty - tbb[1] + 1), txt, fill=font_color, font=tick_font)

        # Unit label to the right of the bar, raised 2px
        draw.text((bx + bar_px + 4, by + (bar_h - tick_h) // 2 - 2), unit_str,
                  fill=font_color, font=tick_font)

    # --- North arrow (upper-right, rotated for CRS convergence) ---
    if north_arrow:
        arrow_size = max(20, min(w, h) // 12)
        arrow_img = render_north_arrow(
            arrow_size, font_color=font_color, accent=accent,
            contrast=contrast, style=north_arrow_style,
        )

        # Compute grid convergence angle if CRS is not 4326
        convergence_deg = _compute_convergence(bounds_4326, crs=crs)
        if abs(convergence_deg) > 0.5:
            arrow_img = arrow_img.rotate(
                -convergence_deg,  # PIL rotates counter-clockwise
                resample=Image.BICUBIC,
                expand=False,
            )

        ax = w - padding - arrow_size
        ay = padding
        frame.paste(arrow_img, (ax, ay), arrow_img)


# ---------------------------------------------------------------------------
#  Auto-viz from image properties
# ---------------------------------------------------------------------------
_DEFAULT_CONTINUOUS_SCALE = 300
_DEFAULT_CONTINUOUS_TIMEOUT = 5


[docs] def auto_viz_continuous( image, geometry, band_names=None, stretch_type="percentile", percentiles=None, n_stddev=2, gamma=1.6, scale=_DEFAULT_CONTINUOUS_SCALE, timeout=_DEFAULT_CONTINUOUS_TIMEOUT, max_scale=None, ): """Build visualization parameters for a continuous ``ee.Image`` by sampling the region. Performs a ``reduceRegion`` at a coarse resolution to compute stretch statistics. If the call times out the scale is doubled and retried until it succeeds or ``max_scale`` is exceeded. Args: image (ee.Image): Image to visualize. Must **not** be an ``ee.ImageCollection`` — reduce the collection first. geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection`` defining the region to sample. band_names (list[str], optional): Bands to visualize — length must be 1 or 3. When ``None`` the first 3 bands are used (or first 1 if fewer than 3 exist). Defaults to ``None``. stretch_type (str): One of ``"percentile"`` (default), ``"min-max"``, or ``"stddev"``. percentiles (list[int], optional): ``[lower, upper]`` percentiles for the ``"percentile"`` stretch. Defaults to ``[0, 95]``. n_stddev (float): Number of standard deviations for the ``"stddev"`` stretch (symmetric around the mean). Default 2. gamma (float, optional): Gamma correction applied to the output viz params. Values > 1 brighten midtones (lifts dark pixels without blowing out highlights); values < 1 darken midtones. ``1.0`` means no correction. Included in the returned dict as ``"gamma"`` when not ``1.0``. Defaults to ``1.6``. scale (int): Starting spatial resolution in meters for ``reduceRegion``. Default 300. timeout (int): ``getInfo`` timeout in seconds per attempt. Default 5. max_scale (int, optional): Stop retrying when ``scale`` exceeds this value. Default is ``scale * 16`` (4 doublings). Returns: dict: Visualization parameters with ``bands``, ``min``, ``max`` keys, and ``gamma`` when gamma is not 1.0. ``min`` / ``max`` are scalars for single-band images and lists for 3-band images. Raises: TypeError: If *image* is an ``ee.ImageCollection``. ValueError: If *band_names* length is not 1 or 3, or if *stretch_type* is unrecognised. Example: >>> viz = auto_viz_continuous( ... s2_composite, study_area, ... band_names=["swir2", "nir", "red"], ... stretch_type="percentile", percentiles=[0, 99], ... ) >>> sorted(viz.keys()) ['bands', 'gamma', 'max', 'min'] >>> viz["gamma"] 1.6 """ if isinstance(image, ee.ImageCollection): image = ee.Image(image.mosaic()) if max_scale is None: max_scale = scale * 16 if percentiles is None: percentiles = [0, 95] if stretch_type not in ("percentile", "min-max", "stddev"): raise ValueError( f"stretch_type must be 'percentile', 'min-max', or 'stddev', " f"got '{stretch_type}'" ) # --- resolve geometry --------------------------------------------------- geom = _to_geometry(geometry) # --- resolve bands ------------------------------------------------------ img = ee.Image(image) if band_names is not None: if len(band_names) not in (1, 3): raise ValueError( f"band_names must have length 1 or 3, got {len(band_names)}" ) img = img.select(band_names) else: # Pick first 3 (or first 1) on the server side all_bands = img.bandNames() n_bands = all_bands.size() selected = ee.Algorithms.If( n_bands.gte(3), all_bands.slice(0, 3), all_bands.slice(0, 1) ) img = img.select(ee.List(selected)) # --- build reducer ------------------------------------------------------ if stretch_type == "min-max": reducer = ee.Reducer.minMax() elif stretch_type == "stddev": reducer = ee.Reducer.mean().combine( ee.Reducer.stdDev(), sharedInputs=True ) else: # percentile reducer = ee.Reducer.percentile(percentiles) # --- reduceRegion with timeout + scale doubling ------------------------- current_scale = scale stats = None while current_scale <= max_scale: try: reduction = img.reduceRegion( reducer=reducer, geometry=geom, scale=current_scale, bestEffort=True, maxPixels=1e7, ) with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: future = pool.submit(reduction.getInfo) try: stats = future.result(timeout=timeout) except concurrent.futures.TimeoutError: stats = None if stats is not None and any( v is not None for v in stats.values() ): break stats = None except Exception: stats = None current_scale *= 2 # --- determine actual band names ---------------------------------------- if band_names is not None: used_bands = list(band_names) else: try: used_bands = img.bandNames().getInfo() except Exception: used_bands = ["B0"] # --- parse stats into min / max lists ----------------------------------- if stats is None: min_vals = [0] * len(used_bands) max_vals = [1] * len(used_bands) elif stretch_type == "min-max": min_vals = [stats.get(f"{b}_min", 0) or 0 for b in used_bands] max_vals = [stats.get(f"{b}_max", 1) or 1 for b in used_bands] elif stretch_type == "stddev": min_vals, max_vals = [], [] for b in used_bands: mean = stats.get(f"{b}_mean", 0) or 0 sd = stats.get(f"{b}_stdDev", 1) or 1 min_vals.append(mean - n_stddev * sd) max_vals.append(mean + n_stddev * sd) else: # percentile p_lo = f"p{percentiles[0]}" p_hi = f"p{percentiles[1]}" min_vals = [stats.get(f"{b}_{p_lo}", 0) or 0 for b in used_bands] max_vals = [stats.get(f"{b}_{p_hi}", 1) or 1 for b in used_bands] # Simplify single-band to scalar and add band-aware palette if len(used_bands) == 1: min_vals = min_vals[0] max_vals = max_vals[0] return { "bands": used_bands, "min": min_vals, "max": max_vals, "palette": _get_palette_for_band(used_bands[0]), } viz = {"bands": used_bands, "min": min_vals, "max": max_vals} if gamma is not None and gamma != 1.0: viz["gamma"] = gamma return viz
[docs] def auto_viz( ee_obj, band_name=None, geometry=None, stretch_type="percentile", percentiles=None, n_stddev=2, gamma=1.6, scale=_DEFAULT_CONTINUOUS_SCALE, timeout=_DEFAULT_CONTINUOUS_TIMEOUT, ): """Build visualization parameters automatically from image properties. For **thematic** data (images with ``{band}_class_values`` and ``{band}_class_palette`` properties) returns a palette-based viz dict mapping class values to colours. For **continuous** data: * When ``geometry`` is provided, delegates to :func:`auto_viz_continuous` which samples the region to compute data-driven min/max. * Otherwise falls back to hard-coded defaults. Args: ee_obj (ee.Image or ee.ImageCollection): Earth Engine object to inspect. band_name (str, optional): Specific band to visualize. geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection``. When provided continuous data is stretched from actual region values. stretch_type (str): Stretch for continuous data — ``"percentile"`` (default), ``"min-max"``, or ``"stddev"``. percentiles (list[int], optional): ``[lower, upper]`` for percentile stretch. Default ``[5, 95]``. n_stddev (float): Standard deviations for ``"stddev"`` stretch. gamma (float, optional): Gamma correction for continuous data. Values > 1 brighten midtones; < 1 darken. Included in the returned dict as ``"gamma"`` when not ``1.0``. Ignored for thematic data. Defaults to ``1.6``. scale (int): Starting scale (m) for ``reduceRegion``. timeout (int): Timeout (s) per ``reduceRegion`` attempt. Returns: dict: Visualization parameters suitable for ``ee.Image.getThumbURL()``. For continuous data includes ``bands``, ``min``, ``max``, and ``gamma`` (when not 1.0). For thematic data includes ``bands``, ``min``, ``max``, and ``palette``. Example: >>> viz = auto_viz(lcms.select(["Land_Cover"])) >>> viz["bands"] ['Land_Cover'] >>> viz = auto_viz(s2_composite, geometry=study_area, ... stretch_type="percentile", percentiles=[2, 98]) >>> viz["gamma"] 1.6 """ info = cl.get_obj_info(ee_obj, band_names=[band_name] if band_name else None) band_names_list = info["band_names"] # Detect pre-visualized RGB images (vis-red/vis-green/vis-blue only) # Do NOT match raw sensor band names like "red", "green", "blue" — # those are reflectance values that need a computed stretch. _VIS_RGB_NAMES = {"vis-red", "vis-green", "vis-blue", "vis_red", "vis_green", "vis_blue"} if len(band_names_list) == 3 and all( b.lower() in _VIS_RGB_NAMES for b in band_names_list ): return {"bands": band_names_list[:3], "min": 0, "max": 255} if info["is_thematic"] and band_names_list: bn = band_names_list[0] ci = info["class_info"].get(bn, {}) values = ci.get("class_values", []) palette = ci.get("class_palette", []) if values and palette: return { "bands": [bn], "min": min(values), "max": max(values), "palette": palette, } # Continuous — if geometry provided, compute data-driven stretch if geometry is not None: band_arg = [band_name] if band_name else None if band_arg is None and len(band_names_list) >= 3: band_arg = band_names_list[:3] elif band_arg is None and band_names_list: band_arg = [band_names_list[0]] return auto_viz_continuous( ee_obj, geometry, band_names=band_arg, stretch_type=stretch_type, percentiles=percentiles, n_stddev=n_stddev, gamma=gamma, scale=scale, timeout=timeout, ) # Continuous — no geometry, use defaults if len(band_names_list) >= 3: return {"bands": band_names_list[:3], "min": 0, "max": 0.4} elif band_names_list: return {"bands": [band_names_list[0]], **_CONTINUOUS_DEFAULTS} return _CONTINUOUS_DEFAULTS.copy()
def _complete_viz_params(viz_params, ee_obj, band_name=None, geometry=None, stretch_type="percentile", percentiles=None, n_stddev=2, gamma=1.6, scale=_DEFAULT_CONTINUOUS_SCALE, timeout=_DEFAULT_CONTINUOUS_TIMEOUT): """Fill in missing ``min`` / ``max`` in partial viz_params via auto_viz. When the user supplies ``viz_params`` with a ``palette`` but no ``min`` / ``max``, runs :func:`auto_viz` to compute the stretch and merges the user's palette on top. Returns *viz_params* unmodified when it is ``None`` or already complete. """ if viz_params is None: return auto_viz(ee_obj, band_name=band_name, geometry=geometry, stretch_type=stretch_type, percentiles=percentiles, n_stddev=n_stddev, gamma=gamma, scale=scale, timeout=timeout) has_min = viz_params.get("min") is not None has_max = viz_params.get("max") is not None if has_min and has_max: return viz_params # already complete # Partial — compute auto stretch then overlay user's keys auto = auto_viz(ee_obj, band_name=band_name, geometry=geometry, stretch_type=stretch_type, percentiles=percentiles, n_stddev=n_stddev, gamma=gamma, scale=scale, timeout=timeout) merged = {**auto, **{k: v for k, v in viz_params.items() if v is not None}} return merged # --------------------------------------------------------------------------- # Core thumbnail URL functions # ---------------------------------------------------------------------------
[docs] def get_thumb_url(ee_obj, geometry=None, viz_params=None, dimensions=_DEFAULT_DIMENSIONS, band_name=None, crs=_DEFAULT_CRS, transform=None, scale=None, burn_in_geometry=False, geometry_outline_color=None, geometry_fill_color=None, geometry_outline_weight=2, clip_to_geometry=True): """Get a PNG thumbnail URL for an Earth Engine image. Generates an ``ee.Image.getThumbURL()`` call with automatic visualization detection when ``viz_params`` is not supplied. The image is optionally clipped to ``geometry`` and reprojected when ``crs`` is provided. For ``ee.ImageCollection`` inputs, the collection is reduced to a single image (mode for thematic data, median for continuous). Args: ee_obj (ee.Image or ee.ImageCollection): Image to thumbnail. Collections are reduced to a single representative image. geometry (ee.Geometry or ee.Feature or ee.FeatureCollection, optional): Region to clip and bound the thumbnail. Defaults to ``None`` (full image extent). viz_params (dict, optional): Visualization parameters (``bands``, ``min``, ``max``, ``palette``, etc.). Auto-detected via :func:`auto_viz` when ``None``. Defaults to ``None``. dimensions (int, optional): Thumbnail width in pixels. Defaults to ``640``. band_name (str, optional): Band to visualize when using auto-detection. Defaults to ``None`` (first band). crs (str, optional): Coordinate reference system code (e.g. ``"EPSG:4326"``, ``"EPSG:32612"``). When provided, ``setDefaultProjection`` is applied to the image. Defaults to ``None``. transform (list, optional): Affine transform as a 6-element list. Requires ``crs``. Defaults to ``None``. scale (float, optional): Nominal pixel scale in meters. Requires ``crs``. Defaults to ``None``. Returns: str: PNG thumbnail URL string from the Earth Engine servers. Raises: ValueError: If ``transform`` or ``scale`` is provided without ``crs``. Example: >>> url = get_thumb_url( ... image, study_area, ... {"min": 0, "max": 3000, "bands": ["swir1", "nir", "red"]}, ... ) >>> url[:5] 'https' """ _validate_projection_params(crs, transform, scale) # Allow ee_obj=None for geometry-only thumbnails (just burn in boundary) _is_geom_only = ee_obj is None if _is_geom_only: img = ee.Image() if viz_params is None: viz_params = {} burn_in_geometry = True else: img = _to_image(ee_obj) img = _apply_projection(img, crs, transform, scale) if not _is_geom_only: viz_params = _complete_viz_params(viz_params, img, band_name=band_name, geometry=geometry) params = {**viz_params, "dimensions": dimensions, "format": "png"} if geometry is not None: geom = _to_geometry(geometry) img = img.clip(geom) if burn_in_geometry: gc = geometry_outline_color if geometry_outline_color is not None else (255, 255, 255) _fill = "33333366" if _is_geom_only else None # 0.4 opacity img = _paint_boundary(img, geom, gc, viz_params=viz_params, fill_color=_fill or geometry_fill_color, width=geometry_outline_weight, crs=crs) params = {"min": 0, "max": 255, "dimensions": dimensions, "format": "png"} if clip_to_geometry: params["region"] = geom else: params["region"] = geom.bounds() return img.getThumbURL(params)
[docs] def get_animation_url(ee_obj, geometry=None, viz_params=None, dimensions=_DEFAULT_DIMENSIONS, fps=_DEFAULT_FPS, band_name=None, max_frames=_MAX_GIF_FRAMES, crs=_DEFAULT_CRS, transform=None, scale=None): """Get an animated GIF thumbnail URL for an ``ee.ImageCollection``. .. note:: For tiled collections (LCMS, NLCD, etc.) this may produce blank frames. Use :func:`generate_gif` instead, which properly mosaics per time step and supports date burn-in. Args: ee_obj: ``ee.ImageCollection``. geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection``. viz_params (dict, optional): Must include ``bands`` (3 for RGB, or 1 + ``palette``). Auto-detected if not provided. dimensions (int): Width in pixels. fps (int): Frames per second. Default 2. band_name (str, optional): Band to visualize (for auto_viz). max_frames (int): Maximum frames to include. Default 40. crs (str, optional): CRS code (e.g. ``"EPSG:4326"``). transform (list, optional): Affine transform. Requires ``crs``. scale (float, optional): Nominal scale in meters. Requires ``crs``. Returns: str: Animated GIF thumbnail URL. Raises: ValueError: If ``transform`` or ``scale`` is provided without ``crs``. """ _validate_projection_params(crs, transform, scale) col = ee.ImageCollection(ee_obj) if viz_params is None: viz_params = auto_viz(col, band_name=band_name) # Mosaic per time step to handle tiled collections if geometry is not None: geom = _to_geometry(geometry) col = col.filterBounds(geom) col = _mosaic_by_date(col) col = _apply_projection_to_collection(col, crs, transform, scale) # Clip each frame to the geometry if geometry is not None: if clip_to_geometry: col = col.map(lambda img: img.clip(geom).copyProperties(img, ["system:time_start"])) params = { **viz_params, "dimensions": dimensions, "framesPerSecond": fps, } if geometry is not None: params["region"] = geom count = col.size().getInfo() if count > max_frames: col = col.limit(max_frames) return col.getVideoThumbURL(params)
[docs] def get_filmstrip_url(ee_obj, geometry=None, viz_params=None, dimensions=_DEFAULT_DIMENSIONS, band_name=None, max_frames=_MAX_GIF_FRAMES, crs=_DEFAULT_CRS, transform=None, scale=None): """Get a filmstrip thumbnail URL — all frames side-by-side in one PNG. Args: ee_obj: ``ee.ImageCollection``. geometry: Clip region. viz_params (dict, optional): Auto-detected if not provided. dimensions (int): Width per frame. band_name (str, optional): Band to visualize. max_frames (int): Maximum frames. crs (str, optional): CRS code (e.g. ``"EPSG:4326"``). transform (list, optional): Affine transform. Requires ``crs``. scale (float, optional): Nominal scale in meters. Requires ``crs``. Returns: str: Filmstrip PNG thumbnail URL. Raises: ValueError: If ``transform`` or ``scale`` is provided without ``crs``. """ _validate_projection_params(crs, transform, scale) col = ee.ImageCollection(ee_obj) if viz_params is None: viz_params = auto_viz(col, band_name=band_name) if geometry is not None: geom = _to_geometry(geometry) col = col.filterBounds(geom) col = _mosaic_by_date(col) col = _apply_projection_to_collection(col, crs, transform, scale) # Clip each frame to the geometry if geometry is not None: if clip_to_geometry: col = col.map(lambda img: img.clip(geom).copyProperties(img, ["system:time_start"])) count = col.size().getInfo() if count > max_frames: col = col.limit(max_frames) params = {**viz_params, "format": "png"} if geometry is not None: params["region"] = geom return col.getFilmstripThumbURL(params)
# --------------------------------------------------------------------------- # Animated GIF with date burn-in # ---------------------------------------------------------------------------
[docs] def generate_gif(ee_obj, geometry, viz_params=None, band_name=None, dimensions=_DEFAULT_DIMENSIONS, fps=_DEFAULT_FPS, max_frames=_MAX_GIF_FRAMES, burn_in_date=True, date_format="YYYY", date_position="upper-left", date_font_size=None, burn_in_legend=True, legend_scale=1.0, bg_color=None, font_color=None, font_outline_color=None, output_path=None, crs=_DEFAULT_CRS, transform=None, scale=None, margin=_DEFAULT_MARGIN, basemap=None, overlay_opacity=None, scalebar=True, scalebar_units="metric", north_arrow=True, north_arrow_style="solid", inset_map=True, inset_basemap=None, inset_scale=0.3, inset_on_map=True, title=None, title_font_size=_DEFAULT_TITLE_FONT_SIZE, label_font_size=_DEFAULT_LABEL_FONT_SIZE, burn_in_geometry=False, geometry_outline_color=None, geometry_fill_color=None, geometry_outline_weight=2, clip_to_geometry=True): """Generate an animated GIF from an Earth Engine ImageCollection. Downloads individual frame thumbnails, properly mosaics **tiled collections** (LCMS, NLCD, etc.) by time step, and composites them into an animated GIF. Optional cartographic elements include date burn-in, thematic legend panel, basemap underlay, scalebar, north arrow, inset overview map, and title strip. Args: ee_obj (ee.ImageCollection): Image collection to animate. geometry (ee.Geometry or ee.Feature or ee.FeatureCollection): Region to clip and bound each frame. viz_params (dict, optional): Visualization parameters (``bands``, ``min``, ``max``, ``palette``). Auto-detected via :func:`auto_viz` when ``None``. Defaults to ``None``. band_name (str, optional): Band to visualize when using auto-detection. Defaults to ``None`` (first band). dimensions (int, optional): Width of each frame in pixels. Defaults to ``640``. fps (int, optional): Frames per second in the output GIF. Defaults to ``2``. max_frames (int, optional): Maximum number of frames to include. Defaults to ``50``. burn_in_date (bool, optional): Burn the date label from ``system:time_start`` into each frame. Defaults to ``True``. date_format (str, optional): Date format string. Supported values include ``"YYYY"``, ``"YYYY-MM"``, ``"YYYY-MM-dd"``, ``"MMM YYYY"``, ``"MMMM YYYY"``, ``"MM/YYYY"``, ``"MM/dd/YYYY"``. Defaults to ``"YYYY"``. date_position (str, optional): Position of the date label on each frame -- ``"upper-left"``, ``"upper-right"``, ``"lower-left"``, or ``"lower-right"``. Defaults to ``"upper-left"``. date_font_size (int, optional): Font size in pixels for the date label. Auto-scaled from ``dimensions`` when ``None``. Defaults to ``None``. burn_in_legend (bool, optional): Append a legend panel to the right side of each frame for thematic data. Only rendered when class names and palette are available in image properties. Defaults to ``True``. legend_scale (float, optional): Scale multiplier for the legend panel size. Defaults to ``1.0``. bg_color (str or None, optional): Background color for transparent areas, legend panel, and margins. Accepts CSS color names or hex strings. Resolved via theme when ``None``. Defaults to ``None``. font_color (str or tuple or None, optional): Text color for date labels and legend text. Resolved via theme when ``None``. Defaults to ``None``. font_outline_color (str or tuple or None, optional): Outline / halo color for text readability. Auto-derived to contrast with ``font_color`` when ``None``. Defaults to ``None``. output_path (str, optional): File path to save the GIF to disk. Parent directories are created automatically. Defaults to ``None`` (not saved). crs (str, optional): CRS code (e.g. ``"EPSG:4326"``). Applies ``setDefaultProjection`` to each frame. Defaults to ``None``. transform (list, optional): Affine transform as a 6-element list. Requires ``crs``. Defaults to ``None``. scale (float, optional): Nominal pixel scale in meters. Requires ``crs``. Defaults to ``None``. margin (int, optional): Pixel margin on all sides of each frame. Defaults to ``16``. basemap (str or dict or None, optional): Basemap to composite behind the EE data. A preset name (e.g. ``"esri-satellite"``, ``"usfs-topo"``), a config dict with ``type`` and ``url`` keys, or a raw tile URL template. Defaults to ``None`` (no basemap). overlay_opacity (float or None, optional): Opacity of the EE overlay when a basemap is present (0.0 -- 1.0). Defaults to ``None`` (auto: ``0.8`` with basemap, ``1.0`` without). scalebar (bool, optional): Draw a scalebar on each frame. Only rendered when ``basemap`` or ``inset_basemap`` is set and bounds are available. Defaults to ``True``. scalebar_units (str, optional): Unit system for the scalebar -- ``"metric"`` or ``"imperial"``. Defaults to ``"metric"``. north_arrow (bool, optional): Draw a north arrow on each frame. Defaults to ``True``. north_arrow_style (str, optional): Arrow style -- ``"solid"``, ``"classic"``, or ``"outline"``. Defaults to ``"solid"``. inset_map (bool, optional): Include an inset overview map. Defaults to ``True``. inset_basemap (str or dict or None, optional): Basemap for the inset. Falls back to ``basemap`` when ``None``. Defaults to ``None``. inset_scale (float, optional): Relative height of the inset compared to the frame height. Defaults to ``0.3``. inset_on_map (bool, optional): Place the inset directly on the map (lower-right) rather than below it. Defaults to ``True``. title (str, optional): Title text rendered as a strip above the GIF frames. Defaults to ``None`` (no title). Returns: dict: A dictionary with the following keys: - ``"html"`` (str): HTML ``<figure>`` element containing the GIF as a base64-embedded ``<img>`` tag. - ``"gif_bytes"`` (bytes): Raw animated GIF byte data. Raises: ValueError: If ``transform`` or ``scale`` is provided without ``crs``. Example: >>> result = generate_gif( ... lcms.select(["Land_Cover"]), ... study_area, ... burn_in_date=True, ... date_format="YYYY", ... basemap="esri-satellite", ... title="LCMS Land Cover", ... ) >>> gif_bytes = result["gif_bytes"] >>> html_snippet = result["html"] """ _validate_projection_params(crs, transform, scale) col = ee.ImageCollection(ee_obj) geom = _to_geometry(geometry) viz_params = _complete_viz_params(viz_params, col, band_name=band_name, geometry=geometry) # Extract legend info (thematic or continuous) legend_info = None if burn_in_legend: legend_info = _extract_legend_info(col, band_name=band_name, viz_params=viz_params) # Resolve font colors early (needed for burn_in_geometry fallback) font_color, font_outline_color, theme, bg_color = _resolve_font_colors( bg_color, font_color, font_outline_color) # Filter to region, mosaic per time step, apply projection, and clip col = col.filterBounds(geom) col = _mosaic_by_date(col) col = _apply_projection_to_collection(col, crs, transform, scale) if clip_to_geometry: col = col.map(lambda img: img.clip(geom).copyProperties(img, ["system:time_start"])) # Burn in geometry boundary (pre-visualizes the collection) if burn_in_geometry: bounds = _get_bounds_4326(geom) gc = _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds) col = _paint_boundary(col, geom, gc, viz_params=viz_params, fill_color=geometry_fill_color, width=geometry_outline_weight, crs=crs) viz_params = {"min": 0, "max": 255} count = col.size().getInfo() if count > max_frames: col = col.limit(max_frames) count = max_frames if count == 0: return {"html": "<p>No images available for GIF.</p>", "gif_bytes": b""} # Always use the PIL path so we can return bytes pil_frames, date_labels = _download_frames(col, geom if clip_to_geometry else geom.bounds(), viz_params, dimensions, count, date_format) if not pil_frames: return {"html": "<p>Failed to generate GIF frames.</p>", "gif_bytes": b""} # Resolve overlay opacity: default 0.8 when basemap is set if overlay_opacity is None: overlay_opacity = 0.8 if basemap is not None else 1.0 # Fetch basemap once for all frames (same geometry) basemap_img = None bounds = _get_bounds_4326(geom) if basemap is not None: if bounds is not None and pil_frames: fw, fh = pil_frames[0].size basemap_img = _fetch_basemap(bounds, fw, fh, basemap, crs=crs) # Auto-scale font size for date if date_font_size is None: date_font_size = label_font_size font = _get_font(date_font_size) # Build legend panel once (same for all frames) legend_panel = _build_legend_panel_from_info( legend_info, target_height=pil_frames[0].size[1], bg_color=bg_color, scale=legend_scale, font_color=font_color, ) # Composite basemap (adds padding border) or add blank padding _has_inset_source = (basemap is not None or inset_basemap is not None) final_frames = [] for i, frame in enumerate(pil_frames): if basemap_img is not None: frame = _composite_with_basemap(frame, basemap_img, overlay_opacity) else: frame = _add_thumb_padding(frame, bg_color=bg_color) if burn_in_date and i < len(date_labels) and date_labels[i]: _burn_in_text(frame, date_labels[i], date_position, font_color, font_outline_color, font) # Resolve geometry colors for inset extent rectangle _gif_gc = gc if burn_in_geometry else ( _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds) if geometry_outline_color else None) # Assemble with legend, inset, scalebar, north arrow, title frame, has_bottom = _assemble_with_cartography( frame, bounds, bg_color=bg_color, font_color=font_color, font_outline_color=font_outline_color, title=title, scalebar=scalebar if bounds is not None else False, scalebar_units=scalebar_units, north_arrow=north_arrow if bounds is not None else False, north_arrow_style=north_arrow_style, inset_map=inset_map if (_has_inset_source and bounds is not None) else False, inset_basemap=inset_basemap if inset_basemap else basemap, inset_scale=inset_scale, inset_on_map=inset_on_map, inset_rect_color=_gif_gc if _gif_gc is not None else None, inset_rect_fill_color=_hex_fill_to_rgba(geometry_fill_color) if geometry_fill_color else None, legend_panel=legend_panel, margin=margin, crs=crs, ) mt = 0 if title else margin mb = margin // 3 if has_bottom else margin frame = _add_margin(frame, (mt, margin, mb, margin), bg_color=bg_color) final_frames.append(frame) gif_bytes = _frames_to_gif(final_frames, fps, bg_color=bg_color) if output_path: _write_bytes(output_path, gif_bytes) b64 = base64.b64encode(gif_bytes).decode("ascii") html = ( f'<figure class="gif-container">' f'<img src="data:image/gif;base64,{b64}">' f'</figure>' ) return {"html": html, "gif_bytes": gif_bytes}
# --------------------------------------------------------------------------- # Filmstrip: date-labeled grid of frames # ---------------------------------------------------------------------------
[docs] def generate_filmstrip(ee_obj, geometry, viz_params=None, band_name=None, dimensions=_DEFAULT_DIMENSIONS, max_frames=_MAX_GIF_FRAMES, columns=3, date_format="YYYY", burn_in_legend=True, legend_scale=1.0, legend_position="bottom", bg_color=None, font_color=None, font_outline_color=None, output_path=None, crs=_DEFAULT_CRS, transform=None, scale=None, margin=_DEFAULT_MARGIN, basemap=None, overlay_opacity=None, scalebar=True, scalebar_units="metric", north_arrow=True, north_arrow_style="solid", inset_map=True, inset_basemap=None, inset_scale=0.3, inset_on_map=False, title=None, burn_in_geometry=False, geometry_outline_color=None, geometry_fill_color=None, geometry_outline_weight=2, clip_to_geometry=True, geometry_legend_label="Study Area", title_font_size=_DEFAULT_TITLE_FONT_SIZE, label_font_size=_DEFAULT_LABEL_FONT_SIZE): """Generate a filmstrip grid image from an Earth Engine ImageCollection. Downloads individual frame thumbnails, mosaics tiled collections by date, labels each frame with its date, and arranges them in a grid layout. Optionally composites a basemap behind the EE data and appends cartographic elements including a legend panel, scalebar, north arrow, inset overview map, and title strip. Args: ee_obj (ee.ImageCollection): Image collection to render. geometry (ee.Geometry or ee.Feature or ee.FeatureCollection): Region to clip and bound each frame. viz_params (dict, optional): Visualization parameters (``bands``, ``min``, ``max``, ``palette``). Auto-detected via :func:`auto_viz` when ``None``. Defaults to ``None``. band_name (str, optional): Band to visualize when using auto-detection. Defaults to ``None`` (first band). dimensions (int, optional): Width per frame in pixels. Defaults to ``640``. max_frames (int, optional): Maximum number of frames to include in the grid. Defaults to ``50``. columns (int, optional): Number of columns in the grid layout. Defaults to ``3``. date_format (str, optional): Date label format above each frame. Supports ``"YYYY"``, ``"YYYY-MM"``, ``"YYYY-MM-dd"``, ``"MMM YYYY"``, etc. Defaults to ``"YYYY"``. burn_in_legend (bool, optional): Append a legend panel for thematic data. Only rendered when class names and palette are available. Defaults to ``True``. legend_scale (float, optional): Scale multiplier for legend size. Defaults to ``1.0``. legend_position (str, optional): Where to place the legend relative to the grid -- ``"bottom"`` or ``"top"``. Defaults to ``"bottom"``. bg_color (str or None, optional): Background color for the grid, margins, and legend panel. Resolved via theme when ``None``. Defaults to ``None``. font_color (str or tuple or None, optional): Text color for date labels and legend text. Resolved via theme when ``None``. Defaults to ``None``. font_outline_color (str or tuple or None, optional): Outline / halo color for text readability. Auto-derived when ``None``. Defaults to ``None``. output_path (str, optional): File path to save the PNG. Parent directories are created automatically. Defaults to ``None`` (not saved). crs (str, optional): CRS code (e.g. ``"EPSG:4326"``). Applies ``setDefaultProjection`` to each frame. Defaults to ``None``. transform (list, optional): Affine transform as a 6-element list. Requires ``crs``. Defaults to ``None``. scale (float, optional): Nominal pixel scale in meters. Requires ``crs``. Defaults to ``None``. margin (int, optional): Pixel margin on all sides of the final image. Defaults to ``16``. basemap (str or dict or None, optional): Basemap to composite behind each frame. A preset name (e.g. ``"esri-satellite"``), a config dict, or a raw tile URL. Defaults to ``None`` (no basemap). overlay_opacity (float or None, optional): Opacity of the EE overlay when a basemap is present (0.0 -- 1.0). Defaults to ``None`` (auto: ``0.8`` with basemap, ``1.0`` without). scalebar (bool, optional): Include a scalebar below the grid. Only rendered when cartographic context is available. Defaults to ``True``. scalebar_units (str, optional): Unit system for the scalebar -- ``"metric"`` or ``"imperial"``. Defaults to ``"metric"``. north_arrow (bool, optional): Include a north arrow below the grid. Defaults to ``True``. north_arrow_style (str, optional): Arrow style -- ``"solid"``, ``"classic"``, or ``"outline"``. Defaults to ``"solid"``. inset_map (bool, optional): Include an inset overview map below the grid. Defaults to ``True``. inset_basemap (str or dict or None, optional): Basemap for the inset. Falls back to ``basemap`` when ``None``. Defaults to ``None``. inset_scale (float, optional): Relative height of the inset compared to the frame height. Defaults to ``0.3``. inset_on_map (bool, optional): Place the inset on the map rather than as a separate strip. For filmstrips this controls positioning in the bottom strip area. Defaults to ``True``. title (str, optional): Title text rendered as a strip above the grid. Defaults to ``None`` (no title). Returns: dict: A dictionary with the following keys: - ``"html"`` (str): HTML ``<figure>`` element containing the filmstrip as a base64-embedded PNG ``<img>`` tag. - ``"thumb_bytes"`` (bytes): Raw PNG byte data. Raises: ValueError: If ``transform`` or ``scale`` is provided without ``crs``. Example: >>> result = generate_filmstrip( ... lcms.select(["Land_Cover"]), ... study_area, ... columns=4, ... date_format="YYYY", ... basemap="esri-satellite", ... title="LCMS Land Cover Time Series", ... ) >>> png_bytes = result["thumb_bytes"] """ _validate_projection_params(crs, transform, scale) from PIL import Image, ImageDraw col = ee.ImageCollection(ee_obj) geom = _to_geometry(geometry) viz_params = _complete_viz_params(viz_params, col, band_name=band_name, geometry=geometry) legend_info = None if burn_in_legend: legend_info = _extract_legend_info(col, band_name=band_name, viz_params=viz_params) col = col.filterBounds(geom) col = _mosaic_by_date(col) col = _apply_projection_to_collection(col, crs, transform, scale) if clip_to_geometry: col = col.map(lambda img: img.clip(geom).copyProperties(img, ["system:time_start"])) # Resolve font/theme colors early (needed for legend, labels, dividers, boundary) font_color, font_outline_color, fs_theme, bg_color = _resolve_font_colors( bg_color, font_color, font_outline_color) # Burn in geometry boundary (pre-visualizes the collection) if burn_in_geometry: bounds = _get_bounds_4326(geom) gc = _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds) col = _paint_boundary(col, geom, gc, viz_params=viz_params, fill_color=geometry_fill_color, width=geometry_outline_weight, crs=crs) viz_params = {"min": 0, "max": 255} count = col.size().getInfo() if count > max_frames: col = col.limit(max_frames) count = max_frames if count == 0: return {"html": "<p>No images available.</p>", "thumb_bytes": b""} pil_frames, date_labels = _download_frames(col, geom if clip_to_geometry else geom.bounds(), viz_params, dimensions, count, date_format) # Resolve overlay opacity: default 0.8 when basemap is set if overlay_opacity is None: overlay_opacity = 0.8 if basemap is not None else 1.0 # Fetch basemap once (same geometry for all frames) and composite bounds = _get_bounds_4326(geom) if basemap is not None and pil_frames: if bounds is not None: fw, fh = pil_frames[0].size basemap_img = _fetch_basemap(bounds, fw + 2 * _THUMB_PADDING, fh + 2 * _THUMB_PADDING, basemap, crs=crs) if basemap_img is not None: pil_frames = [_composite_with_basemap(f, basemap_img, overlay_opacity) for f in pil_frames] else: pil_frames = [_add_thumb_padding(f, bg_color=bg_color) for f in pil_frames] elif pil_frames: pil_frames = [_add_thumb_padding(f, bg_color=bg_color) for f in pil_frames] if not pil_frames: return {"html": "<p>Failed to download frames.</p>", "thumb_bytes": b""} # Draw scalebar + north arrow on the first frame if bounds is not None and (scalebar or north_arrow): _draw_scalebar_and_arrow_on_frame( pil_frames[0], bounds, scalebar=scalebar, north_arrow=north_arrow, north_arrow_style=north_arrow_style, font_color=font_color, contrast=font_outline_color, accent=fs_theme.accent, label_font_size=label_font_size, crs=crs, ) # Resolve geometry colors for inset extent rectangle _fs_inset_gc = gc if burn_in_geometry else ( _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds) if geometry_outline_color else None) _fs_inset_fill = _hex_fill_to_rgba(geometry_fill_color) if geometry_fill_color else None # Draw inset on the last frame (lower-right) only when inset_on_map if inset_map and inset_on_map and bounds is not None and len(pil_frames) > 0: _ib = inset_basemap if inset_basemap else basemap if _ib is not None: last_frame = pil_frames[-1] _fw, _fh = last_frame.size target_h = int(_fh * inset_scale) _fs_inset_kw = {} if _fs_inset_gc is not None: _fs_inset_kw["rect_color"] = _fs_inset_gc if _fs_inset_fill is not None: _fs_inset_kw["rect_fill_color"] = _fs_inset_fill inset_img = _build_inset_image(bounds, size=target_h, inset_basemap=_ib, **_fs_inset_kw) if inset_img is not None: src_w, src_h = inset_img.size aspect = src_w / src_h if src_h > 0 else 1.0 iw = int(target_h * aspect) ih = target_h if iw > _fw // 3: iw = _fw // 3 ih = int(iw / aspect) inset_resized = inset_img.resize((iw, ih), Image.LANCZOS) pad = max(4, _fw // 60) px = _fw - iw - pad py = _fh - ih - pad last_frame.paste( inset_resized, (px, py), inset_resized if inset_resized.mode == "RGBA" else None, ) text_color = font_color panel_bg = _resolve_color(bg_color) fw, fh = pil_frames[0].size n_cols = min(columns, len(pil_frames)) n_rows = -(-len(pil_frames) // n_cols) # ceil division # Match label font size to legend font size for visual consistency if legend_info is not None and legend_info.get("type") == "thematic": n_classes = len(legend_info.get("class_names", [])) est_grid_h = n_rows * fh lg_padding = max(6, int(8 * legend_scale)) lg_usable = est_grid_h - 2 * lg_padding label_font_size = max(8, int(lg_usable / max(n_classes, 1) * 0.6 * legend_scale)) label_font_size = min(label_font_size, int(16 * legend_scale)) else: pass # use provided label_font_size label_font = _get_font(label_font_size) label_h = label_font_size + 8 cell_h = fh + label_h frame_gap = 3 # will be used in the loop below too grid_w = n_cols * fw + max(n_cols - 1, 0) * frame_gap grid_h = n_rows * cell_h # Build legend panel (placed to the right of the first row, aligned with frame) legend_panel_img = None if legend_info is not None: # Use frame height (fh) not cell_h, since legend starts at label_h offset legend_target_h = fh legend_panel_img = _build_legend_panel_from_info( legend_info, target_height=legend_target_h, bg_color=bg_color, scale=legend_scale, font_color=font_color, ) # Build inset for right column (below legend) when inset_on_map is False inset_panel = None if inset_map and not inset_on_map and bounds is not None: _ib = inset_basemap if inset_basemap else basemap if _ib is not None: target_h = int(fh * 0.8) _fs_inset_kw2 = {} if _fs_inset_gc is not None: _fs_inset_kw2["rect_color"] = _fs_inset_gc if _fs_inset_fill is not None: _fs_inset_kw2["rect_fill_color"] = _fs_inset_fill inset_img = _build_inset_image(bounds, size=target_h, inset_basemap=_ib, **_fs_inset_kw2) if inset_img is not None: src_w, src_h = inset_img.size aspect = src_w / src_h if src_h > 0 else 1.0 iw = int(target_h * aspect) ih = target_h inset_panel = inset_img.resize((iw, ih), Image.LANCZOS) # Divider line color + row padding divider_rgba = fs_theme.divider + (255,) row_pad = 4 # padding below frames before divider # Width: grid + legend/inset column (if present) legend_col_w = legend_panel_img.size[0] if legend_panel_img is not None else 0 inset_col_w = 0 if inset_panel is not None and n_rows == 1: # Single row: inset goes beside legend aspect = inset_panel.size[0] / inset_panel.size[1] if inset_panel.size[1] > 0 else 1.0 inset_col_w = int(fh * aspect) + 8 elif inset_panel is not None and n_rows > 1: # Multi-row: inset goes in same column, take the wider legend_col_w = max(legend_col_w, inset_panel.size[0] + 8) total_w = grid_w + legend_col_w + inset_col_w # Build per-row images (allows page breaks in PDF between rows) row_images = [] row_h = cell_h + row_pad # extra padding at bottom of each row for row_i in range(n_rows): is_last = (row_i == n_rows - 1) cur_h = cell_h if is_last else row_h # no extra pad on last row row_img = Image.new("RGBA", (total_w, cur_h), panel_bg + (255,)) row_draw = ImageDraw.Draw(row_img) for col_i in range(n_cols): idx = row_i * n_cols + col_i if idx >= len(pil_frames): break frame = pil_frames[idx] x = col_i * (fw + frame_gap) label = date_labels[idx] if idx < len(date_labels) else "" if label: bbox = row_draw.textbbox((0, 0), label, font=label_font) tw = bbox[2] - bbox[0] row_draw.text((x + (fw - tw) // 2, 3), label, font=label_font, fill=text_color) row_img.paste(frame, (x, label_h)) # Legend to the right of the first row, aligned with frame (below date label) if row_i == 0 and legend_panel_img is not None: lp_rgba = legend_panel_img.convert("RGBA") row_img.paste(lp_rgba, (grid_w, label_h), lp_rgba) # Inset in right column if row_i == 0 and inset_panel is not None and n_rows == 1: # Single row: place inset right of legend (extend right column) inset_pad_left = max(4, margin // 4) lp_w = legend_panel_img.size[0] if legend_panel_img is not None else 0 ip_x = grid_w + lp_w + inset_pad_left # Scale inset to fit frame height ip = inset_panel target_h = fh aspect = ip.size[0] / ip.size[1] if ip.size[1] > 0 else 1.0 iw = int(target_h * aspect) ip = ip.resize((iw, target_h), Image.LANCZOS) ip_rgba = ip.convert("RGBA") row_img.paste(ip_rgba, (ip_x, label_h), ip_rgba) elif row_i == 1 and inset_panel is not None and n_rows > 1: # Multi-row: place inset at row 1, aligned with frames inset_pad_left = max(4, margin // 4) avail_h = cur_h - label_h - 2 ip = inset_panel if ip.size[1] > avail_h and avail_h > 20: aspect = ip.size[0] / ip.size[1] ip = ip.resize((int(avail_h * aspect), avail_h), Image.LANCZOS) if avail_h > 20: ip_rgba = ip.convert("RGBA") row_img.paste(ip_rgba, (grid_w + inset_pad_left, label_h), ip_rgba) # Horizontal divider between rows (with padding gap) if not is_last: y_line = cur_h - 1 row_draw.line([(0, y_line), (total_w - 1, y_line)], fill=divider_rgba, width=1) row_images.append(row_img) # Build combined grid for thumb_bytes total_h = sum(img.size[1] for img in row_images) grid = Image.new("RGBA", (total_w, total_h), panel_bg + (255,)) y_offset = 0 for row_img in row_images: grid.paste(row_img, (0, y_offset)) y_offset += row_img.size[1] # Title strip (above grid) — font size = 1.5x the label/timestamp font bg_rgba = _resolve_color(bg_color) + (255,) if title: title_font = _get_font(title_font_size) tmp_d = ImageDraw.Draw(Image.new("RGBA", (1, 1))) tb = tmp_d.textbbox((0, 0), title, font=title_font) tw = tb[2] - tb[0] th = tb[3] - tb[1] title_pad = max(6, label_font_size // 2) strip_h = title_pad + th + title_pad title_strip = Image.new("RGBA", (total_w, strip_h), bg_rgba) td = ImageDraw.Draw(title_strip) tx = (total_w - tw) // 2 td.text((tx, title_pad - tb[1]), title, font=title_font, fill=font_color) combined_h = title_strip.size[1] + grid.size[1] assembled = Image.new("RGBA", (total_w, combined_h), bg_rgba) assembled.paste(title_strip, (0, 0), title_strip if title_strip.mode == "RGBA" else None) assembled.paste(grid, (0, title_strip.size[1]), grid if grid.mode == "RGBA" else None) grid = assembled grid = _add_margin(grid, margin, bg_color=bg_color) rgb_grid = _rgba_to_rgb(grid, bg_color) buf = io.BytesIO() rgb_grid.save(buf, format="PNG") thumb_bytes = buf.getvalue() if output_path: _write_bytes(output_path, thumb_bytes) # Build HTML — single image for simplicity b64 = base64.b64encode(thumb_bytes).decode("ascii") html = ( f'<figure class="filmstrip">' f'<img src="data:image/png;base64,{b64}" style="max-width:100%;">' f'</figure>' ) return {"html": html, "thumb_bytes": thumb_bytes}
# --------------------------------------------------------------------------- # Shared frame download helper # --------------------------------------------------------------------------- def _download_frames(col, geom, viz_params, dimensions, count, date_format="YYYY"): """Download individual frames and extract date labels from a collection. Returns: tuple: ``(pil_frames, date_labels)`` — list of RGBA PIL Images and list of formatted date strings. """ from datetime import datetime as dt from PIL import Image dates = col.aggregate_array("system:time_start").getInfo() py_fmt = _DATE_FORMAT_MAP.get(date_format, date_format) if "%" not in py_fmt: py_fmt = "%Y" date_labels = [] for ts in dates: if ts is not None: date_labels.append(dt.fromtimestamp(ts / 1000).strftime(py_fmt)) else: date_labels.append("") img_list = col.toList(count) frame_params = {**viz_params, "dimensions": dimensions, "format": "png", "region": geom} def _get_frame(i): img = ee.Image(img_list.get(i)) url = img.getThumbURL(frame_params) data = download_thumb(url) return i, data frames_data = [None] * count with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: futures = [pool.submit(_get_frame, i) for i in range(count)] for f in concurrent.futures.as_completed(futures): idx, data = f.result() frames_data[idx] = data pil_frames = [] for data in frames_data: if data is None: continue try: pil_frames.append(Image.open(io.BytesIO(data)).convert("RGBA")) except Exception: continue return pil_frames, date_labels def _extract_legend_info(ee_obj, band_name=None, viz_params=None): """Extract legend info from an ee object — thematic or continuous. For **thematic** data (images with ``{band}_class_values`` and ``{band}_class_palette`` properties), returns a dict with ``class_names``, ``class_palette``, and ``"type": "thematic"``. For **continuous** single-band data where *viz_params* supplies a ``palette``, returns a dict with ``min``, ``max``, ``palette``, ``band_name``, and ``"type": "continuous"``. Returns ``None`` when no legend can be generated. """ try: info = cl.get_obj_info(ee_obj, band_names=[band_name] if band_name else None) except Exception: info = None # --- Thematic path --- if info is not None and info.get("is_thematic"): band_names_list = info.get("band_names", []) if band_names_list: bn = band_names_list[0] ci = info.get("class_info", {}).get(bn, {}) class_names = ci.get("class_names", []) class_palette = ci.get("class_palette", []) if class_names and class_palette: n = min(len(class_names), len(class_palette)) return { "type": "thematic", "class_names": class_names[:n], "class_palette": class_palette[:n], } # --- Fallback: pre-visualized RGB images with class properties --- # Check all image properties for *_class_names/*_class_palette patterns if info is None or not info.get("is_thematic"): try: sample = ee_obj if isinstance(ee_obj, ee.ImageCollection): sample = ee_obj.first() props = ee.Image(sample).getInfo().get("properties", {}) for key in props: if key.endswith("_class_names"): prefix = key.replace("_class_names", "") cn = props.get(f"{prefix}_class_names", []) cp = props.get(f"{prefix}_class_palette", []) if cn and cp: n = min(len(cn), len(cp)) return { "type": "thematic", "class_names": cn[:n], "class_palette": cp[:n], } except Exception: pass # --- Continuous path — single band + palette in viz_params --- if viz_params is not None: palette = viz_params.get("palette") bands = viz_params.get("bands", []) vmin = viz_params.get("min") vmax = viz_params.get("max") if palette and vmin is not None and vmax is not None: # Only generate for single-band (palette doesn't apply to RGB) if len(bands) <= 1: bn = bands[0] if bands else (band_name or "value") # Normalise min/max to scalar if isinstance(vmin, (list, tuple)): vmin = vmin[0] if isinstance(vmax, (list, tuple)): vmax = vmax[0] # Normalise palette to a list of color strings if isinstance(palette, str): pal_list = [c.strip() for c in palette.split(",")] elif isinstance(palette, (list, tuple)): pal_list = list(palette) else: pal_list = [str(palette)] return { "type": "continuous", "min": vmin, "max": vmax, "palette": pal_list, "band_name": bn, } return None def _hex_to_rgb(hex_color): """Convert a hex or named color string to an (R, G, B) tuple.""" h = hex_color.strip().lstrip("#") if len(h) == 3: h = h[0] * 2 + h[1] * 2 + h[2] * 2 if len(h) == 6: try: return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) except ValueError: pass # Fall back to _resolve_color for named colors try: return _resolve_color(hex_color) except Exception: return (128, 128, 128) def _build_legend_panel(class_names, class_palette, target_height, bg_color="black", scale=1.0, font_color=None): """Build a vertical legend panel image for thematic map data. Creates a PIL image with colored swatches and class labels arranged vertically, sized to sit alongside (or below) a map frame. Font size is automatically calculated to fit all classes within the available height, capped at a readable maximum. Args: class_names (list[str]): Display names for each thematic class. class_palette (list[str]): Hex color strings (without ``#`` prefix) corresponding to each class. target_height (int): Pixel height the panel must match, typically the height of the adjacent map frame. bg_color (str, optional): Background color name or hex string (e.g. ``"black"``, ``"white"``, ``"#1a1a2e"``). Defaults to ``"black"``. scale (float, optional): Multiplier for text and swatch size. Values greater than 1.0 enlarge the legend. Defaults to ``1.0``. font_color (tuple or None, optional): ``(R, G, B)`` text color. When ``None``, derived from the theme based on ``bg_color``. Defaults to ``None``. Returns: PIL.Image.Image: RGB legend panel image with dimensions ``(auto_width, target_height)``. Example: >>> panel = _build_legend_panel( ... ["Forest", "Water", "Urban"], ... ["228B22", "4682B4", "808080"], ... target_height=400, ... ) >>> panel.size[1] 400 """ from PIL import Image, ImageDraw n_classes = len(class_names) theme = _get_theme(bg_color) panel_bg = _resolve_color(bg_color) text_color = font_color if font_color is not None else theme.text divider_color = theme.divider swatch_outline = theme.swatch_outline # Compute font size: fit all classes within target_height pad_left = max(4, int(6 * scale)) # left padding only usable_h = target_height # no top/bottom padding # Each row: swatch + gap; font size drives row height base_font = max(8, int(usable_h / n_classes * 0.6 * scale)) if n_classes > 0 else 10 # Cap at label_font_size from theme base_font = min(base_font, _DEFAULT_LABEL_FONT_SIZE) font = _get_font(base_font) swatch_size = max(6, base_font) # Measure text widths tmp_img = Image.new("RGB", (1, 1)) tmp_draw = ImageDraw.Draw(tmp_img) text_widths = [] for name in class_names: bbox = tmp_draw.textbbox((0, 0), name, font=font) text_widths.append(bbox[2] - bbox[0]) max_text_w = max(text_widths) if text_widths else 40 gap = max(4, int(4 * scale)) panel_w = pad_left + swatch_size + gap + max_text_w + pad_left # Row height: distribute evenly across usable height row_height = usable_h / n_classes if n_classes > 0 else usable_h # Build panel panel = Image.new("RGB", (panel_w, target_height), panel_bg) draw = ImageDraw.Draw(panel) # Draw entries starting at top (no vertical padding) y_start = 0 for i, (name, hex_col) in enumerate(zip(class_names, class_palette)): rgb = _hex_to_rgb(hex_col) row_y = y_start + int(i * row_height) # Center swatch + text vertically within the row center_y = row_y + int(row_height / 2) # Color swatch sx = pad_left sy = center_y - swatch_size // 2 draw.rounded_rectangle( [(sx, sy), (sx + swatch_size - 1, sy + swatch_size - 1)], radius=max(1, swatch_size // 5), fill=rgb, outline=swatch_outline, width=1, ) # Text label — vertically centered on same center_y as swatch tx = sx + swatch_size + gap bbox = draw.textbbox((0, 0), name, font=font) # bbox[1] is the top offset from the anchor; account for it # so the visual center of the text aligns with center_y ty = center_y - (bbox[1] + bbox[3]) // 2 draw.text((tx, ty), name, font=font, fill=text_color) return panel def _build_continuous_legend_panel(vmin, vmax, palette, target_height, band_name="", bg_color="black", scale=1.0, font_color=None, n_ticks=5): """Build a vertical gradient colorbar panel for continuous data. Creates a PIL image with a smooth vertical gradient derived from ``palette`` colours, with evenly-spaced numeric tick labels running from ``vmax`` (top) to ``vmin`` (bottom). Args: vmin (float): Minimum value at the bottom of the bar. vmax (float): Maximum value at the top of the bar. palette (list[str]): Hex colour strings (without ``#``) defining the gradient from low to high. target_height (int): Pixel height the panel must match. band_name (str, optional): Band label drawn above the bar. bg_color (str, optional): Background colour. Defaults to ``"black"``. scale (float, optional): Size multiplier. Defaults to ``1.0``. font_color (tuple or None, optional): ``(R, G, B)`` text colour. n_ticks (int, optional): Number of tick labels. Defaults to 5. Returns: PIL.Image.Image: RGB legend panel image. """ from PIL import Image, ImageDraw theme = _get_theme(bg_color) panel_bg = _resolve_color(bg_color) text_color = font_color if font_color is not None else theme.text swatch_outline = theme.swatch_outline padding = max(8, int(10 * scale)) base_font = max(9, int(12 * scale)) font = _get_font(base_font) # Measure tick label width tmp = Image.new("RGB", (1, 1)) tmp_d = ImageDraw.Draw(tmp) def _fmt(v): if abs(v) >= 1000: return f"{v:.0f}" elif abs(v) >= 1: return f"{v:.1f}" else: return f"{v:.3f}" tick_vals = [vmin + (vmax - vmin) * i / max(n_ticks - 1, 1) for i in range(n_ticks)] tick_labels = [_fmt(v) for v in tick_vals] max_tw = max( (tmp_d.textbbox((0, 0), t, font=font)[2] for t in tick_labels), default=30, ) bar_w = max(12, int(16 * scale)) tick_gap = max(4, int(6 * scale)) panel_w = padding + bar_w + tick_gap + max_tw + padding # Band name above the bar — 1.15x the tick font size name_font_size = max(base_font, int(base_font * 1.15)) name_font = _get_font(name_font_size) name_h = 0 if band_name: nb = tmp_d.textbbox((0, 0), band_name, font=name_font) name_w = nb[2] - nb[0] name_h = (nb[3] - nb[1]) + padding # Widen panel if band name is wider than bar + ticks panel_w = max(panel_w, padding + name_w + padding) bar_top = padding + name_h bar_bottom = target_height - padding bar_h = max(bar_bottom - bar_top, 20) panel = Image.new("RGB", (panel_w, target_height), panel_bg) draw = ImageDraw.Draw(panel) # Draw band name if band_name: draw.text((padding, padding), band_name, font=name_font, fill=text_color) # Build gradient column — interpolate palette colours pal_rgb = [_hex_to_rgb(c) for c in palette] n_colors = len(pal_rgb) for py in range(bar_h): # py=0 is top (max), py=bar_h-1 is bottom (min) frac = 1.0 - py / max(bar_h - 1, 1) # 0 at bottom, 1 at top # Map frac to palette index idx_f = frac * (n_colors - 1) lo = int(idx_f) hi = min(lo + 1, n_colors - 1) t = idx_f - lo r = int(pal_rgb[lo][0] * (1 - t) + pal_rgb[hi][0] * t) g = int(pal_rgb[lo][1] * (1 - t) + pal_rgb[hi][1] * t) b = int(pal_rgb[lo][2] * (1 - t) + pal_rgb[hi][2] * t) draw.line( [(padding, bar_top + py), (padding + bar_w - 1, bar_top + py)], fill=(r, g, b), ) # Bar outline draw.rectangle( [(padding, bar_top), (padding + bar_w - 1, bar_top + bar_h - 1)], outline=swatch_outline, width=1, ) # Tick labels (evenly spaced, top = max, bottom = min) tx = padding + bar_w + tick_gap for i in range(n_ticks): # i=0 → min (bottom), i=n_ticks-1 → max (top) frac = i / max(n_ticks - 1, 1) py = bar_top + int((1.0 - frac) * (bar_h - 1)) label = tick_labels[i] tb = draw.textbbox((0, 0), label, font=font) th = tb[3] - tb[1] draw.text((tx, py - th // 2), label, font=font, fill=text_color) # Small tick mark draw.line( [(padding + bar_w - 3, py), (padding + bar_w, py)], fill=swatch_outline, width=1, ) return panel def _build_legend_panel_from_info(legend_info, target_height, bg_color="black", scale=1.0, font_color=None): """Build the appropriate legend panel from a legend_info dict. Dispatches to :func:`_build_legend_panel` for thematic data or :func:`_build_continuous_legend_panel` for continuous data based on ``legend_info["type"]``. Returns ``None`` if ``legend_info`` is ``None``. """ if legend_info is None: return None from PIL import Image as _PILImage, ImageDraw as _PILDraw # Build geometry swatch row if present (prepended above the main legend) geom_swatch = legend_info.get("geometry_swatch") geom_row = None geom_row_h = 0 if geom_swatch: theme = _get_theme(bg_color) text_color = font_color if font_color is not None else theme.text font = _get_font(_DEFAULT_LABEL_FONT_SIZE) swatch_sz = max(8, _DEFAULT_LABEL_FONT_SIZE) pad_left = max(4, int(6 * scale)) gap = 4 geom_row_h = int(_DEFAULT_LABEL_FONT_SIZE * 2) outline_rgb = _hex_to_rgb(geom_swatch["outline_hex"]) # Parse fill: may be None, or hex like "FFFFFF33" (RRGGBBAA with alpha) fill_hex = geom_swatch.get("fill_hex") bg_rgb = _resolve_color(bg_color) if fill_hex and len(fill_hex) >= 6: fill_rgb = _hex_to_rgb(fill_hex[:6]) # Extract alpha if present (last 2 hex chars), default fully opaque fill_alpha = int(fill_hex[6:8], 16) if len(fill_hex) >= 8 else 255 # Alpha-blend fill over background for the swatch preview a = fill_alpha / 255.0 fill_blended = tuple(int(f * a + b * (1 - a)) for f, b in zip(fill_rgb, bg_rgb)) else: fill_blended = bg_rgb # match panel bg # Measure label width tmp_d = _PILDraw.Draw(_PILImage.new("RGB", (1, 1))) lbb = tmp_d.textbbox((0, 0), geom_swatch["label"], font=font) label_w = lbb[2] - lbb[0] row_w = pad_left + swatch_sz + gap + label_w + pad_left geom_row = _PILImage.new("RGB", (row_w, geom_row_h), bg_rgb) rd = _PILDraw.Draw(geom_row) cy = geom_row_h // 2 # Draw swatch: alpha-blended fill inside, outline border sx, sy = pad_left, cy - swatch_sz // 2 rd.rectangle( [(sx, sy), (sx + swatch_sz - 1, sy + swatch_sz - 1)], fill=fill_blended, outline=outline_rgb, width=2, ) tbb = rd.textbbox((0, 0), geom_swatch["label"], font=font) ty = cy - (tbb[1] + tbb[3]) // 2 rd.text((pad_left + swatch_sz + gap, ty), geom_swatch["label"], font=font, fill=text_color) if legend_info.get("type") == "continuous": colorbar_h = target_height - geom_row_h panel = _build_continuous_legend_panel( legend_info["min"], legend_info["max"], legend_info["palette"], target_height=max(colorbar_h, 40), band_name=legend_info.get("band_name", ""), bg_color=bg_color, scale=scale, font_color=font_color, ) # Prepend geometry swatch above colorbar if geom_row is not None and panel is not None: pw = max(panel.size[0], geom_row.size[0]) combined = _PILImage.new("RGB", (pw, geom_row_h + panel.size[1]), _resolve_color(bg_color)) combined.paste(geom_row, (0, 0)) combined.paste(panel, (0, geom_row_h)) panel = combined return panel # thematic (default) # thematic (default) thematic_h = target_height - geom_row_h panel = _build_legend_panel( legend_info["class_names"], legend_info["class_palette"], target_height=max(thematic_h, 20), bg_color=bg_color, scale=scale, font_color=font_color, ) # Prepend geometry swatch above thematic legend if geom_row is not None and panel is not None: pw = max(panel.size[0], geom_row.size[0]) combined = _PILImage.new("RGB", (pw, geom_row_h + panel.size[1]), _resolve_color(bg_color)) combined.paste(geom_row, (0, 0)) combined.paste(panel, (0, geom_row_h)) return combined return panel def _build_horizontal_legend(class_names, class_palette, width, bg_color="black", font_color=None, scale=1.0): """Build a full-width horizontal legend with wrapping rows of swatches. Arranges coloured swatches and labels in rows that fill the given *width*, wrapping to additional rows as needed. Args: class_names (list[str]): Class display names. class_palette (list[str]): Hex colour strings (no ``#``). width (int): Target width in pixels. bg_color (str): Background colour. font_color (tuple or None): ``(R, G, B)`` text colour. scale (float): Size multiplier. Returns: PIL.Image.Image: RGBA image spanning *width* pixels wide. """ from PIL import Image, ImageDraw theme = _get_theme(bg_color) panel_bg = _resolve_color(bg_color) + (255,) text_color = font_color if font_color is not None else theme.text swatch_outline = theme.swatch_outline font_size = max(9, int(11 * scale)) font = _get_font(font_size) swatch_sz = max(8, font_size) gap = max(4, int(5 * scale)) item_gap = max(10, int(14 * scale)) pad = max(6, int(8 * scale)) # Measure each item width tmp = ImageDraw.Draw(Image.new("RGB", (1, 1))) items = [] for name, hex_col in zip(class_names, class_palette): bbox = tmp.textbbox((0, 0), name, font=font) tw = bbox[2] - bbox[0] item_w = swatch_sz + gap + tw items.append((name, hex_col, item_w)) # Layout into rows usable_w = width - 2 * pad rows = [] cur_row = [] cur_w = 0 for name, hex_col, item_w in items: needed = item_w + (item_gap if cur_row else 0) if cur_row and cur_w + needed > usable_w: rows.append(cur_row) cur_row = [(name, hex_col, item_w)] cur_w = item_w else: cur_row.append((name, hex_col, item_w)) cur_w += needed if cur_row: rows.append(cur_row) row_h = swatch_sz + pad total_h = pad + len(rows) * row_h + pad panel = Image.new("RGBA", (width, total_h), panel_bg) draw = ImageDraw.Draw(panel) for ri, row in enumerate(rows): # Centre the row total_row_w = sum(iw for _, _, iw in row) + item_gap * (len(row) - 1) x = (width - total_row_w) // 2 y = pad + ri * row_h for name, hex_col, item_w in row: rgb = _hex_to_rgb(hex_col) # Swatch draw.rounded_rectangle( [(x, y), (x + swatch_sz - 1, y + swatch_sz - 1)], radius=max(1, swatch_sz // 5), fill=rgb, outline=swatch_outline, width=1, ) # Label bbox = draw.textbbox((0, 0), name, font=font) ty = y + (swatch_sz - (bbox[3] - bbox[1])) // 2 - bbox[1] draw.text((x + swatch_sz + gap, ty), name, font=font, fill=text_color) x += item_w + item_gap return panel def _extend_frame_with_legend(frame, legend_panel, bg_color="black"): """Create a new image with the frame on the left and legend on the right. Args: frame (PIL.Image.Image): The map frame (RGBA or RGB). legend_panel (PIL.Image.Image): The legend panel (RGB), same height. bg_color (str): Background color for the combined canvas. Returns: PIL.Image.Image: New RGBA image with frame + legend side by side. """ from PIL import Image fw, fh = frame.size lw, lh = legend_panel.size bg_rgb = _resolve_color(bg_color) fill = bg_rgb + (255,) combined = Image.new("RGBA", (fw + lw, max(fh, lh)), fill) combined.paste(frame, (0, 0)) combined.paste(legend_panel.convert("RGBA"), (fw, 0)) return combined def _burn_legend_into_url(url, legend_info, bg_color="black", scale=1.0): """Download a thumbnail, extend it with a legend panel, return as base64. Args: url (str): Thumbnail URL. legend_info (dict): Dict with ``class_names`` and ``class_palette``. bg_color (str): Background color for the legend panel. scale (float): Legend scale factor. Returns: str: Base64 data URI of the modified PNG. """ from PIL import Image data = download_thumb(url) frame = Image.open(io.BytesIO(data)).convert("RGBA") legend_panel = _build_legend_panel_from_info( legend_info, target_height=frame.size[1], bg_color=bg_color, scale=scale, ) if legend_panel is None: return url # No legend to burn in combined = _extend_frame_with_legend(frame, legend_panel, bg_color=bg_color) buf = io.BytesIO() combined.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode("ascii") return f"data:image/png;base64,{b64}" def _get_font(size): """Try to load a TrueType font, falling back to default.""" from PIL import ImageFont # Try common system font paths font_candidates = [ "arial.ttf", "Arial.ttf", "DejaVuSans-Bold.ttf", "DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/arialbd.ttf", "/System/Library/Fonts/Helvetica.ttc", ] for font_path in font_candidates: try: return ImageFont.truetype(font_path, size) except (OSError, IOError): continue # Fallback to default try: return ImageFont.load_default(size=size) except TypeError: return ImageFont.load_default() def _burn_in_text(frame, text, position, color, outline_color, font): """Draw text with outline onto a PIL Image frame.""" from PIL import ImageDraw draw = ImageDraw.Draw(frame) w, h = frame.size # Get text bounding box bbox = draw.textbbox((0, 0), text, font=font) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] # Calculate position margin = max(8, w // 40) if position == "upper-left": x, y = margin, margin elif position == "upper-right": x, y = w - tw - margin, margin elif position == "lower-left": x, y = margin, h - th - margin elif position == "lower-right": x, y = w - tw - margin, h - th - margin else: x, y = margin, margin # Draw outline/shadow for readability outline_width = max(1, font.size // 12) if hasattr(font, 'size') else 1 for dx in range(-outline_width, outline_width + 1): for dy in range(-outline_width, outline_width + 1): if dx != 0 or dy != 0: draw.text((x + dx, y + dy), text, font=font, fill=outline_color) # Draw main text draw.text((x, y), text, font=font, fill=color)
[docs] def generate_map_chart( ee_obj, geometry, viz_params=None, band_name=None, dimensions=_DEFAULT_DIMENSIONS, bg_color=None, font_color=None, font_outline_color=None, output_path=None, crs=_DEFAULT_CRS, transform=None, scale=None, margin=_DEFAULT_MARGIN, basemap=None, overlay_opacity=None, scalebar=True, scalebar_units="metric", north_arrow=True, north_arrow_style="solid", inset_map=True, inset_basemap=None, inset_scale=0.25, title=None, # Chart params chart_type=None, chart_scale=30, area_format="Percentage", chart_height=None, legend_position="right", include_masked_area=True, burn_in_geometry=True, burn_in_legend=True, title_font_size=_DEFAULT_TITLE_FONT_SIZE, label_font_size=_DEFAULT_LABEL_FONT_SIZE, geometry_outline_color=None, geometry_fill_color=None, geometry_outline_weight=2, clip_to_geometry=True, # Multi-feature params feature_label=None, columns=2, thumb_width=None, # Scatter params band_names=None, thematic_band_name=None, opacity=0.7, # Layout layout="side-by-side", ): """Generate a combined map + chart output. For ``ee.Image`` inputs, produces a static PNG with a map thumbnail beside (or above) a chart. For ``ee.ImageCollection`` inputs, automatically delegates to :func:`generate_map_chart_gif` and returns an animated GIF with cumulative time-series charts. The title appears once on the combined output — the chart itself has no title. For thematic data the legend appears on the map thumbnail only (not duplicated on the chart). Supports: - **ee.Image + single geometry** (``ee.Geometry`` / ``ee.Feature``) with thematic data -> map + bar or donut chart - **ee.Image + single geometry** with continuous data -> map + horizontal bar chart of band means - **ee.Image + multi-feature** ``ee.FeatureCollection`` with thematic data -> per-feature map grid + grouped/stacked bar or per-feature donut chart - **ee.Image + multi-feature FC** with ``chart_type="scatter"`` -> map of bounding region with sample points burned in + scatter plot (optionally coloured by *thematic_band_name*) - **ee.ImageCollection + any geometry** -> delegates to :func:`generate_map_chart_gif`, returning ``gif_bytes`` Args: ee_obj: ``ee.Image`` or ``ee.ImageCollection``. geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection``. viz_params (dict, optional): Visualization parameters for the map thumbnail. Auto-detected via :func:`auto_viz` when ``None``. band_name (str, optional): Band to visualize on the map. dimensions (int, optional): Map thumbnail width in pixels. Defaults to ``640``. bg_color (str, optional): Background colour. Dark theme when ``None``. font_color (str or tuple, optional): Font colour override. font_outline_color (str or tuple, optional): Font outline. output_path (str, optional): Save output to this path. crs (str, optional): Output CRS (e.g. ``"EPSG:5070"``). Defaults to ``"EPSG:3857"``. transform (list, optional): CRS transform. scale (int, optional): Pixel scale in metres. margin (int, optional): Margin around the output in pixels. basemap (str or dict, optional): Basemap preset name (e.g. ``"esri-satellite"``) or config dict. overlay_opacity (float, optional): Opacity of EE data over basemap. Default ``0.8`` when basemap is set. scalebar (bool, optional): Draw scalebar. Defaults to ``True``. scalebar_units (str, optional): ``"metric"`` or ``"imperial"``. north_arrow (bool, optional): Draw north arrow. Defaults to ``True``. north_arrow_style (str, optional): Arrow style. inset_map (bool, optional): Show inset overview map. inset_basemap: Basemap for inset. inset_scale (float, optional): Inset size as fraction of frame. title (str, optional): Title displayed once above the combined map + chart output. chart_type (str, optional): ``"bar"`` (default for Image), ``"stacked_bar"``, ``"donut"``, ``"scatter"``, or any time-series type (``"line+markers"``, etc.). ``None`` auto-detects: ``"bar"`` for Image, ``"line+markers"`` for ImageCollection. chart_scale (int, optional): Scale in metres for zonal stats ``reduceRegion``. Defaults to ``30``. area_format (str, optional): ``"Percentage"`` (default), ``"Hectares"``, ``"Acres"``, or ``"Pixels"``. chart_height (int, optional): Chart height in pixels. Defaults to map height for side-by-side, map width for stacked. legend_position (str or dict, optional): Chart legend position. Suppressed automatically for thematic data when ``burn_in_legend=True`` (legend on thumb only). include_masked_area (bool, optional): Include masked pixels in area totals. Defaults to ``True``. burn_in_geometry (bool, optional): Paint geometry boundary on map frames. Defaults to ``True``. burn_in_legend (bool, optional): Add legend panel to the map thumbnail. Defaults to ``True``. title_font_size (int, optional): Title font size. Default 18. label_font_size (int, optional): Label font size. Default 12. geometry_outline_color (str, optional): Boundary colour. geometry_fill_color (str, optional): Boundary fill (hex+alpha). geometry_outline_weight (int, optional): Boundary width. clip_to_geometry (bool, optional): Mask data outside boundary. feature_label (str, optional): FC property name for per-feature labels (multi-feature mode). columns (int, optional): Columns for multi-feature grid or multi-feature donut subplot layout. Defaults to ``2``. thumb_width (int, optional): Per-feature thumbnail width. band_names (list[str], optional): Bands for scatter x/y axes. Uses first two image bands when ``None``. thematic_band_name (str, optional): Thematic band name for colouring scatter points by class. The image must carry ``{band}_class_values/names/palette`` properties. opacity (float, optional): Point opacity for scatter charts. Defaults to ``0.7``. layout (str, optional): ``"side-by-side"`` (default) places the chart to the right of the map. ``"stacked"`` places the chart below the map. Returns: dict: For ``ee.Image`` inputs: ``{"html": str, "thumb_bytes": bytes, "df": DataFrame, "fig": Figure}``. For ``ee.ImageCollection`` inputs (delegated to GIF): ``{"html": str, "gif_bytes": bytes}``. """ from PIL import Image, ImageDraw _validate_projection_params(crs, transform, scale) # --- Auto-detect ImageCollection and delegate to GIF version --- _is_ic = False try: _is_ic = isinstance(ee_obj, ee.ImageCollection) or ( hasattr(ee_obj, "getInfo") and ee.ImageCollection(ee_obj).size().getInfo() > 0 and not isinstance(ee_obj, ee.Image) ) except Exception: pass # If user explicitly wraps as IC, detect it if not _is_ic: try: obj_info_check = cl.get_obj_info(ee_obj) _is_ic = obj_info_check["obj_type"] == "ImageCollection" except Exception: pass if _is_ic: return generate_map_chart_gif( ee_obj, geometry, viz_params=viz_params, band_name=band_name, dimensions=dimensions, bg_color=bg_color, font_color=font_color, font_outline_color=font_outline_color, output_path=output_path, crs=crs, transform=transform, scale=scale, margin=margin, basemap=basemap, overlay_opacity=overlay_opacity, scalebar=scalebar, scalebar_units=scalebar_units, north_arrow=north_arrow, north_arrow_style=north_arrow_style, inset_map=inset_map, inset_basemap=inset_basemap, inset_scale=inset_scale, title=title, chart_type=chart_type or "line+markers", chart_scale=chart_scale, area_format=area_format, chart_height=chart_height, legend_position=legend_position, include_masked_area=include_masked_area, burn_in_geometry=burn_in_geometry, title_font_size=title_font_size, label_font_size=label_font_size, geometry_outline_color=geometry_outline_color, geometry_fill_color=geometry_fill_color, geometry_outline_weight=geometry_outline_weight, clip_to_geometry=clip_to_geometry, ) # Detect if multi-feature is_multi = _is_multi_feature(geometry) _is_scatter = str(chart_type).lower().strip() == "scatter" if chart_type else False # --- Detect thematic to avoid duplicate legends --- _is_thematic = False try: _obj_info = cl.get_obj_info(ee_obj) _is_thematic = _obj_info.get("is_thematic", False) except Exception: pass # --- Generate the map thumbnail --- # For thematic data, legend goes on the thumb only (not duplicated on chart) thumb_kwargs = dict( viz_params=viz_params, band_name=band_name, dimensions=dimensions, bg_color=bg_color, font_color=font_color, font_outline_color=font_outline_color, crs=crs, transform=transform, scale=scale, margin=0, basemap=basemap, overlay_opacity=overlay_opacity, scalebar=scalebar, scalebar_units=scalebar_units, north_arrow=north_arrow, north_arrow_style=north_arrow_style, inset_map=inset_map, inset_basemap=inset_basemap, inset_scale=inset_scale, burn_in_geometry=burn_in_geometry, geometry_outline_color=geometry_outline_color, geometry_fill_color=geometry_fill_color, geometry_outline_weight=geometry_outline_weight, clip_to_geometry=clip_to_geometry, title_font_size=title_font_size, label_font_size=label_font_size, burn_in_legend=burn_in_legend, ) if is_multi and not _is_scatter: thumb_kwargs["feature_label"] = feature_label thumb_kwargs["columns"] = columns if thumb_width: thumb_kwargs["thumb_width"] = thumb_width # For scatter: use FC bounds for the map, burn in points as geometry _map_geometry = geometry if _is_scatter and is_multi: _map_geometry = ee.FeatureCollection(geometry).geometry().bounds() # Burn in the sample points on the map thumb_kwargs["burn_in_geometry"] = True thumb_kwargs["clip_to_geometry"] = False # Use the FC itself as the geometry overlay (shows point dots) _map_ee_obj = ee_obj # Paint points onto the image before thumbnailing _fc_geom = ee.FeatureCollection(geometry) _bounds = _get_bounds_4326(_map_geometry) _gc = _resolve_geometry_color( geometry_outline_color, _resolve_font_colors(bg_color, font_color, font_outline_color)[0], basemap, _bounds, ) _img = _to_image(ee_obj) _img = _apply_projection(_img, crs, transform, scale) _vp = _complete_viz_params(viz_params, _img, band_name=band_name, geometry=_map_geometry) _img = _paint_boundary(_img, _fc_geom, _gc, viz_params=_vp, fill_color=geometry_fill_color, width=max(1, geometry_outline_weight), crs=crs) # Pass pre-visualized image, skip further viz thumb_kwargs["viz_params"] = {"min": 0, "max": 255} thumb_kwargs["burn_in_geometry"] = False # already painted thumb_result = generate_thumbs(_img, _map_geometry, **thumb_kwargs) else: thumb_result = generate_thumbs(ee_obj, _map_geometry, **thumb_kwargs) map_img = Image.open(io.BytesIO(thumb_result["thumb_bytes"])).convert("RGBA") # --- Generate the chart --- chart_kwargs = dict( scale=chart_scale, area_format=area_format, include_masked_area=include_masked_area, opacity=opacity, ) # For thematic data, suppress chart legend since it's on the thumb if _is_thematic and burn_in_legend: chart_kwargs["legend_position"] = {"visible": False} else: chart_kwargs["legend_position"] = legend_position # Auto chart_type if chart_type is None: chart_type = "bar" chart_kwargs["chart_type"] = chart_type if band_names: chart_kwargs["band_names"] = band_names if thematic_band_name: chart_kwargs["thematic_band_name"] = thematic_band_name if feature_label: chart_kwargs["feature_label"] = feature_label chart_kwargs["columns"] = columns result = cl.summarize_and_chart(ee_obj, geometry, **chart_kwargs) # Unpack (varies by chart type) if isinstance(result, tuple) and len(result) == 3: df, fig, extra = result # sankey elif isinstance(result, tuple): df, fig = result else: df, fig = result, None if fig is None: thumb_bytes = _pil_to_png_bytes(map_img, bg_color) return {"html": "", "thumb_bytes": thumb_bytes, "df": df, "fig": None} # Suppress chart legend for thematic (already on thumb) if _is_thematic and burn_in_legend: fig.update_layout(showlegend=False) # Remove chart title — the combined output has its own title strip fig.update_layout(title=None) # --- Render chart to PNG --- mw, mh = map_img.size if chart_height is None: chart_height = mh # match map height for side-by-side if layout == "side-by-side": chart_width = max(400, int(mw * 0.8)) else: chart_width = mw chart_png_bytes = fig.to_image(format="png", width=chart_width, height=chart_height) chart_img = Image.open(io.BytesIO(chart_png_bytes)).convert("RGBA") # --- Compose map + chart --- font_color_resolved, _, theme, bg_color = _resolve_font_colors( bg_color, font_color, font_outline_color) bg_rgba = _resolve_color(bg_color) + (255,) if layout == "side-by-side": gap = max(4, margin // 2) total_w = mw + gap + chart_img.size[0] total_h = max(mh, chart_img.size[1]) combined = Image.new("RGBA", (total_w, total_h), bg_rgba) combined.paste(map_img, (0, (total_h - mh) // 2), map_img if map_img.mode == "RGBA" else None) combined.paste(chart_img, (mw + gap, (total_h - chart_img.size[1]) // 2), chart_img if chart_img.mode == "RGBA" else None) else: # stacked gap = max(4, margin // 2) total_w = max(mw, chart_img.size[0]) total_h = mh + gap + chart_img.size[1] combined = Image.new("RGBA", (total_w, total_h), bg_rgba) combined.paste(map_img, ((total_w - mw) // 2, 0), map_img if map_img.mode == "RGBA" else None) combined.paste(chart_img, ((total_w - chart_img.size[0]) // 2, mh + gap), chart_img if chart_img.mode == "RGBA" else None) # Title if title: tfont = _get_font(title_font_size) tmp_d = ImageDraw.Draw(Image.new("RGBA", (1, 1))) tb = tmp_d.textbbox((0, 0), title, font=tfont) tw = tb[2] - tb[0] th = tb[3] - tb[1] tpad = max(4, title_font_size // 3) strip_h = tpad + th + tpad with_title = Image.new("RGBA", (combined.size[0], strip_h + combined.size[1]), bg_rgba) td = ImageDraw.Draw(with_title) td.text(((combined.size[0] - tw) // 2, tpad - tb[1]), title, font=tfont, fill=font_color_resolved) with_title.paste(combined, (0, strip_h), combined if combined.mode == "RGBA" else None) combined = with_title combined = _add_margin(combined, margin, bg_color=bg_color) thumb_bytes = _pil_to_png_bytes(combined, bg_color) if output_path: _write_bytes(output_path, thumb_bytes) html = _bytes_to_html_figure(thumb_bytes, "png", css_class="map-chart") return {"html": html, "thumb_bytes": thumb_bytes, "df": df, "fig": fig}
[docs] def generate_map_chart_gif( ee_obj, geometry, viz_params=None, band_name=None, dimensions=_DEFAULT_DIMENSIONS, fps=_DEFAULT_FPS, max_frames=_MAX_GIF_FRAMES, date_format="YYYY", bg_color=None, font_color=None, font_outline_color=None, output_path=None, crs=_DEFAULT_CRS, transform=None, scale=None, margin=_DEFAULT_MARGIN, basemap=None, overlay_opacity=None, scalebar=True, scalebar_units="metric", north_arrow=True, north_arrow_style="solid", inset_map=True, inset_basemap=None, inset_scale=0.25, title=None, # Chart params chart_type="line+markers", chart_scale=30, area_format="Percentage", chart_height=None, legend_position="bottom", include_masked_area=True, burn_in_geometry=True, title_font_size=_DEFAULT_TITLE_FONT_SIZE, label_font_size=_DEFAULT_LABEL_FONT_SIZE, geometry_outline_color=None, geometry_fill_color=None, geometry_outline_weight=2, clip_to_geometry=True, ): """Generate an animated GIF with map thumbnails and cumulative line charts. Each frame shows a map thumbnail for one time step above a chart that accumulates data from the first year up to the current year. The chart's x-axis spans the full time range so the frame-to-frame progression is visually stable. A legend is placed below the chart. This mirrors the layout of ``https://storage.googleapis.com/lcms-gifs/San_Juan_NF_Land_Cover.gif``. Args: ee_obj (ee.ImageCollection): Multi-temporal image collection. geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection``. viz_params (dict, optional): Viz params for the map thumbnails. Auto-detected when ``None``. band_name (str, optional): Band to visualize. dimensions (int): Map thumbnail width in pixels. fps (int): Frames per second. max_frames (int): Max number of frames. date_format (str): Date format for labels (e.g. ``"YYYY"``). bg_color: Background colour. font_color: Font colour. font_outline_color: Font outline colour. output_path (str, optional): Save GIF to this path. crs, transform, scale: Projection params for thumbnails. margin (int): Margin in pixels. basemap: Basemap preset for map thumbnails. overlay_opacity (float): Opacity of EE data over basemap. scalebar (bool): Draw scalebar on map. scalebar_units (str): ``"metric"`` or ``"imperial"``. north_arrow (bool): Draw north arrow on map. north_arrow_style (str): Arrow style. title (str, optional): Title above the map. chart_type (str): Chart type for the time series. Default ``"line+markers"``. chart_scale (int): Scale in metres for ``reduceRegion``. area_format (str): ``"Percentage"``, ``"Hectares"``, ``"Acres"``. chart_height (int, optional): Chart height in pixels. Default is ``dimensions * 0.6``. legend_position (str or dict): Legend placement on chart. Returns: dict: ``{"html": str, "gif_bytes": bytes}`` """ from PIL import Image, ImageDraw import plotly.graph_objects as go _validate_projection_params(crs, transform, scale) col = ee.ImageCollection(ee_obj) geom = _to_geometry(geometry) # Resolve viz viz_params = _complete_viz_params(viz_params, col, band_name=band_name, geometry=geometry) # Resolve colours font_color, font_outline_color, theme, bg_color = _resolve_font_colors( bg_color, font_color, font_outline_color) if overlay_opacity is None: overlay_opacity = 0.8 if basemap is not None else 1.0 if chart_height is None: chart_height = max(200, int(dimensions * 0.6)) # --- Prepare collection --- col = col.filterBounds(geom) col = _mosaic_by_date(col) col = _apply_projection_to_collection(col, crs, transform, scale) if clip_to_geometry: col = col.map(lambda img: img.clip(geom).copyProperties(img, ["system:time_start"])) # Burn in geometry boundary (pre-visualizes the collection) if burn_in_geometry: bounds = _get_bounds_4326(geom) gc = _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds) col = _paint_boundary(col, geom, gc, viz_params=viz_params, fill_color=geometry_fill_color, width=geometry_outline_weight, crs=crs) viz_params = {"min": 0, "max": 255} count = col.size().getInfo() if count > max_frames: col = col.limit(max_frames) count = max_frames if count == 0: return {"html": "<p>No images.</p>", "gif_bytes": b""} # --- Download map frames + run zonal stats in parallel --- bounds = _get_bounds_4326(geom) def _do_frames(): return _download_frames(col, geom if clip_to_geometry else geom.bounds(), viz_params, dimensions, count, date_format) def _do_stats(): return cl.zonal_stats( ee_obj, geometry, band_names=[band_name] if band_name else None, scale=chart_scale, area_format=area_format, date_format=date_format, include_masked_area=include_masked_area, ) with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: frames_future = pool.submit(_do_frames) stats_future = pool.submit(_do_stats) pil_frames, date_labels = frames_future.result() df = stats_future.result() if not pil_frames: return {"html": "<p>Failed to download frames.</p>", "gif_bytes": b""} # Composite basemap (adds padding border) or add blank padding if basemap is not None and bounds is not None: fw0, fh0 = pil_frames[0].size bm = _fetch_basemap(bounds, fw0 + 2 * _THUMB_PADDING, fh0 + 2 * _THUMB_PADDING, basemap, crs=crs) if bm is not None: pil_frames = [_composite_with_basemap(f, bm, overlay_opacity) for f in pil_frames] else: pil_frames = [_add_thumb_padding(f, bg_color=bg_color) for f in pil_frames] else: pil_frames = [_add_thumb_padding(f, bg_color=bg_color) for f in pil_frames] # Scalebar + north arrow on all map frames if bounds is not None: for f in pil_frames: _draw_scalebar_and_arrow_on_frame( f, bounds, scalebar=scalebar, north_arrow=north_arrow, north_arrow_style=north_arrow_style, font_color=font_color, contrast=font_outline_color, accent=theme.accent, label_font_size=label_font_size, crs=crs, ) # Resolve geometry colors for inset extent rectangle _mcg_gc = gc if burn_in_geometry else ( _resolve_geometry_color(geometry_outline_color, font_color, basemap, bounds) if geometry_outline_color else None) _mcg_fill = _hex_fill_to_rgba(geometry_fill_color) if geometry_fill_color else None # Inset map on all frames (lower-right corner) if inset_map and bounds is not None and pil_frames: _ib = inset_basemap if inset_basemap else basemap if _ib is not None: from PIL import Image as _PILImage _fw, _fh = pil_frames[0].size target_h = int(_fh * inset_scale) _mcg_kw = {} if _mcg_gc is not None: _mcg_kw["rect_color"] = _mcg_gc if _mcg_fill is not None: _mcg_kw["rect_fill_color"] = _mcg_fill inset_img = _build_inset_image(bounds, size=target_h, inset_basemap=_ib, **_mcg_kw) if inset_img is not None: src_w, src_h = inset_img.size aspect = src_w / src_h if src_h > 0 else 1.0 iw = int(target_h * aspect) ih = target_h if iw > _fw // 3: iw = _fw // 3 ih = int(iw / aspect) inset_resized = inset_img.resize((iw, ih), _PILImage.LANCZOS) pad = max(4, _fw // 60) for f in pil_frames: px = f.size[0] - iw - pad py = f.size[1] - ih - pad f.paste(inset_resized, (px, py), inset_resized if inset_resized.mode == "RGBA" else None) obj_info = cl.get_obj_info(ee_obj, [band_name] if band_name else None) class_info = obj_info.get("class_info", {}) y_label = cl.AREA_FORMAT_DICT.get(area_format, {}).get("label", area_format) if obj_info["is_thematic"] else "Mean" # Build a name→color lookup so colors match even when classes are masked _color_lookup = {} if class_info: for bn in obj_info["band_names"]: ci = class_info.get(bn, {}) cn = ci.get("class_names", []) cp = ci.get("class_palette", []) for i, name in enumerate(cn): if i < len(cp): _color_lookup[name] = cl._ensure_hex_color(cp[i]) # Map colors to actual DataFrame columns (which may be a subset) columns = list(df.columns) colors = [_color_lookup.get(c) for c in columns] if _color_lookup else None # Full x range for consistent axis all_x = list(df.index) try: all_x_int = [int(v) for v in all_x] except (ValueError, TypeError): all_x_int = None plotly_mode, is_stacked = cl._parse_chart_type(chart_type) # --- Build legend panel from class info --- legend_info = _extract_legend_info(ee_obj, band_name=band_name, viz_params=viz_params) fw = pil_frames[0].size[0] fh = pil_frames[0].size[1] # Build full-width horizontal legend — only for classes in the data horiz_legend = None if legend_info is not None and legend_info.get("type") == "thematic": # Filter to classes that actually appear in the DataFrame data_cols = set(df.columns) leg_names = [] leg_palette = [] for name, pal in zip(legend_info["class_names"], legend_info["class_palette"]): if name in data_cols: leg_names.append(name) leg_palette.append(pal) if leg_names: horiz_legend = _build_horizontal_legend( leg_names, leg_palette, width=fw, bg_color=bg_color, font_color=font_color, ) elif legend_info is not None: # Continuous — use vertical colorbar centered vp = _build_legend_panel_from_info( legend_info, target_height=max(60, chart_height // 3), bg_color=bg_color, scale=1.0, font_color=font_color, ) if vp is not None: lw, lh = vp.size horiz_legend = Image.new("RGBA", (fw, lh), _resolve_color(bg_color) + (255,)) horiz_legend.paste(vp.convert("RGBA"), ((fw - lw) // 2, 0), vp.convert("RGBA")) # --- Compute fixed y-axis range across all frames --- columns = list(df.columns) if is_stacked: # For stacked: sum across columns per row row_sums = df[columns].sum(axis=1) y_min_data = 0 y_max_data = float(row_sums.max()) else: y_min_data = float(df[columns].min().min()) y_max_data = float(df[columns].max().max()) y_span = y_max_data - y_min_data y_buf = y_span * 0.1 y_range_min = 0 if y_min_data == 0 else y_min_data - y_buf y_range_max = 100 if 99.5 <= y_max_data <= 101 else y_max_data + y_buf # For single-frame data, use stacked bar instead of line if len(df) == 1 and plotly_mode != "bar": plotly_mode = "bar" is_stacked = True # --- Render per-frame chart as PNG --- chart_pngs = [] for i in range(len(pil_frames)): # Cumulative data: rows 0..i sub_df = df.iloc[:i + 1] sub_x = list(sub_df.index) try: sub_x_plot = [int(v) for v in sub_x] except (ValueError, TypeError): sub_x_plot = sub_x fig = go.Figure() for ci_idx, col_name in enumerate(columns): color = colors[ci_idx] if colors and ci_idx < len(colors) else None if plotly_mode == "bar": fig.add_trace(go.Bar( x=sub_x_plot, y=sub_df[col_name].values, name=col_name, marker_color=color, showlegend=False, )) else: fig.add_trace(go.Scatter( x=sub_x_plot, y=sub_df[col_name].values, mode=plotly_mode, name=col_name, line=dict(color=color, width=1.5), marker=dict(color=color, size=3), stackgroup="one" if is_stacked else None, showlegend=False, )) # Fix x-axis to full range, y-axis to fixed range x_kw = {"tickformat": "d"} if all_x_int else {} if all_x_int: x_kw["tickvals"] = all_x_int x_kw["range"] = [min(all_x_int) - 0.5, max(all_x_int) + 0.5] bar_mode = "stack" if is_stacked and plotly_mode == "bar" else ( "group" if plotly_mode == "bar" else None) fig.update_layout( xaxis=dict(title="Year", **x_kw), yaxis=dict(title=y_label, automargin=True, range=[y_range_min, y_range_max]), plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", font=dict(family="Roboto", color=cl._ensure_hex_color( "{:02x}{:02x}{:02x}".format(*font_color))), width=fw, height=chart_height, margin=dict(l=50, r=10, b=35, t=5, pad=2), barmode=bar_mode, ) from geeViz.outputLib import themes as _themes _themes.apply_plotly_theme(fig, "dark", bg_color=bg_color) chart_png = fig.to_image(format="png", width=fw, height=chart_height) chart_pngs.append(Image.open(io.BytesIO(chart_png)).convert("RGBA")) # --- Assemble frames: title + map + chart + legend --- bg_rgba = _resolve_color(bg_color) + (255,) assembled = [] for i, (map_frame, chart_img) in enumerate(zip(pil_frames, chart_pngs)): parts = [] # Title with year if title: label = f"{title} : {date_labels[i]}" if i < len(date_labels) else title else: label = date_labels[i] if i < len(date_labels) else "" if label: tfont = _get_font(title_font_size) tmp_d = ImageDraw.Draw(Image.new("RGBA", (1, 1))) tb = tmp_d.textbbox((0, 0), label, font=tfont) tw = tb[2] - tb[0] th = tb[3] - tb[1] tpad = max(4, title_font_size // 3) strip = Image.new("RGBA", (fw, tpad + th + tpad), bg_rgba) td = ImageDraw.Draw(strip) td.text(((fw - tw) // 2, tpad - tb[1]), label, font=tfont, fill=font_color) parts.append(strip) parts.append(map_frame) parts.append(chart_img) # Legend (full-width horizontal, below chart) if horiz_legend is not None: parts.append(horiz_legend) # Stack vertically total_h = sum(p.size[1] for p in parts) combined = Image.new("RGBA", (fw, total_h), bg_rgba) y = 0 for p in parts: combined.paste(p, (0, y), p if p.mode == "RGBA" else None) y += p.size[1] # Add margin combined = _add_margin(combined, max(4, margin // 2), bg_color=bg_color) assembled.append(combined) gif_bytes = _frames_to_gif(assembled, fps, bg_color=bg_color) if output_path: _write_bytes(output_path, gif_bytes) b64 = base64.b64encode(gif_bytes).decode("ascii") html = f'<figure class="map-chart-gif"><img src="data:image/gif;base64,{b64}"></figure>' return {"html": html, "gif_bytes": gif_bytes}
def _frames_to_gif(pil_frames, fps, bg_color="black"): """Convert PIL frames to an animated GIF with a consistent global palette. Builds a single 256-color palette from all frames combined, then quantizes each frame with that palette so colors don't shift between frames. Args: pil_frames: List of RGBA PIL Images. fps: Frames per second. bg_color: Background color for transparent areas. """ from PIL import Image # Flatten to RGB rgb_frames = [] for frame in pil_frames: bg = Image.new("RGBA", frame.size, bg_color) composite = Image.alpha_composite(bg, frame) rgb_frames.append(composite.convert("RGB")) # Build a global palette by stacking all frames into one tall image # and quantizing it to 256 colors fw, fh = rgb_frames[0].size combined = Image.new("RGB", (fw, fh * len(rgb_frames))) for i, f in enumerate(rgb_frames): combined.paste(f, (0, i * fh)) global_palette_img = combined.quantize(colors=256, method=Image.Quantize.MEDIANCUT) palette = global_palette_img.getpalette() # Quantize each frame using the global palette palette_frames = [] for f in rgb_frames: # quantize() with palette= requires a P-mode image as reference qf = f.quantize(palette=global_palette_img, dither=Image.Dither.FLOYDSTEINBERG) palette_frames.append(qf) buf = io.BytesIO() duration_ms = int(1000 / fps) palette_frames[0].save( buf, format="GIF", save_all=True, append_images=palette_frames[1:], duration=duration_ms, loop=0, ) return buf.getvalue() # --------------------------------------------------------------------------- # Per-feature thumbnails # ---------------------------------------------------------------------------
[docs] def get_thumb_urls_by_feature(ee_obj, features, viz_params=None, dimensions=_DEFAULT_DIMENSIONS, feature_label=None, band_name=None, max_features=10): """Get thumbnail URLs for an image clipped to each feature in a collection. Iterates over features sequentially, clipping the image to each feature's geometry and generating a separate thumbnail URL. For faster processing with many features, use :func:`get_thumb_urls_by_feature_parallel` instead. Args: ee_obj (ee.Image or ee.ImageCollection): Image to thumbnail. Collections are reduced to a single representative image. features (ee.FeatureCollection): Collection of features; each feature's geometry is used to clip a separate thumbnail. viz_params (dict, optional): Visualization parameters. Auto-detected via :func:`auto_viz` when ``None``. Defaults to ``None``. dimensions (int, optional): Width in pixels per thumbnail. Defaults to ``640``. feature_label (str, optional): Property name to use as a human-readable label for each feature. Auto-detected when ``None``. Defaults to ``None``. band_name (str, optional): Band to visualize when using auto-detection. Defaults to ``None``. max_features (int, optional): Maximum number of features to process. Defaults to ``10``. Returns: list[dict]: List of dictionaries, one per feature, each containing: - ``"label"`` (str): Feature label from ``feature_label`` property. - ``"url"`` (str): PNG thumbnail URL. - ``"geometry"`` (ee.Geometry): The feature's geometry. Example: >>> results = get_thumb_urls_by_feature( ... image, counties.limit(3), feature_label="NAME", ... ) >>> results[0].keys() dict_keys(['label', 'url', 'geometry']) """ img = _to_image(ee_obj) if viz_params is None: viz_params = auto_viz(img, band_name=band_name) if feature_label is None: feature_label = cl._detect_feature_label(features) # Get feature list feat_list = features.toList(max_features).getInfo() results = [] for feat_dict in feat_list: feat = ee.Feature(feat_dict) geom = feat.geometry() label = feat_dict.get("properties", {}).get(feature_label, "unknown") params = {**viz_params, "dimensions": dimensions, "format": "png", "region": geom} url = img.clip(geom).getThumbURL(params) results.append({"label": label, "url": url, "geometry": geom}) return results
[docs] def get_thumb_urls_by_feature_parallel(ee_obj, features, viz_params=None, dimensions=_DEFAULT_DIMENSIONS, feature_label=None, band_name=None, max_features=10, max_workers=6, burn_in_params=None, clip_to_geometry=True): """Generate per-feature thumbnail URLs in parallel using a thread pool. Like :func:`get_thumb_urls_by_feature`, but uses ``concurrent.futures.ThreadPoolExecutor`` to issue multiple ``getThumbURL()`` requests concurrently, significantly reducing wall-clock time for collections with many features. Args: ee_obj (ee.Image or ee.ImageCollection): Image to thumbnail. Collections are reduced to a single representative image. features (ee.FeatureCollection): Collection of features; each feature's geometry is used to clip a separate thumbnail. viz_params (dict, optional): Visualization parameters. Auto-detected via :func:`auto_viz` when ``None``. Defaults to ``None``. dimensions (int, optional): Width in pixels per thumbnail. Defaults to ``640``. feature_label (str, optional): Property name to use as a human-readable label for each feature. Auto-detected when ``None``. Defaults to ``None``. band_name (str, optional): Band to visualize when using auto-detection. Defaults to ``None``. max_features (int, optional): Maximum number of features to process. Defaults to ``10``. max_workers (int, optional): Maximum threads in the pool. Defaults to ``6``. Returns: list[dict]: List of dictionaries, one per feature, each containing: - ``"label"`` (str): Feature label from ``feature_label`` property. - ``"url"`` (str): PNG thumbnail URL. Example: >>> counties = ee.FeatureCollection("TIGER/2018/Counties") >>> results = get_thumb_urls_by_feature_parallel( ... image, counties.limit(5), ... feature_label="NAME", ... ) >>> results[0]["label"] 'Some County' """ img = _to_image(ee_obj) if viz_params is None: viz_params = auto_viz(img, band_name=band_name) if feature_label is None: feature_label = cl._detect_feature_label(features) feat_list = features.toList(max_features).getInfo() def _get_one(feat_dict): feat = ee.Feature(feat_dict) geom = feat.geometry() label = feat_dict.get("properties", {}).get(feature_label, "unknown") _img = img _vp = viz_params # Per-feature geometry burn-in: paint only this feature's boundary if burn_in_params is not None: _img = _paint_boundary( _img, geom, burn_in_params["color"], viz_params=burn_in_params["viz_params"], fill_color=burn_in_params.get("fill_color"), width=burn_in_params.get("weight", 2), crs=burn_in_params.get("crs"), ) _vp = {"min": 0, "max": 255} _out_img = _img.clip(geom) if clip_to_geometry else _img # Request at padded dimensions with expanded region so EE data # fills to the basemap edges _padded_dims = dimensions + 2 * _THUMB_PADDING _bounds = _get_bounds_4326(geom) if _bounds is not None: _expanded = _expand_bounds_for_padding(_bounds, dimensions) _ee_region = ee.Geometry.Rectangle(_expanded, proj=ee.Projection("EPSG:4326"), evenOdd=False) else: _ee_region = geom if clip_to_geometry else geom.bounds() params = {**_vp, "dimensions": _padded_dims, "format": "png", "region": _ee_region} url = _out_img.getThumbURL(params) return {"label": label, "url": url, "geometry": geom} with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: results = list(pool.map(_get_one, feat_list)) return results
# --------------------------------------------------------------------------- # Thumbnail download & embedding # ---------------------------------------------------------------------------
[docs] def download_thumb(url, timeout=120): """Download raw image bytes from an Earth Engine thumbnail URL. Fetches the PNG or GIF data from a URL returned by ``ee.Image.getThumbURL()`` or ``ee.ImageCollection.getVideoThumbURL()``. Args: url (str): Thumbnail URL from ``getThumbURL()`` or ``getVideoThumbURL()``. timeout (int, optional): HTTP request timeout in seconds. Defaults to ``120``. Returns: bytes: Raw image data (PNG or GIF format). Example: >>> data = download_thumb("https://earthengine.googleapis.com/...") >>> len(data) > 0 True """ req = urllib.request.Request(url, headers={"User-Agent": "geeViz-thumbLib/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read()
[docs] def thumb_to_base64(url, timeout=120): """Download a thumbnail and return it as a base64 data URI string. Fetches image bytes from the given URL, detects the format (PNG or GIF) from the magic bytes, and encodes the result as a ``data:image/...;base64,...`` URI suitable for embedding in HTML. Args: url (str): Thumbnail URL from ``getThumbURL()`` or ``getVideoThumbURL()``. timeout (int, optional): HTTP request timeout in seconds. Defaults to ``120``. Returns: str: Base64 data URI string (e.g. ``"data:image/png;base64,iVBOR..."``). Example: >>> data_uri = thumb_to_base64("https://earthengine.googleapis.com/...") >>> data_uri.startswith("data:image/") True """ data = download_thumb(url, timeout=timeout) fmt = "gif" if data[:3] == b"GIF" else "png" b64 = base64.b64encode(data).decode("ascii") return f"data:image/{fmt};base64,{b64}"
[docs] def embed_thumb(url, title="", width=None, download=False): """Generate an embeddable HTML ``<figure>`` element for a thumbnail. Wraps a thumbnail URL (or base64 data URI) in an HTML ``<figure>`` with an ``<img>`` tag and optional ``<figcaption>``. When ``download`` is True the image bytes are fetched and embedded inline as a base64 data URI so the resulting HTML is fully self-contained. Args: url (str): Thumbnail URL from ``getThumbURL()`` or a ``data:image/...`` base64 data URI. title (str, optional): Alt text and caption for the image. Defaults to ``""``. width (int, optional): CSS width in pixels applied via an inline style. Defaults to ``None`` (natural size). download (bool, optional): Download the image from ``url`` and embed it as a base64 data URI for self-contained HTML. Defaults to ``False`` (reference the URL directly). Returns: str: HTML string containing a ``<figure>`` with ``<img>`` and optional ``<figcaption>`` elements. Example: >>> html = embed_thumb( ... "https://earthengine.googleapis.com/...", ... title="Study Area", width=400, ... ) >>> "<img" in html True """ if download: src = thumb_to_base64(url) else: src = url style = f' style="width:{width}px;"' if width else "" caption = f"<figcaption>{title}</figcaption>" if title else "" return ( f'<figure class="thumb">' f'<img src="{src}" alt="{title}"{style}>' f'{caption}' f'</figure>' )
[docs] def embed_thumb_grid(thumb_results, columns=3, thumb_width=300, download=False): """Generate an HTML CSS-grid layout of multiple thumbnails. Takes a list of per-feature thumbnail results (from :func:`get_thumb_urls_by_feature` or :func:`get_thumb_urls_by_feature_parallel`) and assembles them into a responsive CSS grid ``<div>`` with labeled ``<figure>`` elements. Args: thumb_results (list[dict]): List of thumbnail result dictionaries, each containing ``"label"`` (str) and ``"url"`` (str) keys. columns (int, optional): Number of grid columns. Defaults to ``3``. thumb_width (int, optional): Display width in pixels for each thumbnail image. Defaults to ``300``. download (bool, optional): Download each image and embed as base64 for self-contained HTML. Defaults to ``False``. Returns: str: HTML string containing a ``<div>`` with CSS grid styling and one ``<figure>`` per thumbnail. Example: >>> results = get_thumb_urls_by_feature_parallel(image, counties) >>> grid_html = embed_thumb_grid(results, columns=4, thumb_width=250) >>> "thumb-grid" in grid_html True """ items = [] for r in thumb_results: items.append(embed_thumb(r["url"], title=r["label"], width=thumb_width, download=download)) grid_css = ( f"display:grid; grid-template-columns:repeat({columns}, 1fr); " f"gap:12px; margin:16px 0;" ) inner = "\n".join(items) return f'<div class="thumb-grid" style="{grid_css}">\n{inner}\n</div>'
# --------------------------------------------------------------------------- # Convenience: all-in-one thumbnail section for reports # ---------------------------------------------------------------------------
[docs] def generate_thumbs(ee_obj, geometry, viz_params=None, band_name=None, dimensions=_DEFAULT_DIMENSIONS, feature_label=None, max_features=6, columns=3, thumb_width=300, burn_in_legend=True, legend_scale=1.0, bg_color=None, font_color=None, font_outline_color=None, output_path=None, crs=_DEFAULT_CRS, transform=None, scale=None, margin=_DEFAULT_MARGIN, basemap=None, overlay_opacity=None, scalebar=True, scalebar_units="metric", north_arrow=True, north_arrow_style="solid", inset_map=True, inset_basemap=None, inset_scale=0.3, inset_on_map=False, title=None, burn_in_geometry=False, geometry_outline_color=None, geometry_fill_color=None, geometry_outline_weight=2, clip_to_geometry=True, geometry_legend_label="Study Area", title_font_size=_DEFAULT_TITLE_FONT_SIZE, label_font_size=_DEFAULT_LABEL_FONT_SIZE): """Generate a publication-ready thumbnail PNG for a report section. Provides an all-in-one workflow: auto-viz detection, thumbnail URL generation, image download, basemap compositing, and optional cartographic embellishments (legend, scalebar, north arrow, inset map, title). For ``ee.FeatureCollection`` geometries with multiple features, produces a labeled grid of per-feature thumbnails. For single geometries, produces a single thumbnail with optional cartographic elements. For ``ee.ImageCollection`` input, the collection is reduced to a single representative image using the temporal mode (thematic data) or median (continuous data). Args: ee_obj (ee.Image or ee.ImageCollection): Image to thumbnail. Collections are reduced to a single representative image. geometry (ee.Geometry or ee.Feature or ee.FeatureCollection): Region to clip and bound the thumbnail. When a ``FeatureCollection`` with multiple features is provided, a per-feature grid is generated instead. viz_params (dict, optional): Visualization parameters (``bands``, ``min``, ``max``, ``palette``). Auto-detected via :func:`auto_viz` when ``None``. Defaults to ``None``. band_name (str, optional): Band to visualize when using auto-detection. Defaults to ``None`` (first band). dimensions (int, optional): Thumbnail width in pixels. Defaults to ``640``. feature_label (str, optional): Property name for per-feature labels in grid mode. Auto-detected when ``None``. Defaults to ``None``. max_features (int, optional): Maximum features to include in the grid. Defaults to ``6``. columns (int, optional): Number of columns in the per-feature grid. Defaults to ``3``. thumb_width (int, optional): Width in pixels for each cell in the per-feature grid. Defaults to ``300``. burn_in_legend (bool, optional): Append a legend panel for thematic data. Only rendered when class names and palette are available in image properties. Defaults to ``True``. legend_scale (float, optional): Scale multiplier for the legend panel size. Defaults to ``1.0``. bg_color (str or None, optional): Background color for margins, legend panel, and transparent areas. Resolved via theme when ``None``. Defaults to ``None``. font_color (str or tuple or None, optional): Text color for labels and legend text. Resolved via theme when ``None``. Defaults to ``None``. font_outline_color (str or tuple or None, optional): Outline / halo color for text readability. Auto-derived when ``None``. Defaults to ``None``. output_path (str, optional): File path to save the PNG. Parent directories are created automatically. Defaults to ``None`` (not saved). crs (str, optional): CRS code (e.g. ``"EPSG:4326"``). Applies ``setDefaultProjection`` to the image. Defaults to ``None``. transform (list, optional): Affine transform as a 6-element list. Requires ``crs``. Defaults to ``None``. scale (float, optional): Nominal pixel scale in meters. Requires ``crs``. Defaults to ``None``. margin (int, optional): Pixel margin on all sides of the final image. Defaults to ``16``. basemap (str or dict or None, optional): Basemap to composite behind the EE data. A preset name (e.g. ``"esri-satellite"``, ``"usfs-topo"``), a config dict with ``type`` and ``url`` keys, or a raw tile URL template. Defaults to ``None`` (no basemap). overlay_opacity (float or None, optional): Opacity of the EE overlay when a basemap is present (0.0 -- 1.0). Defaults to ``None`` (auto: ``0.8`` with basemap, ``1.0`` without). scalebar (bool, optional): Draw a scalebar on the thumbnail. Only rendered when cartographic context is available. Defaults to ``True``. scalebar_units (str, optional): Unit system for the scalebar -- ``"metric"`` or ``"imperial"``. Defaults to ``"metric"``. north_arrow (bool, optional): Draw a north arrow on the thumbnail. Defaults to ``True``. north_arrow_style (str, optional): Arrow style -- ``"solid"``, ``"classic"``, or ``"outline"``. Defaults to ``"solid"``. inset_map (bool, optional): Include an inset overview map. Defaults to ``True``. inset_basemap (str or dict or None, optional): Basemap for the inset. Falls back to ``basemap`` when ``None``. Defaults to ``None``. inset_scale (float, optional): Relative height of the inset compared to the frame height. Defaults to ``0.3``. inset_on_map (bool, optional): Place the inset directly on the map rather than below it. Defaults to ``True``. title (str, optional): Title text rendered as a strip above the thumbnail. Defaults to ``None`` (no title). burn_in_geometry (bool, optional): Paint the geometry boundary outline onto the image using ``FeatureCollection.style()``. Defaults to ``False``. geometry_outline_color (tuple or None, optional): ``(R, G, B)`` colour for the boundary outline. When ``None``, auto-detected from the basemap luminance. Defaults to ``None``. geometry_fill_color (str or None, optional): CSS fill colour for the geometry interior (e.g. ``"33333366"``). Used for geometry-only thumbnails (``ee_obj=None``). Defaults to ``None``. geometry_outline_weight (int, optional): Width of the boundary outline in pixels. Defaults to ``2``. geometry_legend_label (str, optional): Label for the geometry swatch in the legend. Defaults to ``"Study Area"``. clip_to_geometry (bool, optional): When ``True``, clip the image to the geometry. When ``False``, use the geometry's bounding box as the region (data extends beyond boundary). Defaults to ``True``. title_font_size (int, optional): Font size in pixels for the title strip. Defaults to ``18``. label_font_size (int, optional): Font size in pixels for date labels, feature labels, scalebar ticks, and legend text. Defaults to ``12``. Returns: dict: A dictionary with the following keys: - ``"html"`` (str): HTML ``<figure>`` element containing the thumbnail as a base64-embedded ``<img>`` tag. - ``"thumb_bytes"`` (bytes): Raw PNG byte data. - ``"is_grid"`` (bool): ``True`` if a multi-feature grid was produced, ``False`` for a single thumbnail. Raises: ValueError: If ``transform`` or ``scale`` is provided without ``crs``. Example: >>> result = generate_thumbs( ... lcms.select(["Land_Cover"]).first(), ... study_area, ... basemap="esri-satellite", ... title="LCMS Land Cover 2023", ... ) >>> result["is_grid"] False >>> len(result["thumb_bytes"]) > 0 True """ _validate_projection_params(crs, transform, scale) from PIL import Image _is_geom_only = ee_obj is None if _is_geom_only: img = ee.Image() if viz_params is None: viz_params = {} burn_in_geometry = True else: img = _to_image(ee_obj) img = _apply_projection(img, crs, transform, scale) if not _is_geom_only: viz_params = _complete_viz_params(viz_params, img, band_name=band_name, geometry=geometry) # Resolve overlay opacity: default 0.8 when basemap is set if overlay_opacity is None: overlay_opacity = 0.8 if basemap is not None else 1.0 # Resolve font colors from theme font_color, font_outline_color, theme, bg_color = _resolve_font_colors( bg_color, font_color, font_outline_color) # Extract legend info BEFORE boundary painting (which changes viz_params) legend_info = None if burn_in_legend: legend_info = _extract_legend_info(ee_obj if not _is_geom_only else None, band_name=band_name, viz_params=viz_params) # Resolve geometry color (needed for both boundary and legend) _resolved_gc = None if burn_in_geometry and geometry is not None: _bounds_gc = _get_bounds_4326(_to_geometry(geometry)) _resolved_gc = _resolve_geometry_color(geometry_outline_color, font_color, basemap, _bounds_gc) # Add geometry outline to legend when burning in geometry if burn_in_geometry and burn_in_legend and geometry_legend_label and _resolved_gc is not None: _gc_hex = "{:02x}{:02x}{:02x}".format(int(_resolved_gc[0]), int(_resolved_gc[1]), int(_resolved_gc[2])) # Resolve fill color for legend swatch _fill_for_legend = geometry_fill_color if _fill_for_legend is None and _is_geom_only: _fill_for_legend = "33333366" # Store geometry swatch info for all legend types _geom_swatch = { "label": geometry_legend_label, "outline_hex": _gc_hex, "fill_hex": _fill_for_legend, # may be None or hex string } if legend_info is not None: legend_info["geometry_swatch"] = _geom_swatch else: legend_info = {"type": "thematic", "class_names": [], "class_palette": [], "geometry_swatch": _geom_swatch} is_multi = _is_multi_feature(geometry) # Burn in geometry boundary (pre-visualizes the image) # For multi-feature grids, defer to per-feature painting so each frame # only shows its own boundary (not all features at once). if burn_in_geometry and geometry is not None and _resolved_gc is not None and not is_multi: _fill = "33333366" if _is_geom_only else None # 0.4 opacity img = _paint_boundary(img, geometry, _resolved_gc, viz_params=viz_params if not _is_geom_only else None, fill_color=_fill or geometry_fill_color, width=geometry_outline_weight, crs=crs) # Geometry-only: styled boundary is already visualized, no viz needed viz_params = {} if _is_geom_only else {"min": 0, "max": 255} if is_multi: fc = ee.FeatureCollection(geometry) # For multi-feature, pass burn-in params so each feature paints only its own boundary _burn_params = None if burn_in_geometry and _resolved_gc is not None: _burn_params = { "color": _resolved_gc, "fill_color": geometry_fill_color, "weight": geometry_outline_weight, "crs": crs, "viz_params": viz_params, } results = get_thumb_urls_by_feature_parallel( img, fc, viz_params=viz_params, dimensions=dimensions, feature_label=feature_label, max_features=max_features, burn_in_params=_burn_params, clip_to_geometry=clip_to_geometry, ) # Download all frames and build a PIL grid pil_thumb = _build_thumb_grid_image( results, columns=columns, thumb_width=thumb_width, legend_info=legend_info, bg_color=bg_color, legend_scale=legend_scale, basemap=basemap, overlay_opacity=overlay_opacity, scalebar=scalebar, north_arrow=north_arrow, north_arrow_style=north_arrow_style, inset_map=inset_map, inset_basemap=inset_basemap if inset_basemap else basemap, inset_scale=inset_scale, font_color=font_color, title=title, title_font_size=title_font_size, label_font_size=label_font_size, inset_rect_color=_resolved_gc if _resolved_gc is not None else None, inset_rect_fill_color=_hex_fill_to_rgba(geometry_fill_color) if geometry_fill_color else None, ) pil_thumb = _add_margin(pil_thumb, margin, bg_color=bg_color) thumb_bytes = _pil_to_png_bytes(pil_thumb, bg_color) if output_path: _write_bytes(output_path, thumb_bytes) html = _bytes_to_html_figure(thumb_bytes, "png", css_class="thumb-grid") return {"html": html, "thumb_bytes": thumb_bytes, "is_grid": True} else: geom = _to_geometry(geometry) bounds = _get_bounds_4326(geom) padded_dims = dimensions + 2 * _THUMB_PADDING # Expand region so EE data fills to basemap edges (including padding margin) if basemap is not None and bounds is not None: expanded = _expand_bounds_for_padding(bounds, dimensions) ee_region = ee.Geometry.Rectangle(expanded, proj=ee.Projection("EPSG:4326"), evenOdd=False) _out_img = img.clip(geom) if clip_to_geometry else img url = _out_img.getThumbURL({ **viz_params, "dimensions": padded_dims, "format": "png", "region": ee_region, }) else: region = geom if clip_to_geometry else geom.bounds() _out_img = img.clip(geom) if clip_to_geometry else img url = _out_img.getThumbURL({ **viz_params, "dimensions": padded_dims, "format": "png", "region": region, }) data = download_thumb(url) frame = Image.open(io.BytesIO(data)).convert("RGBA") # Composite basemap underneath or add blank padding if basemap is not None and bounds is not None: # Basemap at same expanded extent and size basemap_img = _fetch_basemap(bounds, padded_dims, padded_dims, basemap, crs=crs) if basemap_img is not None: frame = _composite_with_basemap(frame, basemap_img, overlay_opacity) # Use expanded bounds for inset (reflects actual visible extent) if basemap is not None and bounds is not None: bounds = _expand_bounds_for_padding(bounds, dimensions) # Build legend panel (used by _assemble_with_cartography) legend_panel = _build_legend_panel_from_info( legend_info, target_height=frame.size[1], bg_color=bg_color, scale=legend_scale, font_color=font_color, ) # Assemble with cartographic elements # Scalebar and north arrow work whenever bounds are available (no basemap needed). # Inset requires a basemap tile source. _has_inset_source = (basemap is not None or inset_basemap is not None) frame, has_bottom = _assemble_with_cartography( frame, bounds, bg_color=bg_color, font_color=font_color, font_outline_color=font_outline_color, title=title, scalebar=scalebar if bounds is not None else False, scalebar_units=scalebar_units, north_arrow=north_arrow if bounds is not None else False, north_arrow_style=north_arrow_style, inset_map=inset_map if (_has_inset_source and bounds is not None) else False, inset_basemap=inset_basemap if inset_basemap else basemap, inset_scale=inset_scale, inset_on_map=inset_on_map, inset_rect_color=_resolved_gc if _resolved_gc is not None else None, inset_rect_fill_color=_hex_fill_to_rgba(geometry_fill_color) if geometry_fill_color else None, legend_panel=legend_panel, margin=margin, crs=crs, ) # Title strip includes the top margin, so set top to 0 mt = 0 if title else margin mb = margin // 3 if has_bottom else margin frame = _add_margin(frame, (mt, margin, mb, margin), bg_color=bg_color) thumb_bytes = _pil_to_png_bytes(frame, bg_color) if output_path: _write_bytes(output_path, thumb_bytes) html = _bytes_to_html_figure(thumb_bytes, "png", css_class="thumb") return {"html": html, "thumb_bytes": thumb_bytes, "is_grid": False}
# --------------------------------------------------------------------------- # Internal helpers — image byte conversion # --------------------------------------------------------------------------- def _add_margin(pil_img, margin, bg_color="black"): """Add a colored margin (border) around a PIL image. Creates a new image that is larger than the source by the specified margin amounts on each side, pastes the source image onto the center, and fills the margin area with the background color. Args: pil_img (PIL.Image.Image): Source image in any mode (RGB, RGBA, etc.). margin (int or tuple): Margin size in pixels. An ``int`` applies uniformly to all sides. A 4-element tuple specifies ``(top, right, bottom, left)`` individually. bg_color (str, optional): Background color for the margin area, as a CSS color name or hex string. Defaults to ``"black"``. Returns: PIL.Image.Image: New image with the margin added, in the same mode as the input. Example: >>> from PIL import Image >>> img = Image.new("RGBA", (100, 100), (255, 0, 0, 255)) >>> result = _add_margin(img, 10, bg_color="white") >>> result.size (120, 120) """ if isinstance(margin, (list, tuple)): mt, mr, mb, ml = margin else: mt = mr = mb = ml = margin if mt <= 0 and mr <= 0 and mb <= 0 and ml <= 0: return pil_img from PIL import Image bg_rgb = _resolve_color(bg_color) fill = bg_rgb + (255,) if pil_img.mode == "RGBA" else bg_rgb new_w = pil_img.size[0] + ml + mr new_h = pil_img.size[1] + mt + mb canvas = Image.new(pil_img.mode, (new_w, new_h), fill) canvas.paste(pil_img, (ml, mt)) return canvas def _rgba_to_rgb(pil_img, bg_color="black"): """Flatten an RGBA image to RGB with the given background color.""" from PIL import Image bg = Image.new("RGBA", pil_img.size, bg_color) composite = Image.alpha_composite(bg, pil_img.convert("RGBA")) return composite.convert("RGB") def _pil_to_png_bytes(pil_img, bg_color="black"): """Convert a PIL Image (RGBA or RGB) to PNG bytes.""" rgb = _rgba_to_rgb(pil_img, bg_color) if pil_img.mode == "RGBA" else pil_img buf = io.BytesIO() rgb.save(buf, format="PNG") return buf.getvalue() def _write_bytes(path, data): """Write raw bytes to a file, creating parent directories as needed.""" import os os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "wb") as f: f.write(data) def _bytes_to_html_figure(data, fmt="png", css_class="thumb"): """Wrap raw image bytes in an HTML ``<figure>`` with base64 src.""" b64 = base64.b64encode(data).decode("ascii") return ( f'<figure class="{css_class}">' f'<img src="data:image/{fmt};base64,{b64}">' f'</figure>' ) def _build_thumb_grid_image(thumb_results, columns=3, thumb_width=300, legend_info=None, bg_color="black", legend_scale=1.0, basemap=None, overlay_opacity=1.0, gap=3, scalebar=True, north_arrow=True, north_arrow_style="solid", inset_map=True, inset_basemap=None, inset_scale=0.3, inset_on_map=False, font_color=None, title=None, title_font_size=_DEFAULT_TITLE_FONT_SIZE, label_font_size=_DEFAULT_LABEL_FONT_SIZE, inset_rect_color=None, inset_rect_fill_color=None): """Download per-feature thumbnails and assemble into a PIL grid image. Args: thumb_results (list[dict]): Each dict has ``label``, ``url``, and optionally ``geometry`` (ee.Geometry for per-feature basemap). columns (int): Grid columns. thumb_width (int): Target width per cell. legend_info (dict | None): Legend info (thematic or continuous). bg_color (str): Background color. legend_scale (float): Legend scale. basemap: Basemap preset name, config dict, or URL. overlay_opacity (float): Overlay opacity for basemap compositing. gap (int): Pixel gap between frames. Default 3. scalebar (bool): Draw scalebar on the first frame. north_arrow (bool): Draw north arrow on the first frame. north_arrow_style (str): North arrow style. inset_map (bool): Draw inset overview on the last frame. inset_basemap: Basemap for the inset. Falls back to *basemap*. inset_scale (float): Inset height as fraction of frame height. font_color (tuple or None): Text color override. Returns: PIL.Image.Image: RGBA grid image. """ from PIL import Image, ImageDraw theme = _get_theme(bg_color) text_color = font_color if font_color is not None else theme.text panel_bg = _resolve_color(bg_color) + (255,) font_outline_color = theme.divider # Download all thumbnails in parallel # EE thumbnails are already requested at padded dimensions with expanded region padded_width = thumb_width + 2 * _THUMB_PADDING def _dl(r): data = download_thumb(r["url"]) frame = Image.open(io.BytesIO(data)).convert("RGBA") # Resize to padded target width (EE thumb was requested at padded dims) if frame.size[0] != padded_width: ratio = padded_width / frame.size[0] new_h = int(frame.size[1] * ratio) frame = frame.resize((padded_width, new_h), Image.LANCZOS) bounds = None if "geometry" in r: bounds = _get_bounds_4326(r["geometry"]) if basemap is not None and bounds is not None: fw_p, fh_p = frame.size bm = _fetch_basemap(bounds, fw_p, fh_p, basemap) if bm is not None: frame = _composite_with_basemap(frame, bm, overlay_opacity) return frame, r.get("label", ""), bounds with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: results = list(pool.map(_dl, thumb_results)) if not results: return Image.new("RGBA", (thumb_width, 100), panel_bg) # Find max width and height across all frames so all cells are uniform fw = max(frame.size[0] for frame, _, _ in results) fh = max(frame.size[1] for frame, _, _ in results) n_total = len(results) # Pad each frame to the uniform cell size (centered) _uniform = [] for frame, label, bounds in results: if frame.size[0] != fw or frame.size[1] != fh: padded = Image.new("RGBA", (fw, fh), panel_bg) ox = (fw - frame.size[0]) // 2 oy = (fh - frame.size[1]) // 2 padded.paste(frame, (ox, oy), frame if frame.mode == "RGBA" else None) _uniform.append((padded, label, bounds)) else: _uniform.append((frame, label, bounds)) results = _uniform n_cols = min(columns, n_total) n_rows = -(-n_total // n_cols) # Label font label_font = _get_font(label_font_size) label_h = label_font_size + 8 cell_h = fh + label_h # -- Draw scalebar + north arrow on first frame (index 0) -- first_frame, first_label, first_bounds = results[0] if first_bounds is not None and (scalebar or north_arrow): _draw_scalebar_and_arrow_on_frame( first_frame, first_bounds, scalebar=scalebar, north_arrow=north_arrow, north_arrow_style=north_arrow_style, font_color=text_color, contrast=font_outline_color, accent=theme.accent, label_font_size=label_font_size, ) # -- Draw inset on the last frame (only when inset_on_map) -- last_idx = n_total - 1 last_frame, last_label, last_bounds = results[last_idx] if inset_map and inset_on_map and last_bounds is not None: _ib = inset_basemap if inset_basemap else basemap if True: # build_inset_image uses default hillshade when _ib is None target_h = int(fh * inset_scale) _grid_kw = {} if inset_rect_color is not None: _grid_kw["rect_color"] = inset_rect_color if inset_rect_fill_color is not None: _grid_kw["rect_fill_color"] = inset_rect_fill_color inset_img = _build_inset_image( last_bounds, size=target_h, inset_basemap=_ib, **_grid_kw, ) if inset_img is not None: src_w, src_h = inset_img.size aspect = src_w / src_h if src_h > 0 else 1.0 iw = int(target_h * aspect) ih = target_h if iw > fw // 3: iw = fw // 3 ih = int(iw / aspect) inset_resized = inset_img.resize((iw, ih), Image.LANCZOS) pad = max(4, fw // 60) px = last_frame.size[0] - iw - pad py = last_frame.size[1] - ih - pad last_frame.paste( inset_resized, (px, py), inset_resized if inset_resized.mode == "RGBA" else None, ) # -- Build legend panel (right of first row, aligned with frame) -- legend_panel = None if legend_info is not None: legend_panel = _build_legend_panel_from_info( legend_info, target_height=fh, # frame height, not cell_h bg_color=bg_color, scale=legend_scale, font_color=text_color, ) legend_col_w = legend_panel.size[0] if legend_panel is not None else 0 grid_w = n_cols * fw + (n_cols - 1) * gap total_w = grid_w + (legend_col_w if legend_panel else 0) grid = Image.new("RGBA", (total_w, n_rows * cell_h + (n_rows - 1) * gap), panel_bg) draw = ImageDraw.Draw(grid) for idx, (frame, label, _bounds) in enumerate(results): col_i = idx % n_cols row_i = idx // n_cols x = col_i * (fw + gap) y = row_i * (cell_h + gap) if label: bbox = draw.textbbox((0, 0), str(label), font=label_font) tw = bbox[2] - bbox[0] draw.text((x + (fw - tw) // 2, y + 3), str(label), font=label_font, fill=text_color) grid.paste(frame, (x, y + label_h)) # Legend to right of first row, aligned with frame (below feature label) legend_bottom_y = label_h if legend_panel is not None: lp_rgba = legend_panel.convert("RGBA") grid.paste(lp_rgba, (grid_w, label_h), lp_rgba) legend_bottom_y = label_h + legend_panel.size[1] # Inset in right column (when inset_on_map=False) # Use the union of all feature bounds for the inset overview if inset_map and not inset_on_map and first_bounds is not None: _ib = inset_basemap if inset_basemap else basemap # build_inset_image uses a default hillshade basemap when _ib is None # Compute overall bounds from all features for a better overview all_bounds = [b for _, _, b in results if b is not None] if all_bounds: overview_bounds = ( min(b[0] for b in all_bounds), min(b[1] for b in all_bounds), max(b[2] for b in all_bounds), max(b[3] for b in all_bounds), ) else: overview_bounds = first_bounds max_w = max(legend_col_w - 8, 60) # Determine inset placement and available space if n_rows > 1: # Multi-row: place in legend column aligned with row 2 inset_y = cell_h + gap + label_h avail_h = cell_h - label_h - 4 else: # Single-row: place below legend in the legend column avail_h = grid.size[1] - legend_bottom_y - 4 # If not enough space in the legend column, expand the grid # to make room for the inset below target_inset_h = min(max_w, fh // 2) # desired inset size if avail_h < target_inset_h and n_rows <= 1: expand = target_inset_h - avail_h + 8 new_grid = Image.new("RGBA", (grid.size[0], grid.size[1] + expand), panel_bg) new_grid.paste(grid, (0, 0), grid if grid.mode == "RGBA" else None) grid = new_grid avail_h = target_inset_h inset_y = legend_bottom_y elif n_rows <= 1: inset_y = legend_bottom_y if avail_h < 40: avail_h = fh # Fetch inset at final display size _inset_display = min(avail_h, int(max_w)) _grid_kw2 = {} if inset_rect_color is not None: _grid_kw2["rect_color"] = inset_rect_color if inset_rect_fill_color is not None: _grid_kw2["rect_fill_color"] = inset_rect_fill_color inset_img = _build_inset_image(overview_bounds, size=max(60, _inset_display), inset_basemap=_ib, **_grid_kw2) if inset_img is not None: src_w, src_h = inset_img.size aspect = src_w / src_h if src_h > 0 else 1.0 iw = min(max_w, int(avail_h * aspect)) ih = int(iw / aspect) if ih > avail_h: ih = max(avail_h, 30) iw = int(ih * aspect) if iw > 10 and ih > 10: inset_resized = inset_img.resize((iw, ih), Image.LANCZOS) inset_pad = max(4, 4) grid.paste(inset_resized.convert("RGBA"), (grid_w + inset_pad, inset_y), inset_resized.convert("RGBA")) # Title strip — font size = 1.5x the label font if title: t_font = _get_font(title_font_size) tmp_d = ImageDraw.Draw(Image.new("RGBA", (1, 1))) tb = tmp_d.textbbox((0, 0), title, font=t_font) t_tw = tb[2] - tb[0] t_th = tb[3] - tb[1] t_pad = max(6, label_font_size // 2) strip_h = t_pad + t_th + t_pad combined_h = strip_h + grid.size[1] assembled = Image.new("RGBA", (total_w, combined_h), panel_bg) td = ImageDraw.Draw(assembled) tx = (total_w - t_tw) // 2 td.text((tx, t_pad - tb[1]), title, font=t_font, fill=text_color) assembled.paste(grid, (0, strip_h), grid if grid.mode == "RGBA" else None) grid = assembled return grid # --------------------------------------------------------------------------- # Internal helpers — EE object conversion # --------------------------------------------------------------------------- def _to_image(ee_obj): """Convert ee_obj to ee.Image (mosaic if ImageCollection).""" if isinstance(ee_obj, ee.ImageCollection): # Check if thematic — use mode; else median info = cl.get_obj_info(ee_obj) if info["is_thematic"]: return ee.Image(ee_obj.mode().copyProperties(ee_obj.first())) else: return ee.Image(ee_obj.median().copyProperties(ee_obj.first())) return ee.Image(ee_obj) def _to_geometry(geometry): """Extract ee.Geometry from various geometry-like inputs.""" if isinstance(geometry, ee.FeatureCollection): return geometry.geometry() if isinstance(geometry, (ee.Feature, ee.element.Element)): return ee.Feature(geometry).geometry() if isinstance(geometry, ee.Geometry): return geometry # Fallback: wrap in ee.Feature to handle ComputedObject results # (e.g. fc.first() returns ee.ComputedObject, not ee.Feature) try: return ee.Feature(geometry).geometry() except Exception: return ee.Geometry(geometry) def _is_multi_feature(geometry): """Check if geometry is a multi-feature FeatureCollection.""" if not isinstance(geometry, ee.FeatureCollection): return False try: return geometry.size().getInfo() > 1 except Exception: return False def _mosaic_by_date(col): """Mosaic a tiled ImageCollection by unique date. Groups images by ``system:time_start`` (truncated to day) and mosaics each group into a single image. This handles tiled datasets like LCMS that have multiple spatial tiles per time step. Args: col: ``ee.ImageCollection``. Returns: ee.ImageCollection: One image per unique date, sorted by time. """ # Get distinct dates def _add_date_millis(img): d = ee.Date(img.get("system:time_start")) # Truncate to start of day day_start = ee.Date.fromYMD(d.get("year"), d.get("month"), d.get("day")) return img.set("date_millis", day_start.millis()) col = col.map(_add_date_millis) distinct_dates = col.aggregate_array("date_millis").distinct().sort() def _mosaic_date(date_millis): date_millis = ee.Number(date_millis) filtered = col.filter(ee.Filter.eq("date_millis", date_millis)) mosaic = filtered.mosaic() return mosaic.set("system:time_start", date_millis) \ .copyProperties(filtered.first()) return ee.ImageCollection(distinct_dates.map(_mosaic_date)) clip_to_geometry=True,