render_optscene — OPTICS scene op

• Data kinds: table × table × table → rgbimage

• Call: import fullseye as fs; fs.ledger.render_optscene(scene, camera, lights, ambient: 'float' = 0.0, depth: 'int' = 2, shadows: 'bool' = True, supersample: 'int' = 1, adaptive: 'bool' = False, light_samples: 'int' = None, edge_threshold: 'float' = 0.06, saturate_at: 'float' = None, wavelength_nm: 'float' = 550.0, environment=None, environment_gain: 'float' = 1.0, environment_samples: 'int' = 12, spectral: 'bool' = False) -> 'np.ndarray' (to call the implementation directly, import optscene; optscene.render_optscene(scene, camera, lights, ambient: 'float' = 0.0, depth: 'int' = 2, shadows: 'bool' = True, supersample: 'int' = 1, adaptive: 'bool' = False, light_samples: 'int' = None, edge_threshold: 'float' = 0.06, saturate_at: 'float' = None, wavelength_nm: 'float' = 550.0, environment=None, environment_gain: 'float' = 1.0, environment_samples: 'int' = 12, spectral: 'bool' = False) -> 'np.ndarray'; from the registry, opsoptics.get("render_optscene"))

Usage

Shoot for inspection: place real luminaires in physical units and count the direct light (with ground truth).

No multiple reflections or ambient light for appearance are included. If you need a pretty picture, use render_studio ―― a separate op built differently; mixing them up muddies the basis of the photometry. Returns linear RGB radiance (H, W, 3), before tone mapping.

lights is the result of illumdesign.light_source (1 or several side by side). ambient is uniform ambient light, depth the recursion depth through dielectrics, shadows=False turns off cast shadows (faster, but unusable for inspections that assume shadows). supersample raises the samples per 1 pixel to n×n ―― real pixels integrate over their area, so this removes the staircase jaggies at edges and machining marks (cost n² times).

3 knobs for mass generation (speed is a feature of the generator):

• adaptive=True ―― first shoot with 1 sample, then split only pixels with geometric edges or large luminance gradients into n×n. No samples are wasted on flat surfaces, so it gets faster while keeping edge quality.

• light_samples ―― thins the emitter points of area lights to this number (brightness corrected by weights). What is lost is the softness of shadow edges, not the absolute brightness.

• saturate_at ―― pixels above this luminance are not refined (they clip to white anyway).

wavelength_nm is the wavelength of the light source. It enters the fraction a surface of roughness σ returns as specular, exp(-(4πσcosθ/λ)²), so the same surface looks different depending on wavelength. For broadband (halogen) shoot at several wavelengths and add; for a laser shoot at a single wavelength.

Passing environment lets you mix the way display pictures are made into this: the environment drawn through the anisotropic lobe of metals is multiplied by environment_gain and added. Brushed metal looks like metal because the environment is reflected stretched along the brush marks; with point lights alone it becomes only hard stripes (user insight of 2026-09-05). Default is off ―― so as not to silently mix terms for appearance into inspection images that need a photometric basis. Passing optscene.env_studio is the easy way; your own function ((...,3) direction -> (...,) brightness) also works.

spectral=True shoots monochrome at that 1 wavelength: materials are reduced to the value at wavelength_nm (metals via Fresnel from the n,k of the material table, dielectrics via n(λ), lambert via reflectance_curve). The 3 channels get the same value. Integration over the light source spectrum, transmittance and QE is bundled by layout_capture(mode="spectral").

If there is coaxial illumination (light_spec(kind="coaxial")), the image is multiplied by the transmittance of the half mirror (the camera looks through it). The reflectance on the side that reaches the part is already folded into the intensity by light_spec.

To round for display, apply **(1/2.2) yourself. To add the distortion, PSF, vignetting and sensor noise of a real lens, apply lensimage.render_through_lens afterwards; for quantization and read noise only, apply sensor_capture afterwards.

Family-wide input contract (fail-closed)

Every optics op validates its input before computing (nothing slips through silently):

• Units are baked into the argument name — _mm / _um / _deg / _mrad. Confusing mm with µm does not crash; it yields a plausible-looking wrong answer, so the name prevents it. Nothing here guesses the unit from the magnitude.

• Strings raise ValueError — float('50') succeeds, so an unparsed configuration value would slip through as a length (measured: thin_lens('50', '200') returned a plausible 66.667 mm). bool is refused too, as the implicit promotion True == 1.

• complex / masked arrays raise ValueError (real-valued slots only; silently dropping the imaginary part or peeling off the mask is refused). NaN/Inf raises ValueError on every input.

• Division by zero and its relatives are refused by name: focal length 0, radius of curvature 0, refractive index <= 0, a fully opaque aperture (all zeros, so the normalisation is 0/0), a PSF whose sum is <= 0, a Stokes vector with S0 = 0, and an object sitting at the front focal point (the image is at infinity).

• Only two ops return a non-finite value, and both state it as a contract: depth_of_field returns far_mm = inf beyond the hyperfocal distance (that is what the hyperfocal distance means), and gaussian_beam returns wavefront_radius_mm = inf at the waist (the radius of curvature of a plane wavefront). Both also return a finite companion (far_is_infinite / curvature_per_mm). Any other silent NaN/Inf is detected internally and raises ValueError — "float64 overflowed" and "the answer is infinite" are different claims, so the first is never returned wearing the face of the second.

• Size caps: generated grids are capped by optics.MAX_GRID (4096); supplied fields/PSFs/apertures by optics.MAX_FIELD_ELEMENTS (2^24); ABCD element chains by optics.MAX_SYSTEM_ELEMENTS (1024); Zernike by MAX_ZERNIKE_TERMS (512) / MAX_ZERNIKE_ORDER (40) / MAX_ZERNIKE_BASIS (2^25). This closes, fail-closed, the paths where a small argument triggers a huge internal allocation (measured: n_max=40 × 4096² needs 108 GB).

• Physically impossible states are refused too: a Stokes vector with degree of polarisation > 1, negative transmittance, negative intensity, and invalid Zernike indices such as n-|m| odd.

Detailed usage guide

• optics_imaging family guide

Background guides (the physics and conventions behind this op)

• dataset_conventions — Training-dataset conventions — COCO / YOLO / VOC and pitfalls in visual inspection

• mv_cameras — Industrial camera makers (sensor mapping, line scan / TDI)

• mv_illumination_practice — Lighting in practice — wavelength, polarisation, drive modes, ambient light, safety

• mv_image_sensors — Industrial image sensors (mainly current products)

• virtual_machine_vision — Virtual machine vision — enumerating the parameters and an object model

References (sample data, literature)

• Sample-data catalog (download URLs / licences) — 2-D uses skimage.data (BSD/public domain) plus synthetic images; 3-D lists download URLs for real data sources (Stanford, PDS, …).

• Operator provenance and references — the sources of the research/methods this op family came from.

• The canonical algorithm (author, year) and its uses are named in the family usage guide above.

Runnable examples (verified samples that actually call this op)

• poc_nir_silicon_transmission — py -3.11 examples/poc_nir_silicon_transmission.py

• poc_pocket_interreflection — py -3.11 examples/poc_pocket_interreflection.py

• virtual_machine_vision — py -3.11 examples/virtual_machine_vision.py

Ops the type connects to (they accept rgbimage as input)

clearcoat_shade · wetness · defocus_blur · diffraction_blur · sensor_capture · pixel_shift_capture · false_color_metric

Same category (scene)

scene_material · scene_plane · scene_sphere · scene_box · scene_cylinder · surface_defect · surface_finish · random_defects


*Provenance: optscene.py — OPTICS operator registry. This per-op note is generated by tools/opdocs.py md (do not hand-edit).*

© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.