mitsuba-renderer / mitsuba3

Mitsuba 3: A Retargetable Forward and Inverse Renderer
https://www.mitsuba-renderer.org/
Other
2.05k stars 240 forks source link

[🐛 bug report] Incorrect radiance values using the `radiancemeter` plugin #78

Open leroyvn opened 2 years ago

leroyvn commented 2 years ago

Summary

I get wrong radiance values with a simple setup consisting of a homogeneous volume above a flat surface.

System configuration

Description

I'm simulating radiance values with a simple setup consisting of a homogeneous volume above a flat surface, illuminated with a directional emitter (see monochromatic illustration below).

camera

The sensor setup consists of a set of radiancemeter plugins targeting the [0, 0, 1] point (the top of the participating medium), placed in an arc above the volume at a reasonable distance (order of magnitude 1) from the target point. I get incorrect values for some of those radiance meters. Example:

radiancemeters

On this figure, the sensor index maps to the local zenith angle, from -75° to +75°.

Sensor configurations leading incorrect values to appear seem to depend on:

Steps to reproduce

Run the script below:

# %%
# Imports

import numpy as np
import matplotlib.pyplot as plt
import mitsuba

# %%
# Utility functions

def cos_angle_to_direction(cos_theta, phi):
    cos_theta = np.atleast_1d(cos_theta).astype(float)
    phi = np.atleast_1d(phi)

    sin_theta = np.sqrt(1.0 - np.multiply(cos_theta, cos_theta))
    sin_phi, cos_phi = np.sin(phi), np.cos(phi)
    return np.vstack((sin_theta * cos_phi, sin_theta * sin_phi, cos_theta)).T

def angles_to_direction(angles):
    angles = np.atleast_1d(angles).astype(float)
    if angles.ndim < 2:
        angles = angles.reshape((angles.size // 2, 2))
    if angles.ndim > 2 or angles.shape[1] != 2:
        raise ValueError(f"array must be of shape (N, 2), got {angles.shape}")

    negative_zenith = angles[:, 0] < 0
    angles[negative_zenith, 0] *= -1
    angles[negative_zenith, 1] += np.pi

    return cos_angle_to_direction(np.cos(angles[:, 0]), angles[:, 1])

def map_cube(xmin, xmax, ymin, ymax, zmin, zmax):
    from mitsuba.core import ScalarTransform4f

    scale = ScalarTransform4f.scale(
        [
            0.5 * (xmax - xmin),
            0.5 * (ymax - ymin),
            0.5 * (zmax - zmin),
        ]
    )
    translate = ScalarTransform4f.translate(
        [
            xmin + 0.5 * (xmax - xmin),
            ymin + 0.5 * (ymax - ymin),
            zmin + 0.5 * (zmax - zmin),
        ]
    )
    return translate * scale

# %%
# Scene configuration

# Participating medium transform setup
width = 1.0  # 1 unit wide
bottom = 0.0  # surface level at z = 0
top = 1.0  # top at z = 1
offset = 0.1  # further extend the shape underneath surface

def cube_trafo():
    return map_cube(
        xmin=-0.5 * width,
        xmax=0.5 * width,
        ymin=-0.5 * width,
        ymax=0.5 * width,
        zmin=bottom - offset,
        zmax=top,
    )

# Sensor directions
angles = np.deg2rad([[theta, 0] for theta in np.arange(-75, 76, 5)])
directions = -angles_to_direction(angles)

# %%
# Scene setup

def radiancemeter_dict(direction, target=(0, 0, 0), distance=2.0):
    from mitsuba.core import ScalarTransform4f, coordinate_system

    target = np.array(target)
    origin = target - distance * direction
    _, up = coordinate_system(direction)

    return {
        "type": "radiancemeter",
        "to_world": ScalarTransform4f.look_at(
            origin=origin,
            target=target,
            up=up,
        ),
        "film": {
            "type": "hdrfilm",
            "width": 1,
            "height": 1,
            "rfilter": {"type": "box"},
            "pixel_format": "luminance",
        },
    }

def load_scene():
    from mitsuba.core import ScalarTransform4f, load_dict

    scene_dict = {
        "type": "scene",
        "surface_bsdf": {
            "type": "diffuse",
            "reflectance": 1.0,
        },
        "surface_shape": {
            "type": "rectangle",
            "bsdf": {"type": "ref", "id": "surface_bsdf"},
            "to_world": ScalarTransform4f.scale([0.5, 0.5, 1]),
        },
        "atmosphere_phase": {"type": "rayleigh"},
        "atmosphere_medium": {
            "type": "homogeneous",
            "sigma_t": 1.0,
            "albedo": 0.5,
            "phase": {"type": "ref", "id": "atmosphere_phase"},
        },
        "atmosphere_shape": {
            "type": "cube",
            "to_world": cube_trafo(),
            "interior": {"type": "ref", "id": "atmosphere_medium"},
            "bsdf": {"type": "null"},
        },
        "illumination": {
            "type": "directional",
            "direction": [0, 0, -1],
        },
        "integrator": {"type": "volpathmis"},
    }

    # Add sensors
    scene_dict["camera"] = {
        "type": "perspective",
        "to_world": ScalarTransform4f.look_at(
            origin=[2, 2, 2], target=[0, 0, 0.4], up=[0, 0, 1]
        ),
        "film": {
            "type": "hdrfilm",
            "width": 640,
            "height": 480,
            "pixel_format": "luminance",
        },
    }

    for i, direction in enumerate(directions):
        scene_dict[f"radiancemeter_{str(i).zfill(3)}"] = radiancemeter_dict(
            direction, target=[0, 0, 1], distance=1.6
        )

    return load_dict(scene_dict)

# %%
# Plot radiancemeter output

plt.figure()

for variant in [
    "scalar_mono",
    "scalar_mono_double",
    "scalar_rgb",
    "scalar_rgb_double",
    "scalar_spectral",
    # "llvm_rgb",
]:
    mitsuba.set_variant(variant)
    scene = load_scene()
    result_radiancemeter = np.empty((directions.shape[0],))

    for i in range(1, len(scene.sensors())):
        radiance = np.squeeze(scene.render(spp=10000, seed=i, sensor_index=i))
        result_radiancemeter[i - 1] = radiance

    with plt.style.context({"lines.linestyle": ":", "lines.marker": "."}):
        plt.plot(result_radiancemeter, label=variant)

plt.xlabel("Sensor index")
plt.ylabel("Radiance")
plt.legend()

plt.show()
plt.close()

# %%
# Render an image with the camera

mitsuba.set_variant("scalar_mono")
scene = load_scene()
img = np.array(scene.render(spp=32, sensor_index=0)).squeeze()
plt.imshow(img)
plt.colorbar()
plt.title("scalar_mono")
plt.show()
plt.close()
wjakob commented 2 years ago

Just to clarify: there is some issue with scalar_rgb_double, correct? (and the other radiancemeters give the expected result?) Is this a new bug? (i.e. something that worked previously).

leroyvn commented 2 years ago

I have this with all scalar variants I tried (I can also double-check with LLVM variants if you'd like). I can't tell you whether this specific example actually ever worked, I put it together specifically for this issue.

Some context. We've been having this issue since months with Eradiate. However we didn't report it because we were using the old codebase: we didn't want to waste your time with the old code.

We discussed something similar a few months ago and this led you guys to fixing intersection issues with the rectangle and sphere plugins, IIRC. I tried backporting those fixes to the Eradiate kernel version of Mitsuba, but it didn't fix it. Hoping that the quality of that port would be the problem, I decided to wait and further investigate when we'd align with the Next repo.

We originally spotted that with a modified distant sensor which basically aggregates an arbitrary number of distant radiancemeters. This is what's been buggy since, well, at least 4 months. I couldn't be sure that this wasn't on us (bad sensor implementation); now that we're moving to the new codebase, I can ensure that the MWE setup is exactly the same as the one which produces the bugs in Eradiate.

leroyvn commented 2 years ago

I forgot a very important point: incorrect value locations also move when I stretch the volume (varying the offset parameter in the script). I'll update the issue description.

leroyvn commented 2 years ago

EDIT: Answering you questions properly.

leroyvn commented 2 years ago

I'm coming back to this issue: I updated the sample script above with the recent changes (see this gist).

The issue can still be observed with all scalar double precision variants: image

I couldn't try LLVM variants because of #92.

Speierers commented 2 years ago

It would be great to better understand what causes this discrepancy. Maybe could you try with a simplified setup, e.g. using a constant emitter and isotropic phase function. Also you could try removing the floor plane, to see whether this "extra energy" comes from the scattering in the volume or the light bouncing on that plane.

Also trying with LLVM would be interesting.

Let me know whether this helped pinpointing where the problem is.

🦇 🥷

leroyvn commented 2 years ago

Thanks to you solving #92, I can now run this with the llvm_rgb variants and I get the same result: surface_rayleigh_directional

The outlier point in this "surface, Rayleigh, directional" has the value of the reflected radiance without the participating medium (1/π).

I ran a bunch of different configurations ranging from my original configuration to what you requested (see updated script), i.e. "no surface, isotropic, constant": nosurface_isotropic_constant

There, the outlier takes the value of the emitted radiance (1).


My interpretation is that the ray intersection somehow misses the cube and directly hits what's behind (either the surface or nothing depending on the configuration). The random walk consequently never enters the medium, hence the radiance values we get.

leroyvn commented 2 years ago

Varying the surface reflectance:

No surface, isotropic, constant (radiance without medium: 1.0) nosurface_isotropic_constant

Surface (ρ=0.5), isotropic, constant (radiance without medium: 0.5) surface0 5_isotropic_constant

Surface (ρ=1.0), isotropic, constant (radiance without medium: 1.0) surface1 0_isotropic_constant

(Just making sure I'm picking up the reflected radiance value when there is a surface, not the emitter.)

leroyvn commented 2 years ago

Other things I tried:

Speierers commented 2 years ago

Could you hack in the integrator and make it so that it returns 1.0 when doing the ray marching, and 0.0 otherwise? Just to validate your assumtion above. If that's the case, then you should be able to isolate the ray tracing call that misses the cube and better understand what's going on.

leroyvn commented 2 years ago

Alright, I did what you suggested and it's becoming more and more strange. When I run my script and chain both the single and double precision variants, I get this: nosurface_isotropic_constant_both

In that case, the ray cast by the problematic sensor misses the front face and hits the back face (I expect ray.o.z to be equal to 1):

2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator] ray_ = Ray3d[
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   o = [-0.547232, 6.70166e-17, 2.50351],
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   d = [0.34202, -4.18854e-17, -0.939693],
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   maxt = 1.79769e+308,
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   time = 0,
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator] ]
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator] ray = Ray3d[
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   o = [0.400367, -2.498e-16, -0.1],
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   d = [0.34202, -4.18854e-17, -0.939693],
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   maxt = 1.79769e+308,
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator]   time = 0,
2022-02-26 14:20:40 WARN  main  [VolumetricPathIntegrator] ]

However, this issue disappears when I run only the double precision variant: nosurface_isotropic_constant_double

I made sure that RNG seed values are reproducible independently from variant selection so I wouldn't expect to be the cause of a behaviour variation.

Speierers commented 2 years ago

Interesting. So it seems like the scene instance of the double-variant is interfering with the scene instance of thesingle-variant. Could you maybe try to explicitly delete and garbage collect the scene at every loop iteration in your script? E.g. using gc.collect(); gc.collect()

leroyvn commented 2 years ago

I added a del scene; gc.collect(); gc.collect() to my loop on variants, but this doesn't seem to do much: I still get the same results.

Speierers commented 2 years ago

Okay, I can take a closer look at this issue. Could you share a minimal reproducer that doesn't even use an integrator, but rather do everything in python, e.g. trace a ray and return 1.0 or 0.0. Then I will continue the investigation on my side. Thanks a lot for digging this up!

Speierers commented 2 years ago

Pinging @leroyvn here. Is this issue still relevant, or was that bug fixed in the latest codebase?

leroyvn commented 2 years ago

Hi @Speierers, I'd need some time to get back to this. I couldn't reproduce this issue without the integrator at the time, and I haven't worked on it recently so I don't know if it still happens with the latest codebase. If you wish to close the issue, I can reopen it later if necessary.

Speierers commented 2 years ago

No worries, let's keep it open for now