@@ -757,16 +757,17 @@ def _render_shapes(
757757 # Fast mode: draw one dot per shape at its centroid instead of its geometry.
758758 logger .info ("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored." )
759759 centroids = shapes .geometry .centroid # intrinsic coords, positionally aligned to color_vector
760+ # transform to coordinate-system coords so dots land correctly under non-identity transforms
761+ xy = trans .transform (np .column_stack ([centroids .x .to_numpy (), centroids .y .to_numpy ()]))
760762 _render_centroids_as_points (
761763 ax ,
762764 render_params ,
763- x = centroids . x . to_numpy () ,
764- y = centroids . y . to_numpy () ,
765+ x = xy [:, 0 ] ,
766+ y = xy [:, 1 ] ,
765767 color_vector = color_vector ,
766768 color_source_vector = color_source_vector ,
767769 norm = norm ,
768770 na_color = render_params .cmap_params .na_color ,
769- transform = trans_data , # intrinsic -> coordinate system -> display
770771 adata = table ,
771772 col_for_color = col_for_color ,
772773 palette = palette ,
@@ -1110,6 +1111,34 @@ def _scatter_points(
11101111 )
11111112
11121113
1114+ # as_points: matplotlib draws crisp markers; above this many dots its per-glyph draw dominates
1115+ # (≈18 µs/dot), so auto-switch to datashader. Datashader changes appearance (density raster), so
1116+ # the threshold is conservative and only applies when the user did not pick a backend explicitly.
1117+ AS_POINTS_DS_AUTO = 500_000
1118+
1119+
1120+ def _resolve_as_points_method (
1121+ render_params : ShapesRenderParams | LabelsRenderParams , * , n : int , allow_datashader : bool
1122+ ) -> str :
1123+ """Pick the as_points backend. matplotlib by default; datashader only when it can represent the colors."""
1124+ method = render_params .method
1125+ if not allow_datashader or n == 0 :
1126+ # e.g. no-color labels get one distinct random colour per cell (`_map_color_seg` Case C),
1127+ # which datashader's aggregate-then-shade model cannot represent.
1128+ if method == "datashader" :
1129+ logger .warning ("`as_points` cannot use datashader for this colouring; falling back to matplotlib." )
1130+ return "matplotlib"
1131+ if method == "datashader" :
1132+ return "datashader"
1133+ if method is None and n > AS_POINTS_DS_AUTO :
1134+ logger .info (
1135+ f"`as_points`: { n } centroids exceed { AS_POINTS_DS_AUTO } ; using the datashader backend "
1136+ "(pass `method='matplotlib'` to override)."
1137+ )
1138+ return "datashader"
1139+ return "matplotlib"
1140+
1141+
11131142def _render_centroids_as_points (
11141143 ax : matplotlib .axes .SubplotBase ,
11151144 render_params : ShapesRenderParams | LabelsRenderParams ,
@@ -1120,31 +1149,56 @@ def _render_centroids_as_points(
11201149 color_source_vector : pd .Series | None ,
11211150 norm : Normalize | None ,
11221151 na_color : Any ,
1123- transform : Any ,
11241152 adata : AnnData | None ,
11251153 col_for_color : str | None ,
11261154 palette : Any ,
11271155 fig_params : FigParams ,
11281156 legend_params : LegendParams ,
11291157 colorbar_requests : list [ColorbarSpec ] | None ,
1158+ allow_datashader : bool = True ,
11301159) -> None :
11311160 """Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar.
11321161
1133- Shared "fast mode" draw for shapes/labels; style comes off ``render_params``. ``norm``/``na_color``
1134- stay explicit because they differ between the shapes (locally adjusted) and labels paths.
1162+ Shared "fast mode" draw for shapes/labels. ``x``/``y`` are in **coordinate-system coords** (so the
1163+ datashader canvas and matplotlib's ``transData`` agree). Backend is matplotlib unless
1164+ ``render_params.method`` / the size threshold selects datashader (and the colouring supports it).
1165+ ``norm``/``na_color`` stay explicit because they differ between the shapes and labels paths.
11351166 """
1136- cax = _scatter_points (
1137- ax ,
1138- x ,
1139- y ,
1140- color_vector ,
1141- size = render_params .size ,
1142- cmap = render_params .cmap_params .cmap ,
1143- norm = norm ,
1144- alpha = render_params .fill_alpha ,
1145- trans_data = transform ,
1146- zorder = render_params .zorder ,
1147- )
1167+ method = _resolve_as_points_method (render_params , n = len (np .asarray (x )), allow_datashader = allow_datashader )
1168+ if method == "datashader" :
1169+ df = pd .DataFrame ({"x" : np .asarray (x ), "y" : np .asarray (y )})
1170+ cax , color_vector , color_source_vector = _datashader_points (
1171+ ax ,
1172+ df ,
1173+ col_for_color = col_for_color ,
1174+ color_vector = color_vector ,
1175+ color_source_vector = color_source_vector ,
1176+ norm = norm ,
1177+ cmap_params = render_params .cmap_params ,
1178+ alpha = render_params .fill_alpha ,
1179+ size = render_params .size ,
1180+ zorder = render_params .zorder ,
1181+ # as_points has no user reduction knob: centroids rarely share a pixel, and "max"
1182+ # (the top cell) keeps the datashader output closest to the matplotlib backend.
1183+ ds_reduction = None ,
1184+ default_reduction = "max" ,
1185+ density = False ,
1186+ density_how = "linear" ,
1187+ fig_params = fig_params ,
1188+ )
1189+ else :
1190+ cax = _scatter_points (
1191+ ax ,
1192+ x ,
1193+ y ,
1194+ color_vector ,
1195+ size = render_params .size ,
1196+ cmap = render_params .cmap_params .cmap ,
1197+ norm = norm ,
1198+ alpha = render_params .fill_alpha ,
1199+ trans_data = ax .transData , # x/y are coordinate-system coords
1200+ zorder = render_params .zorder ,
1201+ )
11481202 _add_legend_and_colorbar (
11491203 ax = ax ,
11501204 cax = cax ,
@@ -2266,7 +2320,7 @@ def _render_labels(
22662320 # get instance id based on subsetted table
22672321 instance_id = np .unique (table .obs [instance_key ].values )
22682322
2269- _ , trans_data = _prepare_transformation (label , coordinate_system , ax )
2323+ trans , trans_data = _prepare_transformation (label , coordinate_system , ax )
22702324
22712325 na_color = (
22722326 render_params .color
@@ -2387,11 +2441,14 @@ def _render_labels(
23872441 )
23882442 # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN
23892443 centroids = centroids .reindex (point_ids .astype (labels .dtype , copy = False ))
2444+ # datashader cannot represent one distinct random colour per cell (the no-color path below)
2445+ allow_datashader = True
23902446 if col_for_color is None and not na_color .color_modified_by_user ():
23912447 # no color column: one distinct random colour per cell, matching the mask path
23922448 # (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color.
23932449 point_color_vector = np .random .default_rng (42 ).random ((len (point_ids ), 3 ))
23942450 point_color_source_vector = None
2451+ allow_datashader = False
23952452 elif len (color_vector ) == len (instance_id ):
23962453 # data-driven colour is per-instance
23972454 point_color_vector = np .asarray (color_vector )[keep ]
@@ -2400,22 +2457,24 @@ def _render_labels(
24002457 # literal colour / user-set na_color -> one colour per centroid
24012458 point_color_vector = np .asarray ([na_color .get_hex_with_alpha ()] * len (point_ids ))
24022459 point_color_source_vector = None
2460+ # transform rendered-raster intrinsic centroids to coordinate-system coords
2461+ xy = trans .transform (np .column_stack ([centroids ["x" ].to_numpy (), centroids ["y" ].to_numpy ()]))
24032462 _render_centroids_as_points (
24042463 ax ,
24052464 render_params ,
2406- x = centroids [ "x" ]. to_numpy () ,
2407- y = centroids [ "y" ]. to_numpy () ,
2465+ x = xy [:, 0 ] ,
2466+ y = xy [:, 1 ] ,
24082467 color_vector = point_color_vector ,
24092468 color_source_vector = point_color_source_vector ,
24102469 norm = copy (render_params .cmap_params .norm ), # ax.scatter autoscales in place; don't mutate the shared norm
24112470 na_color = na_color ,
2412- transform = trans_data , # rendered-raster intrinsic coords -> coordinate system -> display
24132471 adata = table if table_name is not None else None ,
24142472 col_for_color = col_for_color ,
24152473 palette = palette ,
24162474 fig_params = fig_params ,
24172475 legend_params = legend_params ,
24182476 colorbar_requests = colorbar_requests ,
2477+ allow_datashader = allow_datashader ,
24192478 )
24202479 return
24212480
0 commit comments