Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 46

Stratified Sampling of Camera Rays

Recommended resources

Hello! Welcome back to our series on 3D generative models.

In our previous lesson, we successfully implemented the logic for generating camera rays, transforming 2D pixel coordinates into 3D rays defined by an origin (rays_o) and a direction (rays_d). This gave us the lines of sight into our 3D scene.

Today, we will address the learning outcome: Implement stratified sampling of points along a camera ray. This is the next crucial step in the NeRF pipeline. We'll take the rays we generated and select a set of 3D points along each one. These are the points where we will eventually query our neural network to ask, "What is the color and density here?" This process turns the continuous problem of integrating along a ray into a discrete, computable sum.

Let's begin.

1. From Continuous Rays to Discrete Points

The core idea of volume rendering is to calculate the color of a pixel by integrating color and density contributions along the corresponding camera ray. The volume rendering equation, which we'll implement in the next lesson, is an integral:

As you know from your machine learning background, we often approximate integrals with a summation, similar to how expectation is approximated with a sample mean. This is exactly what we'll do here. We will approximate this integral using numerical quadrature, which involves evaluating the function at a discrete number of points and computing a weighted sum.

Our task today is to select these discrete points along the ray . The sampling process is bounded by two planes: a near plane () and a far plane (). These define the volume of interest for our scene.


2. Stratified Sampling: A Smart Way to Sample

The simplest way to pick points would be to divide the segment from near to far into equal sections and pick the midpoint of each. This is uniform sampling. However, NeRF uses a slightly more clever technique called stratified sampling.

Stratified sampling works in two steps:

  1. Partition: The interval is divided into uniform, non-overlapping bins.
  2. Sample: Instead of picking a fixed point in each bin (like the midpoint), we draw one sample uniformly at random from within each bin.

Stratified vs Hierarchical Sampling
This image from 'The Annotated NeRF' article illustrates the difference between initial sampling and a refined sampling strategy. The white points represent the initial, evenly-spaced samples from stratified sampling, which we are implementing today. The red points show a later refinement step (hierarchical sampling), where more points are sampled in important regions.

Why bother with the randomness?
During training, the network sees slightly different 3D points for the same ray in every epoch. This prevents the model from overfitting to a fixed, discrete set of locations and forces it to learn a continuous representation of the scene's geometry and appearance. It acts as a form of regularization and reduces aliasing artifacts.

Let's hear a quick justification for this from a practical standpoint.

Instant NGP in 100 lines of PyTorch code | NeRF #13

The video 'Instant NGP in 100 lines of PyTorch code' gives a concise reason for adding this randomness, or 'noise', during the sampling process.

Watch from 12:36 to 13:24. Focus on the explanation for why a bit of noise is added to the sampled points. This directly relates to the 'perturb' step in stratified sampling.


3. Implementing Stratified Sampling

Now, let's dive into the implementation. The "NeRF From Nothing" Google Colab notebook provides a clear and well-documented PyTorch function that does exactly what we need.

NeRF From Nothing - Colab - Google

The 'NeRF From Nothing' Colab notebook provides an excellent, self-contained implementation of stratified sampling. We will study its sample_stratified function to understand the practical details.

In the notebook, find the section titled 'Stratified Sampling'. Read the explanation and carefully analyze the Python code for the sample_stratified function. Pay special attention to the perturb flag. Also, observe the visualization that compares perturbed (stratified) vs. unperturbed samples.


4. Code Walkthrough and Analysis

Let's dissect the sample_stratified function you just reviewed. It takes the ray origins and directions, near/far bounds, and the number of samples as input, and outputs the 3D points and their corresponding depth values.

Here is a slightly simplified version of the core logic:

import torch

def sample_stratified(rays_o: torch.Tensor,
                      rays_d: torch.Tensor,
                      near: float,
                      far: float,
                      n_samples: int,
                      perturb: bool = True):
    # 1. Create partition boundaries
    t_vals = torch.linspace(0., 1., n_samples, device=rays_o.device)
    z_vals = near * (1. - t_vals) + far * (t_vals)
    # z_vals are now the evenly spaced bin boundaries from near to far

    # 2. Get random samples within each bin
    if perturb:
        # Get midpoints between the bin boundaries
        mids = .5 * (z_vals[1:] + z_vals[:-1])
        
        # Define the lower and upper bounds of each bin
        upper = torch.concat([mids, z_vals[-1:]], -1)
        lower = torch.concat([z_vals[:1], mids], -1)
        
        # Draw a random sample 't' from Uniform(0, 1) for each bin
        t_rand = torch.rand([n_samples], device=z_vals.device)
        
        # Linearly interpolate between lower and upper bounds to get the final sample
        z_vals = lower + (upper - lower) * t_rand

    # Expand z_vals to match the batch dimension of rays
    z_vals = z_vals.expand(list(rays_o.shape[:-1]) + [n_samples])
    
    # 3. Compute 3D point locations: r(t) = o + t*d
    # rays_o: [batch_size, 3] -> [batch_size, 1, 3]
    # rays_d: [batch_size, 3] -> [batch_size, 1, 3]
    # z_vals: [batch_size, n_samples] -> [batch_size, n_samples, 1]
    pts = rays_o[..., None, :] + rays_d[..., None, :] * z_vals[..., :, None]
    # pts shape: [batch_size, n_samples, 3]
    
    return pts, z_vals

Key Implementation Points:

  • Step 1 (Partitioning): torch.linspace creates values from 0 to 1, which are then linearly mapped to the [near, far] range. At this point, z_vals holds the edges of our bins.
  • Step 2 (Perturbation): This if perturb: block is the "stratified" part. It defines the lower and upper boundary for each bin and then draws a random sample within it. This is a practical application of inverse transform sampling for a uniform distribution.
  • Step 3 (Point Calculation): The final line pts = ... is the vector equation . The None indexing (... , None, :) is crucial for broadcasting the tensors to the correct shapes so that PyTorch performs the operation for every sample on every ray in the batch. The result is pts, a tensor of 3D coordinates.
Test your understanding!

In the sample_stratified function from the Colab notebook, if you set perturb=False, the if perturb: block is skipped. What points along the ray are actually being sampled in that case? Are they the midpoints of the bins?

Show answer

No, they are not the midpoints. If perturb=False, the if block is skipped, and the original z_vals created by torch.linspace and scaled to the [near, far] range are used. This means the samples are taken exactly at the boundaries of the bins, starting at near and ending at far. This is a subtle but important implementation detail to notice. A different implementation might choose to sample the midpoints when perturbation is off.

The inverse_depth Parameter

You may have noticed an inverse_depth boolean in the Colab function. When set to True, it samples points that are linearly spaced in disparity (1/depth) rather than depth.

z_vals = 1. / (1./near * (1.-t_vals) + 1./far * (t_vals))

This is a useful trick. It allocates more samples to objects closer to the camera and fewer to objects far away, which can be a more efficient distribution of your fixed number of samples, especially for scenes with large depth variations. For now, we'll stick to linear depth sampling.


Conclusion

Excellent work! We have now built the second major component of our renderer. We started with rays and have now generated sets of 3D query points along each one.

Key Takeaways:

  • Volume rendering approximates a continuous integral with a discrete sum, which requires sampling points along each camera ray.
  • Stratified sampling is used to do this. It involves partitioning the ray segment [near, far] into bins and drawing one random sample from each bin.
  • This randomness acts as regularization, preventing the model from overfitting to fixed points and helping to reduce aliasing.
  • The implementation boils down to generating bin boundaries, perturbing them randomly, and then applying the ray equation to get the final 3D point coordinates (pts) and their depth values (z_vals).

Next Lesson Preview:

We are now perfectly set up for the grand finale of the rendering module. We have our rays (rays_o, rays_d) and the points to evaluate (pts, z_vals). In the next lesson, we will implement the volume rendering equation. This involves taking the color and density predictions from our (hypothetical) NeRF model at each point pts and combining them using the principles of volumetric compositing to calculate the final, single color for the pixel.