abtonmoy's picture
Tactus v0.1-preview: tactile pressure sensor pack (weights, card, inference)
561d0f9 verified
Raw
History Blame Contribute Delete
19.2 kB
"""FSR / tactile sensor pack — a projector head into the canonical FE2 text space.
A force-sensitive-resistor (FSR) tactile glove reports a low-dimensional pressure map,
not an optical image. It is the same signal family as the Tremor inertial pack, so it uses
the Tier-3 PROJECTOR-HEAD pattern rather than the vision/adapter path: a small trainable
encoder turns a short window of 32x32 pressure frames into a 512-d tactile feature, and a
trained projector maps that feature into the frozen Qwen base's 2048-d text space.
The head is a plain ``callable(pressure) -> torch.Tensor[.., 2048]`` returning a RAW (un-
normalized, un-whitened) base-space feature, matching the projector-head contract consumed
by :meth:`fusion_embedding.unified.UnifiedEmbedder._project`. The unified read-out then
L2-normalizes; non-text modalities are never whitened. Wire it in exactly like the released
Tremor head::
from fusion_embedding.tactile import TactileEncoder, build_fsr_head, build_projector
enc, proj = TactileEncoder(), build_projector()
head = build_fsr_head(enc, proj)
ue = UnifiedEmbedder.from_pretrained(FE2_REPO, projector_heads={"tactile": head})
v = ue.embed_tactile(pressure_window) # [2048], L2-normalized
Pressure convention: raw frames are uint8 0-255 (548 active sensors on a 32x32 grid);
:func:`preprocess_pressure` scales them to float [0, 1]. The encoder aggregates the F frames
of a window into one tactile event. Three temporal modes are available (``temporal=`` arg):
``"conv"`` (default) concatenates the per-frame SPATIAL feature maps along channels and fuses
them with a 1x1 convolution, replicating STAG's own frame-fusion; ``"attn"`` runs a small
CLS-token Transformer over the per-frame tokens; ``"maxpool"`` is the earlier frame max-pool.
Two per-frame trunks are available (``depth=`` arg): ``"resnet"`` (default) is a compact
ResNet-style trunk (3x3 stem, three stages of two BasicBlocks) and ``"small"`` is the earlier
three-conv net. A ``flow=`` arg (default True) feeds the trunk a second "tactile flow" channel
(the frame-to-frame pressure difference) alongside the raw frame, following the dual raw+flow
input of ROTConvPCE-mv. Every combination preserves the external contract (input shapes, 512-d
feature), so the modes are directly A/B-able.
"""
from __future__ import annotations
from typing import Callable, Union
import numpy as np
import torch
import torch.nn as nn
GRID = 32
FEAT_DIM = 512
OUT_DIM = 2048
TEMPORAL_MODES = ("conv", "attn", "maxpool")
DEPTHS = ("resnet", "resnet18", "small")
# --------------------------------------------------------------------------- #
# Preprocessing
# --------------------------------------------------------------------------- #
def preprocess_pressure(raw) -> torch.Tensor:
"""Raw pressure -> float32 tensor ``[F, 32, 32]`` scaled to [0, 1].
Accepts a numpy array, a nested list, or a torch tensor of shape ``[32, 32]`` (a single
frame) or ``[F, 32, 32]`` (a window). uint8 0-255 input is scaled by 1/255; float input
already in [0, 1] is passed through, and out-of-range float (a raw 0-255 float dump) is
also rescaled. A single frame is promoted to a one-frame window ``[1, 32, 32]``."""
if torch.is_tensor(raw):
arr = raw.detach().cpu().numpy()
else:
arr = np.asarray(raw)
was_integer = np.issubdtype(arr.dtype, np.integer)
t = torch.as_tensor(np.ascontiguousarray(arr), dtype=torch.float32)
if t.dim() == 2:
t = t.unsqueeze(0) # [32,32] -> [1,32,32]
elif t.dim() != 3:
raise ValueError(f"expected pressure of shape [32,32] or [F,32,32], got {list(t.shape)}")
if t.shape[-2:] != (GRID, GRID):
raise ValueError(f"expected a {GRID}x{GRID} pressure grid, got {list(t.shape[-2:])}")
# uint8 dtype, or any float dump that runs past the [0,1] range, is a 0-255 map.
if was_integer or float(t.max().item()) > 1.5:
t = t / 255.0
return t
# --------------------------------------------------------------------------- #
# Encoder + projector
# --------------------------------------------------------------------------- #
class _BasicBlock(nn.Module):
"""Compact ResNet BasicBlock (two 3x3 convs + identity/1x1-projection shortcut).
Written inline so the tactile pack keeps its torch-only dependency footprint (no
torchvision). Pre-activation is NOT used; this is the standard post-activation block."""
def __init__(self, c_in: int, c_out: int, stride: int = 1):
super().__init__()
self.conv1 = nn.Conv2d(c_in, c_out, 3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(c_out)
self.conv2 = nn.Conv2d(c_out, c_out, 3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(c_out)
self.relu = nn.ReLU(inplace=True)
self.down = None
if stride != 1 or c_in != c_out:
self.down = nn.Sequential(nn.Conv2d(c_in, c_out, 1, stride=stride, bias=False),
nn.BatchNorm2d(c_out))
def forward(self, x: torch.Tensor) -> torch.Tensor:
idt = x if self.down is None else self.down(x)
y = self.relu(self.bn1(self.conv1(x)))
y = self.bn2(self.conv2(y))
return self.relu(y + idt)
def _resnet_trunk(token_dim: int = 128, in_ch: int = 1) -> nn.Sequential:
"""ResNet-style per-frame trunk for 32x32 pressure -> ``[N,token_dim,8,8]``.
A 3x3 stem (no 7x7 stem, no initial max-pool: a 32x32 taxel grid cannot afford an early
/4 downsample) followed by three stages of two :class:`_BasicBlock` each at 32 / 64 /
``token_dim`` channels, stride 2 between stages: 32x32 -> 32x32 -> 16x16 -> 8x8. The 8x8
output grid matches the ``"small"`` trunk's, so the temporal stage is interchangeable.
``in_ch`` is 1 for the raw pressure frame alone and 2 when the tactile-flow channel is
stacked on it; only the stem conv's shape changes, parameter NAMES are identical."""
c1, c2, c3 = 32, 64, token_dim
return nn.Sequential(
nn.Conv2d(in_ch, c1, 3, padding=1, bias=False), nn.BatchNorm2d(c1), nn.ReLU(inplace=True),
_BasicBlock(c1, c1), _BasicBlock(c1, c1),
_BasicBlock(c1, c2, stride=2), _BasicBlock(c2, c2),
_BasicBlock(c2, c3, stride=2), _BasicBlock(c3, c3),
)
def _resnet18_trunk(token_dim: int = 512, in_ch: int = 1) -> nn.Sequential:
"""Full ResNet-18-width per-frame trunk for 32x32 pressure -> ``[N,token_dim,4,4]``.
STAG's own classification network uses a stock ResNet-18 (four stages, 64/128/256/512) per
frame; the compact :func:`_resnet_trunk` above is roughly 12x smaller, and in our ablations
encoder capacity, not the readout, was what limited accuracy. This restores the
reference width: 3x3 stem (still no 7x7 / no initial max-pool, since a 32x32 taxel grid cannot
afford an early /4 downsample), then four stages of two :class:`_BasicBlock` each,
32x32 -> 32x32 -> 16x16 -> 8x8 -> 4x4."""
c1, c2, c3, c4 = 64, 128, 256, token_dim
return nn.Sequential(
nn.Conv2d(in_ch, c1, 3, padding=1, bias=False), nn.BatchNorm2d(c1), nn.ReLU(inplace=True),
_BasicBlock(c1, c1), _BasicBlock(c1, c1),
_BasicBlock(c1, c2, stride=2), _BasicBlock(c2, c2),
_BasicBlock(c2, c3, stride=2), _BasicBlock(c3, c3),
_BasicBlock(c3, c4, stride=2), _BasicBlock(c4, c4),
)
class TactileEncoder(nn.Module):
"""Per-frame 2D CNN + temporal aggregation -> a 512-d tactile feature.
Input is a window of 32x32 pressure frames. Shapes accepted: ``[32,32]`` (single frame)
and ``[F,32,32]`` (a window) both return an unbatched ``[512]`` feature; ``[B,F,32,32]``
returns ``[B,512]``. Each frame passes through the trunk independently to a per-frame
spatial feature map; the F maps are then aggregated into one window descriptor and
projected to 512-d.
Per-frame trunk (``depth=``):
* ``"resnet"`` (default) — :func:`_resnet_trunk`, a compact ResNet (3x3 stem, three
stages of two BasicBlocks, 32/64/``token_dim`` channels), matching STAG's per-frame
ResNet-18 idiom at a size that suits a 32x32 grid.
* ``"small"`` — the earlier three-conv net, kept for A/B (and for loading the
checkpoints trained with it: the ``cnn.*`` parameter names are unchanged).
Temporal aggregation (``temporal=``):
* ``"conv"`` (default) — STAG's frame fusion: concatenate the F per-frame spatial maps
along the CHANNEL axis and fuse with a single ``Conv2d(frames*token_dim, token_dim,
kernel_size=1)``, then global-average-pool. The fusion conv is defined for the
CONFIGURED ``frames`` (=K) count, so a window whose actual F differs is fitted to K
first: F < K repeat-pads the last frame, F > K takes ``K`` evenly spaced frames
(a stride subsample, not a head truncation, so a long recording-level window is still
covered end to end).
* ``"attn"`` — prepend a learned CLS token to the F frame tokens, add a learned
per-position embedding, run ``n_layers`` ``nn.TransformerEncoderLayer``
(``batch_first``, ``n_heads`` heads, GELU, ``dropout``) over the ``F+1`` tokens, and
read the CLS output. Works for ``F==1`` (a length-2 sequence with CLS).
* ``"maxpool"`` — the earlier max-pool over the F frame tokens, kept for A/B.
For ``"attn"``, frame position is embedded up to ``max_frames`` (default 64, covering the
recording-level eval cap); a longer window is truncated to ``max_frames`` frames.
Tactile flow (``flow=``, default True):
ROTConvPCE-mv (Cao et al.) feeds its tactile network BOTH the raw frames and a "tactile
flow" field, on the argument that a per-frame trunk otherwise throws away the grasp
DYNAMICS: how the contact pattern moves and builds between frames. Here the flow channel
is the first-order temporal difference ``frame[i] - frame[i-1]`` (zeros for ``i == 0``,
which has no preceding frame), stacked as a second input channel so the trunk sees
``[N,2,32,32]`` instead of ``[N,1,32,32]``. The difference is taken on the [0,1]-scaled
frames and is NOT renormalized, so it lives in [-1, 1] and its magnitude stays comparable
to the raw channel. Only the stem conv's ``in_channels`` changes; ``flow=False`` restores
the exact single-channel behavior with identical parameter names and shapes, so
checkpoints trained before this option still load.
"""
def __init__(self, temporal: str = "conv", token_dim: int = 128,
n_layers: int = 2, n_heads: int = 4, max_frames: int = 64,
dropout: float = 0.1, depth: str = "resnet", frames: int = 8,
flow: bool = True):
super().__init__()
if temporal not in TEMPORAL_MODES:
raise ValueError(f"temporal must be one of {TEMPORAL_MODES}, got {temporal!r}")
if depth not in DEPTHS:
raise ValueError(f"depth must be one of {DEPTHS}, got {depth!r}")
if int(frames) < 1:
raise ValueError(f"frames must be >= 1, got {frames!r}")
self.temporal = temporal
self.depth = depth
self.frames = int(frames)
self.token_dim = token_dim
self.max_frames = max_frames
self.flow = bool(flow)
in_ch = 2 if self.flow else 1
self.in_ch = in_ch
if depth == "resnet18":
# Full ResNet-18 width, as STAG's own classifier uses. token_dim defaults to 128 for
# the compact trunk; widen it here so the stage widths match the reference.
self.token_dim = token_dim = max(int(token_dim), 512)
self.trunk = _resnet18_trunk(token_dim, in_ch=in_ch)
elif depth == "resnet":
self.trunk = _resnet_trunk(token_dim, in_ch=in_ch)
else:
self.cnn = nn.Sequential(
nn.Conv2d(in_ch, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(64, token_dim, 3, padding=1), nn.BatchNorm2d(token_dim),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d(1),
)
if temporal == "conv":
self.fuse = nn.Conv2d(self.frames * token_dim, token_dim, kernel_size=1)
if temporal == "attn":
self.cls = nn.Parameter(torch.zeros(1, 1, token_dim))
self.pos = nn.Parameter(torch.zeros(1, max_frames + 1, token_dim))
nn.init.trunc_normal_(self.cls, std=0.02)
nn.init.trunc_normal_(self.pos, std=0.02)
layer = nn.TransformerEncoderLayer(
d_model=token_dim, nhead=n_heads, dim_feedforward=token_dim * 4,
dropout=dropout, activation="gelu", batch_first=True, norm_first=True)
self.transformer = nn.TransformerEncoder(
layer, num_layers=n_layers, enable_nested_tensor=False)
self.fc = nn.Linear(token_dim, FEAT_DIM)
self.act = nn.GELU()
def _stack_flow(self, x: torch.Tensor) -> torch.Tensor:
"""``[B,F,H,W]`` -> per-frame trunk input ``[B*F,in_ch,H,W]``.
With ``flow=False`` this is just the raw frame as one channel. With ``flow=True`` a
second channel carries the tactile flow ``frame[i] - frame[i-1]``, and frame 0 (which
has no predecessor) gets an all-zero flow channel. The difference is taken on the
already-[0,1]-scaled frames and is deliberately left unnormalized."""
B, Fr, H, W = x.shape
if not self.flow:
return x.reshape(B * Fr, 1, H, W)
if Fr > 1:
diff = torch.cat([torch.zeros_like(x[:, :1]), x[:, 1:] - x[:, :-1]], dim=1)
else:
diff = torch.zeros_like(x)
return torch.stack([x, diff], dim=2).reshape(B * Fr, 2, H, W)
def _spatial(self, flat: torch.Tensor) -> torch.Tensor:
"""Per-frame trunk on a flattened ``[N,in_ch,32,32]`` batch -> spatial map ``[N,C,h,w]``.
For ``depth="small"`` this is the three-conv net WITHOUT its trailing
``AdaptiveAvgPool2d(1)``, so the pooled token path stays numerically identical to the
earlier encoder while the conv-fusion path gets the full 8x8 map."""
if self.depth in ("resnet", "resnet18"):
return self.trunk(flat)
for m in list(self.cnn)[:-1]: # drop the trailing AdaptiveAvgPool2d(1)
flat = m(flat)
return flat
def _fit_frames(self, maps: torch.Tensor) -> torch.Tensor:
"""Fit the frame axis of ``[B,F,C,h,w]`` to the configured ``frames`` (=K) count.
F == K passes through. F < K repeat-pads the last frame. F > K takes K evenly spaced
frames, so a long window (recording-level eval feeds up to RECORDING_CAP frames) is
still sampled across its whole span rather than truncated to its head."""
Fr = maps.shape[1]
K = self.frames
if Fr == K:
return maps
if Fr < K:
pad = maps[:, -1:].expand(-1, K - Fr, -1, -1, -1)
return torch.cat([maps, pad], dim=1)
idx = torch.linspace(0, Fr - 1, K, device=maps.device).round().long()
return maps.index_select(1, idx)
def forward(self, x) -> torch.Tensor:
if not torch.is_tensor(x):
x = torch.as_tensor(x, dtype=torch.float32)
x = x.float()
squeeze = False
if x.dim() == 2: # [32,32] -> B=1, F=1
x = x.unsqueeze(0).unsqueeze(0)
squeeze = True
elif x.dim() == 3: # [F,32,32] -> B=1
x = x.unsqueeze(0)
squeeze = True
elif x.dim() != 4: # [B,F,32,32]
raise ValueError(
f"expected pressure [32,32], [F,32,32] or [B,F,32,32], got {list(x.shape)}")
B, Fr, H, W = x.shape
maps = self._spatial(self._stack_flow(x)) # [B*F,token,h,w]
C, h, w = maps.shape[1], maps.shape[2], maps.shape[3]
if self.temporal == "conv":
maps = self._fit_frames(maps.reshape(B, Fr, C, h, w)) # [B,K,token,h,w]
fused = self.fuse(maps.reshape(B, self.frames * C, h, w)) # 1x1 over cat channels
z = nn.functional.adaptive_avg_pool2d(fused, 1).reshape(B, C)
z = self.act(self.fc(z))
return z.squeeze(0) if squeeze else z
z = nn.functional.adaptive_avg_pool2d(maps, 1) # [B*F,token,1,1]
z = z.reshape(B, Fr, self.token_dim) # [B,F,token]
if self.temporal == "maxpool":
z = z.max(dim=1).values # max-pool over frames -> [B,token]
else:
if Fr > self.max_frames: # clamp overlong windows to the pos budget
z = z[:, :self.max_frames]
Fr = self.max_frames
cls = self.cls.expand(B, -1, -1) # [B,1,token]
seq = torch.cat([cls, z], dim=1) # [B,F+1,token]
seq = seq + self.pos[:, :Fr + 1] # learned position embedding
seq = self.transformer(seq) # [B,F+1,token]
z = seq[:, 0] # CLS output -> [B,token]
z = self.act(self.fc(z)) # [B,512]
return z.squeeze(0) if squeeze else z
def build_projector(in_dim: int = FEAT_DIM, out_dim: int = OUT_DIM) -> nn.Sequential:
"""The tactile projector head (same arch idiom as the Tremor 'ln' projector):
LayerNorm(512) -> Linear(512,1024) -> GELU -> Dropout(0.3) -> Linear(1024,2048)."""
return nn.Sequential(
nn.LayerNorm(in_dim), nn.Linear(in_dim, 1024), nn.GELU(),
nn.Dropout(0.3), nn.Linear(1024, out_dim),
)
def build_fsr_head(encoder: TactileEncoder, projector: nn.Module) -> Callable:
"""Wrap ``encoder`` + ``projector`` into a projector-head callable.
Returns ``head(pressure) -> [.., 2048]`` a RAW (un-normalized) base-space feature, ready
to pass as ``projector_heads={"tactile": head}`` to ``UnifiedEmbedder.from_pretrained``
(or ``projectors={"tactile": head}`` to ``UnifiedEmbedder(...)``). Non-tensor input (a
numpy array / list) is run through :func:`preprocess_pressure` first; a tensor is used
as-is (training passes a pre-scaled ``[B,1,32,32]`` batch straight through)."""
def head(pressure: Union[torch.Tensor, np.ndarray, list]) -> torch.Tensor:
x = pressure if torch.is_tensor(pressure) else preprocess_pressure(pressure)
p = next(projector.parameters(), None)
if p is not None:
x = x.to(p.device)
return projector(encoder(x))
return head