BruNet
Weights for BruNet (BruNet: A Cross-Domain Transfer Framework for Bruise Segmentation). BruNet trains only on HAM10000 skin-lesion masks and segments bruises in photographs without bruise labels and without any prompt.
| Folder | Encoder | Input | Bruise F1 | Bruise IoU |
|---|---|---|---|---|
lingbot/ |
LingBot-Vision ViT-B/16 | 512 ร 512 | 0.8911 | 0.8251 |
dinov3/ |
DINOv3 ViT-B/16 | 224 ร 224 | 0.8081 | 0.7289 |
Each folder contains:
model.safetensors: the full modelstate_dict(fp32, 92 M values, 368 MB). The optimizer and RNG state of the training checkpoint are not included.config.yaml: the training config stored in the checkpoint. The two dataset paths are replaced with/path/to/...placeholders.
Model
- Encoder. ViT-B/16. LingBot-Vision takes a 512 ร 512 input and yields a 32 ร 32 token grid; DINOv3 takes 224 ร 224 and yields 14 ร 14.
- Adapter. Projects the token grid to 256 channels, upsamples it bilinearly to 56 ร 56 and applies two 3 ร 3 convolution blocks (GroupNorm with 8 groups, GELU). The output has the shape of a SAM ViT-B image embedding, so one decoder serves both encoders.
- Decoder. The architecture of the SAM ViT-B mask decoder, randomly initialised: no SAM weights are loaded. Its 44 linear layers stay at their random initialisation and receive rank-8 LoRA updates; its normalisation layers, output tokens, prompt embeddings and upscaling convolutions train directly.
- No prompt. The sparse prompt holds only SAM's padding-point token and the dense prompt its no-mask embedding, the same for every image, in training and at test time.
- Output. One logit map (
low_res_masks, 224 ร 224). Upsample it to the image size, apply a sigmoid and threshold at 0.5.
Training
- Data. HAM10000 lesion masks: 7,010 training images, each used as captured and after Multi-Scale Retinex with Chromaticity Preservation (MSRCP), giving 14,020 samples. No bruise image was used for training, checkpoint selection or threshold choice.
- Recipe. AdamW, bfloat16, Dice plus binary cross-entropy loss. No text encoder and no auxiliary loss.
- DINOv3. 150 epochs at learning rate 2e-5 on 224 ร 224 crops, batch 64.
- LingBot. 10 epochs with the backbone frozen, then 40 epochs unfrozen (backbone 3e-6, adapter 2e-5, LoRA 3e-4) on 512 ร 512 crops, batch 8 with 8 gradient-accumulation steps.
- Checkpoint. The lowest-training-loss epoch: epoch 148 of 150 (DINOv3) and epoch 40 of the 40 unfrozen epochs (LingBot).
- Validation. HAM10000 validation Dice at threshold 0.5 (1,502 images, both views): LingBot 0.9559, DINOv3 0.9579. For both models, 0.5 is the Dice-maximising threshold on this split among ten values from 0.5 to 0.999.
Bruise results
86 bruise photographs (256 ร 256), each with two expert contours: pixels inside the confident inner contour are bruise, pixels outside the permissive outer contour are background, and the band between them is ignored. Threshold 0.5; each metric is computed per image and averaged over the 86 images. F1 is identical to Dice.
| Model | Precision | Recall | F1 | IoU | Accuracy |
|---|---|---|---|---|---|
BruNet-LingBot (lingbot/) |
0.9126 | 0.9003 | 0.8911 | 0.8251 | 0.9583 |
BruNet-DINOv3 (dinov3/) |
0.7793 | 0.9202 | 0.8081 | 0.7289 | 0.9181 |
Usage
Requires the BruNet code (the clipdinosam package). The LingBot model also needs the
lingbot-vision package pinned in requirements-lingbot.txt.
import cv2
import torch
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from clipdinosam.config import load_config_with_overrides
from clipdinosam.models import build_clipdinosam_from_cfg
repo, variant = "chandlerWang/BruNet", "lingbot" # or "dinov3"
cfg = load_config_with_overrides(hf_hub_download(repo, f"{variant}/config.yaml"), [])
cfg["model"]["backbone"]["pretrained"] = False # every weight comes from model.safetensors
model = build_clipdinosam_from_cfg(cfg, torch.device("cpu"))
model.load_state_dict(load_file(hf_hub_download(repo, f"{variant}/model.safetensors")), strict=True)
model.eval()
size = cfg["data"]["crop"] # 512 for lingbot, 224 for dinov3
img = cv2.cvtColor(cv2.imread("bruise.jpg"), cv2.COLOR_BGR2RGB)
x = torch.from_numpy(cv2.resize(img, (size, size))).permute(2, 0, 1).float() / 255
mean = torch.tensor([0.485, 0.456, 0.406])[:, None, None]
std = torch.tensor([0.229, 0.224, 0.225])[:, None, None]
with torch.no_grad():
logits = model(((x - mean) / std)[None])["low_res_masks"][:, :1]
logits = torch.nn.functional.interpolate(logits, size=img.shape[:2], mode="bilinear", align_corners=False)
mask = torch.sigmoid(logits)[0, 0] >= 0.5
scripts/eval_dual_region.py expects a PyTorch checkpoint; wrap the weights first with
torch.save({"model": load_file("model.safetensors")}, "best.pt").
Limitations
- Evaluated on 86 bruise photographs only; per-image skin-tone and age labels were not collected.
- Research use only; not validated for clinical or forensic use.