Skip to content

Vectorized raster portrayal with a callable raster_portrayal API - #341

Open
Tejasv-Singh wants to merge 9 commits into
mesa:mainfrom
Tejasv-Singh:feat/raster-portrayal
Open

Vectorized raster portrayal with a callable raster_portrayal API#341
Tejasv-Singh wants to merge 9 commits into
mesa:mainfrom
Tejasv-Singh:feat/raster-portrayal

Conversation

@Tejasv-Singh

Copy link
Copy Markdown
Contributor

Description

Adds a callable portrayal API for RasterLayer bands and replaces the per-cell colormap loop
in the Leaflet renderer with a vectorized one.

Stacked on #339. That branch does not exist on this repo, so this PR is opened against main
and its diff currently includes #339's commits. Once #339 merges it reduces to
geospace_component.py, pyproject.toml and tests/test_raster_portrayal.py.

Usage

from mesa.visualization.components import PropertyLayerStyle
from mesa_geo.visualization import make_geospace_component

# one style for every band
make_geospace_component(agent_portrayal, raster_portrayal=PropertyLayerStyle(colormap="viridis"))


# or a style per band, returning None to skip one
def raster_portrayal(layer_name, band_name):
    if band_name == "elevation":
        return PropertyLayerStyle(colormap="terrain", vmin=0, vmax=3000)
    return None


make_geospace_component(agent_portrayal, raster_portrayal=raster_portrayal)

What changed

  • raster_portrayal, keyword-only on make_geospace_component. Takes a bare
    PropertyLayerStyle for every band, or a callable
    (layer_name, band_name) -> PropertyLayerStyle | None. Callable rather than dict because
    core deprecated dict portrayals for removal in Mesa 4.0. The renderer iterates the layer's
    own bands and asks the callable about each, mirroring core's property_layers.items() loop,
    which makes unknown-band errors impossible by construction.
  • Vectorized colormap step: one array operation per band instead of a Python call per cell.
  • Raster and vector rendering separated into _RasterRenderer and _VectorRenderer,
    composed by MapModule, so a later GeoSpaceRenderer extraction is a mechanical move. Pure
    code move, render(model) output identical on both paths.
  • Vector colours normalized for Leaflet. color and fillColor go through
    matplotlib.colors.to_rgba. Core's AgentPortrayalStyle.color defaults to "tab:blue",
    which Leaflet cannot parse, so a portrayal written against core rendered wrong with no error.
  • matplotlib added as an explicit dependency, since this imports matplotlib.colors and
    matplotlib.colormaps directly rather than relying on mesa[rec].

Backward compatibility

raster_portrayal is keyword-only, defaults to None, and is forwarded to MapModule only
when set, so the default call is byte-identical to before. The legacy
agent_portrayal-returns-RGBA path for raster cells is unchanged.
tests/test_geospace_component.py and tests/test_MapModule.py pass unmodified.
MapModule._get_marker stays on MapModule and is injected into _VectorRenderer, so a
subclass override is still consulted after the split.

Two things do change, both fixes, both pinned by tests: with marker_type absent and an
explicit radius given, the caller's radius was overwritten with 5 and is now respected;
and the caller's portrayal dict was mutated, so a dict reused across renders lost its
marker_type key after the first call.

Notes for review

Reads go through get_band(), not _data. Reading _data avoids a copy and looks like
the obvious optimisation, but it is only a construction-time snapshot until #332 lands, so the
raster would render frozen at t=0 for any model mutating cells in step(). It fails silently
and only for models that change over time. There is a regression test and a comment on that
line.

Following from that, the read into the colormap step is still an O(cells) Python loop until
#332 merges, so the path is not vectorized end to end.

vmin/vmax use is not None rather than core's truthiness check, so vmin=0 is respected
instead of silently auto-ranging. Zero is the most common lower bound for raster data. The
one-line fix belongs upstream in core and should be filed separately.

PropertyLayerStyle.colorbar is ignored and documented as such. Support is a follow-up:
ScalarMappable with fig.colorbar covers the drawing, but the figure cache keys on
auto-derived vmin/vmax so it never hits for a mutating layer, and the widgets created per
render are never closed.

The deprecated leaflet_viz.MapModule is a divergent copy and is intentionally untouched.

Tests

tests/test_raster_portrayal.py, covering pixel-exact output after the CRS transform, band
selection and z-order, vmin=0, nan-aware auto-ranging, constant bands, NaN transparency, the
live-cell read, marker override delegation, portrayal dict reuse, colour normalization, and
type validation.

Suite: 157 passed, 1 skipped. The skip is pre-existing and unrelated.

Extract _RasterRenderer and _VectorRenderer from MapModule. Renderers
accept resolved data (no model coupling), no delegation shims. Snapshot
tests use decoded RGBA arrays instead of brittle base64 strings.
Convert matplotlib colour strings (tab:blue, C0, xkcd:sky blue) and RGBA
tuples to CSS hex strings in _VectorRenderer for Leaflet compatibility.
Map fillColor <-> fill_color across point markers and GeoJSON options,
and pop marker_type before marker construction.
Document raster_portrayal parameter in make_geospace_component and
MapModule.__init__ with PropertyLayerStyle field reference and examples.
…l dict and alpha


Also defers colorbar support to a follow-up PR: the stacked-colorbar commit
was dropped from this branch, and the pre-existing _COLORBAR_STATE warn-once
block is removed rather than reinstated (warning on a field that defaults to
True would warn every user who never asked for a colorbar -- mesa#3455).
PropertyLayerStyle.colorbar is documented as ignored by the Leaflet renderer.
Resolve the colormap where the style is validated, so _band_rgba consumes a
real Colormap and every accepted form (name, Colormap, list or tuple of
colors) goes through one code path. Tuples used to fall past every isinstance
branch into cmap(norm(data)); empty colormaps ([] or "") passed the truthiness
test into the colour branch and died at to_rgba(None). Both now raise a
TypeError naming the accepted forms, and the colormap check uses `is not None`
to match the vmin/vmax convention.

Guard vmin > vmax beside the all-NaN and non-finite guards, rather than
letting matplotlib's "minvalue must be less than or equal to maxvalue"
escape the render callback and blank the visualization.

Warn once when the space has agents but no agent_portrayal was given, so the
optional-portrayal default cannot silently draw zero agents.

Carry marker alpha as opacity/fill_opacity instead of 8-digit hex, which the
ipywidgets Color trait rejected before 8.1. The GeoJSON path keeps #rrggbbaa;
point markers no longer round-trip colours through hex twice, so alpha is no
longer quantized to 8 bits.
The flag is module-level by design, which makes it leak across tests exactly
as _COLORBAR_STATE did. Without this fixture, test_warns_once_across_renders
fails with `assert 0 == 1` whenever an earlier test in the class has already
tripped the warning.
…ion path

Forward raster_portrayal to GeoSpaceLeaflet only when it is set, so the default
call is byte-identical to the pre-feature one. tests/test_geospace_component.py
is restored to its base content: it is a backward-compatibility guard, and
editing it to accept the new keyword silenced exactly what it exists to catch.

Give _VectorRenderer an optional marker factory and have MapModule pass its own
bound _get_marker. Moving _get_marker onto the renderer had made a MapModule
subclass override dead code -- markers silently reverted to the default Circle
with no error, the worst failure shape for user-supplied code.

Default radius=5 only when the portrayal named no marker_type at all. An
explicit marker_type="Circle" keeps ipyleaflet's own 1000 m default, as it did
before; applying 5 there shrank such markers 200x with no diagnostic. An
explicit radius is still honoured, which the base overwrote.

Pass "none" and "transparent" through _css_color unchanged: Leaflet
distinguishes fill:none from a transparent fill for hit-testing. This is scoped
to the GeoJSON path, since ipyleaflet's Color trait rejects both keywords and
markers must still resolve them to a colour.

Add an EPSG:3857 source test asserting decoded pixel equality through a real
warp. Every other raster test uses EPSG:4326, where to_crs is the identity, so
the uint8-after-to_crs ordering had no coverage; this test fails if the cast is
moved above to_crs, since ImageLayer.to_crs reprojects into a fresh float64
array and silently discards any earlier cast.
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 3bcb4fb8-f8fa-4ef5-855a-57b7e78fe1a6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Tejasv-Singh

Copy link
Copy Markdown
Contributor Author

All failing checks are red on main as well and come from dependency releases, not this PR:

build (every lane): test_RasterWebTile.py::test_from_xyzservices fails because a recent xyzservices release added an apikey field to provider metadata (present by 2026.9.0, absent in 2024.9.0) that the test's hard-coded dict does not expect.

build (3.11 only): test_geospace_component fails because 3.11 resolves mesa 3.3.1, whose ComponentsView calls solara.v.TabsItems, removed in ipyvuetify 3.0.0. Fixed in mesa 3.4+, which 3.12+ pull.

Test GIS examples: affine 3.0.1 raises a PendingDeprecationWarning on *, and the examples run under -Werror.

These are pre-existing on main, not introduced by this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant