Files
PozzettiAndrea-ComfyUI-Geom…/nodes/visualization/render_mesh_batch.py
T
PozzettiAndreaandClaude Opus 4.8 1dc05beb5d Preview Mesh Batch Render: revert GPU process-pool (kept serial + shading)
The per-process-EGL GPU pool fell back to vtkXOpenGLRenderWindow with no
DISPLAY ("bad X server connection") and ran ~12s/mesh -- worse than
serial. Gate the process pool back to the software path only; GPU renders
per-mesh on the serial path (fresh plotter -> shading). Software CPU pool
and the shading fix are kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:39:13 +02:00

376 lines
15 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
Preview Mesh Batch Render - render each mesh in a TRIMESH batch with PyVista
(offscreen) and return an IMAGE batch. The mesh filename is drawn as the title.
Options:
* resolution, background, mesh_opacity
* show_edges (+ edge_opacity, shown only when edges are on)
* show_connected_components -> color by pyvista connectivity() RegionId
* revert_yz_flip -> swap Y/Z axes (undo a y/z flip)
* three_plane -> 2x2 grid: iso(random) view, then XY, YZ, XZ
* use_gpu -> try native/EGL offscreen (fast), software fallback
When OFF, rendering is parallelised across CPU cores with a
process pool (software rasterisation is embarrassingly parallel).
Progress shown via tqdm + the ComfyUI bar.
"""
import logging
from comfy_api.latest import io
log = logging.getLogger("geometrypack")
def _setup_offscreen(use_gpu):
import os
import pyvista as pv
pv.OFF_SCREEN = True
def _probe():
try:
p = pv.Plotter(off_screen=True, window_size=[64, 64])
p.add_mesh(pv.Sphere())
p.show(auto_close=False)
cls = ""
try:
cls = p.render_window.GetClassName()
except Exception:
pass
p.close()
return cls or "ok"
except Exception as e:
log.warning("[PreviewMeshBatch] offscreen probe failed: %s", e)
return None
if use_gpu:
cls = _probe()
if cls:
return f"gpu/native ({cls})"
log.info("[PreviewMeshBatch] GPU offscreen unavailable; using software")
if os.name != "nt" and not os.environ.get("DISPLAY"):
try:
pv.start_xvfb()
except Exception as e:
log.debug("[PreviewMeshBatch] start_xvfb: %s", e)
cls = _probe()
return f"software ({cls})" if cls else "none"
def _mesh_title(mesh, idx):
name = None
try:
name = (mesh.metadata or {}).get("file_name")
except Exception:
pass
return str(name) if name else f"mesh {idx}"
def _add_geometry(p, poly, mesh_opacity, edges, edge_opacity, show_cc):
"""Add the surface (optionally colored by connected component) + optional edges."""
if show_cc:
try:
cc = poly.connectivity() # adds 'RegionId'
p.add_mesh(cc, scalars="RegionId", cmap="tab20", show_scalar_bar=False,
opacity=mesh_opacity, smooth_shading=True)
base = cc
except Exception as e:
log.warning("[PreviewMeshBatch] connectivity() failed: %s", e)
p.add_mesh(poly, color="lightgray", opacity=mesh_opacity, smooth_shading=True)
base = poly
else:
p.add_mesh(poly, color="lightgray", opacity=mesh_opacity, smooth_shading=True)
base = poly
if edges:
try:
p.add_mesh(base.extract_all_edges(), color="dimgray",
opacity=edge_opacity, line_width=1)
except Exception:
pass
def _make_plotter(o):
"""Create an offscreen plotter (2x2 for three-plane, else single)."""
import pyvista as pv
if o["three"]:
return pv.Plotter(shape=(2, 2), off_screen=True, window_size=[o["res"], o["res"]])
return pv.Plotter(off_screen=True, window_size=[o["res"], o["res"]])
def _prep_poly(mesh, o):
"""Wrap a trimesh as PolyData, applying the optional Y/Z revert."""
import numpy as np
import pyvista as pv
poly = pv.wrap(mesh)
if o["revert_yz"]:
# Deep-copy so we never mutate the upstream mesh. Swapping Y/Z is a
# reflection: it flips face winding/normals (which can make a lit surface
# render dark/inside-out), so rebuild normals afterwards.
poly = poly.copy(deep=True)
pts = np.asarray(poly.points, dtype=np.float64).copy()
pts[:, [1, 2]] = pts[:, [2, 1]]
poly.points = pts
try:
poly.flip_normals()
except Exception:
pass
return poly
def _draw_into(p, poly, title, o):
"""Populate an already-created, freshly p.clear()'d plotter with the mesh and
requested view(s). Titles/captions use named add_text so they replace cleanly
when the plotter is reused across meshes."""
import random
bg_rgb, tcol, fs, titled = o["bg_rgb"], o["text_color"], o["fs"], o["titled"]
def _surf():
_add_geometry(p, poly, o["m_op"], o["edges"], o["e_op"], o["show_cc"])
if o["three"]:
for (r, c, label) in [(0, 0, "iso"), (0, 1, "XY"), (1, 0, "YZ"), (1, 1, "XZ")]:
p.subplot(r, c)
p.background_color = bg_rgb
_surf()
if label == "iso":
p.view_isometric()
try:
p.camera.azimuth = random.uniform(0, 360)
p.camera.elevation = random.uniform(-25, 25)
except Exception:
pass
p.reset_camera()
cap = title if titled else ""
else:
getattr(p, f"view_{label.lower()}")()
p.reset_camera()
cap = label + (f" {title}" if titled else "")
if cap:
p.add_text(cap, font_size=max(7, fs - 3), color=tcol, name=f"cap_{r}{c}")
else:
p.background_color = bg_rgb
_surf()
if titled:
p.add_text(title, position="upper_edge", font_size=fs, color=tcol, name="gp_title")
p.view_isometric()
p.reset_camera()
def _screenshot_arr(p):
import numpy as np
shot = p.screenshot(return_img=True)
arr = np.asarray(shot, dtype=np.float32) / 255.0
if arr.ndim == 3 and arr.shape[-1] == 4:
arr = arr[..., :3]
return arr
def _render_mesh_array(mesh, title, o):
"""One-shot render with its own plotter -- used by the process-pool workers."""
p = _make_plotter(o)
try:
_draw_into(p, _prep_poly(mesh, o), title, o)
return _screenshot_arr(p)
finally:
try:
p.close()
except Exception:
pass
def _render_worker(payload):
"""ProcessPool worker (SOFTWARE path only). payload = (spec, title, opts) where
spec is ("path", filepath) or ("mesh", trimesh). The parent started Xvfb with no
GL context (fork-safe); each child makes its own software context.
Returns the array, or ("ERR", message, res) on failure."""
try:
spec, title, o = payload
import pyvista as pv
pv.OFF_SCREEN = True
if spec[0] == "path":
from ..io import mesh_io
mesh, err = mesh_io.load_mesh_file(spec[1])
if mesh is None:
return ("ERR", f"load failed: {err}", o["res"])
else:
mesh = spec[1]
return _render_mesh_array(mesh, title, o)
except Exception as e:
try:
res = payload[2]["res"]
except Exception:
res = 512
return ("ERR", str(e), res)
class RenderMeshBatch(io.ComfyNode):
"""Render each mesh in a batch (PyVista offscreen), titled by filename."""
INPUT_IS_LIST = True
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackPreviewMeshBatchRender",
display_name="Preview Mesh Batch Render",
category="geompack/visualization",
inputs=[
io.Custom("TRIMESH").Input("meshes", tooltip="Batch/list of meshes to render."),
io.Int.Input("resolution", default=512, min=64, max=4096, step=16,
tooltip="Square render size (px)."),
io.Combo.Input("background", options=["white", "black", "gray"], default="white"),
io.Float.Input("mesh_opacity", default=1.0, min=0.0, max=1.0, step=0.05,
tooltip="Opacity of the mesh surface."),
io.Boolean.Input("show_edges", default=False, tooltip="Overlay wireframe edges."),
io.Float.Input("edge_opacity", default=1.0, min=0.0, max=1.0, step=0.05, optional=True,
tooltip="Opacity of the edge wireframe (only used when show_edges is on)."),
io.Boolean.Input("show_connected_components", default=False,
tooltip="Color the mesh by connected component (pyvista connectivity / RegionId)."),
io.Boolean.Input("revert_yz_flip", default=False,
tooltip="Swap the Y and Z axes (undo a y/z flip)."),
io.Boolean.Input("three_plane", default=False,
tooltip="Render a 2x2 grid: iso view, then XY, YZ, XZ."),
io.Boolean.Input("show_title", default=True,
tooltip="Draw the mesh filename as the title."),
io.Boolean.Input("use_gpu", default=True,
tooltip="Try GPU/native (EGL) offscreen for speed. When OFF, render is "
"parallelised across CPU cores with a process pool."),
],
outputs=[
io.Image.Output(display_name="images"),
io.String.Output(display_name="titles"),
],
)
@classmethod
def execute(cls, meshes, resolution=512, background="white", mesh_opacity=1.0,
show_edges=False, edge_opacity=1.0, show_connected_components=False,
revert_yz_flip=False, three_plane=False, show_title=True, use_gpu=True):
import os
import numpy as np
import torch
import pyvista as pv
def _s(x, d):
return (x[0] if x else d) if isinstance(x, list) else x
res = int(_s(resolution, 512))
bg = _s(background, "white")
gpu = bool(_s(use_gpu, True))
three = bool(_s(three_plane, False))
show_cc = bool(_s(show_connected_components, False))
if len(meshes) == 1 and isinstance(meshes[0], list):
meshes = meshes[0]
meshes = [m for m in meshes if m is not None]
if not meshes:
raise ValueError("Preview Mesh Batch Render: no meshes provided.")
bg_rgb = {"white": "white", "black": "black", "gray": (0.5, 0.5, 0.5)}.get(bg, "white")
opts = {
"res": res, "bg_rgb": bg_rgb,
"text_color": "black" if bg == "white" else "white",
"fs": max(8, int(res / 45)),
"m_op": float(_s(mesh_opacity, 1.0)),
"edges": bool(_s(show_edges, False)),
"e_op": float(_s(edge_opacity, 1.0)),
"show_cc": show_cc,
"revert_yz": bool(_s(revert_yz_flip, False)),
"three": three,
"titled": bool(_s(show_title, True)),
}
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(len(meshes))
except Exception:
pbar = None
try:
from tqdm import tqdm
except Exception:
tqdm = None
titles = [_mesh_title(m, i) for i, m in enumerate(meshes)]
imgs = [None] * len(meshes)
def _run_pool():
"""SOFTWARE-only: render across CPU cores via a process pool (max workers).
Each worker renders in its own software GL context and returns the small
image (not the mesh). Returns True on success, False -> serial fallback.
(GPU does NOT use this -- multiple EGL contexts gave 'bad X server' fallbacks
and were slower than serial; GPU goes through the per-mesh serial path.)"""
# Children share a parent-started Xvfb (started WITH NO GL context so the
# fork stays safe).
try:
if os.name != "nt" and not os.environ.get("DISPLAY"):
pv.start_xvfb()
except Exception as e:
log.debug("[PreviewMeshBatch] start_xvfb: %s", e)
payloads = []
for i, mesh in enumerate(meshes):
fp = None
try:
fp = (mesh.metadata or {}).get("file_path")
except Exception:
pass
spec = ("path", fp) if (fp and os.path.isfile(fp)) else ("mesh", mesh)
payloads.append((spec, titles[i], opts))
from concurrent.futures import ProcessPoolExecutor
workers = min(len(meshes), (os.cpu_count() or 4)) # max available
log.info("[PreviewMeshBatch] software render: %d mesh(es) @ %dpx across %d processes",
len(meshes), res, workers)
try:
with ProcessPoolExecutor(max_workers=workers) as ex:
gen = ex.map(_render_worker, payloads)
if tqdm is not None:
gen = tqdm(gen, total=len(meshes), desc="Preview Mesh Batch", unit="mesh")
for i, out in enumerate(gen):
if isinstance(out, tuple) and out and out[0] == "ERR":
log.error("[PreviewMeshBatch] render failed for %s: %s", titles[i], out[1])
imgs[i] = np.ones((res, res, 3), np.float32)
else:
imgs[i] = out
if pbar is not None:
pbar.update(1)
return True
except Exception as e:
log.warning("[PreviewMeshBatch] parallel render failed (%s); serial fallback", e)
return False
# Process pool ONLY for the software path; GPU uses the serial per-mesh path.
done = _run_pool() if ((not gpu) and len(meshes) > 1) else False
if not done:
# ---- serial path (single mesh, or pool fallback) ----
# Fresh plotter per mesh (default lights -> shading). Reuse+clear() dropped
# the lights AND barely helped (cost is geometry upload + GPU readback).
backend = _setup_offscreen(gpu)
log.info("[PreviewMeshBatch] serial backend: %s | %d mesh(es) @ %dpx%s%s",
backend, len(meshes), res, " 3-plane" if three else "", " CC" if show_cc else "")
it = enumerate(meshes)
if tqdm is not None:
it = tqdm(list(it), desc="Preview Mesh Batch", unit="mesh")
for i, mesh in it:
try:
imgs[i] = _render_mesh_array(mesh, titles[i], opts)
except Exception as e:
log.error("[PreviewMeshBatch] render failed for %s: %s", titles[i], e)
imgs[i] = np.ones((res, res, 3), np.float32)
if pbar is not None:
pbar.update(1)
h = max(a.shape[0] for a in imgs)
w = max(a.shape[1] for a in imgs)
batch = np.stack([
np.pad(a, ((0, h - a.shape[0]), (0, w - a.shape[1]), (0, 0)), constant_values=1.0)
for a in imgs
], axis=0)
return io.NodeOutput(torch.from_numpy(batch), " | ".join(titles))
NODE_CLASS_MAPPINGS = {"GeomPackPreviewMeshBatchRender": RenderMeshBatch}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackPreviewMeshBatchRender": "Preview Mesh Batch Render"}