Source code for squarenet.artist

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from pathlib import Path
from .utils import project

_STYLES = ["checkerboard", "mesh", "scatter"]

_CONFIG_DESCR = """\
===============================================================
I. MAIN ARGUMENTS  β€”  passed directly to sqplot()
===============================================================
    style      : rendering style. One of:
                    'checkerboard' β€” points coloured in a 2-colour tile pattern
                    'mesh'         β€” grid lines drawn at 4 levels of density,
                    'scatter'      β€” plain point cloud; depth-coloured (cmap) in
                                    3-D, black in 2-D.
    animate    : False β†’ single static PNG.
                True  β†’ GIF that morphs continuously from the input grid
                        to the identity grid and back.
    save       : whether to write the output to disk.
    save_path  : destination path.

===============================================================
II. EXTRA ARGUMENTS  β€”  passed as cfg = {...}
===============================================================
All keys are optional. Only specify what you want to override;
everything else is filled from the defaults shown here.

Layout
    figsize      : (width, heigth) size of the base figure

Projection
    projection   : 3-tuple of axis indices, e.g. (0, 1, 2).
                    Used if ndim >= 3. this selects both 
                    which axes to keep AND in what order

Scale & density
    scale_factor : positive integer. bigger -> finer details
    pointsize    : base size for a single point 
    linewidth    : base width for a mesh lines.
    mesh_long_edge : length ratio (relative to the median) above wich an edge will not be ploted in 'mesh' style

Colours
    colors_checkerboard       : 2 colors for the 'checkerboard' style.
    cmap_scatter         : colormap e.g. 'plasma', 'coolwarm' used in 'scatter' to encode the depth.

Animation
    frames       : number of frames in the animation
    interval     : inter-frame delay in milliseconds (for interactive sessions).
    fps          : frames per second written (for the saved GIF).

Display
    show         : if True, calls plt.show(). 
                Will fail if session is not interactive
\n
"""

# =========================================================
# UTILS
# =========================================================
[docs] def default_config(): return { "supported styles": _STYLES, # --- layout --- "figsize" : (4, 4), # --- projection --- "projection" : (0, 1, 2), # --- scale --- "scale_factor" : 1, "pointsize" : 1, "linewidth" : 1, #mesh style only "mesh_long_edge": 30, #mesh only # --- style --- "colors_checkerboard" : ("peru", "mediumblue"), #checkerboard style only "cmap_scatter" : "plasma", #scatter style only (3-D depth) # --- animation --- "frames" : 60, "interval" : 30, "fps" : 20, # --- show and export --- "show" : True, }
default_config.__doc__ = _CONFIG_DESCR
[docs] def kill_long_edges(x, threshold = 1): long_edge = (np.diff(x, axis = 0)**2).sum(axis = -1) >= threshold x[:-1][long_edge] = np.nan
[docs] def validate(g): g = np.asarray(g.astype(float)).copy() g[~np.isfinite(g)] = np.nan return g
[docs] def atmost3D(g, projection=(0, 1, 2)): if g.ndim <= 3: return g return project(g, feature_axes=projection)
def _safe_path(path: str, suffix: str) -> Path: p = Path(path) if p.suffix != suffix: p = p.with_suffix(suffix) p.parent.mkdir(parents=True, exist_ok=True) if not p.exists(): return p stem, parent = p.stem, p.parent i = 1 while (candidate := parent / f"{stem}_{i}{suffix}").exists(): i += 1 return candidate # ========================================================= # GRID PREPROCESSING # ========================================================= def _prepare_grid(g, DS=1): n1, n2 = g.shape[:2] n3 = 1 is3D = ((g.ndim -1) == 3) if is3D: n3 = g.shape[2] npoints = (n1*n2*n3) if min(g.shape[:-1]) <= 10: #downsampling (thin case) if npoints > 100_000: DS = max(DS, 2) if npoints > 300_000: DS = max(DS, 3) if npoints > 1_000_000: DS = max(DS, 4) if npoints > 3_000_000: DS = max(DS, 5) else: #downsampling (volumic case) if npoints > 500_000: DS = max(DS, 2) if npoints > 2_000_000: DS = max(DS, 3) if npoints > 5_000_000: DS = max(DS, 4) safe_mean_g = np.nanmean(g.reshape(-1, g.shape[-1]), axis = 0) if is3D: g = g[::DS, ::DS, ::DS, :3] P = np.array([ [ 1, 1 / np.sqrt(3), -np.sqrt(2)/ np.sqrt(3)], [-1, 1 / np.sqrt(3), -np.sqrt(2)/ np.sqrt(3)], [ 0, 2 / np.sqrt(3), np.sqrt(2)/ np.sqrt(3)], ]) g = (g - safe_mean_g[None, None, None, :]) @ P else: g = g[::DS, ::DS, :2] g = g - safe_mean_g[None, None, :] g_min, g_max = np.nanmin(g), np.nanmax(g) g = (g - g_min) / (g_max - g_min + 1e-8) return g # ========================================================= # SURFACE EXTRACTION # ========================================================= def _get_surfaces(grid): D = grid.shape[-1] if grid.ndim == 4: surfaces = [grid[0, :, :], grid[:, 0, :], grid[:, :, -1]] shell_mask = np.ones_like(grid, dtype = bool) i, j, k, _ = grid.shape mid_i, mid_j, mid_k = i//2, j//2, k//2 di, dj, dk = max(i-mid_i-10, 0), max(j-mid_j-10, 0), max(k-mid_k-10, 0) shell_mask[mid_i-di:mid_i+di, mid_j-dj: mid_j+dj, mid_k-dk:mid_k+dk] = False shell_mask = shell_mask.reshape(-1, D)[:, 0] return surfaces, grid.reshape(-1, D)[shell_mask] return [grid], grid.reshape(-1, D) # ========================================================= # CHECKERBOARD MASK # ========================================================= def _checkerboard_mask(surface, stepi, stepj): ni, nj = surface.shape[:2] ii, jj = np.indices((ni, nj)) return (((ii // stepi) % 2) == ((jj // stepj) % 2)).reshape(-1) # ========================================================= # CORE β€” BUILD ANIMATION # ========================================================= def _build_animation(grid, cfg): """ Layout depends on style: - "checkerboard" : tile the surface (checkerboard -like). - "mesh" : mesh the surface (horizontal and vertical lines). - "scatter" : scatter the surface (depth map if ndim >= 3). """ sf = cfg["scale_factor"] style = cfg["style"] anim = cfg["animate"] assert style in ["mesh", "checkerboard", "scatter"], \ f"Unknown plot style {style}, must be 'mesh', 'checkerboard' or 'scatter'" if anim and (style == "checkerboard"): scales = [8] lws = [-1] elif (style == "checkerboard"): scales = [2, 4, 8] lws = [-1, -1, -1] elif (style == "mesh"): scales = [2, 4, 8, 16] lws = [1.5, 1, 0.7, 0.4] lws = [l*cfg["linewidth"] for l in lws] else: # scatter β€” scales/lws unused but keep variables defined scales = [1] lws = [-1] scales = [sf*sc for sc in scales] maxscales = max(scales) DS = 1 frames = cfg["frames"] colors = cfg["colors_checkerboard"] figsize = cfg["figsize"] cmap = cfg["cmap_scatter"] # --- Precompute both endpoints --- coords = [np.linspace(0, 1, s) for s in grid.shape[:-1]] identity_raw = np.stack(np.meshgrid(*coords, indexing="ij"), axis=-1) g_prep = _prepare_grid(grid, DS=DS) id_prep = _prepare_grid(identity_raw, DS=DS) is_3d = g_prep.ndim == 4 # --- Point size normalisation --- n1, n2 = g_prep.shape[:2] n3 = g_prep.shape[2] if is_3d else 0 npoints = n1 * n2 + (n1 * n3 + n2 * n3 if is_3d else 0) pt_size = cfg["pointsize"] * (80_000 / npoints) def interp(t): return (1 - t) * g_prep + t * id_prep # ------------------------------------------------------- # Build artists at t=0/1 # ------------------------------------------------------- surfaces0, full0 = _get_surfaces(interp(0.0)) surfaces1, full1 = _get_surfaces(interp(1.0)) if style == "mesh": long_edge = 0 axes = [0, 1, 2] if n3 > 1 else [0, 1] for axis in axes: long_edge += np.nanmedian((np.diff(g_prep, axis = axis)**2).sum(axis = -1)) long_edge *= ((cfg["mesh_long_edge"])**2)/len(axes) if is_3d: depth = full0[:, 2] order = np.argsort(depth) full0, full1 = np.ascontiguousarray(full0[order]), np.ascontiguousarray(full1[order]) if style == "scatter": for i in range(len(surfaces0)): D = surfaces0[i].shape[-1] s0, s1 = surfaces0[i].reshape(-1, D), surfaces1[i].reshape(-1, D) depth = s0[:, 2] order = np.argsort(depth) surfaces0[i], surfaces1[i] = s0[order], s1[order] def _lerp(t): return [(1 - t) * s0 + t * s1 for (s0, s1) in zip(surfaces0, surfaces1)], (1 - t) * full0 + t * full1 # ------------------------------------------------------- # Figure setup # ------------------------------------------------------- if style == "checkerboard": fig, axes = plt.subplots( 1, len(scales), figsize=(figsize[0] * len(scales), figsize[1]) ) axes = [axes] if len(scales) == 1 else list(axes) for ax in axes: ax.set_xlim(-0.05, 1.05) ax.set_ylim(-0.05, 1.05) ax.axis("off") ax.set_aspect("equal") elif style == "scatter": fig, ax_single = plt.subplots(1, 1, figsize=figsize) ax_single.set_xlim(-0.05, 1.05) ax_single.set_ylim(-0.05, 1.05) ax_single.axis("off") ax_single.set_aspect("equal") axes = [ax_single] else: fig, ax_single = plt.subplots(1, 1, figsize=figsize) ax_single.set_xlim(-0.05, 1.05) ax_single.set_ylim(-0.05, 1.05) ax_single.axis("off") ax_single.set_aspect("equal") axes = [ax_single] * len(scales) # ------------------------------------------------------- # checkerboard / mesh (unchanged logic below) # ------------------------------------------------------- seen_axes = [] bg_artists = [] for ax in axes: if id(ax) not in seen_axes: color = "lightgrey" if (style == "scatter") and (not is_3d): color = "black" seen_axes.append(id(ax)) bg_artists.append( ax.scatter( full0[:, 0], full0[:, 1], c=color, s=pt_size, linewidths=0 ) ) scale_artists = [] for ax, sc, lw in zip(axes, scales, lws): surface_artists = [] for s in surfaces0: ni, nj = s.shape[:2] if style == "scatter": if is_3d: sc = ax.scatter(s[:, 0], s[:, 1], c = s[:, 2], cmap = cfg["cmap_scatter"], s=pt_size, linewidths=0) surface_artists.append(("scatter", sc)) elif style == "checkerboard": stepi = int(max(ni // sc, 1)) stepj = int(max(nj // sc, 1)) mask = _checkerboard_mask(s, stepi, stepj) pts = s.reshape(-1, s.shape[-1]) sc_a = ax.scatter(pts[ mask, 0], pts[ mask, 1], c=colors[0], s=pt_size, linewidths=0) sc_b = ax.scatter(pts[~mask, 0], pts[~mask, 1], c=colors[1], s=pt_size, linewidths=0) surface_artists.append(("checker", mask, sc_a, sc_b)) elif style == "mesh": basei = int(max(ni//maxscales, 1)) stepi = basei*int(maxscales//sc) basej = int(max(nj//maxscales, 1)) stepj = basej*int(maxscales//sc) for i in list(range(0, ni, stepi)): kill_long_edges(s[i], threshold = long_edge) for j in list(range(0, nj, stepj)): kill_long_edges(s[:, j], threshold = long_edge) lines_i = [ ax.plot(s[i, :, 0], s[i, :, 1], color="black", lw=lw)[0] for i in list(range(0, ni, stepi)) ] lines_j = [ ax.plot(s[:, j, 0], s[:, j, 1], color="black", lw=lw)[0] for j in list(range(0, nj, stepj)) ] surface_artists.append( ("mesh", ni, nj, stepi, stepj, lines_i, lines_j) ) scale_artists.append(surface_artists) plt.tight_layout() def _iter_all_artists(): yield from bg_artists for surf_list in scale_artists: for info in surf_list: if info[0] == "scatter": yield info[1] elif info[0] == "checker": yield info[2]; yield info[3] elif info[0] == "mesh": yield from info[5]; yield from info[6] def _update(frame): t = 2* (frame / max(frames - 1, 1)) if t > 1: t = 2-t surfaces, full = _lerp(t) for bg in bg_artists: bg.set_offsets(full[:, :2]) for surf_list in scale_artists: for s_idx, info in enumerate(surf_list): s = surfaces[s_idx] pts = s.reshape(-1, s.shape[-1]) if info[0] == "scatter": _, sc = info sc.set_offsets(pts[:, :2]) elif info[0] == "checker": _, mask, sc_a, sc_b = info if pts.shape[1] == 3: order = np.argsort(pts[:, 2]) pts = pts[order] mask_sorted = mask[order] else: mask_sorted = mask sc_a.set_offsets(pts[ mask_sorted, :2]) sc_b.set_offsets(pts[~mask_sorted, :2]) elif info[0] == "mesh": _, ni, nj, stepi, stepj, lines_i, lines_j = info for k, i in enumerate(range(0, ni, stepi)): lines_i[k].set_data(s[i, :, 0], s[i, :, 1]) for k, j in enumerate(range(0, nj, stepj)): lines_j[k].set_data(s[:, j, 0], s[:, j, 1]) return list(_iter_all_artists()) ani = animation.FuncAnimation( fig, _update, frames=frames, interval=cfg["interval"], blit=True, ) return fig, ani # ========================================================= # PUBLIC API # =========================================================
[docs] def sqplot(grid, verbose, style="checkerboard", animate=False, save=True, save_path="sqrnet/plot", cfg=None): """ Render a structured grid as either a static figure or a morphing animation. The input grid is displayed using one of several rendering styles and may optionally be animated by continuously interpolating between the input grid and the corresponding identity grid. Parameters ---------- grid : ndarray Structured grid of shape ``(..., D)``, where ``D`` is either 2 or 3. verbose : bool If ``True``, prints progress information while rendering or exporting. style : {"checkerboard", "mesh", "scatter"}, default="checkerboard" Rendering style. - ``"checkerboard"``: alternating colours on adjacent grid cells. - ``"mesh"``: draw the grid connectivity as a wireframe. - ``"scatter"``: draw only the grid points. In 3-D, points are coloured according to their depth after projection; in 2-D they are drawn in black. animate : bool, default=False If ``True``, generate an animation interpolating between the input grid and the identity grid. Otherwise, produce a single static figure. save : bool, default=True If ``True``, save the generated figure or animation to disk. save_path : str, default="sqrnet/plot" Output path used when ``save=True``. cfg : dict, optional Rendering configuration. Any omitted entries are filled with the default values returned by ``default_config()``. See ``help(default_config)`` for the complete list of supported options. Returns ------- fig : matplotlib.figure.Figure The generated figure. ani : matplotlib.animation.FuncAnimation or None The animation object if ``animate=True``; otherwise ``None``. See Also -------- default_config Default rendering options and their documentation. """ grid = validate(grid) full_cfg = default_config() if cfg is not None: full_cfg.update(cfg) full_cfg.update({"style": style, "animate":animate, "save": save, "save_path": save_path }) grid = atmost3D(grid, projection=full_cfg["projection"]) animate = full_cfg["animate"] save_path = full_cfg["save_path"] if animate: fig, ani = _build_animation(grid, full_cfg) if save_path is not None: has_ffmpeg = animation.writers.is_available("ffmpeg") suffix = ".mp4" if has_ffmpeg else ".gif" writer = "ffmpeg" if has_ffmpeg else "pillow" save_path = _safe_path(save_path, suffix) if verbose >=1: print(f"figure will be saved at {save_path}") N = np.prod(grid.shape[:-1]) rounded = 10 ** int(np.log10(N)) if N >= 50_000 and suffix == ".gif": if style == "scatter": print(f"ffmpeg unavailable. Npoints = {rounded} and style is 'scatter' -> could take 1/2 minutes") else: print(f"ffmpeg unavailable. Npoints = {rounded} -> could take 20/30 seconds") if has_ffmpeg: from matplotlib.animation import FFMpegWriter # On passe le fps ICI, lors de la crΓ©ation de l'objet writer = FFMpegWriter(fps=full_cfg["fps"], codec="h264", bitrate=-1) # Et on ne le passe PLUS dans .save() ani.save(save_path, writer=writer) else: writer = "pillow" # Avec le string "pillow", on doit passer le fps dans .save() ani.save(save_path, writer=writer, fps=full_cfg["fps"]) else: if cfg["show"]: plt.show() return fig, ani else: static_cfg = full_cfg.copy() static_cfg["frames"] = 1 fig, _ = _build_animation(grid, static_cfg) if save_path is not None: save_path = _safe_path(save_path, ".png") print(f"figure will be saved at {save_path}") fig.savefig(save_path, bbox_inches="tight") if cfg["show"]: plt.show() return fig, None