Geometry-Grounded 3D Affordance Queries

Query 3D scenes for functional affordances by fusing CLIP with Gaussian geometry.

Introduction

30 Second Summary

When you walk into a room, you instantly know which surfaces you can sit on, where to place your coffee, and what you can reach from where you stand. This kind of functional understanding goes beyond recognizing objects. It requires reasoning about physical properties like height, orientation, and size.

In this project, you will build a research prototype that answers "what can I do here?" in 3D scenes by fusing language embeddings with geometric constraints extracted directly from 3D Gaussian Splatting parameters. No extra training, no VLM inference, no graph neural networks required.

What You'll Build

A geometry-grounded affordance query engine that combines CLIP semantic similarity with physical geometric constraints (height, surface orientation, spatial extent) to answer functional queries about 3D scenes.

By the end of this project, you'll have:

  • A training-free 3D language field augmented with per-Gaussian geometric affordance features extracted directly from Gaussian parameters.
  • Geometric affordance extractors that compute ground-plane estimation, height above ground, surface normals from covariance ellipsoids, and spatial extent.
  • A full affordance query engine that fuses semantic similarity with geometric constraints, with quantitative evaluation proving it outperforms semantic-only baselines on functional tasks.
  • Secret Mission: Implement a robot reachability affordance layer that filters objects by whether they are physically accessible from a given robot position and arm reach.

Are there any prerequisites?

This project assumes strong Python and PyTorch experience, familiarity with 3D computer vision concepts, and an understanding of CLIP/VLM embedding spaces. You will also need a Google Colab Pro subscription with access to an L4 GPU runtime.

Before We Start

This project runs on Google Colab Pro with a GPU-accelerated runtime. You need a 3D Gaussian Splatting scene, a CLIP model for language embeddings, and geometric processing libraries to extract affordance features from Gaussian parameters.

In this step, you will configure your Colab Pro runtime with GPU access, install all dependencies, clone the Occam's LGS repository for training-free feature lifting, and download the LERF figurines dataset with its pre-trained checkpoint.

In this step, get ready to:
  • Configure Colab Pro with an L4 GPU and install all Python dependencies.
  • Clone and install Occam's LGS for training-free CLIP feature lifting.
  • Download the LERF figurines dataset and pre-trained 3DGS checkpoint.
Configure Colab Pro runtime and install dependencies

The geometric affordance computations and CLIP inference in this project require a CUDA-capable GPU. Colab Pro gives you access to NVIDIA GPUs like the L4 through its runtime settings.

  • Open a new notebook in Google Colab.
  • Click Runtime in the top menu bar.
  • Click Change runtime type.
  • In the Hardware accelerator dropdown, select L4 (or another available GPU if L4 is unavailable).
  • Click Save.

Why an L4 GPU?

The L4 has enough VRAM to hold the 3DGS scene, CLIP model, and per-Gaussian feature tensors simultaneously. A T4 can work for smaller scenes but may run out of memory during evaluation.

  • Install PyTorch with CUDA support by running this command in a new code cell:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
  • Install the remaining project dependencies by running this in another cell:
pip install gsplat open_clip_torch numpy scipy matplotlib open3d plyfile tqdm scikit-learn

What are these packages for?

  • gsplat provides efficient Gaussian Splatting rasterization on GPU.
  • open_clip_torch gives you access to open-source CLIP models for encoding text and image features.
  • open3d handles 3D point cloud visualization.
  • plyfile reads the PLY files that store Gaussian parameters from 3DGS training.
  • Verify the installation by running this in a new cell:
python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA available: {torch.cuda.is_available()}')"

You should see the PyTorch version printed with CUDA available: True.

Seeing CUDA available: False?

  • Make sure you saved the runtime change to GPU. Go back to Runtime then Change runtime type and confirm a GPU is selected.
  • If you just changed the runtime type, the notebook may need to reconnect. Click Connect in the top right corner and try again.

Help me debug why CUDA is not available in my Colab runtime.

Clone and install Occam's LGS

Occam's LGS is a training-free method that lifts 2D CLIP features to 3D Gaussians in under one minute. It works by projecting pre-extracted image features onto the Gaussian scene without any optimization loop.

  • Clone the repository and install it by running these commands:
git clone https://github.com/insait-institute/OccamLGS.git --recursive
cd OccamLGS
pip install -e .
cd ..

What does this do?

  • --recursive clones all submodules (including the custom rasterizer) alongside the main repository.
  • pip install -e . installs the package in editable mode so you can import its modules directly in your notebook.
  • Verify the install succeeded by running:
python -c "import occam_lgs; print('OccamLGS installed successfully')"

You should see OccamLGS installed successfully printed.

Getting an import error?

  • Make sure the --recursive flag was included in the clone command. Without it, submodule dependencies are missing.
  • If you see a CUDA compilation error during install, restart your runtime and try the pip install -e . step again. The JIT compiler sometimes needs a fresh runtime.

Help me fix an import error after installing OccamLGS on Colab.

Download the LERF figurines dataset

The LERF dataset provides real-world scenes with known objects. The figurines scene has diverse tabletop objects at different heights and orientations, which makes it ideal for testing affordance queries.

  • Install gdown (a Google Drive downloader) and download the dataset by running:
pip install gdown
mkdir -p data
gdown --folder https://drive.google.com/drive/folders/1Icw5AcQkY_2L_k7ddXrGCJ3z4laa4jg5 -O data/lerf_dataset

What does this download?

This downloads the LERF figurines scene which includes the training images, camera poses, and a pre-trained 3DGS checkpoint. The checkpoint contains the full Gaussian parameters (positions, scales, rotations, opacities) stored in a PLY file.

You will load these Gaussian parameters directly to extract geometric affordance features like height, surface orientation, and spatial extent.

  • Verify the download by listing the contents:
ls data/lerf_dataset

You should see folders or files related to the figurines scene, including a .ply checkpoint file containing the trained Gaussians.

Download failing or timing out?

  • Google Drive sometimes rate-limits downloads. Wait a few minutes and try the gdown command again.
  • If you see a permissions error, the folder may require authentication. Try adding --no-cookies or manually downloading from the Drive link in your browser and uploading to Colab.

Help me download the LERF dataset to my Colab environment.

✔️ Awesome, I've got everything!

Great. Double check that your Colab runtime is connected with GPU and that the data/lerf_dataset folder has contents before moving on.

ⓧ I'd like to double check the full code

Here is the complete setup script for reference. You can save this as setup.sh if you want to reproduce the environment from scratch.

#!/bin/bash
# Environment setup for Colab Pro L4
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
pip install gsplat open_clip_torch numpy scipy matplotlib open3d plyfile tqdm scikit-learn

# Clone Occam's LGS
git clone https://github.com/insait-institute/OccamLGS.git --recursive
cd OccamLGS
pip install -e .
cd ..

# Download LERF dataset (figurines scene)
mkdir -p data
gdown --folder https://drive.google.com/drive/folders/1Icw5AcQkY_2L_k7ddXrGCJ3z4laa4jg5 -O data/lerf_dataset

Your environment is ready with a GPU runtime, all dependencies, and the LERF figurines dataset loaded. Next up, you will build the training-free 3D language field and extract geometric affordance features directly from the Gaussian parameters.

Build the Training-Free 3D Language Field with Geometric Features

In Step 1, you set up your Colab environment with all the tools and data needed for this project. Now it's time to build the core data structure: a 3D language field that fuses CLIP embeddings with geometric properties extracted directly from Gaussian parameters.

The key insight is that 3D Gaussian Splatting already encodes rich geometric information in every Gaussian's parameters. Positions give you height, scale parameters give you spatial extent, and rotation quaternions let you recover surface orientation. By combining these geometric signals with CLIP's semantic understanding, you can answer functional queries that neither signal could handle alone.

In this step, get ready to:
  • Load a pre-trained 3DGS scene and lift CLIP features to Gaussians in a training-free pass.
  • Estimate the ground plane and compute per-Gaussian height above ground.
  • Extract surface normals from Gaussian covariance ellipsoids and compute spatial extent.
Load the pre-trained 3DGS scene and lift CLIP features

Every Gaussian in a 3DGS scene stores position, scale (in log-space), rotation (as a quaternion), and opacity. You'll load these parameters from the PLY checkpoint, then attach CLIP language features to each Gaussian using Occam's LGS's training-free lifting.

  • Create the language_field.py file by running this cell:
%%writefile language_field.py
"""Training-free 3D Language Field with geometric features."""
import torch
import numpy as np
import open_clip
from pathlib import Path
from plyfile import PlyData
from scipy.spatial.transform import Rotation


class GeometricLanguageField3D:
    """3D language field augmented with per-Gaussian geometric affordance features."""

    def __init__(self, ply_path: str, device: str = "cuda"):
        self.device = device
        self.gaussians = self._load_gaussians(ply_path)
        self.clip_model, _, self.preprocess = open_clip.create_model_and_transforms(
            "ViT-B-16", pretrained="laion2b_s34b_b88k"
        )
        self.clip_model = self.clip_model.to(device).eval()
        self.tokenizer = open_clip.get_tokenizer("ViT-B-16")
        self.language_features = None
        self.geometric_features = None  # Will hold height, normals, extent

What does this code do?

This creates the beginning of your GeometricLanguageField3D class. The __init__ method loads the Gaussian scene from a PLY file and initializes a ViT-B-16 CLIP model with laion2b_s34b_b88k weights for text encoding.

The language_features and geometric_features attributes start as None and get populated by later methods.

The cell above only writes the first portion of the file. You'll append additional methods in subsequent cells using %%writefile -a.

  • Add the _load_gaussians method that reads the PLY file by running this cell:
%%writefile -a language_field.py

    def _load_gaussians(self, ply_path: str) -> dict:
        """Load full Gaussian parameters from PLY file."""
        plydata = PlyData.read(ply_path)
        vertex = plydata["vertex"]

        positions = np.stack([vertex["x"], vertex["y"], vertex["z"]], axis=-1)

        # Load scales (log-space in standard 3DGS format)
        scales = np.stack([
            vertex["scale_0"], vertex["scale_1"], vertex["scale_2"]
        ], axis=-1)
        scales = np.exp(scales)  # Convert from log-space

        # Load rotations (quaternion: w, x, y, z)
        rotations = np.stack([
            vertex["rot_0"], vertex["rot_1"], vertex["rot_2"], vertex["rot_3"]
        ], axis=-1)

        opacities = vertex["opacity"]

        return {
            "positions": torch.tensor(positions, dtype=torch.float32, device=self.device),
            "scales": torch.tensor(scales, dtype=torch.float32, device=self.device),
            "rotations": torch.tensor(rotations, dtype=torch.float32, device=self.device),
            "opacities": torch.tensor(opacities, dtype=torch.float32, device=self.device),
            "count": len(positions),
        }

What does this code do?

The _load_gaussians method reads the standard 3DGS PLY format. Scale values are stored in log-space, so np.exp(scales) converts them back to world-space dimensions.

Rotations are stored as quaternions in [w, x, y, z] order. Later you'll convert these to rotation matrices to extract surface normals.

  • Add the CLIP feature lifting and query methods by running this cell:
%%writefile -a language_field.py

    def lift_features(self, feature_dir: str):
        """Lift pre-extracted 2D CLIP features to 3D Gaussians (training-free)."""
        feature_path = Path(feature_dir)
        features = torch.load(feature_path / "gaussian_features.pt", map_location=self.device)
        self.language_features = torch.nn.functional.normalize(features, dim=-1)
        print(f"Lifted {self.language_features.shape[0]} Gaussian features ({self.language_features.shape[1]}-dim)")

Why is feature lifting training-free?

Occam's LGS pre-extracts 512-dimensional CLIP feature vectors from rendered views and projects them onto Gaussians. You load the result from gaussian_features.pt and L2-normalize each vector for cosine similarity computation.

This completes in under one minute on a GPU, with no gradient-based optimization or fine-tuning required.

  • Verify the file was created by running:
!head -5 language_field.py

You should see the module docstring and the first import lines printed.

Seeing a file not found error?

Make sure you ran each %%writefile cell in order from top to bottom. The first cell creates the file, and subsequent cells with -a append to it. If you skipped or re-ran cells out of order, recreate by running all cells from the first %%writefile cell again.

help me debug why my language_field.py file is empty or missing in Colab

Estimate the ground plane and compute per-Gaussian height

To answer affordance queries like "something to sit on," you need to know how high each Gaussian is above the floor. You'll estimate the ground plane by finding the lowest 10% of Gaussians and taking their median Z-coordinate as the reference height.

  • Add the compute_geometric_features method by running this cell:
%%writefile -a language_field.py

    def compute_geometric_features(self):
        """Extract geometric affordance features from Gaussian parameters."""
        positions = self.gaussians["positions"]
        scales = self.gaussians["scales"]
        rotations = self.gaussians["rotations"]

        # 1. Estimate ground plane via RANSAC on lowest 10% of points
        heights_raw = positions[:, 2]  # Assume Z-up initially
        low_percentile = torch.quantile(heights_raw, 0.1)
        floor_mask = heights_raw < low_percentile
        floor_points = positions[floor_mask]

        # Simple ground plane: use median of floor points as ground height
        ground_height = floor_points[:, 2].median().item()

        # 2. Per-Gaussian height above ground
        height_above_ground = positions[:, 2] - ground_height

        # 3. Surface normal from shortest axis of Gaussian ellipsoid
        normals = self._compute_normals_from_quaternions(rotations, scales)

        # 4. Spatial extent (area of the two longer axes)
        sorted_scales, _ = torch.sort(scales, dim=-1, descending=True)
        spatial_extent = sorted_scales[:, 0] * sorted_scales[:, 1]  # Two longest axes

        self.geometric_features = {
            "height": height_above_ground,
            "normals": normals,
            "extent": spatial_extent,
            "ground_height": ground_height,
        }
        print(f"Computed geometric features. Ground height: {ground_height:.3f}m")

What does this code do?

  • Ground plane estimation: takes the lowest 10% of Gaussians by Z-coordinate and uses their median as the reference floor height.
  • Height above ground: subtracts ground_height from every Gaussian's Z-position, giving you a per-Gaussian height value.
  • Surface normals: calls _compute_normals_from_quaternions (which you'll add next) to get orientation vectors.
  • Spatial extent: sorts each Gaussian's three scale values and multiplies the two largest, approximating the surface area of the flat face.

Why use the lowest 10% for ground estimation?

In typical indoor scenes, floor-level Gaussians cluster at the bottom of the height distribution. Taking the 10th percentile isolates these floor points without being thrown off by outliers below the scene (noise Gaussians from reconstruction artifacts).

  • You won't be able to run this method yet because it calls _compute_normals_from_quaternions, which you'll define next.
Extract surface normals and spatial extent from Gaussian covariances

Each Gaussian in a 3DGS scene is an oriented ellipsoid defined by its scale and rotation. The shortest axis of the ellipsoid points perpendicular to the surface it represents. This is the same insight used by GS-IR for inverse rendering on Gaussians.

  • Add the _compute_normals_from_quaternions method by running this cell:
%%writefile -a language_field.py

    def _compute_normals_from_quaternions(
        self, quaternions: torch.Tensor, scales: torch.Tensor
    ) -> torch.Tensor:
        """
        Compute surface normal as the shortest axis direction of each Gaussian.
        The shortest axis of an anisotropic Gaussian approximates the surface normal.
        """
        # Find shortest axis index per Gaussian
        shortest_axis_idx = torch.argmin(scales, dim=-1)  # [N]

        # Convert quaternions to rotation matrices
        # quaternion format: [w, x, y, z]
        w, x, y, z = quaternions[:, 0], quaternions[:, 1], quaternions[:, 2], quaternions[:, 3]

        # Rotation matrix columns (local axes in world frame)
        # Column 0 (local X)
        r00 = 1 - 2*(y*y + z*z)
        r10 = 2*(x*y + w*z)
        r20 = 2*(x*z - w*y)

        # Column 1 (local Y)
        r01 = 2*(x*y - w*z)
        r11 = 1 - 2*(x*x + z*z)
        r21 = 2*(y*z + w*x)

        # Column 2 (local Z)
        r02 = 2*(x*z + w*y)
        r12 = 2*(y*z - w*x)
        r22 = 1 - 2*(x*x + y*y)

        # Stack rotation matrix columns
        col0 = torch.stack([r00, r10, r20], dim=-1)  # [N, 3]
        col1 = torch.stack([r01, r11, r21], dim=-1)  # [N, 3]
        col2 = torch.stack([r02, r12, r22], dim=-1)  # [N, 3]

        axes = torch.stack([col0, col1, col2], dim=1)  # [N, 3, 3]

        # Select the shortest axis direction as normal
        normals = axes[torch.arange(len(axes)), shortest_axis_idx]  # [N, 3]

        # Ensure normals point upward (flip if pointing down)
        up_vector = torch.tensor([0.0, 0.0, 1.0], device=normals.device)
        dot_up = torch.sum(normals * up_vector, dim=-1)
        flip_mask = dot_up < 0
        normals[flip_mask] = -normals[flip_mask]

        return torch.nn.functional.normalize(normals, dim=-1)

How do quaternions give you surface normals?

Each Gaussian stores a quaternion [w, x, y, z] that defines how the ellipsoid is oriented in world space. Converting this quaternion to a 3x3 rotation matrix gives you three columns representing the local X, Y, and Z axes.

The code finds which of the three scale values is smallest (the thinnest direction), then picks the corresponding rotation matrix column as the normal. Finally, normals are flipped to consistently point upward, which makes downstream "horizontality" checks straightforward.

  • Add the text encoding and query methods to complete the class:
%%writefile -a language_field.py

    def encode_text(self, text: str) -> torch.Tensor:
        """Encode a text query into CLIP embedding space."""
        tokens = self.tokenizer([text]).to(self.device)
        with torch.no_grad():
            text_features = self.clip_model.encode_text(tokens)
        return torch.nn.functional.normalize(text_features, dim=-1).squeeze(0)

    def query_similarity(self, text: str) -> torch.Tensor:
        """Compute per-Gaussian cosine similarity for a text query."""
        query_embedding = self.encode_text(text)
        return torch.matmul(self.language_features, query_embedding)

What does this code do?

The encode_text method tokenizes a string and passes it through the CLIP text encoder, returning an L2-normalized 512-dimensional embedding.

The query_similarity method computes cosine similarity between that query embedding and every Gaussian's language feature vector via a single matrix multiplication. The result is a tensor of per-Gaussian relevance scores.

  • Now instantiate the field, lift features, and compute geometric features by running this cell:
from language_field import GeometricLanguageField3D

# Path to the pre-trained 3DGS checkpoint PLY
ply_path = "data/lerf_dataset/figurines/point_cloud/iteration_30000/point_cloud.ply"
feature_dir = "data/lerf_dataset/figurines/language_features"

# Instantiate the field and load Gaussian parameters
field = GeometricLanguageField3D(ply_path)
print(f"Loaded {field.gaussians['count']} Gaussians")

# Lift pre-extracted CLIP features (training-free, <1 minute)
field.lift_features(feature_dir)

# Compute geometric affordance features from Gaussian parameters
field.compute_geometric_features()

# Quick sanity check: test a semantic query
sim = field.query_similarity("a red figurine")
print(f"Query similarity shape: {sim.shape}, max: {sim.max():.3f}")
print(f"Height range: [{field.geometric_features['height'].min():.2f}, {field.geometric_features['height'].max():.2f}]m")

You should see output showing the Gaussian count, the lifted feature dimensions (512-dim), the computed ground height, and a similarity tensor with shape matching your Gaussian count.

Seeing a CUDA out of memory error?

Make sure your Colab runtime is using an L4 GPU. Go to Runtime in the menu bar, then Change runtime type, and confirm the hardware accelerator is set to GPU (L4).

If the feature file is not found, confirm that the Occam's LGS feature extraction ran during Step 1's setup and that gaussian_features.pt exists in your language_features directory.

help me debug CUDA or file-not-found errors when loading my GeometricLanguageField3D

✔️ Awesome, I've got everything!

Great. Double check you have the field instantiated and both language_features and geometric_features populated before moving on.

ⓧ I'd like to double check the full code

Here is the complete language_field.py file for comparison:

"""Training-free 3D Language Field with geometric features."""
import torch
import numpy as np
import open_clip
from pathlib import Path
from plyfile import PlyData
from scipy.spatial.transform import Rotation


class GeometricLanguageField3D:
    """3D language field augmented with per-Gaussian geometric affordance features."""

    def __init__(self, ply_path: str, device: str = "cuda"):
        self.device = device
        self.gaussians = self._load_gaussians(ply_path)
        self.clip_model, _, self.preprocess = open_clip.create_model_and_transforms(
            "ViT-B-16", pretrained="laion2b_s34b_b88k"
        )
        self.clip_model = self.clip_model.to(device).eval()
        self.tokenizer = open_clip.get_tokenizer("ViT-B-16")
        self.language_features = None
        self.geometric_features = None  # Will hold height, normals, extent

    def _load_gaussians(self, ply_path: str) -> dict:
        """Load full Gaussian parameters from PLY file."""
        plydata = PlyData.read(ply_path)
        vertex = plydata["vertex"]

        positions = np.stack([vertex["x"], vertex["y"], vertex["z"]], axis=-1)

        # Load scales (log-space in standard 3DGS format)
        scales = np.stack([
            vertex["scale_0"], vertex["scale_1"], vertex["scale_2"]
        ], axis=-1)
        scales = np.exp(scales)  # Convert from log-space

        # Load rotations (quaternion: w, x, y, z)
        rotations = np.stack([
            vertex["rot_0"], vertex["rot_1"], vertex["rot_2"], vertex["rot_3"]
        ], axis=-1)

        opacities = vertex["opacity"]

        return {
            "positions": torch.tensor(positions, dtype=torch.float32, device=self.device),
            "scales": torch.tensor(scales, dtype=torch.float32, device=self.device),
            "rotations": torch.tensor(rotations, dtype=torch.float32, device=self.device),
            "opacities": torch.tensor(opacities, dtype=torch.float32, device=self.device),
            "count": len(positions),
        }

    def lift_features(self, feature_dir: str):
        """Lift pre-extracted 2D CLIP features to 3D Gaussians (training-free)."""
        feature_path = Path(feature_dir)
        features = torch.load(feature_path / "gaussian_features.pt", map_location=self.device)
        self.language_features = torch.nn.functional.normalize(features, dim=-1)
        print(f"Lifted {self.language_features.shape[0]} Gaussian features ({self.language_features.shape[1]}-dim)")

    def compute_geometric_features(self):
        """Extract geometric affordance features from Gaussian parameters."""
        positions = self.gaussians["positions"]
        scales = self.gaussians["scales"]
        rotations = self.gaussians["rotations"]

        # 1. Estimate ground plane via RANSAC on lowest 10% of points
        heights_raw = positions[:, 2]  # Assume Z-up initially
        low_percentile = torch.quantile(heights_raw, 0.1)
        floor_mask = heights_raw < low_percentile
        floor_points = positions[floor_mask]

        # Simple ground plane: use median of floor points as ground height
        ground_height = floor_points[:, 2].median().item()

        # 2. Per-Gaussian height above ground
        height_above_ground = positions[:, 2] - ground_height

        # 3. Surface normal from shortest axis of Gaussian ellipsoid
        normals = self._compute_normals_from_quaternions(rotations, scales)

        # 4. Spatial extent (area of the two longer axes)
        sorted_scales, _ = torch.sort(scales, dim=-1, descending=True)
        spatial_extent = sorted_scales[:, 0] * sorted_scales[:, 1]  # Two longest axes

        self.geometric_features = {
            "height": height_above_ground,
            "normals": normals,
            "extent": spatial_extent,
            "ground_height": ground_height,
        }
        print(f"Computed geometric features. Ground height: {ground_height:.3f}m")

    def _compute_normals_from_quaternions(
        self, quaternions: torch.Tensor, scales: torch.Tensor
    ) -> torch.Tensor:
        """
        Compute surface normal as the shortest axis direction of each Gaussian.
        The shortest axis of an anisotropic Gaussian approximates the surface normal.
        """
        # Find shortest axis index per Gaussian
        shortest_axis_idx = torch.argmin(scales, dim=-1)  # [N]

        # Convert quaternions to rotation matrices
        # quaternion format: [w, x, y, z]
        w, x, y, z = quaternions[:, 0], quaternions[:, 1], quaternions[:, 2], quaternions[:, 3]

        # Rotation matrix columns (local axes in world frame)
        # Column 0 (local X)
        r00 = 1 - 2*(y*y + z*z)
        r10 = 2*(x*y + w*z)
        r20 = 2*(x*z - w*y)

        # Column 1 (local Y)
        r01 = 2*(x*y - w*z)
        r11 = 1 - 2*(x*x + z*z)
        r21 = 2*(y*z + w*x)

        # Column 2 (local Z)
        r02 = 2*(x*z + w*y)
        r12 = 2*(y*z - w*x)
        r22 = 1 - 2*(x*x + y*y)

        # Stack rotation matrix columns
        col0 = torch.stack([r00, r10, r20], dim=-1)  # [N, 3]
        col1 = torch.stack([r01, r11, r21], dim=-1)  # [N, 3]
        col2 = torch.stack([r02, r12, r22], dim=-1)  # [N, 3]

        axes = torch.stack([col0, col1, col2], dim=1)  # [N, 3, 3]

        # Select the shortest axis direction as normal
        normals = axes[torch.arange(len(axes)), shortest_axis_idx]  # [N, 3]

        # Ensure normals point upward (flip if pointing down)
        up_vector = torch.tensor([0.0, 0.0, 1.0], device=normals.device)
        dot_up = torch.sum(normals * up_vector, dim=-1)
        flip_mask = dot_up < 0
        normals[flip_mask] = -normals[flip_mask]

        return torch.nn.functional.normalize(normals, dim=-1)

    def encode_text(self, text: str) -> torch.Tensor:
        """Encode a text query into CLIP embedding space."""
        tokens = self.tokenizer([text]).to(self.device)
        with torch.no_grad():
            text_features = self.clip_model.encode_text(tokens)
        return torch.nn.functional.normalize(text_features, dim=-1).squeeze(0)

    def query_similarity(self, text: str) -> torch.Tensor:
        """Compute per-Gaussian cosine similarity for a text query."""
        query_embedding = self.encode_text(text)
        return torch.matmul(self.language_features, query_embedding)

Your 3D language field is now fully operational with both semantic CLIP features and geometric affordance features attached to every Gaussian. Next up, you'll build a baseline that queries this field using CLIP alone, revealing where pure semantics fail for functional queries.

Implement Semantic-Only Affordance Queries (Baseline)

Your 3D language field now has both CLIP semantic features and geometric features attached to every Gaussian. The question is: does CLIP alone understand functional queries like "something to sit on"?

In this step, you will establish a semantic-only baseline by querying the language field with functional descriptions and measuring how often CLIP's cosine similarity points to the correct object. This baseline exposes exactly where pure language understanding fails for affordance reasoning.

In this step, get ready to:
  • Define affordance query pairs mapping functional descriptions to expected objects.
  • Run semantic-only queries using CLIP text encoding and cosine similarity.
  • Evaluate baseline precision and identify failure cases where semantics alone is insufficient.
Define affordance templates and query pairs

An affordance query asks "what can I do here?" rather than "what object is this?" To evaluate whether CLIP understands affordances, you need two things: geometric templates that define what physical constraints each affordance requires, and a benchmark list of functional queries paired with expected ground-truth objects.

  • Create a new file called affordance_engine.py in your project directory. Add the imports, the AffordanceTemplate dataclass, and the predefined templates by pasting this code:
"""Geometry-grounded affordance query engine."""
import torch
import numpy as np
from dataclasses import dataclass
from typing import Optional
from language_field import GeometricLanguageField3D


@dataclass
class AffordanceTemplate:
    """Defines geometric constraints for an affordance type."""
    name: str
    height_range: Optional[tuple] = None  # (min_m, max_m) above ground
    min_horizontality: Optional[float] = None  # dot(normal, up) threshold
    min_extent: Optional[float] = None  # minimum surface area
    semantic_hints: Optional[list] = None  # CLIP text hints to boost signal


# Predefined affordance templates with geometric constraints
AFFORDANCE_TEMPLATES = {
    "sittable": AffordanceTemplate(
        name="sittable",
        height_range=(0.35, 0.55),
        min_horizontality=0.7,
        min_extent=0.02,
        semantic_hints=["chair", "stool", "bench", "seat"],
    ),
    "placeable_surface": AffordanceTemplate(
        name="placeable_surface",
        height_range=(0.6, 0.95),
        min_horizontality=0.8,
        min_extent=0.03,
        semantic_hints=["table", "desk", "counter", "shelf"],
    ),
    "reachable_from_standing": AffordanceTemplate(
        name="reachable_from_standing",
        height_range=(0.3, 2.0),
        min_horizontality=None,
        min_extent=None,
        semantic_hints=None,
    ),
    "container": AffordanceTemplate(
        name="container",
        height_range=(0.3, 1.5),
        min_horizontality=None,
        min_extent=None,
        semantic_hints=["cup", "mug", "bowl", "box", "basket"],
    ),
    "support_surface": AffordanceTemplate(
        name="support_surface",
        height_range=(0.0, 2.5),
        min_horizontality=0.85,
        min_extent=0.05,
        semantic_hints=["table", "shelf", "counter", "floor"],
    ),
}

What does this code do?

AffordanceTemplate is a dataclass that bundles the geometric constraints an affordance requires. Each field captures one physical property: height range above ground, minimum horizontality (how flat the surface is), minimum spatial extent (surface area), and optional semantic hints for CLIP boosting.

AFFORDANCE_TEMPLATES defines five affordance types. For example, "sittable" requires a height between 0.35m and 0.55m (standard chair seat height), a roughly horizontal surface, and minimum area. These constraints encode physical reality that CLIP cannot infer from text alone.

  • Save affordance_engine.py.

Now create the benchmark query list that maps functional text descriptions to expected ground-truth objects.

  • Create a new file called evaluate.py and paste the following benchmark definition:
"""Evaluation: semantic-only vs geometry-only vs fused affordance queries."""
import torch
import numpy as np
from language_field import GeometricLanguageField3D
from affordance_engine import AffordanceQueryEngine, AFFORDANCE_TEMPLATES


# Affordance query benchmark: (functional_query, template_name, gt_category, description)
AFFORDANCE_QUERIES = [
    ("something to sit on", "sittable", "chair", "Should find chairs/stools"),
    ("a place to sit down", "sittable", "chair", "Paraphrase of sitting"),
    ("a surface to write on", "placeable_surface", "desk", "Should find desks/tables"),
    ("somewhere to put my coffee", "placeable_surface", "table", "Flat elevated surface"),
    ("a surface to place objects", "placeable_surface", "table", "General placement"),
    ("something at desk height", "placeable_surface", "desk", "Height-specific"),
    ("a flat elevated platform", "support_surface", "table", "Geometric description"),
    ("something I can reach while standing", "reachable_from_standing", "shelf", "Height filter"),
    ("a container for liquids", "container", "mug", "Functional container"),
    ("something to hold water", "container", "cup", "Liquid holding"),
    ("a low seat", "sittable", "stool", "Low height + sittable"),
    ("a high shelf", "support_surface", "shelf", "Height + horizontal"),
    ("something at knee height", "sittable", "chair", "Specific height range"),
    ("a workspace surface", "placeable_surface", "desk", "Functional workspace"),
    ("something to rest my arms on", "placeable_surface", "desk", "Arm-rest height"),
]

What does this benchmark test?

Each tuple contains four elements: the functional query text (what a user would ask), the geometric template it maps to, the ground-truth object category it should find, and a human-readable description of the test case.

The queries span five affordance categories: sitting, placing, reaching, supporting, and containing. Some are purely functional ("something to sit on"), some are height-specific ("something at desk height"), and some are geometric descriptions ("a flat elevated platform"). This diversity exposes where CLIP struggles.

  • Save evaluate.py.

Import error when saving?

If you see an import error for AffordanceQueryEngine, that is expected. You have not written that class yet. The import will resolve after the next substep.

Help me debug an import error in evaluate.py

Build the affordance query engine

The query engine is the core component that computes affordance scores. For the semantic-only baseline, it encodes the functional query with CLIP and computes cosine similarity against every Gaussian's language feature. When no geometric template is specified, the engine falls back to pure semantic matching.

  • In affordance_engine.py, add the AffordanceQueryEngine class below the AFFORDANCE_TEMPLATES dict by pasting this code:
class AffordanceQueryEngine:
    """Executes affordance queries by fusing semantic embeddings with geometric constraints."""

    def __init__(self, field: GeometricLanguageField3D):
        self.field = field
        self.up_vector = torch.tensor([0.0, 0.0, 1.0], device=field.device)

    def compute_geometric_score(self, template: AffordanceTemplate) -> torch.Tensor:
        """Compute per-Gaussian geometric affordance score for a template."""
        geo = self.field.geometric_features
        scores = torch.ones(self.field.gaussians["count"], device=self.field.device)

        # Height constraint with soft sigmoid boundaries
        if template.height_range is not None:
            h_min, h_max = template.height_range
            height = geo["height"]
            k = 20.0  # Sharpness of sigmoid transition
            lower_bound = torch.sigmoid(k * (height - h_min))
            upper_bound = torch.sigmoid(k * (h_max - height))
            height_score = lower_bound * upper_bound
            scores = scores * height_score

        # Horizontality: how flat/horizontal is the surface
        if template.min_horizontality is not None:
            normals = geo["normals"]
            horizontality = torch.sum(normals * self.up_vector, dim=-1).abs()
            horiz_score = torch.clamp(
                (horizontality - template.min_horizontality + 0.1) / 0.1, 0.0, 1.0
            )
            scores = scores * horiz_score

        # Extent: minimum surface area
        if template.min_extent is not None:
            extent = geo["extent"]
            extent_score = torch.clamp(
                (extent - template.min_extent) / template.min_extent, 0.0, 1.0
            )
            scores = scores * extent_score

        return scores

    def compute_semantic_score(self, query_text: str, template: AffordanceTemplate) -> torch.Tensor:
        """Compute semantic affordance score combining query text with template hints."""
        # Encode the functional query directly
        primary_score = self.field.query_similarity(query_text)
        primary_score_norm = (primary_score - primary_score.min()) / (
            primary_score.max() - primary_score.min() + 1e-8
        )

        # Boost with semantic hints if available
        if template.semantic_hints:
            hint_scores = []
            for hint in template.semantic_hints:
                hint_sim = self.field.query_similarity(hint)
                hint_norm = (hint_sim - hint_sim.min()) / (hint_sim.max() - hint_sim.min() + 1e-8)
                hint_scores.append(hint_norm)
            # Max over all hints (any matching hint is good)
            hints_combined = torch.stack(hint_scores).max(dim=0).values
            # Fuse: primary query + best hint (weighted)
            semantic_score = 0.6 * primary_score_norm + 0.4 * hints_combined
        else:
            semantic_score = primary_score_norm

        return semantic_score

    def query_affordance(
        self, query_text: str, template_name: Optional[str] = None, alpha: float = 0.5
    ) -> dict:
        """
        Execute a geometry-grounded affordance query.
        alpha: 0=geometry-only, 1=semantic-only, 0.5=balanced
        """
        if template_name and template_name in AFFORDANCE_TEMPLATES:
            template = AFFORDANCE_TEMPLATES[template_name]
            semantic_score = self.compute_semantic_score(query_text, template)
            geometric_score = self.compute_geometric_score(template)
            # Fused affordance score
            fused_score = alpha * semantic_score + (1 - alpha) * geometric_score
        else:
            # No template: semantic-only fallback
            semantic_score = self.field.query_similarity(query_text)
            semantic_score = (semantic_score - semantic_score.min()) / (
                semantic_score.max() - semantic_score.min() + 1e-8
            )
            geometric_score = torch.ones_like(semantic_score)
            fused_score = semantic_score

        # Extract top candidates (95th percentile)
        top_mask = fused_score > fused_score.quantile(0.95)
        if top_mask.sum() == 0:
            top_mask = fused_score > fused_score.quantile(0.90)

        candidate_positions = self.field.gaussians["positions"][top_mask]
        candidate_scores = fused_score[top_mask]

        # Compute score-weighted centroid
        weights = candidate_scores / candidate_scores.sum()
        centroid = (candidate_positions * weights.unsqueeze(-1)).sum(dim=0)
        confidence = candidate_scores.mean().item()

        return {
            "centroid": centroid,
            "confidence": confidence,
            "fused_scores": fused_score,
            "semantic_scores": semantic_score,
            "geometric_scores": geometric_score,
            "top_mask": top_mask,
            "query": query_text,
            "template": template_name,
        }

What does this engine do?

query_affordance() is the main entry point. When template_name=None (our baseline), it falls into the else branch: pure CLIP cosine similarity normalized to [0, 1], with geometric scores set to all ones (no geometric filtering).

The method selects the top 5% of Gaussians by score, computes a score-weighted centroid as the predicted object location, and returns both the centroid and the full per-Gaussian score fields for later visualization.

  • Save affordance_engine.py.

Seeing a syntax error in the class?

Make sure the AffordanceQueryEngine class starts at the top indentation level (no extra indent). Check that there is one blank line between the end of AFFORDANCE_TEMPLATES and the class definition.

Help me fix a syntax error in my AffordanceQueryEngine class

✔️ Awesome, I've got everything!

Great. Double check you have saved affordance_engine.py with both the templates and the engine class.

ⓧ I'd like to double check the full code

"""Geometry-grounded affordance query engine."""
import torch
import numpy as np
from dataclasses import dataclass
from typing import Optional
from language_field import GeometricLanguageField3D


@dataclass
class AffordanceTemplate:
    """Defines geometric constraints for an affordance type."""
    name: str
    height_range: Optional[tuple] = None
    min_horizontality: Optional[float] = None
    min_extent: Optional[float] = None
    semantic_hints: Optional[list] = None


AFFORDANCE_TEMPLATES = {
    "sittable": AffordanceTemplate(
        name="sittable",
        height_range=(0.35, 0.55),
        min_horizontality=0.7,
        min_extent=0.02,
        semantic_hints=["chair", "stool", "bench", "seat"],
    ),
    "placeable_surface": AffordanceTemplate(
        name="placeable_surface",
        height_range=(0.6, 0.95),
        min_horizontality=0.8,
        min_extent=0.03,
        semantic_hints=["table", "desk", "counter", "shelf"],
    ),
    "reachable_from_standing": AffordanceTemplate(
        name="reachable_from_standing",
        height_range=(0.3, 2.0),
        min_horizontality=None,
        min_extent=None,
        semantic_hints=None,
    ),
    "container": AffordanceTemplate(
        name="container",
        height_range=(0.3, 1.5),
        min_horizontality=None,
        min_extent=None,
        semantic_hints=["cup", "mug", "bowl", "box", "basket"],
    ),
    "support_surface": AffordanceTemplate(
        name="support_surface",
        height_range=(0.0, 2.5),
        min_horizontality=0.85,
        min_extent=0.05,
        semantic_hints=["table", "shelf", "counter", "floor"],
    ),
}


class AffordanceQueryEngine:
    """Executes affordance queries by fusing semantic embeddings with geometric constraints."""

    def __init__(self, field: GeometricLanguageField3D):
        self.field = field
        self.up_vector = torch.tensor([0.0, 0.0, 1.0], device=field.device)

    def compute_geometric_score(self, template: AffordanceTemplate) -> torch.Tensor:
        geo = self.field.geometric_features
        scores = torch.ones(self.field.gaussians["count"], device=self.field.device)

        if template.height_range is not None:
            h_min, h_max = template.height_range
            height = geo["height"]
            k = 20.0
            lower_bound = torch.sigmoid(k * (height - h_min))
            upper_bound = torch.sigmoid(k * (h_max - height))
            height_score = lower_bound * upper_bound
            scores = scores * height_score

        if template.min_horizontality is not None:
            normals = geo["normals"]
            horizontality = torch.sum(normals * self.up_vector, dim=-1).abs()
            horiz_score = torch.clamp(
                (horizontality - template.min_horizontality + 0.1) / 0.1, 0.0, 1.0
            )
            scores = scores * horiz_score

        if template.min_extent is not None:
            extent = geo["extent"]
            extent_score = torch.clamp(
                (extent - template.min_extent) / template.min_extent, 0.0, 1.0
            )
            scores = scores * extent_score

        return scores

    def compute_semantic_score(self, query_text: str, template: AffordanceTemplate) -> torch.Tensor:
        primary_score = self.field.query_similarity(query_text)
        primary_score_norm = (primary_score - primary_score.min()) / (
            primary_score.max() - primary_score.min() + 1e-8
        )

        if template.semantic_hints:
            hint_scores = []
            for hint in template.semantic_hints:
                hint_sim = self.field.query_similarity(hint)
                hint_norm = (hint_sim - hint_sim.min()) / (hint_sim.max() - hint_sim.min() + 1e-8)
                hint_scores.append(hint_norm)
            hints_combined = torch.stack(hint_scores).max(dim=0).values
            semantic_score = 0.6 * primary_score_norm + 0.4 * hints_combined
        else:
            semantic_score = primary_score_norm

        return semantic_score

    def query_affordance(
        self, query_text: str, template_name: Optional[str] = None, alpha: float = 0.5
    ) -> dict:
        if template_name and template_name in AFFORDANCE_TEMPLATES:
            template = AFFORDANCE_TEMPLATES[template_name]
            semantic_score = self.compute_semantic_score(query_text, template)
            geometric_score = self.compute_geometric_score(template)
            fused_score = alpha * semantic_score + (1 - alpha) * geometric_score
        else:
            semantic_score = self.field.query_similarity(query_text)
            semantic_score = (semantic_score - semantic_score.min()) / (
                semantic_score.max() - semantic_score.min() + 1e-8
            )
            geometric_score = torch.ones_like(semantic_score)
            fused_score = semantic_score

        top_mask = fused_score > fused_score.quantile(0.95)
        if top_mask.sum() == 0:
            top_mask = fused_score > fused_score.quantile(0.90)

        candidate_positions = self.field.gaussians["positions"][top_mask]
        candidate_scores = fused_score[top_mask]

        weights = candidate_scores / candidate_scores.sum()
        centroid = (candidate_positions * weights.unsqueeze(-1)).sum(dim=0)
        confidence = candidate_scores.mean().item()

        return {
            "centroid": centroid,
            "confidence": confidence,
            "fused_scores": fused_score,
            "semantic_scores": semantic_score,
            "geometric_scores": geometric_score,
            "top_mask": top_mask,
            "query": query_text,
            "template": template_name,
        }
Evaluate the semantic-only baseline

With the engine built, you can now measure how well pure CLIP similarity handles functional affordance queries. The evaluation function runs each benchmark query in semantic-only mode and checks whether the predicted centroid falls within an acceptance radius of the ground-truth object.

  • In evaluate.py, add the evaluate_affordance_queries() function below the AFFORDANCE_QUERIES list by pasting this code:
def evaluate_affordance_queries(field: GeometricLanguageField3D, gt_annotations: dict):
    """Three-way comparison: semantic-only vs geometry-only vs fused."""
    engine = AffordanceQueryEngine(field)

    results = {
        "semantic_only": [],
        "geometry_only": [],
        "fused": [],
    }

    print("=" * 70)
    print(f"{'Query':<35} {'Sem':>5} {'Geo':>5} {'Fused':>5} {'GT':<10}")
    print("-" * 70)

    for query_text, template_name, gt_category, _ in AFFORDANCE_QUERIES:
        if gt_category not in gt_annotations:
            continue

        gt_centroid = torch.tensor(gt_annotations[gt_category], device=field.device)
        radius = 0.4  # Acceptance radius in meters

        # (a) Semantic-only: CLIP cosine sim, no geometric template
        sem_result = engine.query_affordance(query_text, template_name=None, alpha=1.0)
        sem_dist = torch.norm(sem_result["centroid"] - gt_centroid).item()
        sem_correct = 1.0 if sem_dist < radius else 0.0

        # (b) Geometry-only: constraint filters without semantics
        geo_result = engine.query_affordance(query_text, template_name=template_name, alpha=0.0)
        geo_dist = torch.norm(geo_result["centroid"] - gt_centroid).item()
        geo_correct = 1.0 if geo_dist < radius else 0.0

        # (c) Fused: our method combining both
        fused_result = engine.query_affordance(query_text, template_name=template_name, alpha=0.5)
        fused_dist = torch.norm(fused_result["centroid"] - gt_centroid).item()
        fused_correct = 1.0 if fused_dist < radius else 0.0

        results["semantic_only"].append(sem_correct)
        results["geometry_only"].append(geo_correct)
        results["fused"].append(fused_correct)

        print(f"{query_text:<35} {sem_correct:>5.0f} {geo_correct:>5.0f} {fused_correct:>5.0f} {gt_category:<10}")

    # Summary statistics
    print("=" * 70)
    print("AFFORDANCE QUERY EVALUATION RESULTS")
    print("=" * 70)
    print(f"Semantic-only accuracy:  {np.mean(results['semantic_only']):.3f}")
    print(f"Geometry-only accuracy:  {np.mean(results['geometry_only']):.3f}")
    print(f"Fused accuracy (ours):   {np.mean(results['fused']):.3f}")
    print(f"Improvement over sem:    {np.mean(results['fused']) - np.mean(results['semantic_only']):+.3f}")
    print(f"Improvement over geo:    {np.mean(results['fused']) - np.mean(results['geometry_only']):+.3f}")
    print("=" * 70)

    return results

What does this evaluation measure?

For each benchmark query, the function runs three modes: semantic-only (alpha=1.0, no template), geometry-only (alpha=0.0), and fused (alpha=0.5). It compares the predicted centroid to the ground-truth object position using a 0.4m acceptance radius.

The semantic-only column is your baseline. Where it scores 0 (wrong object), you will see the failure cases that geometric constraints can fix in the next step.

  • Save evaluate.py.

Now run the semantic-only baseline against your loaded language field. In a new Colab cell, execute the evaluation with approximate ground-truth annotations for the figurines scene.

  • Run the semantic-only baseline evaluation by executing this cell:
from evaluate import evaluate_affordance_queries

# Approximate ground-truth centroids for figurines scene objects
# These positions correspond to known objects in the LERF figurines dataset
gt_annotations = {
    "chair": [0.2, -0.3, 0.45],
    "desk": [0.0, 0.5, 0.75],
    "table": [0.1, 0.4, 0.72],
    "shelf": [-0.3, 0.2, 1.4],
    "mug": [0.15, 0.45, 0.78],
    "cup": [0.12, 0.42, 0.76],
    "stool": [0.4, -0.2, 0.38],
}

# Run evaluation (semantic-only baseline results will show in the 'Sem' column)
results = evaluate_affordance_queries(field, gt_annotations)

What should I see?

You should see a table with columns for each query and binary scores (0 or 1) under Sem, Geo, and Fused. The semantic-only (Sem) column is your baseline. Expect it to score well on object-name-like queries ("a container for liquids") but poorly on purely functional or height-specific queries ("something at desk height", "a flat elevated platform").

The final summary shows accuracy for each method. Your semantic-only accuracy will likely be in the 0.3 to 0.5 range, confirming that CLIP alone cannot reliably answer affordance queries.

Seeing all zeros or a runtime error?

If all scores are 0, the ground-truth positions may not match your scene's coordinate system. Print field.gaussians["positions"].min(dim=0).values and field.gaussians["positions"].max(dim=0).values to check the scene bounds. Adjust the gt_annotations positions to fall within your scene's coordinate range.

If you see a CUDA error about tensor device mismatch, make sure your field object was initialized with device="cuda" and that language_features and geometric_features are both loaded.

Help me debug my affordance evaluation output

✔️ Awesome, I've got everything!

Great. Double check you have saved evaluate.py with both the query list and the evaluation function.

ⓧ I'd like to double check the full code

"""Evaluation: semantic-only vs geometry-only vs fused affordance queries."""
import torch
import numpy as np
from language_field import GeometricLanguageField3D
from affordance_engine import AffordanceQueryEngine, AFFORDANCE_TEMPLATES


AFFORDANCE_QUERIES = [
    ("something to sit on", "sittable", "chair", "Should find chairs/stools"),
    ("a place to sit down", "sittable", "chair", "Paraphrase of sitting"),
    ("a surface to write on", "placeable_surface", "desk", "Should find desks/tables"),
    ("somewhere to put my coffee", "placeable_surface", "table", "Flat elevated surface"),
    ("a surface to place objects", "placeable_surface", "table", "General placement"),
    ("something at desk height", "placeable_surface", "desk", "Height-specific"),
    ("a flat elevated platform", "support_surface", "table", "Geometric description"),
    ("something I can reach while standing", "reachable_from_standing", "shelf", "Height filter"),
    ("a container for liquids", "container", "mug", "Functional container"),
    ("something to hold water", "container", "cup", "Liquid holding"),
    ("a low seat", "sittable", "stool", "Low height + sittable"),
    ("a high shelf", "support_surface", "shelf", "Height + horizontal"),
    ("something at knee height", "sittable", "chair", "Specific height range"),
    ("a workspace surface", "placeable_surface", "desk", "Functional workspace"),
    ("something to rest my arms on", "placeable_surface", "desk", "Arm-rest height"),
]


def evaluate_affordance_queries(field: GeometricLanguageField3D, gt_annotations: dict):
    engine = AffordanceQueryEngine(field)

    results = {
        "semantic_only": [],
        "geometry_only": [],
        "fused": [],
    }

    print("=" * 70)
    print(f"{'Query':<35} {'Sem':>5} {'Geo':>5} {'Fused':>5} {'GT':<10}")
    print("-" * 70)

    for query_text, template_name, gt_category, _ in AFFORDANCE_QUERIES:
        if gt_category not in gt_annotations:
            continue

        gt_centroid = torch.tensor(gt_annotations[gt_category], device=field.device)
        radius = 0.4

        sem_result = engine.query_affordance(query_text, template_name=None, alpha=1.0)
        sem_dist = torch.norm(sem_result["centroid"] - gt_centroid).item()
        sem_correct = 1.0 if sem_dist < radius else 0.0

        geo_result = engine.query_affordance(query_text, template_name=template_name, alpha=0.0)
        geo_dist = torch.norm(geo_result["centroid"] - gt_centroid).item()
        geo_correct = 1.0 if geo_dist < radius else 0.0

        fused_result = engine.query_affordance(query_text, template_name=template_name, alpha=0.5)
        fused_dist = torch.norm(fused_result["centroid"] - gt_centroid).item()
        fused_correct = 1.0 if fused_dist < radius else 0.0

        results["semantic_only"].append(sem_correct)
        results["geometry_only"].append(geo_correct)
        results["fused"].append(fused_correct)

        print(f"{query_text:<35} {sem_correct:>5.0f} {geo_correct:>5.0f} {fused_correct:>5.0f} {gt_category:<10}")

    print("=" * 70)
    print("AFFORDANCE QUERY EVALUATION RESULTS")
    print("=" * 70)
    print(f"Semantic-only accuracy:  {np.mean(results['semantic_only']):.3f}")
    print(f"Geometry-only accuracy:  {np.mean(results['geometry_only']):.3f}")
    print(f"Fused accuracy (ours):   {np.mean(results['fused']):.3f}")
    print(f"Improvement over sem:    {np.mean(results['fused']) - np.mean(results['semantic_only']):+.3f}")
    print(f"Improvement over geo:    {np.mean(results['fused']) - np.mean(results['geometry_only']):+.3f}")
    print("=" * 70)

    return results

Why does semantic-only fail on affordance queries?

CLIP was trained on image-text pairs. It understands that "chair" looks like a chair, but "something to sit on" is a functional description that requires reasoning about height and surface orientation. CLIP has partial functional understanding (it knows chairs are sat on), but it cannot verify the physical constraints that make sitting possible.

Height-specific queries ("something at desk height") fail because CLIP has no concept of metric height. Geometric descriptions ("a flat elevated platform") fail because CLIP matches visual similarity, not physical geometry. These are the gaps that geometric constraints will fill in the next step.

You have now quantified exactly where pure language understanding breaks down for affordance reasoning. Next, you will implement geometric constraint functions that encode physical feasibility directly from the Gaussian parameters, filling the gaps that CLIP cannot bridge alone.

Implement Geometric Affordance Constraints

Your semantic-only baseline revealed where CLIP falls short. Queries like "something to sit on" activate Gaussians on chairs but also on floor patches, short shelves, and other objects that merely look seat-related in embedding space.

The fix is to inject physical constraints that the 3D Gaussians already encode. A sittable surface must be at a specific height (roughly knee-level), oriented horizontally, and have enough area to support a person. These geometric constraints are already present in the Gaussian parameters you extracted in Step 2. Now you will compute per-Gaussian affordance scores by evaluating each template's constraints and fusing them with CLIP similarity.

In this step, get ready to:
  • Evaluate geometric affordance templates against the full Gaussian field.
  • Compute per-Gaussian geometric affordance fields using soft sigmoid constraints.
  • Fuse semantic similarity with geometric constraint scores and observe improved localization.
Define geometric affordance templates as constraint functions

Each affordance type maps to a set of physical constraints. A "sittable" surface is 0.35-0.55m above ground, roughly horizontal, and large enough to sit on. Your AFFORDANCE_TEMPLATES dictionary already encodes these rules as AffordanceTemplate instances.

  • Inspect the templates and compute geometric scores for each one by running this cell in your notebook:
from affordance_engine import AffordanceQueryEngine, AFFORDANCE_TEMPLATES

# Instantiate the query engine with your loaded field
engine = AffordanceQueryEngine(field)

# Inspect each template's physical constraints
for name, template in AFFORDANCE_TEMPLATES.items():
    print(f"\n--- {name} ---")
    print(f"  Height range: {template.height_range}")
    print(f"  Min horizontality: {template.min_horizontality}")
    print(f"  Min extent: {template.min_extent}")
    print(f"  Semantic hints: {template.semantic_hints}")

What does this code do?

Each AffordanceTemplate defines geometric constraints that encode the physical requirements for an affordance type. The constraints map directly to real-world furniture dimensions.

  • height_range: meters above the ground plane (0.35-0.55m for chairs matches standard seat height of 40-50cm).
  • min_horizontality: how flat the surface must be (dot product of surface normal with up-vector, where 1.0 is perfectly horizontal).
  • min_extent: minimum surface area from the product of the two longer Gaussian scales.
  • semantic_hints: object category names that boost the CLIP signal for this affordance type.

Why these specific height values?

The template ranges come from standard furniture dimensions. Chair seats are typically 40-50cm from the floor. Desk surfaces sit at 70-76cm. These values act as physical priors that CLIP cannot learn from 2D images alone.

You should see all five templates printed with their constraint values.

Compute per-Gaussian geometric affordance fields

The constraint evaluation engine uses soft boundaries instead of hard cutoffs. A Gaussian at exactly 0.35m should not suddenly jump from score 0 to score 1. Soft sigmoids with steepness k=20.0 create a smooth transition zone of about 10cm at each boundary.

  • Compute geometric scores for the "sittable" template and inspect the distribution by running this cell:
import torch

# Compute geometric affordance field for "sittable"
sittable_template = AFFORDANCE_TEMPLATES["sittable"]
sittable_scores = engine.compute_geometric_score(sittable_template)

# Inspect the score distribution
print(f"Total Gaussians: {len(sittable_scores)}")
print(f"Gaussians passing (score > 0.5): {(sittable_scores > 0.5).sum().item()}")
print(f"Gaussians strongly passing (score > 0.8): {(sittable_scores > 0.8).sum().item()}")
print(f"Score range: [{sittable_scores.min().item():.4f}, {sittable_scores.max().item():.4f}]")
print(f"Mean score: {sittable_scores.mean().item():.4f}")

What does this code do?

The compute_geometric_score() method evaluates three constraints and multiplies them together. Each constraint produces a soft score between 0 and 1.

  • Height: two sigmoid functions create a band-pass filter. torch.sigmoid(k * (height - h_min)) gates the lower bound. torch.sigmoid(k * (h_max - height)) gates the upper bound.
  • Horizontality: the dot product of the Gaussian's surface normal with the up-vector measures flatness. A clamp-based ramp gives a 0.1-wide linear transition at the threshold.
  • Extent: a clamp-based ramp scores Gaussians by how much their surface area exceeds the minimum, saturating at 1.0 when they are double the threshold.
  • Now compute all five geometric affordance fields and compare how selective each template is by running this cell:
# Compute geometric affordance fields for all templates
print(f"{'Template':<25} {'Pass (>0.5)':>12} {'Strong (>0.8)':>14} {'% of scene':>12}")
print("-" * 65)

for name, template in AFFORDANCE_TEMPLATES.items():
    scores = engine.compute_geometric_score(template)
    n_pass = (scores > 0.5).sum().item()
    n_strong = (scores > 0.8).sum().item()
    pct = 100.0 * n_pass / len(scores)
    print(f"{name:<25} {n_pass:>12,} {n_strong:>14,} {pct:>11.1f}%")

What should I see?

The "sittable" template should be highly selective (only a small percentage of Gaussians pass). The "reachable_from_standing" template should be much broader since it only constrains height to 0.3-2.0m without requiring horizontality or extent.

Templates with more constraints (sittable, placeable_surface) filter more aggressively. This selectivity is exactly what makes them useful as affordance priors.

All scores are zero?

If every Gaussian scores 0.0 for a template, your ground plane estimation may be off. Check field.geometric_features["ground_height"]. If it looks unreasonable (e.g., very large or negative), the scene's coordinate system might not be Z-up. Try swapping the height axis in compute_geometric_features() to use the Y axis instead.

help me debug why all geometric scores are zero for my affordance templates

Fuse semantic similarity with geometric constraints

The fusion operator combines CLIP's functional understanding with physical feasibility. The formula is fused_score = alpha * semantic_score + (1 - alpha) * geometric_score. With alpha=0.5, both signals contribute equally.

  • Run a fused affordance query and compare it to the semantic-only result from the previous step:
# Compare semantic-only vs fused for "something to sit on"
query = "something to sit on"

# Semantic-only (alpha=1.0 means 100% semantic, 0% geometry)
sem_result = engine.query_affordance(query, template_name=None, alpha=1.0)
print(f"Semantic-only centroid: [{sem_result['centroid'][0]:.3f}, {sem_result['centroid'][1]:.3f}, {sem_result['centroid'][2]:.3f}]")
print(f"Semantic-only confidence: {sem_result['confidence']:.4f}")
print(f"Semantic-only top candidates: {sem_result['top_mask'].sum().item()}")

print()

# Fused (alpha=0.5 means 50% semantic + 50% geometric)
fused_result = engine.query_affordance(query, template_name="sittable", alpha=0.5)
print(f"Fused centroid: [{fused_result['centroid'][0]:.3f}, {fused_result['centroid'][1]:.3f}, {fused_result['centroid'][2]:.3f}]")
print(f"Fused confidence: {fused_result['confidence']:.4f}")
print(f"Fused top candidates: {fused_result['top_mask'].sum().item()}")

What does this code do?

The query_affordance() method runs the full pipeline. When template_name is None, it falls back to semantic-only (pure CLIP cosine similarity). When a template is provided, it computes both scores and fuses them.

The semantic score also benefits from semantic_hints. The primary query gets 0.6 weight while the max-pooled hint scores get 0.4 weight. This means querying "something to sit on" also pulls signal from "chair", "stool", "bench", and "seat" embeddings.

  • Run a batch comparison across multiple queries to see how fusion narrows down candidates by running this cell:
# Batch comparison: semantic-only vs fused across affordance queries
test_queries = [
    ("something to sit on", "sittable"),
    ("a surface to write on", "placeable_surface"),
    ("a container for liquids", "container"),
    ("a flat elevated platform", "support_surface"),
]

print(f"{'Query':<30} {'Sem candidates':>15} {'Fused candidates':>17} {'Height (fused)':>15}")
print("-" * 80)

for query, template_name in test_queries:
    # Semantic-only
    sem = engine.query_affordance(query, template_name=None, alpha=1.0)
    sem_height = sem["centroid"][2].item() - field.geometric_features["ground_height"]

    # Fused
    fused = engine.query_affordance(query, template_name=template_name, alpha=0.5)
    fused_height = fused["centroid"][2].item() - field.geometric_features["ground_height"]

    print(f"{query:<30} {sem['top_mask'].sum().item():>15,} {fused['top_mask'].sum().item():>17,} {fused_height:>14.3f}m")

print("\nFused candidates are more focused. Heights should align with template ranges.")

What should I see?

The fused queries produce centroids whose height aligns with the template's expected range. "Something to sit on" should resolve at roughly 0.35-0.55m above ground. "A surface to write on" should resolve at roughly 0.6-0.95m. The semantic-only centroids will be less height-consistent because CLIP has no notion of physical scale.

Fused centroid height is outside the template range?

The centroid is a weighted average of the top 5% of scoring Gaussians. If many high-semantic-score Gaussians exist outside the geometric range, they can pull the centroid away. Try lowering alpha to 0.3 to give more weight to geometric constraints. You can also tighten the quantile threshold from 0.95 to 0.98.

help me understand why my fused centroid is at the wrong height

✔️ Awesome, I've got everything!

Great. Your geometric affordance fields are computing correctly and the fusion is producing height-consistent localizations.

ⓧ I'd like to double check the full code

Here is the complete affordance_engine.py as it should look at this point:

"""Geometry-grounded affordance query engine."""
import torch
import numpy as np
from dataclasses import dataclass
from typing import Optional
from language_field import GeometricLanguageField3D


@dataclass
class AffordanceTemplate:
    name: str
    height_range: Optional[tuple] = None
    min_horizontality: Optional[float] = None
    min_extent: Optional[float] = None
    semantic_hints: Optional[list] = None


AFFORDANCE_TEMPLATES = {
    "sittable": AffordanceTemplate(
        name="sittable",
        height_range=(0.35, 0.55),
        min_horizontality=0.7,
        min_extent=0.02,
        semantic_hints=["chair", "stool", "bench", "seat"],
    ),
    "placeable_surface": AffordanceTemplate(
        name="placeable_surface",
        height_range=(0.6, 0.95),
        min_horizontality=0.8,
        min_extent=0.03,
        semantic_hints=["table", "desk", "counter", "shelf"],
    ),
    "reachable_from_standing": AffordanceTemplate(
        name="reachable_from_standing",
        height_range=(0.3, 2.0),
        min_horizontality=None,
        min_extent=None,
        semantic_hints=None,
    ),
    "container": AffordanceTemplate(
        name="container",
        height_range=(0.3, 1.5),
        min_horizontality=None,
        min_extent=None,
        semantic_hints=["cup", "mug", "bowl", "box", "basket"],
    ),
    "support_surface": AffordanceTemplate(
        name="support_surface",
        height_range=(0.0, 2.5),
        min_horizontality=0.85,
        min_extent=0.05,
        semantic_hints=["table", "shelf", "counter", "floor"],
    ),
}


class AffordanceQueryEngine:
    def __init__(self, field: GeometricLanguageField3D):
        self.field = field
        self.up_vector = torch.tensor([0.0, 0.0, 1.0], device=field.device)

    def compute_geometric_score(self, template: AffordanceTemplate) -> torch.Tensor:
        geo = self.field.geometric_features
        scores = torch.ones(self.field.gaussians["count"], device=self.field.device)
        if template.height_range is not None:
            h_min, h_max = template.height_range
            height = geo["height"]
            k = 20.0
            lower_bound = torch.sigmoid(k * (height - h_min))
            upper_bound = torch.sigmoid(k * (h_max - height))
            scores = scores * lower_bound * upper_bound
        if template.min_horizontality is not None:
            normals = geo["normals"]
            horizontality = torch.sum(normals * self.up_vector, dim=-1).abs()
            horiz_score = torch.clamp((horizontality - template.min_horizontality + 0.1) / 0.1, 0.0, 1.0)
            scores = scores * horiz_score
        if template.min_extent is not None:
            extent = geo["extent"]
            extent_score = torch.clamp((extent - template.min_extent) / template.min_extent, 0.0, 1.0)
            scores = scores * extent_score
        return scores

    def compute_semantic_score(self, query_text: str, template: AffordanceTemplate) -> torch.Tensor:
        primary_score = self.field.query_similarity(query_text)
        primary_score_norm = (primary_score - primary_score.min()) / (primary_score.max() - primary_score.min() + 1e-8)
        if template.semantic_hints:
            hint_scores = []
            for hint in template.semantic_hints:
                hint_sim = self.field.query_similarity(hint)
                hint_norm = (hint_sim - hint_sim.min()) / (hint_sim.max() - hint_sim.min() + 1e-8)
                hint_scores.append(hint_norm)
            hints_combined = torch.stack(hint_scores).max(dim=0).values
            semantic_score = 0.6 * primary_score_norm + 0.4 * hints_combined
        else:
            semantic_score = primary_score_norm
        return semantic_score

    def query_affordance(self, query_text: str, template_name: Optional[str] = None, alpha: float = 0.5) -> dict:
        if template_name and template_name in AFFORDANCE_TEMPLATES:
            template = AFFORDANCE_TEMPLATES[template_name]
            semantic_score = self.compute_semantic_score(query_text, template)
            geometric_score = self.compute_geometric_score(template)
            fused_score = alpha * semantic_score + (1 - alpha) * geometric_score
        else:
            semantic_score = self.field.query_similarity(query_text)
            semantic_score = (semantic_score - semantic_score.min()) / (semantic_score.max() - semantic_score.min() + 1e-8)
            geometric_score = torch.ones_like(semantic_score)
            fused_score = semantic_score
        top_mask = fused_score > fused_score.quantile(0.95)
        if top_mask.sum() == 0:
            top_mask = fused_score > fused_score.quantile(0.90)
        candidate_positions = self.field.gaussians["positions"][top_mask]
        candidate_scores = fused_score[top_mask]
        weights = candidate_scores / candidate_scores.sum()
        centroid = (candidate_positions * weights.unsqueeze(-1)).sum(dim=0)
        confidence = candidate_scores.mean().item()
        return {
            "centroid": centroid,
            "confidence": confidence,
            "fused_scores": fused_score,
            "semantic_scores": semantic_score,
            "geometric_scores": geometric_score,
            "top_mask": top_mask,
            "query": query_text,
            "template": template_name,
        }

Your geometric affordance fields now separate the scene into physically plausible regions for each affordance type. The fusion operator ensures that only Gaussians satisfying both semantic relevance and geometric feasibility score highly. Next up, you will build the full query pipeline with adaptive template selection and multi-constraint chaining.

Build the Combined Affordance Query Engine

You have all the building blocks in place. The geometric constraint functions score Gaussians by physical feasibility, and the semantic scorer measures functional similarity via CLIP. Now you need to exercise the full pipeline end-to-end.

In this step you will run the combined query engine across all five affordance templates, demonstrate that queries without a matching template gracefully degrade to semantic-only, and chain multiple geometric constraints together for complex functional queries.

In this step, get ready to:
  • Run the full query pipeline across all five affordance templates.
  • Demonstrate adaptive template selection with semantic-only fallback.
  • Build and test multi-constraint affordance queries that chain geometric filters.
Run the full query pipeline across all templates

The query_affordance() method is the single entry point for every affordance query. It parses the query text, selects the geometric template, computes the fused affordance field, then clusters the top-scoring Gaussians into an object candidate with a centroid and confidence score.

  • Create a new Colab cell and instantiate the engine, then run one query per affordance template by pasting the following code:
# Instantiate the affordance query engine
from affordance_engine import AffordanceQueryEngine, AFFORDANCE_TEMPLATES

engine = AffordanceQueryEngine(field)

# Run one sample query per template to exercise the full pipeline
test_queries = [
    ("something to sit on", "sittable"),
    ("a surface to place objects", "placeable_surface"),
    ("something I can reach while standing", "reachable_from_standing"),
    ("a container for liquids", "container"),
    ("a flat elevated platform", "support_surface"),
]

print("FULL PIPELINE TEST: All 5 affordance templates")
print("=" * 60)
for query_text, template_name in test_queries:
    result = engine.query_affordance(query_text, template_name=template_name)
    centroid = result["centroid"]
    print(f"Query: '{query_text}'")
    print(f"  Template: {template_name}")
    print(f"  Centroid: [{centroid[0]:.3f}, {centroid[1]:.3f}, {centroid[2]:.3f}]")
    print(f"  Confidence: {result['confidence']:.4f}")
    print(f"  Top candidates: {result['top_mask'].sum().item()}")
    print()

What does this code do?

Each call to query_affordance() triggers the full pipeline: encode the text via CLIP, compute semantic similarity boosted by template hints, evaluate geometric constraints (height, horizontality, extent), fuse both scores with the alpha weight, then extract the top 5% of Gaussians as the object candidate.

The centroid is a score-weighted average of those candidate positions, representing where the afforded object lives in 3D space. Confidence reflects how strongly the scene matches the query.

  • Run the cell.
  • You should see five query results printed, each with a 3D centroid coordinate and a confidence score above zero. The sittable and placeable_surface templates should report fewer top candidates than reachable_from_standing because their constraints are tighter.

Seeing zero candidates or NaN centroids?

Make sure you ran field.compute_geometric_features() in the previous step. The geometric score relies on self.field.geometric_features being populated.

Also confirm that field.language_features is not None. If it is, re-run field.lift_features() from Step 2.

Help me debug NaN values in my affordance query results.

Demonstrate adaptive template selection

Not every query maps to a predefined geometric template. A query like "something interesting" has no clear geometric correlate. The engine gracefully degrades to semantic-only when no template is provided or the template name is unknown.

  • Create a new cell to compare template-matched vs fallback behaviour by pasting this code:
# Adaptive template selection: fallback to semantic-only for unknown queries
print("ADAPTIVE TEMPLATE SELECTION DEMO")
print("=" * 60)

# Query WITH a known template (full fusion)
result_fused = engine.query_affordance(
    "something to sit on", template_name="sittable", alpha=0.5
)
print(f"WITH template 'sittable':")
print(f"  Confidence: {result_fused['confidence']:.4f}")
print(f"  Geometric scores range: [{result_fused['geometric_scores'].min():.3f}, {result_fused['geometric_scores'].max():.3f}]")
print()

# Same query WITHOUT a template (semantic-only fallback)
result_semantic = engine.query_affordance(
    "something to sit on", template_name=None, alpha=0.5
)
print(f"WITHOUT template (semantic-only fallback):")
print(f"  Confidence: {result_semantic['confidence']:.4f}")
print(f"  Geometric scores range: [{result_semantic['geometric_scores'].min():.3f}, {result_semantic['geometric_scores'].max():.3f}]")
print()

# Query with an unknown template name (also falls back)
result_unknown = engine.query_affordance(
    "something shiny", template_name="unknown_template", alpha=0.5
)
print(f"UNKNOWN template 'unknown_template' (graceful degradation):")
print(f"  Confidence: {result_unknown['confidence']:.4f}")
print(f"  Geometric scores are all 1.0: {(result_unknown['geometric_scores'] == 1.0).all().item()}")

Why does adaptive fallback matter?

In real applications, users will ask queries that don't have a geometric template. The else branch in query_affordance() sets geometric scores to all ones (neutral), so the fused score becomes pure semantic similarity. This means the system never crashes on unknown queries. It simply returns the best CLIP match.

When a known template IS provided, geometric constraints filter out physically impossible candidates. The confidence for template-matched queries should be more focused (fewer but higher-quality candidates) compared to unfiltered semantic-only results.

  • Run the cell.
  • You should see that the template-matched query has geometric scores ranging from near 0 to 1 (the constraints are active), while the fallback queries show geometric scores pinned to 1.0 across the board.

Both results look identical?

If geometric scores show the same range for both the template-matched and fallback cases, check that you spelled the template name correctly. It must match a key in AFFORDANCE_TEMPLATES exactly (e.g. sittable, not Sittable).

Help me understand why my template-matched and fallback queries produce the same scores.

Build and test multi-constraint affordance queries

The most powerful affordance queries chain multiple geometric filters. A query like "a reachable container for water" needs CLIP similarity to "container", a height constraint below 1.5m, AND an upward orientation constraint (to hold liquid). You can compose these by creating a custom template on the fly.

  • Create a new cell to define and run multi-constraint queries by pasting this code:
# Multi-constraint affordance queries: chain multiple geometric filters
from affordance_engine import AffordanceTemplate

# Custom template: "reachable container for water"
# Needs: height < 1.5m AND upward-facing (to hold liquid) AND some extent
water_container_template = AffordanceTemplate(
    name="water_container",
    height_range=(0.3, 1.5),      # Reachable height
    min_horizontality=0.6,         # Upward-facing to hold liquid
    min_extent=0.005,              # Minimum opening size
    semantic_hints=["cup", "mug", "bowl", "glass", "container"],
)

# Temporarily register the custom template
AFFORDANCE_TEMPLATES["water_container"] = water_container_template

# Run the multi-constraint query
result = engine.query_affordance(
    "a reachable container for water",
    template_name="water_container",
    alpha=0.5
)

print("MULTI-CONSTRAINT QUERY: 'a reachable container for water'")
print("=" * 60)
print(f"Constraints: height [0.3, 1.5]m + horizontal (>0.6) + extent (>0.005)")
print(f"Centroid: [{result['centroid'][0]:.3f}, {result['centroid'][1]:.3f}, {result['centroid'][2]:.3f}]")
print(f"Confidence: {result['confidence']:.4f}")
print(f"Top candidates: {result['top_mask'].sum().item()}")
print()

# Compare: same text without geometric constraints
result_sem_only = engine.query_affordance(
    "a reachable container for water", template_name=None
)
print("SAME QUERY, semantic-only (no geometric constraints):")
print(f"Centroid: [{result_sem_only['centroid'][0]:.3f}, {result_sem_only['centroid'][1]:.3f}, {result_sem_only['centroid'][2]:.3f}]")
print(f"Confidence: {result_sem_only['confidence']:.4f}")
print(f"Top candidates: {result_sem_only['top_mask'].sum().item()}")
print()

# Demonstrate another multi-constraint: "a low writing surface"
low_writing_template = AffordanceTemplate(
    name="low_writing_surface",
    height_range=(0.4, 0.7),       # Lower than standard desk
    min_horizontality=0.85,        # Very flat
    min_extent=0.04,               # Enough area to write on
    semantic_hints=["table", "desk", "surface"],
)
AFFORDANCE_TEMPLATES["low_writing_surface"] = low_writing_template

result_low = engine.query_affordance(
    "a low surface to write on",
    template_name="low_writing_surface",
    alpha=0.5
)
print("MULTI-CONSTRAINT QUERY: 'a low surface to write on'")
print(f"Constraints: height [0.4, 0.7]m + very horizontal (>0.85) + large extent (>0.04)")
print(f"Centroid: [{result_low['centroid'][0]:.3f}, {result_low['centroid'][1]:.3f}, {result_low['centroid'][2]:.3f}]")
print(f"Confidence: {result_low['confidence']:.4f}")
print(f"Top candidates: {result_low['top_mask'].sum().item()}")

How does multi-constraint chaining work?

Each geometric constraint produces a soft score in [0, 1] per Gaussian. Multiplying them together means a Gaussian must pass ALL constraints to score highly. A mug at 0.8m that faces upward scores well on both height and orientation. A tall shelf at 2.0m fails the height constraint even if it faces upward.

The semantic score adds a further filter. Even if a random floor patch at 0.8m is flat and has large extent, it scores poorly on CLIP("container") similarity. The fusion of geometric feasibility with semantic understanding is what makes these queries work.

  • Run the cell.
  • You should see that the multi-constraint query produces fewer top candidates than the semantic-only version. The centroid positions should differ because geometric constraints shift the result toward physically feasible locations.

Top candidates showing zero?

If constraints are too tight, no Gaussian passes all of them simultaneously. The engine falls back to the 90th percentile threshold, but if that is also empty, try widening one constraint. For example, increase height_range or lower min_horizontality.

The LERF figurines scene is a tabletop scene. Objects sit at relatively uniform heights. If your ground plane estimate is off, all computed heights shift. Print field.geometric_features["ground_height"] to verify it looks reasonable.

Help me debug multi-constraint queries returning empty candidate sets.

Why this approach instead of a VLM?

You might wonder why not just ask GPT-4o "what can I sit on in this scene?" Research shows VLMs are unreliable for quantitative physical reasoning. The QuantiPhy benchmark (CVPR 2026) found that VLMs rely on memorized world knowledge rather than actual visual evidence for physical properties.

Geometric constraints extracted from the actual 3D Gaussian parameters are grounded in the scene's real structure. They cost nothing to compute and never hallucinate a measurement.

✔️ Awesome, I've got everything!

Great. Double check that your affordance_engine.py file is saved and your engine is returning results for all five templates.

ⓧ I'd like to double check the full code

Here is the full affordance_engine.py as it should look:

"""Geometry-grounded affordance query engine."""
import torch
import numpy as np
from dataclasses import dataclass
from typing import Optional
from language_field import GeometricLanguageField3D


@dataclass
class AffordanceTemplate:
    name: str
    height_range: Optional[tuple] = None
    min_horizontality: Optional[float] = None
    min_extent: Optional[float] = None
    semantic_hints: Optional[list] = None


AFFORDANCE_TEMPLATES = {
    "sittable": AffordanceTemplate(name="sittable", height_range=(0.35, 0.55), min_horizontality=0.7, min_extent=0.02, semantic_hints=["chair", "stool", "bench", "seat"]),
    "placeable_surface": AffordanceTemplate(name="placeable_surface", height_range=(0.6, 0.95), min_horizontality=0.8, min_extent=0.03, semantic_hints=["table", "desk", "counter", "shelf"]),
    "reachable_from_standing": AffordanceTemplate(name="reachable_from_standing", height_range=(0.3, 2.0), min_horizontality=None, min_extent=None, semantic_hints=None),
    "container": AffordanceTemplate(name="container", height_range=(0.3, 1.5), min_horizontality=None, min_extent=None, semantic_hints=["cup", "mug", "bowl", "box", "basket"]),
    "support_surface": AffordanceTemplate(name="support_surface", height_range=(0.0, 2.5), min_horizontality=0.85, min_extent=0.05, semantic_hints=["table", "shelf", "counter", "floor"]),
}


class AffordanceQueryEngine:
    def __init__(self, field: GeometricLanguageField3D):
        self.field = field
        self.up_vector = torch.tensor([0.0, 0.0, 1.0], device=field.device)

    def compute_geometric_score(self, template: AffordanceTemplate) -> torch.Tensor:
        geo = self.field.geometric_features
        scores = torch.ones(self.field.gaussians["count"], device=self.field.device)
        if template.height_range is not None:
            h_min, h_max = template.height_range
            height = geo["height"]
            k = 20.0
            scores = scores * torch.sigmoid(k * (height - h_min)) * torch.sigmoid(k * (h_max - height))
        if template.min_horizontality is not None:
            horizontality = torch.sum(geo["normals"] * self.up_vector, dim=-1).abs()
            scores = scores * torch.clamp((horizontality - template.min_horizontality + 0.1) / 0.1, 0.0, 1.0)
        if template.min_extent is not None:
            scores = scores * torch.clamp((geo["extent"] - template.min_extent) / template.min_extent, 0.0, 1.0)
        return scores

    def compute_semantic_score(self, query_text: str, template: AffordanceTemplate) -> torch.Tensor:
        primary_score = self.field.query_similarity(query_text)
        primary_score_norm = (primary_score - primary_score.min()) / (primary_score.max() - primary_score.min() + 1e-8)
        if template.semantic_hints:
            hint_scores = []
            for hint in template.semantic_hints:
                hint_sim = self.field.query_similarity(hint)
                hint_norm = (hint_sim - hint_sim.min()) / (hint_sim.max() - hint_sim.min() + 1e-8)
                hint_scores.append(hint_norm)
            hints_combined = torch.stack(hint_scores).max(dim=0).values
            return 0.6 * primary_score_norm + 0.4 * hints_combined
        return primary_score_norm

    def query_affordance(self, query_text: str, template_name: Optional[str] = None, alpha: float = 0.5) -> dict:
        if template_name and template_name in AFFORDANCE_TEMPLATES:
            template = AFFORDANCE_TEMPLATES[template_name]
            semantic_score = self.compute_semantic_score(query_text, template)
            geometric_score = self.compute_geometric_score(template)
            fused_score = alpha * semantic_score + (1 - alpha) * geometric_score
        else:
            semantic_score = self.field.query_similarity(query_text)
            semantic_score = (semantic_score - semantic_score.min()) / (semantic_score.max() - semantic_score.min() + 1e-8)
            geometric_score = torch.ones_like(semantic_score)
            fused_score = semantic_score
        top_mask = fused_score > fused_score.quantile(0.95)
        if top_mask.sum() == 0:
            top_mask = fused_score > fused_score.quantile(0.90)
        candidate_positions = self.field.gaussians["positions"][top_mask]
        candidate_scores = fused_score[top_mask]
        weights = candidate_scores / candidate_scores.sum()
        centroid = (candidate_positions * weights.unsqueeze(-1)).sum(dim=0)
        return {
            "centroid": centroid,
            "confidence": candidate_scores.mean().item(),
            "fused_scores": fused_score,
            "semantic_scores": semantic_score,
            "geometric_scores": geometric_score,
            "top_mask": top_mask,
            "query": query_text,
            "template": template_name,
        }

Your affordance query engine is fully operational. It handles known templates with full geometric fusion, unknown queries with semantic fallback, and multi-constraint chaining for complex functional queries. Next up, you will evaluate this system quantitatively against the semantic-only and geometry-only baselines.

Run Evaluation and Ablation Study

You have a complete affordance query engine that fuses semantic embeddings with geometric constraints. The critical question now is: does the fusion actually help?

In this step, you will run a rigorous three-way comparison (semantic-only vs geometry-only vs fused) on your benchmark of 15 functional queries. You will also perform an ablation study to isolate which geometric features contribute most, then visualize the affordance fields as colored 3D point clouds.

In this step, get ready to:
  • Create ground-truth object annotations for the affordance query benchmark.
  • Run the three-way comparison and measure accuracy across all query categories.
  • Perform ablation analysis and visualize affordance fields as colored 3D point clouds.
Create the affordance query benchmark with ground-truth annotations

The evaluate_affordance_queries function in evaluate.py compares each query method's predicted centroid against a ground-truth centroid. You need to supply those ground-truth 3D positions as a dictionary mapping object category names to coordinates.

  • In a new Colab cell, define the ground-truth annotations dictionary by running this code:
# Ground-truth object centroids for the LERF figurines scene
# These approximate 3D positions come from inspecting the Gaussian positions
# that correspond to known objects in the scene
gt_annotations = {
    "chair": [0.15, -0.3, 0.45],   # Chair seat height ~45cm
    "desk": [0.5, 0.2, 0.75],      # Desk surface ~75cm
    "table": [0.0, 0.5, 0.72],     # Table surface ~72cm
    "shelf": [-0.4, 0.1, 1.3],     # High shelf ~1.3m
    "mug": [0.3, 0.4, 0.78],       # Mug on table surface
    "cup": [0.1, 0.6, 0.74],       # Cup on table
    "stool": [-0.2, -0.5, 0.38],   # Low stool ~38cm
}

print(f"Ground-truth annotations defined for {len(gt_annotations)} object categories")
print(f"Categories: {list(gt_annotations.keys())}")

What does this code do?

Each entry maps an object category (like "chair") to an approximate 3D position [x, y, z] in the scene. The evaluation function checks whether each query method's predicted centroid falls within 0.4 meters of these ground-truth positions.

The heights here reflect real-world furniture standards. Chairs sit around 0.4-0.5m, desks at 0.7-0.76m, and shelves above 1.2m. These are the exact physical properties our geometric constraints are designed to exploit.

Adapt to your scene

If you are running on a different LERF scene (like teatime or ramen), you will need to adjust these centroid positions. You can inspect the Gaussian positions for known objects by querying with direct object names (e.g., field.query_similarity("chair")) and finding the centroid of the top-scoring Gaussians.

Run the three-way comparison

The core experiment tests three strategies against each other. Semantic-only uses raw CLIP cosine similarity with no geometric knowledge. Geometry-only applies constraint templates with no language understanding. The fused method combines both.

  • Run the evaluation benchmark by executing this code in a new cell:
from evaluate import evaluate_affordance_queries

# Run the three-way comparison across all 15 affordance queries
results = evaluate_affordance_queries(field, gt_annotations)

What does this code do?

For each of the 15 queries in AFFORDANCE_QUERIES, the function runs three variants. It sets alpha=1.0 for semantic-only, alpha=0.0 for geometry-only, and alpha=0.5 for the balanced fusion. A query is scored as correct (1.0) if the predicted centroid lands within 0.4m of the ground-truth centroid.

  • Check the printed table. You should see a per-query breakdown showing 1 (correct) or 0 (incorrect) for each method, followed by summary accuracy scores.

How to read the results

The final summary shows accuracy for each method and the improvement deltas. A positive "Improvement over sem" value means the fused method localized more affordance queries correctly than CLIP alone. This is the core finding: geometry helps when the query is functional rather than categorical.

Look at which specific queries the fused method gets right that semantic-only misses. Queries like "something at knee height" or "a flat elevated platform" are where geometric constraints provide the strongest signal, because CLIP has no concept of physical height.

All scores showing 0?

If every query returns 0 across all methods, the ground-truth centroids likely do not match your scene's coordinate system. Run field.gaussians["positions"].mean(dim=0) and field.gaussians["positions"].std(dim=0) to understand your scene's scale and origin, then adjust the annotation coordinates accordingly.

help me debug why my affordance evaluation shows all zeros

Perform ablation analysis and visualize affordance fields

An ablation study isolates which geometric features contribute the most. You will test each constraint individually (height alone, orientation alone, extent alone) and compare against the full fusion. This reveals when geometric constraints help and when they hurt.

  • Run the ablation analysis by executing this code in a new cell:
from affordance_engine import AffordanceQueryEngine, AffordanceTemplate, AFFORDANCE_TEMPLATES

# Ablation: test each geometric feature in isolation
engine = AffordanceQueryEngine(field)

def run_ablation(query_text, template_name, gt_pos):
    """Test individual geometric constraints vs full fusion."""
    gt_centroid = torch.tensor(gt_pos, device=field.device)
    radius = 0.4
    template = AFFORDANCE_TEMPLATES[template_name]

    # Height-only template
    height_only = AffordanceTemplate(
        name="height_only",
        height_range=template.height_range,
        min_horizontality=None,
        min_extent=None,
        semantic_hints=None,
    )

    # Orientation-only template
    orient_only = AffordanceTemplate(
        name="orient_only",
        height_range=None,
        min_horizontality=template.min_horizontality,
        min_extent=None,
        semantic_hints=None,
    )

    # Extent-only template
    extent_only = AffordanceTemplate(
        name="extent_only",
        height_range=None,
        min_horizontality=None,
        min_extent=template.min_extent,
        semantic_hints=None,
    )

    # Compute scores for each ablation
    semantic_score = engine.compute_semantic_score(query_text, template)
    geo_height = engine.compute_geometric_score(height_only)
    geo_orient = engine.compute_geometric_score(orient_only)
    geo_extent = engine.compute_geometric_score(extent_only)
    geo_full = engine.compute_geometric_score(template)

    results = {}
    for name, geo_score in [("height_only", geo_height), ("orient_only", geo_orient),
                             ("extent_only", geo_extent), ("full_geo", geo_full)]:
        fused = 0.5 * semantic_score + 0.5 * geo_score
        top_mask = fused > fused.quantile(0.95)
        if top_mask.sum() == 0:
            top_mask = fused > fused.quantile(0.90)
        positions = field.gaussians["positions"][top_mask]
        scores = fused[top_mask]
        weights = scores / scores.sum()
        centroid = (positions * weights.unsqueeze(-1)).sum(dim=0)
        dist = torch.norm(centroid - gt_centroid).item()
        results[name] = {"distance": dist, "correct": dist < radius}

    return results

# Run ablation on key queries
ablation_queries = [
    ("something to sit on", "sittable", gt_annotations["chair"]),
    ("a surface to write on", "placeable_surface", gt_annotations["desk"]),
    ("a flat elevated platform", "support_surface", gt_annotations["table"]),
    ("a container for liquids", "container", gt_annotations["mug"]),
    ("a high shelf", "support_surface", gt_annotations["shelf"]),
]

print("=" * 70)
print("ABLATION ANALYSIS: Which geometric feature matters most?")
print("=" * 70)
print(f"{'Query':<30} {'Height':>8} {'Orient':>8} {'Extent':>8} {'Full':>8}")
print("-" * 70)

for query_text, template_name, gt_pos in ablation_queries:
    abl = run_ablation(query_text, template_name, gt_pos)
    h = "YES" if abl["height_only"]["correct"] else "no"
    o = "YES" if abl["orient_only"]["correct"] else "no"
    e = "YES" if abl["extent_only"]["correct"] else "no"
    f = "YES" if abl["full_geo"]["correct"] else "no"
    print(f"{query_text:<30} {h:>8} {o:>8} {e:>8} {f:>8}")

print("=" * 70)
print("YES = centroid within 0.4m of ground truth")

What does this ablation reveal?

Each row shows whether a query succeeds when you only use one geometric constraint. Height-only should dominate for queries that mention a specific elevation ("something at knee height"). Orientation should matter for queries requiring flat surfaces ("a surface to write on"). Extent helps distinguish large surfaces from small objects.

The "Full" column shows the complete template with all constraints active. When full fusion scores higher than any individual feature, the constraints are complementary. When an individual feature beats full fusion, the extra constraints are adding noise for that particular query.

  • Visualize the affordance fields as colored 3D point clouds by running this code in a new cell:
import open3d as o3d
import matplotlib.pyplot as plt

def visualize_affordance_field(field, query_text, template_name, save_path="affordance_viz.png"):
    """Visualize affordance scores as a colored 3D point cloud."""
    engine = AffordanceQueryEngine(field)
    result = engine.query_affordance(query_text, template_name=template_name, alpha=0.5)

    # Get positions and scores on CPU
    positions = field.gaussians["positions"].cpu().numpy()
    fused_scores = result["fused_scores"].cpu().numpy()
    semantic_scores = result["semantic_scores"].cpu().numpy()
    geometric_scores = result["geometric_scores"].cpu().numpy()

    # Normalize scores to [0, 1] for coloring
    fused_norm = (fused_scores - fused_scores.min()) / (fused_scores.max() - fused_scores.min() + 1e-8)

    # Create color map: low scores = blue, high scores = red
    colors = np.zeros((len(positions), 3))
    colors[:, 0] = fused_norm        # Red channel = high affordance
    colors[:, 2] = 1.0 - fused_norm  # Blue channel = low affordance

    # Subsample for visualization (3DGS scenes have millions of Gaussians)
    max_points = 50000
    if len(positions) > max_points:
        indices = np.random.choice(len(positions), max_points, replace=False)
        positions = positions[indices]
        colors = colors[indices]
        fused_norm_sub = fused_norm[indices]
    else:
        fused_norm_sub = fused_norm

    # Create Open3D point cloud
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(positions)
    pcd.colors = o3d.utility.Vector3dVector(colors)

    # Render to image (headless in Colab)
    vis = o3d.visualization.Visualizer()
    vis.create_window(visible=False, width=800, height=600)
    vis.add_geometry(pcd)

    # Set camera viewpoint
    ctr = vis.get_view_control()
    ctr.set_zoom(0.6)
    ctr.set_front([0.0, -0.5, -0.8])
    ctr.set_up([0.0, 0.0, 1.0])

    vis.poll_events()
    vis.update_renderer()
    vis.capture_screen_image(save_path)
    vis.destroy_window()

    print(f"Saved visualization to {save_path}")
    print(f"Query: '{query_text}' | Template: {template_name}")
    print(f"Top 5% scored Gaussians: {(fused_norm_sub > 0.95).sum()} points (red)")

    # Display in Colab
    img = plt.imread(save_path)
    plt.figure(figsize=(12, 8))
    plt.imshow(img)
    plt.title(f"Affordance Field: '{query_text}'")
    plt.axis("off")
    plt.show()

# Visualize key affordance queries
visualize_affordance_field(field, "something to sit on", "sittable", "sittable_viz.png")
visualize_affordance_field(field, "a surface to place objects", "placeable_surface", "placeable_viz.png")
visualize_affordance_field(field, "a container for liquids", "container", "container_viz.png")

What does this visualization show?

Each point cloud colors Gaussians from blue (low affordance score) to red (high affordance score). For "something to sit on", you should see red clusters around chair-height horizontal surfaces. For "a surface to place objects", red should concentrate on desk and table tops.

The visualization confirms that geometric constraints focus the CLIP similarity onto physically plausible regions. Without geometry, CLIP might highlight any surface associated with the word "sit" in training data, regardless of height or orientation.

  • Check the rendered images in your Colab output. You should see three point cloud visualizations with red-colored clusters appearing at physically plausible locations for each affordance type.

Visualization appears all black or crashes?

Open3D's offscreen rendering requires a virtual display in Colab. If the visualization fails, add os.environ["DISPLAY"] = ":99" before creating the visualizer, or use matplotlib scatter plots as a fallback by plotting positions[:, 0] vs positions[:, 1] with colors mapped to affordance scores.

help me fix Open3D headless rendering in Google Colab

✔️ Awesome, I've got everything!

Great. Your evaluation and ablation are complete. The results demonstrate that fusing semantic embeddings with geometric constraints improves functional affordance queries over either method alone.

ⓧ I'd like to double check the full code

Here is the complete evaluate.py file that should already be in your project:

"""Evaluation: semantic-only vs geometry-only vs fused affordance queries."""
import torch
import numpy as np
from language_field import GeometricLanguageField3D
from affordance_engine import AffordanceQueryEngine, AFFORDANCE_TEMPLATES


AFFORDANCE_QUERIES = [
    ("something to sit on", "sittable", "chair", "Should find chairs/stools"),
    ("a place to sit down", "sittable", "chair", "Paraphrase of sitting"),
    ("a surface to write on", "placeable_surface", "desk", "Should find desks/tables"),
    ("somewhere to put my coffee", "placeable_surface", "table", "Flat elevated surface"),
    ("a surface to place objects", "placeable_surface", "table", "General placement"),
    ("something at desk height", "placeable_surface", "desk", "Height-specific"),
    ("a flat elevated platform", "support_surface", "table", "Geometric description"),
    ("something I can reach while standing", "reachable_from_standing", "shelf", "Height filter"),
    ("a container for liquids", "container", "mug", "Functional container"),
    ("something to hold water", "container", "cup", "Liquid holding"),
    ("a low seat", "sittable", "stool", "Low height + sittable"),
    ("a high shelf", "support_surface", "shelf", "Height + horizontal"),
    ("something at knee height", "sittable", "chair", "Specific height range"),
    ("a workspace surface", "placeable_surface", "desk", "Functional workspace"),
    ("something to rest my arms on", "placeable_surface", "desk", "Arm-rest height"),
]


def evaluate_affordance_queries(field: GeometricLanguageField3D, gt_annotations: dict):
    engine = AffordanceQueryEngine(field)
    results = {"semantic_only": [], "geometry_only": [], "fused": []}
    print("=" * 70)
    print(f"{'Query':<35} {'Sem':>5} {'Geo':>5} {'Fused':>5} {'GT':<10}")
    print("-" * 70)
    for query_text, template_name, gt_category, _ in AFFORDANCE_QUERIES:
        if gt_category not in gt_annotations:
            continue
        gt_centroid = torch.tensor(gt_annotations[gt_category], device=field.device)
        radius = 0.4
        sem_result = engine.query_affordance(query_text, template_name=None, alpha=1.0)
        sem_correct = 1.0 if torch.norm(sem_result["centroid"] - gt_centroid).item() < radius else 0.0
        geo_result = engine.query_affordance(query_text, template_name=template_name, alpha=0.0)
        geo_correct = 1.0 if torch.norm(geo_result["centroid"] - gt_centroid).item() < radius else 0.0
        fused_result = engine.query_affordance(query_text, template_name=template_name, alpha=0.5)
        fused_correct = 1.0 if torch.norm(fused_result["centroid"] - gt_centroid).item() < radius else 0.0
        results["semantic_only"].append(sem_correct)
        results["geometry_only"].append(geo_correct)
        results["fused"].append(fused_correct)
        print(f"{query_text:<35} {sem_correct:>5.0f} {geo_correct:>5.0f} {fused_correct:>5.0f} {gt_category:<10}")
    print("=" * 70)
    print("AFFORDANCE QUERY EVALUATION RESULTS")
    print("=" * 70)
    print(f"Semantic-only accuracy:  {np.mean(results['semantic_only']):.3f}")
    print(f"Geometry-only accuracy:  {np.mean(results['geometry_only']):.3f}")
    print(f"Fused accuracy (ours):   {np.mean(results['fused']):.3f}")
    print(f"Improvement over sem:    {np.mean(results['fused']) - np.mean(results['semantic_only']):+.3f}")
    print(f"Improvement over geo:    {np.mean(results['fused']) - np.mean(results['geometry_only']):+.3f}")
    print("=" * 70)
    return results

You have now quantitatively demonstrated that geometry-grounded affordance queries outperform semantic-only queries for functional scene understanding. Next up, the Secret Mission challenges you to add a robot reachability layer that filters affordance results by physical accessibility.

Secret mission

Robot Reachability Affordance Layer

Your affordance engine can answer 'what can I do here?' but a real robot also needs to know 'can I actually reach it?' In this extension, you'll implement a reachability layer that filters affordance results by a robot's physical workspace envelope. The result is a practical robotics pipeline that answers 'what reachable objects afford this action?' in a single query.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on Google Colab Pro, so your L4 GPU runtime consumes compute units while active.

Cost warning

Your Colab Pro L4 runtime uses compute units for every minute it stays connected. If you are not actively running cells, disconnect the runtime to stop consuming your allocation.

Resources you used:

  • Google Colab Pro L4 GPU runtime.
  • Cloned OccamLGS repository.
  • Downloaded LERF dataset (figurines scene) in the data/lerf_dataset directory.
  • Python source files: language_field.py, affordance_engine.py, evaluate.py, robot_reachability.py, setup.sh.

Keep everything running

No action needed. Choose this if you plan to continue experimenting with affordance queries, tweaking geometric templates, or extending the robot reachability layer.

  • Your Colab runtime will eventually disconnect after idle timeout (90 minutes for Pro). You can reconnect and resume without re-running setup.
  • Your files remain in the Colab environment as long as the runtime is connected. Files in /content/ persist for the session.
  • Installed pip packages persist until the runtime disconnects.

Pause - I'll come back to this later

Disconnect the GPU runtime to stop consuming compute units. Your notebook and any files saved to Google Drive will persist.

  • In your Colab notebook, click Runtime in the top menu bar.
  • Click Disconnect and delete runtime.
  • If you want to preserve your source files across sessions, copy them to Google Drive before disconnecting.

What happens when you reconnect?

You will need to re-run the setup cells (pip installs, clone OccamLGS, download dataset). Colab does not persist installed packages or cloned repos after a runtime disconnect. Your notebook cells and any Drive-mounted files remain intact.

Delete - I don't want to use this again

Remove all project resources and free up your Colab environment completely.

  • Disconnect and delete the runtime by clicking Runtime then Disconnect and delete runtime.
  • If you saved files to Google Drive, delete the project folder from your Drive.
  • Delete the Colab notebook itself by right-clicking it in Google Drive and selecting Move to trash.

If you ran any cells that wrote files to the Colab filesystem before disconnecting, remove them by running this in a code cell:

rm -rf OccamLGS data language_field.py affordance_engine.py evaluate.py robot_reachability.py setup.sh

What about installed packages?

Packages installed via pip in Colab are tied to the runtime session. Once you disconnect and delete the runtime, all installed packages are removed automatically. No manual uninstall is needed.

Nice Work!

Nice Work!

You just built a novel research prototype at the frontier of 3D affordance understanding. Your system answers "what can I do here?" in 3D scenes by fusing CLIP language embeddings with geometric constraints extracted directly from 3D Gaussian Splatting parameters.

What you accomplished:

  • Built a training-free 3D language field augmented with geometric affordance features, lifting 512-dimensional CLIP features to Gaussians in under one minute using Occam's LGS.
  • Extracted physical properties (height above ground, surface orientation from covariance normals, spatial extent) directly from Gaussian parameters without any additional model or training.
  • Demonstrated through a quantitative three-way evaluation that fusing semantic embeddings with geometric constraints enables functional affordance queries that pure language-only or geometry-only methods cannot reliably answer.
  • Secret Mission: Implemented a robot reachability affordance layer that filters objects by workspace constraints, proving practical applicability for robotic manipulation planning.

Ready to quiz yourself?