Source code for tbp.monty.frameworks.utils.edge_detection

# Copyright 2025-2026 Thousand Brains Project
#
# Copyright may exist in Contributors' modifications
# and/or contributions to the work.
#
# Use of this source code is governed by the MIT
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.

from __future__ import annotations

import logging
from dataclasses import dataclass

import cv2
import numpy as np

from tbp.monty.frameworks.utils.spatial_arithmetics import (
    TangentFrame,
    normalize,
    project_onto_tangent_plane,
)
from tbp.monty.geometry import SurfacePoseVectors
from tbp.monty.math import DEFAULT_TOLERANCE
from tbp.monty.observations import SensorObservation

logger = logging.getLogger(__name__)

DEFAULT_SURFACE_POSE_VECTORS_2D = SurfacePoseVectors(
    np.array([[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
)


def _gradient_to_tangent_angle(gradient_angle: float) -> float:
    """Convert gradient direction to edge tangent direction and wrap to (0, pi].

    The edge tangent is perpendicular to the gradient direction.

    Args:
        gradient_angle: Gradient direction in radians (any range)

    Returns:
        Edge tangent angle in (0, pi] radians.
    """
    tangent_angle = gradient_angle + np.pi / 2
    return (tangent_angle + 2 * np.pi) % (2 * np.pi)


def _angle_to_pose_2d(
    angle: float,
    cam_to_world: np.ndarray,
    surface_normal: np.ndarray,
    tangent_frame: TangentFrame,
) -> SurfacePoseVectors:
    """Build 2D pose vectors from an edge angle.

    The image-space edge direction is projected onto the local tangent plane and
    expressed in tangent-frame coordinates.

    Args:
        angle: Edge angle in radians in image coordinates (y-down), measured
            from the image x-axis toward the image y-axis (i.e., toward the
            bottom of the image).
        cam_to_world: 4x4 camera-to-world transformation matrix.
        surface_normal: Surface normal defining the local tangent plane.
        tangent_frame: Orthonormal local basis used for the 2D model.

    Returns:
        3x3 array whose rows are [normal, edge_tangent, edge_perp].
        Normal is always [0, 0, 1]; tangent and perp lie in local 2D coordinates.
    """
    R = cam_to_world[:3, :3]  # noqa: N806
    image_x_world = R @ np.array([1.0, 0.0, 0.0])
    image_y_world = R @ np.array([0.0, -1.0, 0.0])

    edge_world = np.cos(angle) * image_x_world + np.sin(angle) * image_y_world
    edge_tangent_world = project_onto_tangent_plane(edge_world, surface_normal)

    if np.linalg.norm(edge_tangent_world) < DEFAULT_TOLERANCE:
        edge_tangent_world = tangent_frame.basis_u
    else:
        edge_tangent_world = normalize(edge_tangent_world)

    edge_tangent_u = np.dot(edge_tangent_world, tangent_frame.basis_u)
    edge_tangent_v = np.dot(edge_tangent_world, tangent_frame.basis_v)
    edge_tangent_2d = normalize(np.array([edge_tangent_u, edge_tangent_v, 0.0]))

    return SurfacePoseVectors(
        np.array(
            [
                [0.0, 0.0, 1.0],
                edge_tangent_2d,
                [-edge_tangent_2d[1], edge_tangent_2d[0], 0.0],
            ]
        )
    )


[docs] @dataclass class StructureTensor: """Structure tensor at a single point. A 2x2 symmetric matrix [[Jxx, Jxy], [Jxy, Jyy]]. """ xx: float yy: float xy: float @property def eigenvalues(self) -> tuple[float, float]: """Returns (lambda_min, lambda_max) of the 2x2 structure tensor.""" matrix = np.array([[self.xx, self.xy], [self.xy, self.yy]]) lambda_min, lambda_max = np.linalg.eigh(matrix)[0] return lambda_min, lambda_max @property def gradient_theta(self) -> float: """Gradient direction in radians (normal to the dominant edge).""" return 0.5 * np.arctan2(2.0 * self.xy, self.xx - self.yy) @property def edge_strength(self) -> float: """Magnitude of the dominant eigenvalue.""" _, lambda_max = self.eigenvalues return np.sqrt(max(lambda_max, 0.0)) @property def coherence(self) -> float: """Edge quality in [0, 1]: 1 means perfectly oriented, 0 means isotropic.""" lambda_min, lambda_max = self.eigenvalues lambda_diff = lambda_max - lambda_min if lambda_diff < DEFAULT_TOLERANCE: return 0.0 return np.clip(lambda_diff / (lambda_max + lambda_min), 0.0, 1.0) @property def edge_angle(self) -> float: """Edge angle in [0, pi] radians.""" return _gradient_to_tangent_angle(self.gradient_theta)
[docs] @dataclass class EdgeFeatures: """Edge features extracted from a single image patch.""" angle: float | None strength: float coherence: float is_geometric_edge: bool has_edge: bool
[docs] class EdgeDetector:
[docs] def __init__( self, gaussian_sigma: float = 1.0, kernel_size: int = 7, strength_threshold: float = 0.1, coherence_threshold: float = 0.5, radius: float = 14.0, sigma_r: float = 7.0, depth_edge_threshold: float = 0.01, max_center_offset: int | None = None, ): """Initialize EdgeDetector. Args: gaussian_sigma: float = 1.0 kernel_size: int = 7 strength_threshold: float = 0.1 coherence_threshold: float = 0.5 radius: float = 14.0 sigma_r: float = 7.0 depth_edge_threshold: float = 0.01 max_center_offset: int | None = None Raises: ValueError: Invalid argument for gaussian_sigma. """ self._gaussian_sigma = gaussian_sigma self._kernel_size = kernel_size self._strength_threshold = strength_threshold self._coherence_threshold = coherence_threshold self._radius = radius self._sigma_r = sigma_r self._depth_edge_threshold = depth_edge_threshold self._max_center_offset = max_center_offset if self._kernel_size % 2 != 1: raise ValueError("Kernel size must be odd.")
def __call__( self, observation: SensorObservation, ) -> EdgeFeatures: """Compute edge features using center-weighted, global-aware structure tensor. This function aggregates structure tensor components over a center-biased neighborhood, giving higher weight to pixels closer to the center and pixels with stronger gradients. Reference: Nazar Khan, "Corner Detection" lecture notes, Section on Structure Tensor. http://faculty.pucit.edu.pk/nazarkhan/teaching/Spring2021/CS565/Lectures/lecture6_corner_detection.pdf Args: observation: Sensor observation. Returns: EdgeFeatures. """ patch = observation["rgba"][:, :, :3] depth = observation["depth"] grayscale = cv2.cvtColor(patch, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0 Ix, Iy = self._compute_sobel_gradients(grayscale) # noqa: N806 tensor_per_pixel = self._compute_per_pixel_structure_tensors(Ix, Iy) weights, total_weight = self._compute_center_weights(grayscale.shape, Ix, Iy) if total_weight < DEFAULT_TOLERANCE: return EdgeFeatures( angle=None, strength=0.0, coherence=0.0, is_geometric_edge=False, has_edge=False, ) aggregated = self._aggregate_tensor(tensor_per_pixel, weights, total_weight) edge_normal_angle = aggregated.edge_angle + np.pi / 2 edge_center_offset = self._compute_center_offset( weights, total_weight, edge_normal_angle, ) if not self._passes_center_check(edge_center_offset): return EdgeFeatures( angle=None, strength=0.0, coherence=0.0, is_geometric_edge=False, has_edge=False, ) has_edge = ( aggregated.edge_strength > self._strength_threshold and aggregated.coherence > self._coherence_threshold ) return EdgeFeatures( angle=aggregated.edge_angle, strength=aggregated.edge_strength, coherence=aggregated.coherence, is_geometric_edge=self._is_geometric_edge( depth, aggregated.edge_angle, edge_center_offset, ), has_edge=has_edge, ) @staticmethod def _compute_sobel_gradients( grayscale: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: """Compute horizontal and vertical Sobel gradients. Args: grayscale: Grayscale image patch as float32 in [0, 1]. Returns: Tuple of (Ix, Iy): horizontal and vertical gradient arrays. """ Ix = cv2.Sobel(grayscale, cv2.CV_32F, 1, 0, ksize=3) # noqa: N806 Iy = cv2.Sobel(grayscale, cv2.CV_32F, 0, 1, ksize=3) # noqa: N806 return Ix, Iy def _compute_per_pixel_structure_tensors( self, Ix: np.ndarray, # noqa: N803 Iy: np.ndarray, # noqa: N803 ) -> np.ndarray: """Build the smoothed per-pixel structure tensor field from Sobel gradients. Args: Ix: Horizontal Sobel gradients. Iy: Vertical Sobel gradients. Returns: Array of shape (h, w, 2, 2) where each entry is the Gaussian-smoothed 2x2 structure tensor [[Jxx, Jxy], [Jxy, Jyy]] at that pixel. """ Jxx = Ix * Ix # noqa: N806 # (h, w) Jyy = Iy * Iy # noqa: N806 # (h, w) Jxy = Ix * Iy # noqa: N806 # (h, w) ksize = self._kernel_size sigma = self._gaussian_sigma Jxx = cv2.GaussianBlur(Jxx, (ksize, ksize), sigma) # noqa: N806 # (h, w) Jyy = cv2.GaussianBlur(Jyy, (ksize, ksize), sigma) # noqa: N806 # (h, w) Jxy = cv2.GaussianBlur(Jxy, (ksize, ksize), sigma) # noqa: N806 # (h, w) h, w = Jxx.shape tensor_per_pixel = np.empty((h, w, 2, 2), dtype=np.float32) tensor_per_pixel[..., 0, 0] = Jxx tensor_per_pixel[..., 1, 1] = Jyy tensor_per_pixel[..., 0, 1] = Jxy tensor_per_pixel[..., 1, 0] = Jxy return tensor_per_pixel def _compute_center_weights( self, shape: tuple[int, int], Ix: np.ndarray, # noqa: N803 Iy: np.ndarray, # noqa: N803 ) -> tuple[np.ndarray, np.floating]: """Build radial + gradient-strength weight map centered on the patch. Weights combine a Gaussian radial falloff (suppressing far-from-center pixels) with local gradient magnitude (so strong off-center edges still contribute). Args: shape: (height, width) of the patch. Ix: Horizontal Sobel gradients. Iy: Vertical Sobel gradients. Returns: Tuple of (weights, total_weight). """ h, w = shape r0, c0 = h // 2, w // 2 rows, cols = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") d_squared = (rows - r0) ** 2 + (cols - c0) ** 2 d = np.sqrt(d_squared) w_r = np.exp(-d_squared / (2.0 * self._sigma_r**2)) w_r[d > self._radius] = 0.0 weights = w_r * (Ix**2 + Iy**2) total_weight = np.sum(weights) return weights, total_weight @staticmethod def _aggregate_tensor( tensor_per_pixel: np.ndarray, weights: np.ndarray, total_weight: float, ) -> StructureTensor: """Reduce a per-pixel structure tensor field to a single representative tensor. Args: tensor_per_pixel: Per-pixel structure tensors, shape (h, w, 2, 2). weights: Per-pixel weights, shape (h, w). total_weight: Sum of weights (must be > 0). Returns: StructureTensor representing the weighted aggregate over the patch. """ w = weights[..., np.newaxis, np.newaxis] aggregated = np.sum(w * tensor_per_pixel, axis=(0, 1)) / total_weight return StructureTensor( xx=float(aggregated[0, 0]), yy=float(aggregated[1, 1]), xy=float(aggregated[0, 1]), ) @staticmethod def _compute_center_offset( weights: np.ndarray, total_weight: np.floating, normal_angle: float, ) -> np.floating: """Estimate signed edge offset from the patch center along a normal. Args: weights: Per-pixel weights. total_weight: Sum of weights. normal_angle: Direction perpendicular to the edge in radians. Returns: Weighted signed offset from the patch center in pixels. """ h, w = weights.shape r0, c0 = h // 2, w // 2 rows, cols = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") nx = np.cos(normal_angle) ny = np.sin(normal_angle) dist_normal = nx * (cols - c0) + ny * (rows - r0) return np.sum(weights * dist_normal) / total_weight def _passes_center_check(self, center_offset: float) -> bool: """Return True if the detected edge passes close enough to the patch center. Args: center_offset: Signed edge offset from the patch center in pixels. Returns: True if edge passes the center check (or check is disabled). """ if self._max_center_offset is None: return True return abs(center_offset) <= self._max_center_offset def _is_geometric_edge( self, depth_patch: np.ndarray, edge_theta: float, edge_center_offset: float, ) -> bool: """Check if detected edge is a geometric edge (depth discontinuity). Geometric edges occur at object boundaries or surface creases where depth changes abruptly. Texture edges will be detected wherever there is an abrupt discontinuity in image intensity. We will use detected geometric edges to identify candidate texture edges that do not correspond to a 2D surface (such as where the red handle of a mug is seen against the black background of a simulator's void). This function computes the depth gradient perpendicular to the detected edge direction near the estimated RGB edge line and checks if robust depth-edge evidence exceeds a threshold. Args: depth_patch: Depth image patch (same size as RGB patch used for edge detection). Values should be in consistent units (e.g., meters). edge_theta: Edge tangent angle in radians from RGB edge detection. edge_center_offset: Signed RGB edge offset from the patch center along the edge normal. Returns: True if edge is geometric, False if texture edge. """ depth_dx = cv2.Sobel(depth_patch, cv2.CV_32F, 1, 0, ksize=3) depth_dy = cv2.Sobel(depth_patch, cv2.CV_32F, 0, 1, ksize=3) edge_normal_angle = edge_theta + np.pi / 2 nx = np.cos(edge_normal_angle) ny = np.sin(edge_normal_angle) h, w = depth_patch.shape cy, cx = h // 2, w // 2 rows, cols = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") dist_normal = nx * (cols - cx) + ny * (rows - cy) depth_edge_band = np.abs(dist_normal - edge_center_offset) <= 1.5 if not np.any(depth_edge_band): return False depth_gradient_perp = np.abs(nx * depth_dx + ny * depth_dy) depth_edge_evidence = np.percentile( depth_gradient_perp[depth_edge_band], 95.0, ) return depth_edge_evidence > self._depth_edge_threshold