fill_alpha is ignored in the datashader render path — shapes always render at full opacity
Description
When render_shapes uses the datashader path (automatically triggered for >10,000 shapes), fill_alpha has no effect. Shapes always render at full opacity (alpha=255) regardless of the fill_alpha value — including fill_alpha=0.0. The matplotlib path correctly applies fill_alpha. Because the method switch from matplotlib to datashader is automatic and silent, large datasets render differently from small ones with no warning to the user.
Root cause: _ds_shade_categorical passes min_alpha=_convert_alpha_to_datashader_range(alpha) to ds.tf.shade(). min_alpha is a floor on alpha for non-empty pixels, not a scaling factor. Setting min_alpha=0 still allows datashader to render shapes at alpha=255.
Environment
spatialdata-plot: 0.3.4.dev (main, 5cfedc7)
spatialdata: 0.5.0
Python: 3.13
Minimal Reproducible Example
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import geopandas as gpd
import dask; dask.config.set({"dataframe.query-planning": False})
import spatialdata as sd
from spatialdata.models import ShapesModel
import spatialdata_plot
from shapely.geometry import box
from PIL import Image
# Create enough shapes to trigger datashader (> 10,000)
n = 12000
shapes = ShapesModel.parse(gpd.GeoDataFrame(
{
"geometry": [box(i % 100, i // 100, i % 100 + 0.8, i // 100 + 0.8) for i in range(n)],
"radius": [0.4] * n,
},
geometry="geometry",
))
sdata = sd.SpatialData(shapes={"s": shapes})
fig, ax = plt.subplots()
sdata.pl.render_shapes("s", fill_alpha=0.0).pl.show(ax=ax)
fig.savefig("/tmp/ds_alpha_test.png", dpi=20, facecolor="white")
arr = np.array(Image.open("/tmp/ds_alpha_test.png"))
print(f"Max alpha in image: {arr[:, :, 3].max()}") # 255 instead of 0
Expected vs. Actual
Expected: fill_alpha=0.0 renders shapes invisible (alpha=0). fill_alpha=0.3 renders at 30% opacity, matching the matplotlib path.
Actual: Shapes render at full opacity (alpha=255) even with fill_alpha=0.0. No warning is emitted that datashader ignores fill_alpha.
Fix Sketch
After _datashader_map_aggregate_to_color returns an RGBA array, post-multiply the alpha channel by fill_alpha:
rgba_array[:, :, 3] = (rgba_array[:, :, 3] * fill_alpha).astype(np.uint8)
This makes the datashader path consistent with the matplotlib path for all fill_alpha values. The fix is applied after datashader compositing, so it does not interfere with datashader's internal alpha handling.
Labels: bug, shapes, priority: high
Triage tier: Tier 2
fill_alphais ignored in the datashader render path — shapes always render at full opacityDescription
When
render_shapesuses the datashader path (automatically triggered for >10,000 shapes),fill_alphahas no effect. Shapes always render at full opacity (alpha=255) regardless of thefill_alphavalue — includingfill_alpha=0.0. The matplotlib path correctly appliesfill_alpha. Because the method switch from matplotlib to datashader is automatic and silent, large datasets render differently from small ones with no warning to the user.Root cause:
_ds_shade_categoricalpassesmin_alpha=_convert_alpha_to_datashader_range(alpha)tods.tf.shade().min_alphais a floor on alpha for non-empty pixels, not a scaling factor. Settingmin_alpha=0still allows datashader to render shapes at alpha=255.Environment
Minimal Reproducible Example
Expected vs. Actual
Expected:
fill_alpha=0.0renders shapes invisible (alpha=0).fill_alpha=0.3renders at 30% opacity, matching the matplotlib path.Actual: Shapes render at full opacity (alpha=255) even with
fill_alpha=0.0. No warning is emitted that datashader ignoresfill_alpha.Fix Sketch
After
_datashader_map_aggregate_to_colorreturns an RGBA array, post-multiply the alpha channel byfill_alpha:This makes the datashader path consistent with the matplotlib path for all
fill_alphavalues. The fix is applied after datashader compositing, so it does not interfere with datashader's internal alpha handling.Labels: bug, shapes, priority: high
Triage tier: Tier 2