Source code for rtgym.agent.sensory.spatial_modulated.diffusion_cell
"""Spatial random fields with wall-aware diffusion from grid_and_place."""
import torch
import torch.nn.functional as F
from rtgym.utils.common import hash_seed
from .sm_base import SMBase
[docs]
class DiffusionCell(SMBase):
"""Smooth random fields in 2D or 3D without averaging through wall cells.
This preserves grid_and_place's box-kernel diffusion and normalization.
It is a separate sensory type because it differs from Gaussian smoothing.
"""
sens_type = 'diffusion_cell'
def __init__(self, arena, n_cells, sensory_key, sigma=8, magnitude=None,
normalize=False, seed=None, device='cpu', **kwargs):
super().__init__(arena, n_cells, sensory_key, seed=seed)
if sigma <= 0:
raise ValueError('sigma must be positive.')
self.sigma = sigma / arena.spatial_resolution
self.magnitude = magnitude
self.normalize = normalize
self.device = torch.device(device)
# Seed field generation independently of the control RNG.
self.field_rng = torch.Generator(device='cpu')
if seed is not None:
field_seed = hash_seed(seed, sensory_key)
self.field_rng.manual_seed(field_seed)
self._init_response_map()
def _init_response_map(self):
# Draw on CPU to retain the original per-group seeded random fields.
shape = (self.n_cells, *self.arena.dimensions)
cells = torch.empty(shape)
cells.normal_(0, 1, generator=self.field_rng)
cells = cells.to(self.device)
self.raw_field = cells.clone()
cells = self.diffusion_smooth(cells)
# Scale mean responses before masking walls, matching the source model.
if self.magnitude is not None:
axes = range(1, cells.ndim)
axes = tuple(axes)
means = cells.mean(dim=axes, keepdim=True)
means = means.clamp(min=1e-8)
scale = self.magnitude / means
cells = cells * scale
# Mask wall cells after matching the requested mean response.
arena_map = self.arena.tensor_map(self.device)
free_mask = arena_map == 0
free_mask = free_mask.float()
self.response_map = cells * free_mask
self._tensor_response_map = None
[docs]
def diffusion_smooth(self, cells, free_mask=None):
ndim = self.arena.ndim
if free_mask is None:
arena_map = self.arena.tensor_map(cells.device)
free_mask = arena_map == 0
free_mask = free_mask.float()
# The repeated box kernel has variance approximately 2*n/3 per axis.
iteration_count = round(1.5 * self.sigma * self.sigma)
n_iters = max(1, iteration_count)
kernel_shape = (1, 1) + (3,) * ndim
kernel = torch.ones(kernel_shape, device=cells.device)
kernel = kernel / 3**ndim
padding = (1, 1) * ndim
conv = F.conv2d if ndim == 2 else F.conv3d
# Normalize each voxel by its free-neighbour weight, including the border.
mask = free_mask[None, None]
mask = F.pad(mask, padding, mode='replicate')
neighbour_weight = conv(mask, kernel)
neighbour_weight = neighbour_weight[0, 0]
neighbour_weight = neighbour_weight.clamp(min=1e-8)
# Diffuse only through the available free neighbours.
cells = cells * free_mask
for _ in range(n_iters):
field = cells.unsqueeze(1)
field = F.pad(field, padding, mode='replicate')
smoothed = conv(field, kernel)
smoothed = smoothed[:, 0]
cells = (smoothed / neighbour_weight) * free_mask
# Normalize over the entire grid, as in the original implementation.
if self.normalize:
axes = range(1, cells.ndim)
axes = tuple(axes)
minimum = cells.amin(dim=axes, keepdim=True)
maximum = cells.amax(dim=axes, keepdim=True)
span = maximum - minimum
span = span.clamp(min=1e-8)
cells = (cells - minimum) / span
return cells