Improved render previews
This commit is contained in:
@@ -39,47 +39,31 @@ class MitsubaBackend(RenderBackend):
|
||||
|
||||
# ── Scene construction ──────────────────────────────────────────
|
||||
|
||||
def _build_scene_dict(
|
||||
def _base_scene_dict(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
first_mesh_path: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build a Mitsuba scene dictionary from our data classes.
|
||||
"""Return a scene dict with everything *except* the shape entries.
|
||||
|
||||
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
|
||||
environment emitter for soft fill, giving well-balanced shading on
|
||||
all faces of the model. Lighting intensities and colors come from
|
||||
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
|
||||
When *first_mesh_path* is given the ground plane / backdrop is sized
|
||||
from its bounding box; otherwise a large default is used.
|
||||
"""
|
||||
import mitsuba as mi
|
||||
|
||||
lighting = settings.lighting
|
||||
ground = settings.ground_plane
|
||||
|
||||
# Map our BSDF types to Mitsuba BSDF dicts
|
||||
bsdf = self._make_bsdf(material)
|
||||
|
||||
# Determine mesh file type from extension
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
|
||||
# Build camera-to-world transform using the Python API
|
||||
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=list(camera.origin),
|
||||
target=list(camera.target),
|
||||
up=list(camera.up),
|
||||
)
|
||||
|
||||
scene = {
|
||||
scene: dict = {
|
||||
"type": "scene",
|
||||
# Integrator
|
||||
"integrator": {
|
||||
"type": "path",
|
||||
"max_depth": settings.max_depth,
|
||||
},
|
||||
# Camera
|
||||
"integrator": {"type": "path", "max_depth": settings.max_depth},
|
||||
"sensor": {
|
||||
"type": "perspective",
|
||||
"fov": camera.fov,
|
||||
@@ -95,7 +79,6 @@ class MitsubaBackend(RenderBackend):
|
||||
"sample_count": settings.spp,
|
||||
},
|
||||
},
|
||||
# Ambient environment fill
|
||||
"emitter": {
|
||||
"type": "constant",
|
||||
"radiance": {
|
||||
@@ -107,59 +90,50 @@ class MitsubaBackend(RenderBackend):
|
||||
],
|
||||
},
|
||||
},
|
||||
# Shape
|
||||
"shape": {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": bsdf,
|
||||
},
|
||||
}
|
||||
|
||||
# ── 3-point lighting (colors and intensities from config) ───
|
||||
# ── 3-point lighting ──────────────────────────────────────────
|
||||
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||
key_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[1.0, -0.8, 1.2],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["key_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": key_to_world,
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=[1.0, -0.8, 1.2],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
),
|
||||
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||
}
|
||||
|
||||
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||
fill_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-1.0, 0.6, 0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["fill_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": fill_to_world,
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=[-1.0, 0.6, 0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
),
|
||||
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||
}
|
||||
|
||||
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||
rim_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-0.3, 1.2, -0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["rim_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": rim_to_world,
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=[-0.3, 1.2, -0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
),
|
||||
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||
}
|
||||
|
||||
# ── Ground plane / backdrop (optional) ─────────────────────
|
||||
# ── Ground plane / backdrop ───────────────────────────────────
|
||||
if ground.enabled:
|
||||
try:
|
||||
# Load mesh to compute bounds for ground/backdrop placement
|
||||
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
|
||||
ext = os.path.splitext(first_mesh_path)[1].lower() if first_mesh_path else ""
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
mesh_shape = mi.load_dict({"type": shape_type, "filename": first_mesh_path})
|
||||
bbox = mesh_shape.bbox()
|
||||
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||
# Ground at model's lowest Z with 0.1% offset
|
||||
model_height = bbox_max[2] - bbox_min[2]
|
||||
ground_z = bbox_min[2] - 0.001 * model_height
|
||||
dx = bbox_max[0] - bbox_min[0]
|
||||
@@ -167,7 +141,6 @@ class MitsubaBackend(RenderBackend):
|
||||
dz = bbox_max[2] - bbox_min[2]
|
||||
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
|
||||
except Exception:
|
||||
# Fallback: place at origin with large default size
|
||||
ground_z = -ground.distance_below
|
||||
diag = 1000.0
|
||||
|
||||
@@ -177,19 +150,15 @@ class MitsubaBackend(RenderBackend):
|
||||
}
|
||||
|
||||
if ground.curved_backdrop:
|
||||
# Photo booth style curved leinwand:
|
||||
# - Flat floor section in front of the model
|
||||
# - Curved cylinder behind that sweeps up and over
|
||||
half_size = diag * 50.0 # huge floor
|
||||
radius = diag * 3.0 # curvature radius
|
||||
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
|
||||
half_size = diag * 50.0
|
||||
radius = diag * 3.0
|
||||
cyl_height = diag * 20.0
|
||||
scene["ground_floor"] = {
|
||||
"type": "rectangle",
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
# Cylinder: axis along Y, positioned behind model, radius sweeps up
|
||||
scene["ground_backdrop"] = {
|
||||
"type": "cylinder",
|
||||
"radius": radius,
|
||||
@@ -200,7 +169,6 @@ class MitsubaBackend(RenderBackend):
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
else:
|
||||
# Simple flat ground plane — very large so edges aren't visible
|
||||
half_size = diag * 50.0
|
||||
scene["ground_plane"] = {
|
||||
"type": "rectangle",
|
||||
@@ -211,6 +179,48 @@ class MitsubaBackend(RenderBackend):
|
||||
|
||||
return scene
|
||||
|
||||
def _build_scene_dict(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> dict:
|
||||
"""Build a single-shape Mitsuba scene dictionary."""
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
|
||||
scene = self._base_scene_dict(camera, settings, first_mesh_path=mesh_path)
|
||||
scene["shape"] = {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": self._make_bsdf(material),
|
||||
}
|
||||
return scene
|
||||
|
||||
def _build_assembly_scene_dict(
|
||||
self,
|
||||
parts: list,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> dict:
|
||||
"""Build a multi-shape Mitsuba scene dictionary.
|
||||
|
||||
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
|
||||
"""
|
||||
first_path = parts[0][0] if parts else None
|
||||
scene = self._base_scene_dict(camera, settings, first_mesh_path=first_path)
|
||||
|
||||
for i, (mesh_path, material) in enumerate(parts):
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
scene[f"shape_{i}"] = {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": self._make_bsdf(material),
|
||||
}
|
||||
return scene
|
||||
|
||||
def _make_bsdf(self, material: RenderMaterial) -> dict:
|
||||
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
|
||||
mt = material.bsdf_type
|
||||
@@ -282,9 +292,8 @@ class MitsubaBackend(RenderBackend):
|
||||
|
||||
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
|
||||
|
||||
# Render
|
||||
try:
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
|
||||
except Exception as e:
|
||||
logger.error(f"Mitsuba render failed: {e}")
|
||||
raise
|
||||
@@ -292,14 +301,48 @@ class MitsubaBackend(RenderBackend):
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
|
||||
# Convert to numpy (H, W, 3)
|
||||
arr = np.array(image, dtype=np.float32)
|
||||
|
||||
# Apply approximate sRGB tonemapping
|
||||
arr = np.clip(arr, 0.0, None)
|
||||
arr = np.power(arr, 1.0 / 2.2) # gamma
|
||||
arr = np.power(arr, 1.0 / 2.2)
|
||||
arr = np.clip(arr, 0.0, 1.0)
|
||||
return arr
|
||||
|
||||
def render_assembly(
|
||||
self,
|
||||
parts: list,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
) -> np.ndarray:
|
||||
"""Render multiple meshes with individual materials.
|
||||
|
||||
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
|
||||
Returns (H, W, 3) float32 RGB array.
|
||||
"""
|
||||
self._set_variant()
|
||||
import mitsuba as mi
|
||||
|
||||
scene_dict = self._build_assembly_scene_dict(parts, camera, settings)
|
||||
scene = mi.load_dict(scene_dict)
|
||||
|
||||
logger.info(
|
||||
f"Rendering assembly ({len(parts)} parts) "
|
||||
f"{settings.width}x{settings.height} @ {settings.spp} spp"
|
||||
)
|
||||
|
||||
try:
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
|
||||
except Exception as e:
|
||||
logger.error(f"Mitsuba assembly render failed: {e}")
|
||||
raise
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
|
||||
arr = np.array(image, dtype=np.float32)
|
||||
arr = np.clip(arr, 0.0, None)
|
||||
arr = np.power(arr, 1.0 / 2.2)
|
||||
arr = np.clip(arr, 0.0, 1.0)
|
||||
return arr
|
||||
|
||||
def render_preview(
|
||||
|
||||
Reference in New Issue
Block a user