Skip to content

Commit 17e6282

Browse files
committed
feat(vision): add local co-segmentation zoo
1 parent 6dcc1ad commit 17e6282

13 files changed

Lines changed: 1817 additions & 0 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Local, toy-first co-segmentation families.
2+
3+
Conventions:
4+
- One family per file.
5+
- Each family exposes `build_<family>_co_segmentor(...)`.
6+
- Each family keeps `_VARIANTS` and a `__main__` smoke test.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from importlib import import_module
12+
from typing import Any
13+
14+
15+
def _import_attr(name: str) -> Any:
16+
if name.startswith("build_") and name.endswith("_co_segmentor"):
17+
stem = name[len("build_") : -len("_co_segmentor")]
18+
module = import_module(f"{__name__}.{stem}")
19+
attr = getattr(module, name)
20+
globals()[name] = attr
21+
return attr
22+
raise AttributeError(name)
23+
24+
25+
def __getattr__(name: str) -> Any: # pragma: no cover
26+
try:
27+
return _import_attr(name)
28+
except AttributeError as e: # pragma: no cover
29+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from e
30+
31+
32+
def __dir__() -> list[str]: # pragma: no cover
33+
return sorted(list(globals().keys()))
34+
35+
36+
__all__: list[str] = []
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from __future__ import annotations
2+
3+
import math
4+
5+
import torch
6+
import torch.nn.functional as F
7+
from torch import nn
8+
9+
from dlhub.vision.backbones._blocks import ConvBNAct
10+
11+
12+
def check_btchw(images: torch.Tensor) -> torch.Tensor:
13+
images = images.to(torch.float32)
14+
if images.ndim != 5:
15+
raise ValueError(f"Expected input shape (B, T, C, H, W), got {tuple(images.shape)}")
16+
return images
17+
18+
19+
def logits_to_masks(logits: torch.Tensor) -> torch.Tensor:
20+
if logits.ndim != 5:
21+
raise ValueError(f"logits must have shape (B, T, K, H, W), got {tuple(logits.shape)}")
22+
return logits.argmax(dim=2)
23+
24+
25+
def flatten_group(x: torch.Tensor) -> torch.Tensor:
26+
if x.ndim < 3:
27+
raise ValueError(f"Expected grouped tensor with shape (B, T, ...), got {tuple(x.shape)}")
28+
b, t = int(x.shape[0]), int(x.shape[1])
29+
return x.contiguous().view(b * t, *x.shape[2:])
30+
31+
32+
def unflatten_group(x: torch.Tensor, *, batch: int, set_size: int) -> torch.Tensor:
33+
batch = int(batch)
34+
set_size = int(set_size)
35+
if batch <= 0 or set_size <= 0:
36+
raise ValueError("batch and set_size must be positive")
37+
expected = batch * set_size
38+
if int(x.shape[0]) != expected:
39+
raise ValueError(
40+
f"Expected leading dimension {expected} for grouped tensor, got {int(x.shape[0])}"
41+
)
42+
return x.contiguous().view(batch, set_size, *x.shape[1:])
43+
44+
45+
class TinyCoSegEncoder(nn.Module):
46+
"""Small multi-scale encoder for co-segmentation toy models."""
47+
48+
def __init__(self, *, in_channels: int, width: int, depth: int, dropout: float = 0.0) -> None:
49+
super().__init__()
50+
c = int(width)
51+
d = max(1, int(depth))
52+
self.stem = nn.Sequential(
53+
ConvBNAct(int(in_channels), c, kernel_size=3, stride=2, act="relu"),
54+
ConvBNAct(c, c, kernel_size=3, stride=1, act="relu"),
55+
)
56+
self.stage2 = self._stage(c, c * 2, depth=d, dropout=float(dropout))
57+
self.stage3 = self._stage(c * 2, c * 4, depth=d, dropout=float(dropout))
58+
self.out_channels = (c, c * 2, c * 4)
59+
60+
@staticmethod
61+
def _stage(in_ch: int, out_ch: int, *, depth: int, dropout: float) -> nn.Sequential:
62+
layers: list[nn.Module] = [ConvBNAct(int(in_ch), int(out_ch), kernel_size=3, stride=2, act="relu")]
63+
for _ in range(max(1, int(depth)) - 1):
64+
layers.append(ConvBNAct(int(out_ch), int(out_ch), kernel_size=3, stride=1, act="relu"))
65+
if float(dropout) > 0:
66+
layers.append(nn.Dropout2d(float(dropout)))
67+
return nn.Sequential(*layers)
68+
69+
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
70+
if x.ndim != 4:
71+
raise ValueError(f"Expected input shape (N, C, H, W), got {tuple(x.shape)}")
72+
x = x.to(torch.float32)
73+
c1 = self.stem(x) # /2
74+
c2 = self.stage2(c1) # /4
75+
c3 = self.stage3(c2) # /8
76+
return c1, c2, c3
77+
78+
79+
class GroupFusionBlock(nn.Module):
80+
"""Fuse per-image features using simple group-level consensus strategies."""
81+
82+
def __init__(
83+
self,
84+
channels: int,
85+
*,
86+
mode: str = "mean",
87+
num_prototypes: int = 4,
88+
) -> None:
89+
super().__init__()
90+
self.mode = str(mode).lower().strip()
91+
c = int(channels)
92+
self.num_prototypes = max(1, int(num_prototypes))
93+
if self.mode == "attention":
94+
self.query = nn.Linear(c, c)
95+
self.key = nn.Linear(c, c)
96+
self.value = nn.Linear(c, c)
97+
elif self.mode == "prototype":
98+
self.assign = nn.Linear(c, self.num_prototypes)
99+
self.proto_proj = nn.Linear(c, c)
100+
self.mix = ConvBNAct(c * 2, c, kernel_size=3, stride=1, act="relu")
101+
102+
def forward(self, feat: torch.Tensor) -> tuple[torch.Tensor, dict[str, torch.Tensor | tuple[torch.Tensor, ...]]]:
103+
if feat.ndim != 5:
104+
raise ValueError(f"Expected grouped feature map (B, T, C, H, W), got {tuple(feat.shape)}")
105+
b, t, c, h, w = feat.shape
106+
desc = feat.mean(dim=(-1, -2))
107+
aux: dict[str, torch.Tensor | tuple[torch.Tensor, ...]] = {}
108+
109+
if self.mode == "mean":
110+
context = desc.mean(dim=1, keepdim=True).expand(-1, t, -1)
111+
elif self.mode == "max":
112+
context = desc.amax(dim=1, keepdim=True).expand(-1, t, -1)
113+
elif self.mode == "attention":
114+
q = self.query(desc)
115+
k = self.key(desc)
116+
v = self.value(desc)
117+
attn = torch.matmul(q, k.transpose(-1, -2)) / math.sqrt(max(1, c))
118+
attn = torch.softmax(attn, dim=-1)
119+
context = torch.matmul(attn, v)
120+
aux["co_attention"] = attn
121+
elif self.mode == "prototype":
122+
weights = torch.softmax(self.assign(desc), dim=1)
123+
proto = torch.einsum("btp,btc->bpc", weights, desc)
124+
denom = weights.sum(dim=1).transpose(1, 0).transpose(0, 1).unsqueeze(-1)
125+
proto = proto / denom.clamp_min(1e-6)
126+
bridge = torch.matmul(self.proto_proj(desc), proto.transpose(-1, -2)) / math.sqrt(max(1, c))
127+
bridge = torch.softmax(bridge, dim=-1)
128+
context = torch.matmul(bridge, proto)
129+
aux["group_tokens"] = proto
130+
aux["prototype_assign"] = bridge
131+
elif self.mode == "consensus":
132+
mean = desc.mean(dim=1, keepdim=True)
133+
peak = desc.amax(dim=1, keepdim=True)
134+
context = (0.75 * mean + 0.25 * peak).expand(-1, t, -1)
135+
else:
136+
raise ValueError(f"Unsupported group fusion mode: {self.mode!r}")
137+
138+
context_map = context.unsqueeze(-1).unsqueeze(-1).expand(-1, -1, -1, h, w)
139+
mixed = torch.cat([feat, context_map], dim=2)
140+
fused = self.mix(flatten_group(mixed))
141+
return unflatten_group(fused, batch=b, set_size=t), aux
142+
143+
144+
class CoSegHead(nn.Module):
145+
def __init__(self, *, in_channels: int, hidden_channels: int, num_classes: int, dropout: float = 0.0) -> None:
146+
super().__init__()
147+
self.net = nn.Sequential(
148+
ConvBNAct(int(in_channels), int(hidden_channels), kernel_size=3, stride=1, act="relu"),
149+
nn.Dropout2d(float(dropout)) if float(dropout) > 0 else nn.Identity(),
150+
nn.Conv2d(int(hidden_channels), int(num_classes), kernel_size=1, bias=True),
151+
)
152+
153+
def forward(self, feat: torch.Tensor, *, out_hw: tuple[int, int]) -> torch.Tensor:
154+
if feat.ndim == 5:
155+
b, t = int(feat.shape[0]), int(feat.shape[1])
156+
flat = flatten_group(feat)
157+
grouped = True
158+
elif feat.ndim == 4:
159+
flat = feat
160+
grouped = False
161+
b = t = 0
162+
else:
163+
raise ValueError(f"Expected feature shape (B, T, C, H, W) or (N, C, H, W), got {tuple(feat.shape)}")
164+
logits = self.net(flat)
165+
logits = F.interpolate(logits, size=out_hw, mode="bilinear", align_corners=False)
166+
if grouped:
167+
logits = unflatten_group(logits, batch=b, set_size=t)
168+
return logits
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
import torch
4+
import torch.nn.functional as F
5+
from torch import nn
6+
7+
from dlhub.vision.backbones._blocks import ConvBNAct
8+
9+
from ._common import (
10+
CoSegHead,
11+
GroupFusionBlock,
12+
TinyCoSegEncoder,
13+
check_btchw,
14+
flatten_group,
15+
logits_to_masks,
16+
unflatten_group,
17+
)
18+
19+
_VARIANTS: dict[str, dict[str, int]] = {
20+
"co_attention_fpn_tiny": {"width": 16, "depth": 1},
21+
"co_attention_fpn_small": {"width": 24, "depth": 2},
22+
"co_attention_fpn_base": {"width": 32, "depth": 3},
23+
}
24+
25+
26+
class CoAttentionFPN(nn.Module):
27+
"""Multi-scale co-attention fusion network."""
28+
29+
def __init__(
30+
self,
31+
*,
32+
in_channels: int,
33+
num_classes: int,
34+
width: int,
35+
depth: int,
36+
dropout: float = 0.0,
37+
) -> None:
38+
super().__init__()
39+
self.encoder = TinyCoSegEncoder(
40+
in_channels=int(in_channels),
41+
width=int(width),
42+
depth=int(depth),
43+
dropout=float(dropout),
44+
)
45+
c1, c2, c3 = (int(x) for x in self.encoder.out_channels)
46+
hidden = max(32, c2)
47+
self.fuse1 = GroupFusionBlock(c1, mode="attention")
48+
self.fuse2 = GroupFusionBlock(c2, mode="attention")
49+
self.fuse3 = GroupFusionBlock(c3, mode="attention")
50+
self.merge = ConvBNAct(c1 + c2 + c3, hidden, kernel_size=3, stride=1, act="relu")
51+
self.head = CoSegHead(
52+
in_channels=hidden,
53+
hidden_channels=hidden,
54+
num_classes=int(num_classes),
55+
dropout=float(dropout),
56+
)
57+
58+
def forward(self, images: torch.Tensor) -> dict[str, torch.Tensor]:
59+
images = check_btchw(images)
60+
b, t, _, h, w = images.shape
61+
flat = flatten_group(images)
62+
c1, c2, c3 = self.encoder(flat)
63+
g1, a1 = self.fuse1(unflatten_group(c1, batch=b, set_size=t))
64+
g2, a2 = self.fuse2(unflatten_group(c2, batch=b, set_size=t))
65+
g3, a3 = self.fuse3(unflatten_group(c3, batch=b, set_size=t))
66+
67+
u2 = F.interpolate(flatten_group(g2), size=c1.shape[-2:], mode="bilinear", align_corners=False)
68+
u3 = F.interpolate(flatten_group(g3), size=c1.shape[-2:], mode="bilinear", align_corners=False)
69+
fused = self.merge(torch.cat([flatten_group(g1), u2, u3], dim=1))
70+
logits = self.head(unflatten_group(fused, batch=b, set_size=t), out_hw=(h, w))
71+
masks = logits_to_masks(logits)
72+
return {
73+
"logits": logits,
74+
"masks": masks,
75+
"co_attention": (
76+
a1["co_attention"],
77+
a2["co_attention"],
78+
a3["co_attention"],
79+
),
80+
}
81+
82+
83+
def build_co_attention_fpn_co_segmentor(
84+
*,
85+
in_channels: int,
86+
num_classes: int,
87+
set_size: int = 3,
88+
image_size: int = 64,
89+
variant: str = "co_attention_fpn_small",
90+
width_mult: float = 1.0,
91+
dropout: float = 0.0,
92+
) -> nn.Module:
93+
del set_size, image_size
94+
name = str(variant).lower().strip()
95+
if name not in _VARIANTS:
96+
raise ValueError(f"Unknown Co-Attention-FPN variant: {variant!r}. Supported: {sorted(_VARIANTS)}")
97+
cfg = _VARIANTS[name]
98+
width = max(8, int(int(cfg["width"]) * float(width_mult)))
99+
return CoAttentionFPN(
100+
in_channels=int(in_channels),
101+
num_classes=int(num_classes),
102+
width=width,
103+
depth=int(cfg["depth"]),
104+
dropout=float(dropout),
105+
)
106+
107+
108+
if __name__ == "__main__":
109+
torch.manual_seed(0)
110+
x = torch.randn(2, 3, 3, 64, 64)
111+
m = build_co_attention_fpn_co_segmentor(
112+
in_channels=3,
113+
num_classes=2,
114+
variant="co_attention_fpn_tiny",
115+
width_mult=0.5,
116+
)
117+
out = m(x)
118+
print("co_attention_fpn_tiny", tuple(out["logits"].shape), len(out["co_attention"]))
119+
loss = out["logits"].mean()
120+
for attn in out["co_attention"]:
121+
loss = loss + attn.mean()
122+
loss.backward()
123+
print("ok")

0 commit comments

Comments
 (0)