DIGIT normal-force MLP head
A lightweight MLP regressor (768 → 256 → 64 → 1) that estimates the normal contact force F_z (in newtons) from a DIGIT vision-based tactile image (the original DIGIT by Meta AI, commercial unit from GelSight; not the newer DIGIT 360). It sits on top of the frozen DINOv2 ViT-B/14 tactile encoder from FeelAnyForce (Shahidzadeh et al., ICRA 2025), which is not retrained. The head reads the encoder's 768-dimensional summary of the whole image (the final CLS token of the vision transformer).
This head is the force estimator of the real-time haptic teleoperation pipeline of the EPFL CREATE Lab semester project Development of Fingertip Haptic Device and Optimal Mapping Method of High Dimensional Tactile Data to Lower Dimensional Haptic Feedback. Code, report and CAD are on GitHub.
Pipeline
DIGIT frame ──► background subtraction ──► resize 224×224 ──► FeelAnyForce DINOv2 ViT-B/14 (frozen) ──► 768-d CLS ──► this MLP ──► F̂_z [N]
I − I_bg + 127 + normalise
Files
| File | Content |
|---|---|
mlp_regressor.pth |
PyTorch state_dict of the nn.Sequential head (keys 0.*, 3.*, 6.*) |
config.json |
Architecture, preprocessing constants, backbone reference and training settings |
Usage
The backbone code and weights come from FeelAnyForce:
git clone https://github.com/prgumd/FeelAnyForce && cd FeelAnyForce && git checkout 1947bc8
hf download amirsh1376/FeelAnyForce checkpoint_v1.pth.tar --local-dir ckpt # ~2 GB
hf download alessandrolapizza/digit-force-mlp mlp_regressor.pth config.json --local-dir . # this head
The code below is the one used in the project's inference server: load_model(), predict() and subtract_bg() copied unchanged from inference_server/model.py. Only FAF_ROOT is set here instead of being imported from the server's config.
import os
import sys
import glob
import json
import numpy as np
import cv2
import torch
import torch.nn as nn
from torchvision import transforms as T
from PIL import Image
FAF_ROOT = "/path/to/FeelAnyForce" # in the server: from config import FAF_ROOT
# Returns (backbone, mlp, transform, device), on CUDA if available, otherwise MPS, otherwise CPU
def load_model(mlp_path: str):
if torch.cuda.is_available():
device = 'cuda'
elif torch.backends.mps.is_available():
device = 'mps'
else:
device = 'cpu'
print(f'\n Device: {device}')
sys.path.insert(0, FAF_ROOT)
from args import get_parser
from composed_model import ComposedModel
# Frozen FeelAnyForce encoder (its constructor calls .cuda(), bypassed while building it)
print(' Loading backbone ...')
ckpt = torch.load(
os.path.join(FAF_ROOT, 'ckpt', 'checkpoint_v1.pth.tar'),
map_location='cpu', weights_only=False,
)
args = get_parser().parse_args([])
for k, v in ckpt['config'].items():
setattr(args, k, v)
original_cuda = nn.Module.cuda
nn.Module.cuda = lambda self, *a, **kw: self
backbone = ComposedModel(args)
nn.Module.cuda = original_cuda
backbone.load_state_dict(
{k.replace('module.', ''): v for k, v in ckpt['state_dict'].items()},
strict=True,
)
backbone.eval()
backbone.to(device)
# MLP head 768 -> 256 -> 64 -> 1
print(' Loading MLP ...')
mlp = nn.Sequential(
nn.Linear(768, 256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 64), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(64, 1),
)
mlp.load_state_dict(torch.load(mlp_path, map_location='cpu', weights_only=True))
mlp.eval()
mlp.to(device)
# Resize to 224 and normalise with FeelAnyForce's background-subtracted statistics
with open(os.path.join(FAF_ROOT, 'mean_and_std.json')) as f:
stats = json.load(f)
mean, std = stats['TacForce_train_set.csv']['nobg']
transform = T.Compose([
T.Resize((224, 224)),
T.ToTensor(),
T.Normalize(mean, std),
])
print(' Model ready.')
return backbone, mlp, transform, device
# Predicted Fz (>= 0) for one background-subtracted BGR frame
def predict(backbone, mlp, transform, device, frame_bgr: np.ndarray) -> float:
rgb = frame_bgr[:, :, ::-1].copy()
pil = Image.fromarray(rgb.astype(np.uint8))
tensor = transform(pil).unsqueeze(0).to(device)
with torch.no_grad():
feats = backbone.get_encoding(tensor)
fz = mlp(feats).item()
return max(0.0, fz)
# clip(frame - background + 127) in RGB, returned as BGR
def subtract_bg(frame: np.ndarray, bg: np.ndarray) -> np.ndarray:
if frame.shape != bg.shape:
bg = cv2.resize(bg, (frame.shape[1], frame.shape[0]))
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
bg_r = cv2.cvtColor(bg, cv2.COLOR_BGR2RGB)
diff = (rgb.astype(np.int16) - bg_r.astype(np.int16) + 127).clip(0, 255).astype(np.uint8)
return cv2.cvtColor(diff, cv2.COLOR_RGB2BGR)
# Usage: BGR uint8 frames (OpenCV order); bg = mean of frames captured with the gel at rest (the server averages 30)
backbone, mlp, transform, device = load_model("mlp_regressor.pth")
fz = predict(backbone, mlp, transform, device, subtract_bg(frame, bg))
Training
Data: alessandrolapizza/digit-force-dataset. It holds 2,940 image–force pairs (4 indenter geometries × 35 contact positions × 21 force levels, 0–10 N), collected with a Franka FR3 arm under closed-loop force control. Labels come from a uniaxial load cell.
Inputs: the background-subtracted images (
tactile_nobg/), encoded once by the frozen backbone.Recipe:
- 80/20 split (
random_state=42); - L1 loss, Adam (lr 1e-3) with ReduceLROnPlateau;
- early stopping (patience 50).
The reference training script is
scripts/training/train_mlp.pyin the GitHub repository.- 80/20 split (
Results
| Metric | Value |
|---|---|
| Validation MAE (588 samples) | 0.227 N (2.3 % of the 0–10 N range), as reported in the project report |
License and attribution
The weights are released under CC BY 4.0. They depend on the FeelAnyForce encoder weights (checkpoint_v1, CC BY 4.0, © Shahidzadeh et al.), which are not redistributed here. Please cite both works.
@misc{dezen2026fingertip,
author = {De Zen, Alessandro},
title = {Development of Fingertip Haptic Device and Optimal Mapping Method of High Dimensional Tactile Data to Lower Dimensional Haptic Feedback},
year = {2026},
howpublished = {Semester project report, CREATE Lab, EPFL},
url = {https://go.epfl.ch/Fingertip-Haptic-Device-and-Optimal-Mapping-Method}
}
@inproceedings{shahidzadeh2025feelanyforce,
author = {Shahidzadeh, Amir-Hossein and Caddeo, Gabriele M. and Alapati, Koushik and Natale, Lorenzo and Ferm{\"u}ller, Cornelia and Aloimonos, Yiannis},
title = {FeelAnyForce: Estimating Contact Force Feedback from Tactile Sensation for Vision-Based Tactile Sensors},
booktitle = {2025 IEEE International Conference on Robotics and Automation (ICRA)},
pages = {251--257},
year = {2025},
doi = {10.1109/ICRA55743.2025.11127723}
}
- Downloads last month
- 12