Source code for pyvale.render.blender.calibration

# ============================================================================
# pyvale: the python validation engine
# License: MIT
# Copyright (C) 2026 Sceptical Rabbit (Lloyd Fletcher)
# ============================================================================
"""Blender calibration-target configuration helpers."""

import importlib
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace

import numpy as np

from ..camera import Camera
from ..light import Light
from ..result import RenderResult
from .adapter import _legacy_light, raise_if_blender_unavailable
from .config import BlenderConfig
from .shader import BlenderMaterial


[docs] @dataclass(frozen=True, slots=True) class BlenderCalibrationData: """Describe the pose sweep used to render a camera calibration target. Parameters ---------- angle_lims : tuple[float, float], optional Inclusive lower and upper target-angle limits in degrees. angle_step : float, optional Angular increment in degrees. plunge_lims : tuple[float, float], optional Inclusive lower and upper target-depth limits. plunge_step : float, optional Target-depth increment. x_limit, y_limit : float or None, optional Optional lateral target-position limits retained for calibration scene generation. max_images : int or None, optional Maximum number of TIFF files to render. ``None`` renders every pose. """ angle_lims: tuple[float, float] = (-10.0, 10.0) angle_step: float = 5.0 plunge_lims: tuple[float, float] = (-5.0, 5.0) plunge_step: float = 5.0 x_limit: float | None = None y_limit: float | None = None max_images: int | None = None
[docs] @dataclass(slots=True) class BlenderCalibrationTarget: """A textured planar target used to generate calibration images. Parameters ---------- size : numpy.ndarray Target width, height, and thickness in world units. image_path : pathlib.Path Calibration-target texture image. millimetres_per_pixel : float Texture resolution used by Blender UV unwrapping. material : BlenderMaterial, optional Backend-owned material controls for the target. """ size: np.ndarray image_path: Path millimetres_per_pixel: float material: BlenderMaterial = field(default_factory=BlenderMaterial) def __post_init__(self) -> None: """Normalise target array and texture path inputs.""" self.size = np.asarray(self.size, dtype=np.float64) self.image_path = Path(self.image_path)
[docs] def calibration_image_count(data: BlenderCalibrationData) -> int: """Return the number of legacy Blender calibration-target images. Parameters ---------- data : BlenderCalibrationData Calibration target pose-sweep configuration. Returns ------- int Number of target poses in the historical nine-position lateral sweep. """ plunge_steps = ( data.plunge_lims[1] - data.plunge_lims[0] ) / data.plunge_step angle_steps = (data.angle_lims[1] - data.angle_lims[0]) / data.angle_step return int((plunge_steps + 1.0) * (angle_steps + 1.0) ** 2 * 9)
[docs] def render_calibration_images( target: BlenderCalibrationTarget, cameras: Sequence[Camera], config: BlenderConfig, data: BlenderCalibrationData | None = None, lights: Sequence[Light] | None = None, ) -> RenderResult: """Render a legacy-compatible Blender calibration-target pose sweep. Parameters ---------- target : BlenderCalibrationTarget Textured physical calibration target. cameras : Sequence[Camera] Exactly two perspective cameras defining the calibration rig. config : BlenderConfig Blender engine, image output, and sampling controls. data : BlenderCalibrationData, optional Target translation and rotation sweep controls. lights : Sequence[Light] or None, optional Optional scene lights. ``None`` preserves Blender's dark default world. Returns ------- RenderResult File-only result containing the calibration TIFF paths. Raises ------ RenderInputError If input is invalid or the Blender backend is unavailable. """ raise_if_blender_unavailable() if not isinstance(target, BlenderCalibrationTarget): raise TypeError("target must be a BlenderCalibrationTarget.") if not isinstance(config, BlenderConfig): raise TypeError("config must be a BlenderConfig.") if data is None: data = BlenderCalibrationData() elif not isinstance(data, BlenderCalibrationData): raise TypeError("data must be a BlenderCalibrationData.") if data.max_images is not None and data.max_images < 1: raise ValueError("data.max_images must be positive when specified.") if target.size.shape != (3,) or np.any(target.size <= 0.0): raise ValueError("target.size must contain three positive dimensions.") camera_data = tuple(cameras) if len(camera_data) != 2 or not all( isinstance(camera, Camera) for camera in camera_data ): raise ValueError("Calibration rendering requires exactly two Cameras.") blender = importlib.import_module("pyvale.blender") scene = blender.Scene() target_object = scene.add_cal_target(target.size) for camera in camera_data: scene.add_camera(camera) for light in lights or (): scene.add_light(_legacy_light(blender, light)) scene.add_speckle( target_object, target.image_path, blender.MaterialData( roughness=target.material.roughness, metallic=target.material.metallic, interpolant=target.material.interpolant, ), target.millimetres_per_pixel, cal=True, ) config.output_dir.mkdir(parents=True, exist_ok=True) render_data = blender.RenderData( cam_data=tuple( _legacy_calibration_camera(camera) for camera in camera_data ), base_dir=config.output_dir, samples=config.samples, max_bounces=config.max_bounces, threads=config.threads, engine=blender.RenderEngine(config.engine.value), device=config.device.value, seed=config.seed, use_denoising=config.use_denoising, use_adaptive_sampling=config.use_adaptive_sampling, ) legacy_data = blender.CalibrationData( angle_lims=data.angle_lims, angle_step=data.angle_step, plunge_lims=data.plunge_lims, plunge_step=data.plunge_step, x_limit=data.x_limit, y_limit=data.y_limit, max_images=data.max_images, ) blender.Tools.render_calibration_images( render_data, legacy_data, target_object ) paths = tuple(sorted((config.output_dir / "calimages").glob("*.tiff"))) return RenderResult(None, paths)
def _legacy_calibration_camera(camera: Camera) -> SimpleNamespace: """Supply the derived camera data required by the legacy target loop.""" values = { field: getattr(camera, field) for field in camera.__dataclass_fields__ } values["pixels_num"] = camera.pixels_num values["pixels_size"] = camera.pixels_size values["image_dist"] = float( np.linalg.norm( camera.pos_world - camera.roi_cent_world, ) ) return SimpleNamespace(**values) __all__ = [ "BlenderCalibrationData", "BlenderCalibrationTarget", "calibration_image_count", "render_calibration_images", ]