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 = 1.5
_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 = 16
_DEFAULT_LABEL_FONT_SIZE = 12

# Default CRS for thumbnail/GIF generation. EE's ``getThumbURL`` renders
# the output raster in whatever CRS is passed via its ``crs`` param —
# passing ``None`` means "no override", which lets EE pick (usually the
# source image's native projection). Web Mercator is a sensible default
# for browser-viewable thumbnails.
_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 color 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 _smart_round(value, sig_figs=3):
    """Round a number to *sig_figs* significant figures.

    Handles the full range gracefully:
    - ``0.000000111`` → ``0.000000111`` (all decimals kept)
    - ``0.1111111`` → ``0.111``
    - ``1.11111`` → ``1.11``
    - ``-31.759663`` → ``-31.8``
    - ``1234.5678`` → ``1230``
    - ``0`` → ``0``

    Returns int when the result has no fractional part.
    """
    if value is None or value == 0:
        return 0
    import math
    magnitude = math.floor(math.log10(abs(value)))
    decimals = max(0, sig_figs - 1 - magnitude)
    rounded = round(value, decimals)
    return int(rounded) if decimals == 0 and rounded == int(rounded) else rounded


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,
    get_basemap_presets,
)


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):
    """Advertise a default projection on the image (no-op if ``crs`` is None).

    This uses ``.setDefaultProjection`` rather than ``.reproject`` — the
    render CRS for thumbnail output is controlled by passing ``crs`` to
    ``getThumbURL(...)`` directly (see the getThumbURL call sites). The
    default projection is only a hint for downstream compute that reads
    ``img.projection()``; it does not resample pixels.

    Args:
        ee_img: ``ee.Image``.
        crs (str, optional): CRS code, e.g. ``"EPSG:5070"``.
        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
        when *crs* is None).
    """
    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:
        kwargs["scale"] = ee_img.projection().nominalScale()
    return ee_img.setDefaultProjection(**kwargs)


def _resolve_effective_crs(source_img, caller_crs):
    """Pick the CRS that drives both the render AND the basemap fetch.

    * ``caller_crs is not None`` → use it verbatim (explicit wins).
    * ``caller_crs is None``     → try to grab the source image's own
      projection so the same grid is used for render + basemap.
      Prefer ``.projection().crs().getInfo()`` (returns e.g.
      ``"EPSG:5070"`` when the source is a canonical CRS); fall back
      to ``.projection().getInfo()["wkt"]`` for ad-hoc projections like
      LCMS's custom Albers Conical Equal Area, which has no EPSG code.

    Returns None only when neither the caller nor the source provides
    a projection — in which case downstream code should skip the
    ``crs`` param entirely (EE rejects ``crs: null``).
    """
    if caller_crs is not None:
        return caller_crs
    if source_img is None:
        return None
    try:
        c = source_img.projection().crs().getInfo()
        if c:
            return c
    except Exception:
        pass
    try:
        info = source_img.projection().getInfo() or {}
        return info.get("wkt") or info.get("crs")
    except Exception:
        return None


def _apply_projection_to_collection(ee_col, crs=None, transform=None, scale=None):
    """Reproject every image in an ``ee.ImageCollection`` to a target CRS.

    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 color 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)`` color 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 color.

    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)`` color 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 color 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 color 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=False,
                                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
            main map frame (overlaid in the lower-right). Defaults to
            ``False`` — the inset is placed below the legend (or below
            the main frame if no legend exists) so it doesn't occlude
            map features. Set to ``True`` for a compact layout.
        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 — keep legend at its
                # natural height (top-justified) and place the inset below
                # at its natural size. Any extra vertical space is left
                # empty rather than stretching either panel.
                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

                # Keep legend at its natural size (don't stretch to fill column)
                legend_natural_h = legend_panel.size[1]
                gap = max(4, margin // 3)
                # Ensure legend + gap + inset fits in the column
                max_legend_h = col_h - ih - gap
                if legend_natural_h > max_legend_h and max_legend_h > 0:
                    # Only shrink if it genuinely doesn't fit — preserve aspect
                    scale = max_legend_h / legend_natural_h
                    new_w = int(legend_panel.size[0] * scale)
                    legend_resized = legend_panel.resize((new_w, max_legend_h), Image.LANCZOS)
                else:
                    legend_resized = legend_panel

                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)
                # Inset placed immediately below the legend (not bottom-anchored),
                # leaving any extra space at the bottom.
                inset_y = legend_resized.size[1] + gap
                right_col.paste(inset_resized, (inset_pad_left, inset_y),
                               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 = 600
_DEFAULT_CONTINUOUS_TIMEOUT = 10


[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 or 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 ------------------------------------------------------ if isinstance(band_names, str): band_names = [b.strip() for b in band_names.split(",")] 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, tileScale=4, ) 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: print(f"WARNING: auto_viz failed to compute stats (timed out at scale={current_scale}m). " f"Returning default min=0, max=1. Try increasing scale= (e.g. scale=10000 for global/continental data) " f"or reducing the geometry size.") 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] # Round values to 3 significant figures for clean display min_vals = [_smart_round(v) for v in min_vals] max_vals = [_smart_round(v) for v in max_vals] # 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 colors. 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 """ if isinstance(ee_obj, ee.ImageCollection) and geometry is not None: ee_obj = ee_obj.filterBounds(_to_geometry(geometry)) 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): Output raster CRS. Passed directly to ``ee.Image.getThumbURL(crs=...)`` so EE renders the thumbnail in this projection. Defaults to ``"EPSG:3857"`` (Web Mercator). Pass ``None`` to omit the ``crs`` key entirely; EE then uses the source image's native projection. (EE rejects a literal ``crs: null``.) 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' """ if isinstance(ee_obj, ee.ImageCollection) and geometry is not None: ee_obj = ee_obj.filterBounds(_to_geometry(geometry)) _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"} # Region is the geometry itself. Passing ``crs`` in the # getThumbURL params dict is what actually controls the render # projection — EE respects that param when set, and falls back # to the image's native projection when ``crs`` is None. params["region"] = geom # ``crs=None`` means "let EE pick" (source native). EE's server # rejects a literal ``crs: null`` in the request body with # "Parameter 'crs' is required and may not be null", so we omit # the key entirely when the caller passed None. if crs is not None: params["crs"] = crs 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=None, 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=False, 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, max_class_label_length=30): """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 ``None`` — auto-detect from the collection's temporal span (yearly for spans > 5y, monthly for shorter spans, daily for spans under 60 days, hourly for closely-spaced frames). 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. Default ``None`` (auto: 1.4× ``label_font_size``). 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): Output raster CRS for the rendered frames. Passed to ``ee.Image.getThumbURL(crs=...)``. Defaults to ``"EPSG:3857"`` (Web Mercator). Pass ``None`` to omit the ``crs`` key entirely; EE then uses the source image's native projection. (EE rejects a literal ``crs: null``.) 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 main map frame (overlaid in the lower-right). Defaults to ``False`` — the inset is placed below the legend (or below the main frame if no legend exists). Set to ``True`` for a compact layout. title (str, optional): Title text rendered as a strip above the GIF frames. Defaults to ``None`` (no title). title_font_size (int, optional): Font size in pixels for the title strip. Defaults to ``16``. label_font_size (int, optional): Font size in pixels for legend labels and scalebar ticks. Defaults to ``12``. burn_in_geometry (bool, optional): Draw the study area geometry outline on each frame. Defaults to ``False``. geometry_outline_color (tuple or None, optional): Color for the geometry outline. When ``None``, auto-derived from ``font_color``. Defaults to ``None``. geometry_fill_color (str or None, optional): Fill color for the geometry interior. Defaults to ``None`` (no fill). geometry_outline_weight (int, optional): Line width in pixels for the geometry outline. Defaults to ``2``. clip_to_geometry (bool, optional): Clip imagery to the geometry boundary. Defaults to ``True``. Returns: dict: A dictionary with the following keys: - ``"html"`` (str): HTML ``<figure>`` element containing the GIF as a base64-embedded ``<img>`` tag. - ``"bytes"`` (bytes): Raw animated GIF byte data. - ``"format"`` (str): ``"gif"``. 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["bytes"] >>> html_snippet = result["html"] """ _validate_projection_params(crs, transform, scale) col = ee.ImageCollection(ee_obj) col = col.filterBounds(_to_geometry(geometry)) geom = _to_geometry(geometry) # Resolve the "effective" render CRS. When the caller passes crs=None # ("use source native"), we probe the source projection so the SAME # CRS drives BOTH the image render AND the basemap fetch. Otherwise # the two land on different grids and the basemap visibly slides out # from under the data (a real user-reported bug). # # Note: ``.projection().crs().getInfo()`` returns None for many # projections (e.g. LCMS is stored in an ad-hoc Albers WKT, not an # EPSG code). Fall back to the full ``.projection().getInfo()`` dict # and use its ``wkt`` field, which ee.Projection and our patched # _fetch_arcgis_export both accept. _effective_crs = _resolve_effective_crs(col.first(), crs) 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, max_class_label_length=max_class_label_length) # 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>", "bytes": b"", "format": "gif"} # Pass the geometry directly as the region. The render CRS is # controlled by ``crs`` (passed through to getThumbURL params inside # _download_frames); ``crs=None`` lets EE choose (usually the source # image's native projection). pil_frames, date_labels = _download_frames(col, geom, viz_params, dimensions, count, date_format, render_crs=_effective_crs) if not pil_frames: return {"html": "<p>Failed to generate GIF frames.</p>", "bytes": b"", "format": "gif"} # 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). Use the SAME # effective CRS the render was requested in — otherwise the basemap # arrives on a different grid and visibly slides out from under the # data (which is the exact bug that used to appear when crs=None # let getThumbURL pick Albers while _fetch_basemap defaulted to 4326). 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=_effective_crs) # Auto-scale font size for date — larger than scalebar tick labels if date_font_size is None: date_font_size = max(label_font_size, int(label_font_size * 1.4)) 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, "bytes": gif_bytes, "format": "gif"}
# --------------------------------------------------------------------------- # 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=None, 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, max_class_label_length=30): """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 ``None`` — auto-detect from the collection's temporal span. 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): Output raster CRS for the rendered frames. Passed to ``ee.Image.getThumbURL(crs=...)``. Defaults to ``"EPSG:3857"`` (Web Mercator). Pass ``None`` to omit the ``crs`` key entirely; EE then uses the source image's native projection. (EE rejects a literal ``crs: null``.) 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 ``False``. title (str, optional): Title text rendered as a strip above the grid. Defaults to ``None`` (no title). title_font_size (int, optional): Font size in pixels for the title strip. Defaults to ``16``. label_font_size (int, optional): Font size in pixels for legend labels and scalebar ticks. Defaults to ``12``. burn_in_geometry (bool, optional): Draw the study area geometry outline on each frame. Defaults to ``False``. geometry_outline_color (tuple or None, optional): Color for the geometry outline. When ``None``, auto-derived from ``font_color``. Defaults to ``None``. geometry_fill_color (str or None, optional): Fill color for the geometry interior. Defaults to ``None`` (no fill). geometry_outline_weight (int, optional): Line width in pixels for the geometry outline. Defaults to ``2``. clip_to_geometry (bool, optional): Clip imagery to the geometry boundary. Defaults to ``True``. geometry_legend_label (str, optional): Label for the geometry in the legend. Defaults to ``"Study Area"``. Returns: dict: A dictionary with the following keys: - ``"html"`` (str): HTML ``<figure>`` element containing the filmstrip as a base64-embedded PNG ``<img>`` tag. - ``"bytes"`` (bytes): Raw PNG byte data. - ``"format"`` (str): ``"png"``. 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["bytes"] """ _validate_projection_params(crs, transform, scale) from PIL import Image, ImageDraw col = ee.ImageCollection(ee_obj) geom = _to_geometry(geometry) # Resolve the "effective" render CRS — same helper as generate_gif. _effective_crs = _resolve_effective_crs(col.first(), crs) 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, max_class_label_length=max_class_label_length) 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>", "bytes": b"", "format": "png"} # See generate_gif — pass geom as-is; render CRS from _effective_crs # so it matches the CRS the basemap is fetched in. pil_frames, date_labels = _download_frames(col, geom, viz_params, dimensions, count, date_format, render_crs=_effective_crs) # 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=_effective_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>", "bytes": b"", "format": "png"} # 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 # Scale fonts proportional to frame dimensions _dim_scale = dimensions / _DEFAULT_DIMENSIONS title_font_size = max(8, int(title_font_size * _dim_scale)) label_font_size = max(6, int(label_font_size * _dim_scale)) # 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 * _dim_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 * _dim_scale)) else: pass # use scaled label_font_size from above 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, "bytes": thumb_bytes, "format": "png"}
# --------------------------------------------------------------------------- # Shared frame download helper # --------------------------------------------------------------------------- def _auto_date_format(timestamps_ms): """Pick a sensible date_format token based on the temporal span and spacing of the frame timestamps. - Frames < 2 hours apart → "YYYY-MM-dd HH:mm" - Frames < 2 days apart → "YYYY-MM-dd HH" - Span < 60 days → "YYYY-MM-dd" - Span < 5 years → "YYYY-MM" - Otherwise → "YYYY" Falls back to ``"YYYY"`` on any issue. """ try: ts = [t for t in timestamps_ms if t is not None] if len(ts) < 2: return "YYYY-MM-dd" if ts else "YYYY" ts_sorted = sorted(ts) span_ms = ts_sorted[-1] - ts_sorted[0] deltas = [ts_sorted[i + 1] - ts_sorted[i] for i in range(len(ts_sorted) - 1)] min_delta_ms = min(deltas) if deltas else span_ms _hour = 60 * 60 * 1000 _day = 24 * _hour _year = 365 * _day # Sub-daily spacing needs hour-level precision if min_delta_ms < 2 * _hour: return "YYYY-MM-dd HH:mm" if min_delta_ms < _day: return "YYYY-MM-dd HH" # Daily or coarser — pick by total span if span_ms < 60 * _day: return "YYYY-MM-dd" if span_ms < 5 * _year: return "YYYY-MM" return "YYYY" except Exception: return "YYYY" def _download_frames(col, geom, viz_params, dimensions, count, date_format=None, render_crs=None): """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. Args: date_format: Format token (e.g. ``"YYYY-MM"``). When ``None`` or ``"auto"``, the format is chosen from the collection's temporal span — yearly for >5y, monthly for <5y, daily for <60d, etc. render_crs: CRS code for the output raster. Passed as ``crs`` in ``getThumbURL`` params so EE renders in this projection. """ from datetime import datetime as dt from PIL import Image dates = col.aggregate_array("system:time_start").getInfo() if date_format is None or (isinstance(date_format, str) and date_format.lower() == "auto"): date_format = _auto_date_format(dates) 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) # EE rejects ``crs: null`` outright, so omit the key when the caller # passed ``render_crs=None`` — EE then uses the image's native. frame_params = {**viz_params, "dimensions": dimensions, "format": "png", "region": geom} if render_crs is not None: frame_params["crs"] = render_crs 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, max_class_label_length=30): """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"``. Args: max_class_label_length (int, optional): Maximum length for class name strings. Longer names are truncated with ``...`` in the middle, preserving the end. Default 30. 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, max_class_label_length=max_class_label_length) 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", "") # Normalize: some assets store these as comma-separated strings cn = cl._normalize_class_prop(props.get(f"{prefix}_class_names", [])) cp = cl._normalize_class_prop(props.get(f"{prefix}_class_palette", [])) if cn and cp: n = min(len(cn), len(cp)) return { "type": "thematic", "class_names": cl.truncate_class_names(cn[:n], max_class_label_length), "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 font at the theme size for readability font_size = min(base_font, _DEFAULT_LABEL_FONT_SIZE) font = _get_font(font_size) # Swatch scales with per-row vertical budget (not the font cap) so that # dark palette colors on dark themes stay visible when there's room. per_row_budget = usable_h / max(n_classes, 1) swatch_size = max(font_size, min(int(per_row_budget * 0.6), font_size * 2)) swatch_size = max(6, swatch_size) # 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: use natural spacing based on font size, not stretched to fill natural_row_height = int(swatch_size * 2.2) natural_total = natural_row_height * n_classes if n_classes > 0 else 0 # If natural height fits, use it (compact legend). Otherwise stretch to fit. if natural_total <= usable_h: row_height = natural_row_height actual_height = natural_total else: row_height = usable_h / n_classes if n_classes > 0 else usable_h actual_height = target_height # Build panel — use actual content height, not full target_height panel = Image.new("RGB", (panel_w, target_height), panel_bg) draw = ImageDraw.Draw(panel) # Top-justify the legend content 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`` colors, 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 color 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 color. Defaults to ``"black"``. scale (float, optional): Size multiplier. Defaults to ``1.0``. font_color (tuple or None, optional): ``(R, G, B)`` text color. 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 colors 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 colored 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 color strings (no ``#``). width (int): Target width in pixels. bg_color (str): Background color. font_color (tuple or None): ``(R, G, B)`` text color. 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 colored by *thematic_band_name*) - **ee.ImageCollection + any geometry** -> delegates to :func:`generate_map_chart_gif`, returning ``bytes`` (GIF format) 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 color. Dark theme when ``None``. font_color (str or tuple, optional): Font color override. font_outline_color (str or tuple, optional): Font outline. output_path (str, optional): Save output to this path. crs (str, optional): Output raster CRS. Passed to ``ee.Image.getThumbURL(crs=...)``. Defaults to ``"EPSG:3857"``. Pass ``None`` to omit the ``crs`` key (EE then uses the image's native projection; EE rejects a literal ``crs: null``). 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 16. label_font_size (int, optional): Label font size. Default 12. geometry_outline_color (str, optional): Boundary color. 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 coloring 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, "bytes": bytes, "format": "png", "df": DataFrame, "fig": Figure}``. For ``ee.ImageCollection`` inputs (delegated to GIF): ``{"html": str, "bytes": bytes, "format": "gif"}``. """ 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 the FC bounds for the map, burn in points as geometry. _map_geometry = geometry if _is_scatter and is_multi: _fc_full_geom = ee.FeatureCollection(geometry).geometry() _map_geometry = _fc_full_geom.bounds(100, crs) if crs is not None else _fc_full_geom.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_ee_obj = _img else: _thumb_ee_obj = ee_obj # --- Build chart kwargs (fast — just Python) so both branches can # kick off in parallel below. --- 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. # EXCEPTION: scatter charts use ``thematic_band_name`` to COLOR # points, but the map thumb shows the two continuous bands # (``band_names``), not the thematic band — so the thumb has no # class legend to piggyback on. Keep the chart legend visible for # scatter regardless of ``_is_thematic``. _chart_type_lc = (chart_type or "").lower().strip() _suppress_chart_legend = ( _is_thematic and burn_in_legend and _chart_type_lc != "scatter" ) if _suppress_chart_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 # --- Run the two slow EE-bound steps in parallel --- # ``generate_thumbs`` and ``cl.summarize_and_chart`` are the two # dominant wall-clock components (each 5-30 s of Google-hosted EE # work). They're independent — the chart doesn't need thumb bytes # and the thumb doesn't need chart data — so ThreadPoolExecutor # halves the total time. EE calls are HTTP I/O so the GIL releases # during network wait; two workers is enough. print(f"[generate_map_chart] starting thumb thread " f"(dimensions={dimensions}, basemap={basemap!r}) ...") print(f"[generate_map_chart] starting chart thread " f"(chart_type={chart_type!r}, chart_scale={chart_scale}m, " f"area_format={area_format!r}) ...") with concurrent.futures.ThreadPoolExecutor( max_workers=2, thread_name_prefix="gmc", ) as _pool: _fut_thumb = _pool.submit( generate_thumbs, _thumb_ee_obj, _map_geometry, **thumb_kwargs ) _fut_chart = _pool.submit( cl.summarize_and_chart, ee_obj, geometry, **chart_kwargs ) # Report completion order — helps operators see which side is # the bottleneck for their workload. Whichever future finishes # first prints first. for _fut in concurrent.futures.as_completed( (_fut_thumb, _fut_chart) ): if _fut is _fut_thumb: print("[generate_map_chart] thumb thread done") else: print("[generate_map_chart] chart thread done") # Collect results (both are already resolved at this point). thumb_result = _fut_thumb.result() result = _fut_chart.result() map_img = Image.open(io.BytesIO(thumb_result["bytes"])).convert("RGBA") # summarize_and_chart returns {"df": ..., "chart": ..., ["matrix": ...]} df = result.get("df") fig = result.get("chart") if fig is None: thumb_bytes = _pil_to_png_bytes(map_img, bg_color) return {"html": "", "bytes": thumb_bytes, "format": "png", "df": df, "fig": None} # Suppress chart legend for thematic (already on thumb) — # same scatter exception as above. if _suppress_chart_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 --- # # Default aspect ratio is 4:3 (width:height). Prior default was # "match map height", which yielded 4:5-ish tall/narrow charts when # the map was square — bar charts looked cramped and scatter plots # looked squashed vertically. 4:3 is a widely-used aesthetic default # for standalone plots (matplotlib, seaborn, plotly all lean this way). # The caller can still pass ``chart_height`` explicitly to override. mw, mh = map_img.size _CHART_ASPECT = 4.0 / 3.0 # width / height if layout == "side-by-side": # Align chart to map height so the side-by-side row lines up # visually. Compute chart_width from the 4:3 ratio. if chart_height is None: chart_height = mh chart_width = max(400, int(chart_height * _CHART_ASPECT)) else: # Stacked: align chart WIDTH to map width so the two panels stack # cleanly. Compute chart_height from the 4:3 ratio. chart_width = mw if chart_height is None: chart_height = max(300, int(chart_width / _CHART_ASPECT)) 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, "bytes": thumb_bytes, "format": "png", "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=None, 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, max_class_label_length=30, ): """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. Delegates map frame generation to :func:`generate_gif` (which handles basemap compositing, scalebar, north arrow, inset map, geometry burn-in, etc.) and runs :func:`cl.zonal_stats` in parallel. The GIF frames are then decomposed and composited with per-frame cumulative charts. 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, optional): Date format for labels (e.g. ``"YYYY-MM"``). Defaults to ``None`` — auto-detect from the collection's temporal span. bg_color: Background color. font_color: Font color. font_outline_color: Font outline color. 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, "bytes": bytes, "format": "gif"}`` """ ee_obj = ee.ImageCollection(ee_obj).filterBounds(_to_geometry(geometry)) from PIL import Image, ImageDraw import plotly.graph_objects as go if chart_height is None: chart_height = max(200, int(dimensions * 0.6)) # --- Step 1: generate_gif + zonal_stats in parallel --- def _do_gif(): return generate_gif( ee_obj, geometry, viz_params=viz_params, band_name=band_name, dimensions=dimensions, fps=fps, max_frames=max_frames, burn_in_date=True, date_format=date_format, burn_in_legend=False, # we build our own legend below the chart bg_color=bg_color, font_color=font_color, font_outline_color=font_outline_color, crs=crs, transform=transform, scale=scale, margin=0, # no margin on individual frames — we add it at the end 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, inset_on_map=True, title=None, # we build our own title strip per frame 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, ) # Default date_format to "YYYY" when the caller didn't pick one. # Passing ``None`` all the way through to charts.prepare_for_reduction # makes EE emit full ISO datetimes ("2020-01-01T00:00:00") as # per-image labels; the ":" and "-" in those strings break the # ``label + "----" + band`` band-name construction that follows # (EE rejects colons in band names). "YYYY" gives clean labels # like "2020" that survive band-name validation. _date_format = date_format or "YYYY" 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: gif_future = pool.submit(_do_gif) stats_future = pool.submit(_do_stats) gif_result = gif_future.result() df = stats_future.result() map_gif_bytes = gif_result.get("bytes", b"") if not map_gif_bytes: return {"html": "<p>No images.</p>", "bytes": b"", "format": "gif"} # --- Step 2: Decompose the GIF into individual PIL frames --- gif_img = Image.open(io.BytesIO(map_gif_bytes)) pil_frames = [] for i in range(gif_img.n_frames): gif_img.seek(i) pil_frames.append(gif_img.copy().convert("RGBA")) if not pil_frames: return {"html": "<p>Failed to extract frames.</p>", "bytes": b"", "format": "gif"} # Date labels come from the DataFrame index (already formatted by zonal_stats) date_labels = [str(v) for v in df.index] # Align frame count with data rows (truncate whichever is longer) n = min(len(pil_frames), len(df)) pil_frames = pil_frames[:n] df = df.iloc[:n] date_labels = date_labels[:n] # --- Step 3: Resolve colors and build chart components --- font_color, font_outline_color, theme, bg_color = _resolve_font_colors( bg_color, font_color, font_outline_color) # Scale font sizes proportionally to dimensions (defaults tuned for 640px) _scale = dimensions / _DEFAULT_DIMENSIONS title_font_size = max(8, int(title_font_size * _scale)) label_font_size = max(6, int(label_font_size * _scale)) 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]) 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) fw = pil_frames[0].size[0] # Build full-width horizontal legend — only for classes in the data legend_info = _extract_legend_info(ee_obj, band_name=band_name, viz_params=viz_params, max_class_label_length=max_class_label_length) horiz_legend = None if legend_info is not None and legend_info.get("type") == "thematic": 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: 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 if is_stacked: 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 if len(df) == 1 and plotly_mode != "bar": plotly_mode = "bar" is_stacked = True # --- Step 4: Render per-frame cumulative chart as PNG --- chart_pngs = [] for i in range(n): 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, )) x_kw = {"tickformat": "d"} if all_x_int else {} if all_x_int: thinned = cl._thin_tick_vals(all_x_int, max_ticks=10) x_kw["tickvals"] = thinned if thinned else 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) _chart_font_size = max(6, int(12 * _scale)) 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", size=_chart_font_size, color=cl._ensure_hex_color( "{:02x}{:02x}{:02x}".format(*font_color))), width=fw, height=chart_height, margin=dict(l=max(20, int(50 * _scale)), r=max(5, int(10 * _scale)), b=max(15, int(35 * _scale)), 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")) # --- Step 5: 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) 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] 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, "bytes": gif_bytes, "format": "gif"}
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) # Render CRS comes from burn_in_params when the caller supplied # one (multi-feature grids pass it through). None → we omit the # ``crs`` key (EE rejects a literal null and falls back to the # image's native projection otherwise). _crs = (burn_in_params or {}).get("crs") 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 params = {**_vp, "dimensions": _padded_dims, "format": "png", "region": _ee_region} if _crs is not None: params["crs"] = _crs 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, max_class_label_length=30): """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): Output raster CRS. Passed to ``ee.Image.getThumbURL(crs=...)``. Defaults to ``"EPSG:3857"`` (Web Mercator). Pass ``None`` to omit the ``crs`` key entirely; EE then uses the source image's native projection. (EE rejects a literal ``crs: null``.) 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 ``False``. 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)`` color for the boundary outline. When ``None``, auto-detected from the basemap luminance. Defaults to ``None``. geometry_fill_color (str or None, optional): CSS fill color 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 ``16``. 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. - ``"bytes"`` (bytes): Raw PNG byte data. - ``"format"`` (str): ``"png"``. - ``"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["bytes"]) > 0 True """ if isinstance(ee_obj, ee.ImageCollection) and geometry is not None: ee_obj = ee_obj.filterBounds(_to_geometry(geometry)) _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) # Resolve the "effective" render CRS — same helper as generate_gif. _effective_crs = _resolve_effective_crs(None if _is_geom_only else img, crs) 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, max_class_label_length=max_class_label_length) # 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, "bytes": thumb_bytes, "format": "png", "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). # ``crs=None`` means "let EE pick"; we omit the key rather than # sending ``crs: null`` (which EE rejects). Use the effective CRS # (resolved from source projection when caller passed None) so # image + basemap end up on the same grid. _crs_kw = {"crs": _effective_crs} if _effective_crs is not None else {} 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, **_crs_kw, }) else: _out_img = img.clip(geom) if clip_to_geometry else img url = _out_img.getThumbURL({ **viz_params, "dimensions": padded_dims, "format": "png", "region": geom, **_crs_kw, }) 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 fetched in the SAME effective CRS as the image # render — otherwise the basemap slides out from under the # data whenever crs=None resolves to a non-3857 native. basemap_img = _fetch_basemap(bounds, padded_dims, padded_dims, basemap, crs=_effective_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, "bytes": thumb_bytes, "format": "png", "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 # Pass ALL per-feature bounds (not just the union) so each tile # gets its own outlined polygon in the inset overview. When only # one feature is present the list collapses to a single-element # list and the inset looks identical to the pre-list behavior. inset_img = _build_inset_image(all_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, preserving projection. 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. ``.mosaic()`` drops per-tile projection info — the mosaic result reports the WGS84 default (~111 km scale) instead of the source's native grid. That silently breaks downstream ``crs=None`` renders (EE picks the mosaic's degenerate WGS84 projection instead of the LCMS-native Albers). We restore it here by grabbing the projection from a source tile before the mosaic and calling ``.setDefaultProjection(wkt, transform)`` on every mosaic'd frame. Args: col: ``ee.ImageCollection``. Returns: ee.ImageCollection: One image per unique date, sorted by time, with the source's native projection preserved. """ # Snapshot the source projection ONCE (one getInfo round-trip per # call — cheap). We stamp it onto every mosaic frame via # setDefaultProjection so downstream ``crs=None`` renders see the # real projection instead of the WGS84 default that .mosaic() would # otherwise leave behind. Passing the WKT + transform client-side # matches the LCMS JS docs example and avoids the "Parameter 'crs' # is required and may not be null" error you get if you try to feed # ``setDefaultProjection(crs=<server-side ee.String>)`` inside a # mapped function. try: _src_proj_info = col.first().projection().getInfo() or {} _src_wkt = _src_proj_info.get("wkt") or _src_proj_info.get("crs") _src_transform = _src_proj_info.get("transform") except Exception: _src_wkt = None _src_transform = None # 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() # Restore projection lost during .mosaic(). Only stamp when we # actually captured a projection above — some inputs (already- # composited images, in-memory constructions) don't have one. if _src_wkt: mosaic = mosaic.setDefaultProjection( crs=_src_wkt, crsTransform=_src_transform, ) return mosaic.set("system:time_start", date_millis) \ .copyProperties(filtered.first()) return ee.ImageCollection(distinct_dates.map(_mosaic_date))