magboola commited on
Commit
e529923
Β·
verified Β·
1 Parent(s): a2af735

deploy sam3-zerogpu

Browse files
NeuFlow/backbone_v7.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+
5
+ class ConvBlock(torch.nn.Module):
6
+ def __init__(self, in_planes, out_planes, kernel_size, stride, padding):
7
+ super(ConvBlock, self).__init__()
8
+
9
+ self.conv1 = torch.nn.Conv2d(in_planes, out_planes, kernel_size=kernel_size, stride=stride, padding=padding, padding_mode='zeros', bias=False)
10
+
11
+ self.conv2 = torch.nn.Conv2d(out_planes, out_planes, kernel_size=3, stride=1, padding=1, bias=False)
12
+
13
+ self.relu = torch.nn.LeakyReLU(negative_slope=0.1, inplace=False)
14
+
15
+ self.norm1 = torch.nn.BatchNorm2d(out_planes)
16
+
17
+ self.norm2 = torch.nn.BatchNorm2d(out_planes)
18
+
19
+ # self.dropout = torch.nn.Dropout(p=0.1)
20
+
21
+ def forward(self, x):
22
+
23
+ # x = self.dropout(x)
24
+
25
+ x = self.relu(self.norm1(self.conv1(x)))
26
+ x = self.relu(self.norm2(self.conv2(x)))
27
+ # x = self.relu(self.conv1(x))
28
+ # x = self.relu(self.conv2(x))
29
+
30
+ return x
31
+
32
+ def forward_fuse(self, x):
33
+
34
+ x = self.relu(self.conv1(x))
35
+ x = self.relu(self.conv2(x))
36
+
37
+ return x
38
+
39
+
40
+ class CNNEncoder(torch.nn.Module):
41
+ def __init__(self, feature_dim_s16, context_dim_s16, feature_dim_s8, context_dim_s8):
42
+ super(CNNEncoder, self).__init__()
43
+
44
+ self.block_8_1 = ConvBlock(3, feature_dim_s8 * 2, kernel_size=8, stride=4, padding=2)
45
+
46
+ self.block_8_2 = ConvBlock(3, feature_dim_s8, kernel_size=6, stride=2, padding=2)
47
+
48
+ self.block_cat_8 = ConvBlock(feature_dim_s8 * 3, feature_dim_s8 + context_dim_s8, kernel_size=3, stride=1, padding=1)
49
+
50
+ self.block_16_1 = ConvBlock(3, feature_dim_s16, kernel_size=6, stride=2, padding=2)
51
+
52
+ self.block_8_16 = ConvBlock(feature_dim_s8 + context_dim_s8, feature_dim_s16, kernel_size=6, stride=2, padding=2)
53
+
54
+ self.block_cat_16 = ConvBlock(feature_dim_s16 * 2, feature_dim_s16 + context_dim_s16 - 2, kernel_size=3, stride=1, padding=1)
55
+
56
+ def init_pos(self, batch_size, height, width, device, amp):
57
+ ys, xs = torch.meshgrid(torch.arange(height, dtype=torch.half if amp else torch.float, device=device), torch.arange(width, dtype=torch.half if amp else torch.float, device=device), indexing='ij')
58
+ ys = (ys-height/2)
59
+ xs = (xs-width/2)
60
+ pos = torch.stack([ys, xs])
61
+ return pos[None].repeat(batch_size,1,1,1)
62
+
63
+ def init_bhwd(self, batch_size, height, width, device, amp):
64
+ self.pos_s16 = self.init_pos(batch_size, height, width, device, amp)
65
+
66
+ def forward(self, img):
67
+
68
+ img = F.avg_pool2d(img, kernel_size=2, stride=2)
69
+ x_8 = self.block_8_1(img)
70
+
71
+ img = F.avg_pool2d(img, kernel_size=2, stride=2)
72
+ x_8_2 = self.block_8_2(img)
73
+
74
+ x_8 = self.block_cat_8(torch.cat([x_8, x_8_2], dim=1))
75
+
76
+ img = F.avg_pool2d(img, kernel_size=2, stride=2)
77
+ x_16 = self.block_16_1(img)
78
+
79
+ x_16_2 = self.block_8_16(x_8)
80
+
81
+ x_16 = self.block_cat_16(torch.cat([x_16, x_16_2], dim=1))
82
+
83
+ x_16 = torch.cat([x_16, self.pos_s16], dim=1)
84
+
85
+ return x_16, x_8
NeuFlow/config.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ feature_dim_s16 = 128
2
+ context_dim_s16 = 64
3
+ iter_context_dim_s16 = 64
4
+ feature_dim_s8 = 128
5
+ context_dim_s8 = 64
6
+ iter_context_dim_s8 = 64
7
+ feature_dim_s1 = 128
NeuFlow/corr.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+ import math
4
+
5
+ from NeuFlow import utils
6
+
7
+
8
+ def bilinear_sample(img, coords):
9
+ """ Wrapper for grid_sample, uses pixel coordinates """
10
+ H, W = img.shape[-2:]
11
+ xgrid, ygrid = coords.split([1,1], dim=-1)
12
+ xgrid = 2*xgrid/(W-1) - 1
13
+ ygrid = 2*ygrid/(H-1) - 1
14
+
15
+ grid = torch.cat([xgrid, ygrid], dim=-1)
16
+
17
+ with torch.backends.cudnn.flags(enabled=False):
18
+ img = F.grid_sample(img, grid, align_corners=True)
19
+
20
+ return img
21
+
22
+
23
+ class CorrBlock:
24
+ def __init__(self, radius, levels):
25
+
26
+ self.radius = radius
27
+ self.levels = levels
28
+
29
+ def init_bhwd(self, batch_size, height, width, device, amp):
30
+
31
+ xy_range = torch.linspace(-self.radius, self.radius, 2*self.radius+1, dtype=torch.half if amp else torch.float, device=device)
32
+
33
+ delta = torch.stack(torch.meshgrid(xy_range, xy_range, indexing='ij'), axis=-1)
34
+ delta = delta.view(1, 2*self.radius+1, 2*self.radius+1, 2)
35
+
36
+ self.grid = utils.coords_grid(batch_size, height, width, device, amp)
37
+ self.delta = delta.repeat(batch_size * height * width, 1, 1, 1)
38
+
39
+ def __call__(self, corr_pyramid, flow):
40
+
41
+ b, _, h, w = flow.shape
42
+
43
+ coords = (self.grid + flow).permute(0, 2, 3, 1)
44
+ coords = coords.reshape(b*h*w, 1, 1, 2)
45
+
46
+ out_list = []
47
+
48
+ for level, corr in enumerate(corr_pyramid):
49
+ curr_coords = coords / 2**level + self.delta
50
+ corr = bilinear_sample(corr, curr_coords)
51
+ corr = corr.view(b, h, w, -1)
52
+ out_list.append(corr)
53
+
54
+ out = torch.cat(out_list, dim=-1)
55
+
56
+ return out.permute(0, 3, 1, 2).contiguous()
57
+
58
+ def init_corr_pyr(self, feature0, feature1):
59
+ b, c, h, w = feature0.shape
60
+ feature0 = feature0.view(b, c, h*w)
61
+ feature1 = feature1.view(b, c, h*w)
62
+
63
+ corr = torch.matmul(feature0.transpose(1,2), feature1)
64
+ corr = corr.view(b*h*w, 1, h, w) / math.sqrt(c)
65
+
66
+ corr_pyramid = [corr]
67
+ for i in range(self.levels-1):
68
+ corr = F.avg_pool2d(corr, kernel_size=2, stride=2)
69
+ corr_pyramid.append(corr)
70
+
71
+ return corr_pyramid
72
+
73
+
74
+ # class RAFTCorrBlock:
75
+ # def __init__(self, fmap1, fmap2, num_levels=4, radius=4):
76
+ # self.num_levels = num_levels
77
+ # self.radius = radius
78
+ # self.corr_pyramid = []
79
+
80
+ # # all pairs correlation
81
+ # corr = RAFTCorrBlock.corr(fmap1, fmap2)
82
+
83
+ # batch, h1, w1, dim, h2, w2 = corr.shape
84
+ # corr = corr.reshape(batch*h1*w1, dim, h2, w2)
85
+
86
+ # self.corr_pyramid.append(corr)
87
+ # for i in range(self.num_levels-1):
88
+ # corr = F.avg_pool2d(corr, 2, stride=2)
89
+ # self.corr_pyramid.append(corr)
90
+
91
+ # def __call__(self, coords):
92
+ # r = self.radius
93
+ # coords = coords.permute(0, 2, 3, 1)
94
+ # batch, h1, w1, _ = coords.shape
95
+
96
+ # out_pyramid = []
97
+ # for i in range(self.num_levels):
98
+ # corr = self.corr_pyramid[i]
99
+ # dx = torch.linspace(-r, r, 2*r+1, device=coords.device)
100
+ # dy = torch.linspace(-r, r, 2*r+1, device=coords.device)
101
+ # delta = torch.stack(torch.meshgrid(dy, dx), axis=-1)
102
+
103
+ # centroid_lvl = coords.reshape(batch*h1*w1, 1, 1, 2) / 2**i
104
+ # delta_lvl = delta.view(1, 2*r+1, 2*r+1, 2)
105
+ # coords_lvl = centroid_lvl + delta_lvl
106
+
107
+ # corr = bilinear_sample(corr, coords_lvl)
108
+ # corr = corr.view(batch, h1, w1, -1)
109
+ # out_pyramid.append(corr)
110
+
111
+ # out = torch.cat(out_pyramid, dim=-1)
112
+ # return out.permute(0, 3, 1, 2).contiguous().float()
113
+
114
+ # @staticmethod
115
+ # def corr(fmap1, fmap2):
116
+ # batch, dim, ht, wd = fmap1.shape
117
+ # fmap1 = fmap1.view(batch, dim, ht*wd)
118
+ # fmap2 = fmap2.view(batch, dim, ht*wd)
119
+
120
+ # corr = torch.matmul(fmap1.transpose(1,2), fmap2)
121
+ # corr = corr.view(batch, ht, wd, 1, ht, wd)
122
+ # return corr / torch.sqrt(torch.tensor(dim).float())
NeuFlow/matching.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn.functional as F
2
+
3
+ from NeuFlow import utils
4
+
5
+
6
+ class Matching:
7
+
8
+ def init_bhwd(self, batch_size, height, width, device, amp):
9
+ self.grid = utils.coords_grid(batch_size, height, width, device, amp) # [B, 2, H, W]
10
+ self.flatten_grid = self.grid.view(batch_size, 2, -1).permute(0, 2, 1) # [B, H*W, 2]
11
+
12
+ def global_correlation_softmax(self, feature0, feature1):
13
+
14
+ b, c, h, w = feature0.shape
15
+
16
+ feature0 = feature0.flatten(-2).permute(0, 2, 1)
17
+ feature1 = feature1.flatten(-2).permute(0, 2, 1)
18
+
19
+ correspondence = F.scaled_dot_product_attention(feature0, feature1, self.flatten_grid)
20
+
21
+ correspondence = correspondence.view(b, h, w, 2).permute(0, 3, 1, 2) # [B, 2, H, W]
22
+
23
+ flow = correspondence - self.grid
24
+
25
+ return flow
NeuFlow/neuflow.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+ from NeuFlow import backbone_v7
5
+ from NeuFlow import transformer
6
+ from NeuFlow import matching
7
+ from NeuFlow import corr
8
+ from NeuFlow import refine
9
+ from NeuFlow import upsample
10
+ from NeuFlow import config
11
+
12
+ from huggingface_hub import PyTorchModelHubMixin
13
+
14
+
15
+ class NeuFlow(torch.nn.Module,
16
+ PyTorchModelHubMixin,
17
+ repo_url="https://github.com/neufieldrobotics/NeuFlow_v2", license="apache-2.0", pipeline_tag="image-to-image"):
18
+ def __init__(self):
19
+ super(NeuFlow, self).__init__()
20
+
21
+ self.backbone = backbone_v7.CNNEncoder(config.feature_dim_s16, config.context_dim_s16, config.feature_dim_s8, config.context_dim_s8)
22
+
23
+ self.cross_attn_s16 = transformer.FeatureAttention(config.feature_dim_s16+config.context_dim_s16, num_layers=2, ffn=True, ffn_dim_expansion=1, post_norm=True)
24
+
25
+ self.matching_s16 = matching.Matching()
26
+
27
+ # self.flow_attn_s16 = transformer.FlowAttention(config.feature_dim_s16)
28
+
29
+ self.corr_block_s16 = corr.CorrBlock(radius=4, levels=1)
30
+ self.corr_block_s8 = corr.CorrBlock(radius=4, levels=1)
31
+
32
+ self.merge_s8 = torch.nn.Sequential(torch.nn.Conv2d(config.feature_dim_s16 + config.feature_dim_s8, config.feature_dim_s8, kernel_size=3, stride=1, padding=1, bias=False),
33
+ torch.nn.GELU(),
34
+ torch.nn.Conv2d(config.feature_dim_s8, config.feature_dim_s8, kernel_size=3, stride=1, padding=1, bias=False),
35
+ torch.nn.BatchNorm2d(config.feature_dim_s8))
36
+
37
+ self.context_merge_s8 = torch.nn.Sequential(torch.nn.Conv2d(config.context_dim_s16 + config.context_dim_s8, config.context_dim_s8, kernel_size=3, stride=1, padding=1, bias=False),
38
+ torch.nn.GELU(),
39
+ torch.nn.Conv2d(config.context_dim_s8, config.context_dim_s8, kernel_size=3, stride=1, padding=1, bias=False),
40
+ torch.nn.BatchNorm2d(config.context_dim_s8))
41
+
42
+ self.refine_s16 = refine.Refine(config.context_dim_s16, config.iter_context_dim_s16, num_layers=5, levels=1, radius=4, inter_dim=128)
43
+ self.refine_s8 = refine.Refine(config.context_dim_s8, config.iter_context_dim_s8, num_layers=5, levels=1, radius=4, inter_dim=96)
44
+
45
+ self.conv_s8 = backbone_v7.ConvBlock(3, config.feature_dim_s1, kernel_size=8, stride=8, padding=0)
46
+ self.upsample_s8 = upsample.UpSample(config.feature_dim_s1, upsample_factor=8)
47
+
48
+ for p in self.parameters():
49
+ if p.dim() > 1:
50
+ torch.nn.init.xavier_uniform_(p)
51
+
52
+ def init_bhwd(self, batch_size, height, width, device, amp=True):
53
+
54
+ self.backbone.init_bhwd(batch_size*2, height//16, width//16, device, amp)
55
+
56
+ self.matching_s16.init_bhwd(batch_size, height//16, width//16, device, amp)
57
+
58
+ self.corr_block_s16.init_bhwd(batch_size, height//16, width//16, device, amp)
59
+ self.corr_block_s8.init_bhwd(batch_size, height//8, width//8, device, amp)
60
+
61
+ self.refine_s16.init_bhwd(batch_size, height//16, width//16, device, amp)
62
+ self.refine_s8.init_bhwd(batch_size, height//8, width//8, device, amp)
63
+
64
+ self.init_iter_context_s16 = torch.zeros(batch_size, config.iter_context_dim_s16, height//16, width//16, device=device, dtype=torch.half if amp else torch.float)
65
+ self.init_iter_context_s8 = torch.zeros(batch_size, config.iter_context_dim_s8, height//8, width//8, device=device, dtype=torch.half if amp else torch.float)
66
+
67
+ def split_features(self, features, context_dim, feature_dim):
68
+
69
+ context, features = torch.split(features, [context_dim, feature_dim], dim=1)
70
+
71
+ context, _ = context.chunk(chunks=2, dim=0)
72
+ feature0, feature1 = features.chunk(chunks=2, dim=0)
73
+
74
+ return features, torch.relu(context)
75
+
76
+ def forward(self, img0, img1, iters_s16=1, iters_s8=8):
77
+
78
+ flow_list = []
79
+
80
+ img0 /= 255.
81
+ img1 /= 255.
82
+
83
+ features_s16, features_s8 = self.backbone(torch.cat([img0, img1], dim=0))
84
+
85
+ features_s16 = self.cross_attn_s16(features_s16)
86
+
87
+ features_s16, context_s16 = self.split_features(features_s16, config.context_dim_s16, config.feature_dim_s16)
88
+ features_s8, context_s8 = self.split_features(features_s8, config.context_dim_s8, config.feature_dim_s8)
89
+
90
+ feature0_s16, feature1_s16 = features_s16.chunk(chunks=2, dim=0)
91
+
92
+ flow0 = self.matching_s16.global_correlation_softmax(feature0_s16, feature1_s16)
93
+
94
+ # flow0 = self.flow_attn_s16(feature0_s16, flow0)
95
+
96
+ corr_pyr_s16 = self.corr_block_s16.init_corr_pyr(feature0_s16, feature1_s16)
97
+
98
+ iter_context_s16 = self.init_iter_context_s16
99
+
100
+ for i in range(iters_s16):
101
+
102
+ if self.training and i > 0:
103
+ flow0 = flow0.detach()
104
+ # iter_context_s16 = iter_context_s16.detach()
105
+
106
+ corrs = self.corr_block_s16(corr_pyr_s16, flow0)
107
+
108
+ iter_context_s16, delta_flow = self.refine_s16(corrs, context_s16, iter_context_s16, flow0)
109
+
110
+ flow0 = flow0 + delta_flow
111
+
112
+ if self.training:
113
+ up_flow0 = F.interpolate(flow0, scale_factor=16, mode='bilinear') * 16
114
+ flow_list.append(up_flow0)
115
+
116
+ flow0 = F.interpolate(flow0, scale_factor=2, mode='nearest') * 2
117
+
118
+ features_s16 = F.interpolate(features_s16, scale_factor=2, mode='nearest')
119
+
120
+ features_s8 = self.merge_s8(torch.cat([features_s8, features_s16], dim=1))
121
+
122
+ feature0_s8, feature1_s8 = features_s8.chunk(chunks=2, dim=0)
123
+
124
+ corr_pyr_s8 = self.corr_block_s8.init_corr_pyr(feature0_s8, feature1_s8)
125
+
126
+ context_s16 = F.interpolate(context_s16, scale_factor=2, mode='nearest')
127
+
128
+ context_s8 = self.context_merge_s8(torch.cat([context_s8, context_s16], dim=1))
129
+
130
+ iter_context_s8 = self.init_iter_context_s8
131
+
132
+ for i in range(iters_s8):
133
+
134
+ if self.training and i > 0:
135
+ flow0 = flow0.detach()
136
+ # iter_context_s8 = iter_context_s8.detach()
137
+
138
+ corrs = self.corr_block_s8(corr_pyr_s8, flow0)
139
+
140
+ iter_context_s8, delta_flow = self.refine_s8(corrs, context_s8, iter_context_s8, flow0)
141
+
142
+ flow0 = flow0 + delta_flow
143
+
144
+ if self.training or i == iters_s8 - 1:
145
+
146
+ feature0_s1 = self.conv_s8(img0)
147
+ up_flow0 = self.upsample_s8(feature0_s1, flow0) * 8
148
+ flow_list.append(up_flow0)
149
+
150
+ return flow_list
NeuFlow/refine.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from NeuFlow import utils
3
+
4
+
5
+ class ConvBlock(torch.nn.Module):
6
+ def __init__(self, in_planes, out_planes, kernel_size, stride, padding):
7
+ super(ConvBlock, self).__init__()
8
+
9
+ self.conv = torch.nn.Conv2d(in_planes, out_planes, kernel_size=kernel_size, stride=stride, padding=padding, padding_mode='zeros', bias=False)
10
+ self.relu = torch.nn.LeakyReLU(negative_slope=0.1, inplace=False)
11
+
12
+ def forward(self, x):
13
+ return self.relu(self.conv(x))
14
+
15
+ class Refine(torch.nn.Module):
16
+ def __init__(self, context_dim, iter_context_dim, num_layers, levels, radius, inter_dim):
17
+ super(Refine, self).__init__()
18
+
19
+ self.radius = radius
20
+
21
+ self.conv1 = ConvBlock((radius*2+1)**2*levels+context_dim+iter_context_dim+2+1, context_dim+iter_context_dim, kernel_size=3, stride=1, padding=1)
22
+
23
+ self.conv2 = ConvBlock(context_dim+iter_context_dim, inter_dim, kernel_size=3, stride=1, padding=1)
24
+
25
+ self.conv_layers = torch.nn.ModuleList([ConvBlock(inter_dim, inter_dim, kernel_size=3, stride=1, padding=1)
26
+ for i in range(num_layers)])
27
+
28
+ self.conv3 = torch.nn.Conv2d(inter_dim, iter_context_dim+2, kernel_size=3, stride=1, padding=1, padding_mode='zeros', bias=True)
29
+
30
+ # self.hidden_act = torch.nn.Tanh()
31
+ self.hidden_act = torch.nn.Hardtanh(min_val=-4.0, max_val=4.0)
32
+ # self.hidden_norm = torch.nn.BatchNorm2d(feature_dim)
33
+
34
+ def init_bhwd(self, batch_size, height, width, device, amp):
35
+ self.radius_emb = torch.tensor(self.radius, dtype=torch.half if amp else torch.float, device=device).view(1,-1,1,1).expand([batch_size,1,height,width])
36
+
37
+ def forward(self, corrs, context, iter_context, flow0):
38
+
39
+ x = torch.cat([corrs, context, iter_context, flow0, self.radius_emb], dim=1)
40
+
41
+ x = self.conv1(x)
42
+
43
+ x = self.conv2(x)
44
+
45
+ for layer in self.conv_layers:
46
+ x = layer(x)
47
+
48
+ x = self.conv3(x)
49
+
50
+ return self.hidden_act(x[:,2:]), x[:,:2]
NeuFlow/transformer.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+ class TransformerLayer(torch.nn.Module):
5
+ def __init__(self,
6
+ feature_dim,
7
+ ffn=True,
8
+ ffn_dim_expansion=1
9
+ ):
10
+ super(TransformerLayer, self).__init__()
11
+
12
+ # multi-head attention
13
+ self.q_proj = torch.nn.Linear(feature_dim, feature_dim)
14
+ self.k_proj = torch.nn.Linear(feature_dim, feature_dim)
15
+ self.v_proj = torch.nn.Linear(feature_dim, feature_dim)
16
+
17
+ self.merge = torch.nn.Linear(feature_dim, feature_dim)
18
+
19
+ # self.multi_head_attn = torch.nn.MultiheadAttention(feature_dim, 2, batch_first=True, device='cuda')
20
+
21
+ self.norm1 = torch.nn.LayerNorm(feature_dim)
22
+
23
+ self.ffn = ffn
24
+
25
+ if self.ffn:
26
+ in_channels = feature_dim * 2
27
+ self.mlp = torch.nn.Sequential(
28
+ torch.nn.Linear(in_channels, in_channels * ffn_dim_expansion, bias=False),
29
+ torch.nn.GELU(),
30
+ torch.nn.Linear(in_channels * ffn_dim_expansion, feature_dim, bias=False),
31
+ )
32
+
33
+ self.norm2 = torch.nn.LayerNorm(feature_dim)
34
+
35
+ def forward(self, source, target):
36
+ # source, target: [B, L, C]
37
+ query, key, value = source, target, target
38
+
39
+ # single-head attention
40
+ query = self.q_proj(query) # [B, L, C]
41
+ key = self.k_proj(key) # [B, L, C]
42
+ value = self.v_proj(value) # [B, L, C]
43
+
44
+ message = F.scaled_dot_product_attention(query, key, value, dropout_p=0.0)
45
+
46
+ message = self.merge(message)
47
+
48
+ # message, _ = self.multi_head_attn(query, key, value, need_weights=False)
49
+ message = self.norm1(message)
50
+
51
+ if self.ffn:
52
+ message = self.mlp(torch.cat([source, message], dim=-1))
53
+ message = self.norm2(message)
54
+
55
+ return source + message
56
+
57
+ class FeatureAttention(torch.nn.Module):
58
+ def __init__(self, feature_dim, num_layers, ffn=True, ffn_dim_expansion=1, post_norm=False):
59
+ super(FeatureAttention, self).__init__()
60
+
61
+ self.layers = torch.nn.ModuleList([
62
+ TransformerLayer(feature_dim, ffn=ffn, ffn_dim_expansion=ffn_dim_expansion
63
+ )
64
+ for i in range(num_layers)])
65
+
66
+ self.post_norm = post_norm
67
+
68
+ if self.post_norm:
69
+ self.norm = torch.nn.BatchNorm2d(feature_dim)
70
+
71
+ def forward(self, concat_features0):
72
+
73
+ b, c, h, w = concat_features0.shape
74
+
75
+ concat_features0 = concat_features0.flatten(-2).permute(0, 2, 1) # [B, H*W, C]
76
+ concat_features1 = torch.cat(concat_features0.chunk(chunks=2, dim=0)[::-1], dim=0)
77
+
78
+ for layer in self.layers:
79
+ concat_features0 = layer(concat_features0, concat_features1)
80
+ concat_features1 = torch.cat(concat_features0.chunk(chunks=2, dim=0)[::-1], dim=0)
81
+
82
+ # reshape back
83
+ concat_features0 = concat_features0.view(b, h, w, c).permute(0, 3, 1, 2).contiguous() # [B, C, H, W]
84
+
85
+ if self.post_norm:
86
+ concat_features0 = self.norm(concat_features0)
87
+
88
+ return concat_features0
89
+
90
+
91
+ class FlowAttention(torch.nn.Module):
92
+ """
93
+ flow propagation with self-attention on feature
94
+ query: feature0, key: feature0, value: flow
95
+ """
96
+
97
+ def __init__(self, feature_dim):
98
+ super(FlowAttention, self).__init__()
99
+
100
+ self.q_proj = torch.nn.Linear(feature_dim, feature_dim)
101
+ self.k_proj = torch.nn.Linear(feature_dim, feature_dim)
102
+
103
+ def forward(self, feature, flow):
104
+ # q, k: feature [B, C, H, W], v: flow [B, 2, H, W]
105
+ b, c, h, w = feature.size()
106
+
107
+ feature = feature.flatten(-2).permute(0, 2, 1) # [B, H*W, C]
108
+
109
+ flow = flow.flatten(-2).permute(0, 2, 1)
110
+
111
+ query = self.q_proj(feature) # [B, H*W, C]
112
+ key = self.k_proj(feature) # [B, H*W, C]
113
+
114
+ flow = F.scaled_dot_product_attention(query, key, flow)
115
+
116
+ flow = flow.view(b, h, w, 2).permute(0, 3, 1, 2)
117
+
118
+ return flow
NeuFlow/upsample.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+ # from spatial_correlation_sampler import SpatialCorrelationSampler
4
+
5
+ class UpSample(torch.nn.Module):
6
+ def __init__(self, feature_dim, upsample_factor):
7
+ super(UpSample, self).__init__()
8
+
9
+ self.upsample_factor = upsample_factor
10
+
11
+ self.conv1 = torch.nn.Conv2d(2 + feature_dim, 256, 3, 1, 1)
12
+ self.conv2 = torch.nn.Conv2d(256, 512, 3, 1, 1)
13
+ self.conv3 = torch.nn.Conv2d(512, upsample_factor ** 2 * 9, 1, 1, 0)
14
+ self.relu = torch.nn.ReLU(inplace=True)
15
+
16
+ def forward(self, feature, flow):
17
+
18
+ concat = torch.cat((flow, feature), dim=1)
19
+
20
+ mask = self.conv3(self.relu(self.conv2(self.relu(self.conv1(concat)))))
21
+
22
+ b, _, h, w = flow.shape
23
+
24
+ mask = mask.view(b, 1, 9, self.upsample_factor, self.upsample_factor, h, w) # [B, 1, 9, K, K, H, W]
25
+ mask = torch.softmax(mask, dim=2)
26
+
27
+ # up_flow = F.unfold(self.upsample_factor * flow, [3, 3], padding=1)
28
+ up_flow = F.unfold(flow, [3, 3], padding=1)
29
+ up_flow = up_flow.view(b, 2, 9, 1, 1, h, w) # [B, 2, 9, 1, 1, H, W]
30
+
31
+ up_flow = torch.sum(mask * up_flow, dim=2) # [B, 2, K, K, H, W]
32
+ up_flow = up_flow.permute(0, 1, 4, 2, 5, 3) # [B, 2, K, H, K, W]
33
+ up_flow = up_flow.reshape(b, 2, self.upsample_factor * h,
34
+ self.upsample_factor * w) # [B, 2, K*H, K*W]
35
+
36
+ return up_flow
NeuFlow/utils.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+ # def normalize(x):
5
+ # x_min = x.min()
6
+ # return (x - x_min) / (x.max() - x_min)
7
+
8
+ def coords_grid(b, h, w, device, amp):
9
+ ys, xs = torch.meshgrid(torch.arange(h, dtype=torch.half if amp else torch.float, device=device), torch.arange(w, dtype=torch.half if amp else torch.float, device=device), indexing='ij') # [H, W]
10
+
11
+ grid = torch.stack([xs, ys], dim=0) # [2, H, W] or [3, H, W]
12
+
13
+ grid = grid[None].repeat(b, 1, 1, 1) # [B, 2, H, W] or [B, 3, H, W]
14
+
15
+ return grid
16
+
17
+ def bilinear_sample(img, sample_coords):
18
+
19
+ b, _, h, w = sample_coords.shape
20
+
21
+ # Normalize to [-1, 1]
22
+ x_grid = 2 * sample_coords[:, 0] / (w - 1) - 1
23
+ y_grid = 2 * sample_coords[:, 1] / (h - 1) - 1
24
+
25
+ grid = torch.stack([x_grid, y_grid], dim=-1) # [B, H, W, 2]
26
+
27
+ img = F.grid_sample(img, grid, mode='bilinear', padding_mode='zeros', align_corners=True)
28
+
29
+ return img
30
+
31
+ def flow_warp(feature, flow):
32
+
33
+ b, c, h, w = feature.size()
34
+
35
+ grid = coords_grid(b, h, w).to(flow.device) + flow # [B, 2, H, W]
36
+
37
+ return bilinear_sample(feature, grid)
app.py CHANGED
@@ -119,10 +119,12 @@ def _get_video_predictor() -> tuple[str, Any]:
119
  print("[GPU worker] Falling back to HF Transformers Sam3VideoModel...")
120
  from transformers import Sam3VideoModel, Sam3VideoProcessor
121
 
122
- _video_hf_processor = Sam3VideoProcessor.from_pretrained("facebook/sam3", token=HF_TOKEN)
123
- _video_hf_model = Sam3VideoModel.from_pretrained("facebook/sam3", token=HF_TOKEN, torch_dtype=torch.bfloat16).to(
124
- "cuda"
125
  )
 
 
 
126
  print("[GPU worker] HF Sam3VideoModel loaded")
127
  return ("hf_sam3", (_video_hf_model, _video_hf_processor))
128
 
@@ -145,7 +147,9 @@ def _get_image_model_and_processor() -> tuple[Any, Any]:
145
  _image_model = model
146
  _image_processor = Sam3Processor(_image_model)
147
  dtype_sample = next(model.parameters()).dtype
148
- print(f"[GPU worker] SAM 3 image model ready (first-param dtype={dtype_sample})")
 
 
149
  return _image_model, _image_processor
150
 
151
 
@@ -327,16 +331,28 @@ def _run_tracking_gpu(video_path: str, query: str) -> dict[str, Any]:
327
  video_storage_device="cpu",
328
  dtype=torch.bfloat16,
329
  )
330
- inference_session = processor.add_text_prompt(inference_session=inference_session, text=query)
 
 
331
  outputs_per_frame: dict[int, dict[str, Any]] = {}
332
- for model_outputs in model.propagate_in_video_iterator(inference_session=inference_session):
 
 
333
  processed = processor.postprocess_outputs(inference_session, model_outputs)
334
  outputs_per_frame[model_outputs.frame_idx] = processed
335
 
336
- visible_indices = [fi for fi, payload in outputs_per_frame.items() if (payload.get("object_ids") or [])]
 
 
 
 
337
  visible_indices.sort()
338
 
339
- fps_est = len(frames) / _video_duration_s(video_path) if _video_duration_s(video_path) > 0 else SAMPLE_FPS
 
 
 
 
340
  tracked_segments: list[dict[str, float]] = []
341
  if visible_indices:
342
  current_start = visible_indices[0]
@@ -429,7 +445,9 @@ def _download_image_bytes(url: str, *, timeout_s: float = 30.0) -> bytes:
429
  for chunk in response.iter_bytes(chunk_size=256 * 1024):
430
  buf.write(chunk)
431
  if buf.tell() > MAX_IMAGE_BYTES:
432
- raise ValueError(f"Image exceeds {MAX_IMAGE_BYTES // (1024 * 1024)} MB limit")
 
 
433
  return buf.getvalue()
434
 
435
 
@@ -507,7 +525,9 @@ def _masks_to_rle(output: Any) -> list[dict[str, Any] | None]:
507
  masks = candidate
508
  break
509
  if masks is None:
510
- print(f"[GPU worker] mask extraction: no mask-like key in output (got {sorted(output.keys())})")
 
 
511
  if masks is None:
512
  return []
513
 
@@ -565,7 +585,9 @@ def _binary_mask_to_rle(mask: np.ndarray) -> dict[str, Any]:
565
  prev = 1 - prev
566
  return {
567
  "size": [int(h), int(w)],
568
- "counts": base64.b64encode(",".join(str(r) for r in runs).encode("ascii")).decode("ascii"),
 
 
569
  "format": "uncompressed_rle_b64",
570
  }
571
 
@@ -747,7 +769,9 @@ def _bbox_to_rle(
747
 
748
  return {
749
  "size": [int(img_h), int(img_w)],
750
- "counts": base64.b64encode(",".join(str(r) for r in runs).encode("ascii")).decode("ascii"),
 
 
751
  "format": "bbox_rle_b64",
752
  }
753
 
@@ -922,7 +946,9 @@ SAPIENS_MAX_PERSONS_PER_FRAME = 12
922
  SAPIENS_INPUT_HEIGHT = 1024
923
  SAPIENS_INPUT_WIDTH = 768
924
 
925
- SAPIENS_KEYPOINT_NAMES_PATH = os.path.join(os.path.dirname(__file__), "goliath_keypoints.txt")
 
 
926
 
927
  # Worker-local model cache β€” separate from the SAM caches above so the
928
  # two model families don't compete for the same global slot.
@@ -937,7 +963,9 @@ def _sapiens_load_keypoint_names() -> list[str]:
937
  return _sapiens_worker_keypoint_names
938
  if os.path.exists(SAPIENS_KEYPOINT_NAMES_PATH):
939
  with open(SAPIENS_KEYPOINT_NAMES_PATH) as f:
940
- names = [line.strip() for line in f if line.strip() and not line.startswith("#")]
 
 
941
  if names:
942
  _sapiens_worker_keypoint_names = names
943
  return _sapiens_worker_keypoint_names
@@ -1045,12 +1073,19 @@ def _sapiens_preprocess(image: np.ndarray) -> tuple[Any, tuple[int, int]]:
1045
  from PIL import Image
1046
 
1047
  orig_h, orig_w = image.shape[:2]
1048
- pil = Image.fromarray(image).resize((SAPIENS_INPUT_WIDTH, SAPIENS_INPUT_HEIGHT), Image.BILINEAR)
 
 
1049
  arr = np.array(pil).astype(np.float32) / 255.0
1050
  mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
1051
  std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
1052
  arr = (arr - mean) / std
1053
- tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to("cuda", dtype=torch.float32)
 
 
 
 
 
1054
  return tensor, (orig_h, orig_w)
1055
 
1056
 
@@ -1180,7 +1215,9 @@ def _sapiens_extract_video_frames(
1180
  subprocess.run(cmd, check=True, capture_output=True, timeout=30)
1181
  frame_bgr = cv2.imread(out_path)
1182
  if frame_bgr is not None:
1183
- extracted_ff.append((ts, cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)))
 
 
1184
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1185
  print(f"[sapiens] ffmpeg seek failed for ts={ts}: {exc}")
1186
  continue
@@ -1227,7 +1264,10 @@ def _sapiens_track_persons_across_frames(
1227
  # ByteTrack drops rejected detections from its output, so output
1228
  # length ≀ input length, and order isn't preserved. Per-input-row
1229
  # bbox-equality matching is the only safe way to assign IDs.
1230
- if getattr(tracked, "tracker_id", None) is not None and getattr(tracked, "xyxy", None) is not None:
 
 
 
1231
  for out_idx in range(len(tracked.tracker_id)):
1232
  tid = tracked.tracker_id[out_idx]
1233
  if tid is None:
@@ -1236,7 +1276,10 @@ def _sapiens_track_persons_across_frames(
1236
  for p in frame_persons:
1237
  if "id" in p:
1238
  continue # already matched to a tracker_id
1239
- if abs(p["bbox"]["x1"] - float(out_x1)) < 1.0 and abs(p["bbox"]["y1"] - float(out_y1)) < 1.0:
 
 
 
1240
  p["id"] = f"p_{int(tid)}"
1241
  break
1242
 
@@ -1269,7 +1312,9 @@ def api_pose_image(
1269
  image_array = _sapiens_decode_image_np(raw)
1270
  h, w = image_array.shape[:2]
1271
 
1272
- person_bboxes = _sapiens_detect_persons(image_array, detector_conf=detector_conf)
 
 
1273
  if not person_bboxes:
1274
  return {
1275
  "ok": True,
@@ -1283,7 +1328,9 @@ def api_pose_image(
1283
  }
1284
 
1285
  pose_model = _sapiens_load_pose_model()
1286
- persons = _sapiens_infer_pose_for_persons(pose_model, image_array, person_bboxes, confidence_threshold)
 
 
1287
  for j, p in enumerate(persons):
1288
  p["id"] = f"p_{j}"
1289
 
@@ -1363,10 +1410,14 @@ def api_pose_video_frames(
1363
 
1364
  for ts, image_array in frames:
1365
  h, w = image_array.shape[:2]
1366
- person_bboxes = _sapiens_detect_persons(image_array, detector_conf=detector_conf)
 
 
1367
  if len(person_bboxes) >= SAPIENS_MAX_PERSONS_PER_FRAME:
1368
  any_capped = True
1369
- persons = _sapiens_infer_pose_for_persons(pose_model, image_array, person_bboxes, confidence_threshold)
 
 
1370
  per_frame_persons.append(persons)
1371
  per_frame_meta.append({"timestamp_s": ts, "width": w, "height": h})
1372
 
@@ -1433,45 +1484,62 @@ NEUFLOW_MAX_TIMESTAMPS = 32
1433
  NEUFLOW_DEFAULT_PAIR_GAP_S = 0.1
1434
  NEUFLOW_DEFAULT_DOWNSAMPLE = 384
1435
 
 
 
 
1436
  _neuflow_worker_model: Any = None
 
1437
 
1438
 
1439
- def _neuflow_load_model() -> Any:
1440
- global _neuflow_worker_model
1441
- if _neuflow_worker_model is not None:
1442
- return _neuflow_worker_model
1443
 
1444
- # NeuFlow v2 publishes a NeuFlow class via the neufieldrobotics
1445
- # repo that registers PyTorchModelHubMixin. We avoid pinning the
1446
- # GitHub install in requirements.txt (would force a heavy build);
1447
- # instead resolve the class at runtime so the Space build stays
1448
- # cheap and any neuflow API drift surfaces here, not at import.
1449
- try:
1450
- from neuflow_v2 import NeuFlow # type: ignore[import-not-found]
1451
- except ImportError:
1452
- from neuflow.neuflow import NeuFlow # type: ignore[import-not-found,no-redef]
 
 
 
 
 
 
 
 
 
 
1453
 
1454
- print(f"[neuflow] Loading {NEUFLOW_REPO} into GPU memory...")
1455
- model = NeuFlow.from_pretrained(NEUFLOW_REPO).to("cuda")
1456
- model.eval()
1457
- _neuflow_worker_model = model
1458
- print("[neuflow] NeuFlow v2 ready on CUDA")
1459
  return _neuflow_worker_model
1460
 
1461
 
1462
  def _neuflow_preprocess(image: np.ndarray, max_dim: int) -> tuple[Any, tuple[int, int]]:
1463
- """Downsample to max_dim on the long side; convert to (1,3,H,W) [0,1] tensor."""
 
 
 
 
 
1464
  h, w = image.shape[:2]
1465
  scale = max_dim / max(h, w)
1466
  if scale < 1.0:
1467
  new_h, new_w = int(h * scale), int(w * scale)
1468
- image_small = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
1469
  else:
1470
- image_small = image
1471
-
1472
- arr = image_small.astype(np.float32) / 255.0
1473
- tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to("cuda", dtype=torch.float32)
1474
- return tensor, (image_small.shape[0], image_small.shape[1])
 
 
 
 
 
 
1475
 
1476
 
1477
  def _neuflow_aggregate_flow(
@@ -1483,7 +1551,11 @@ def _neuflow_aggregate_flow(
1483
  pair_timestamp_s: float,
1484
  pair_gap_s: float,
1485
  ) -> dict[str, Any]:
1486
- """Reduce a (1, 2, H', W') flow tensor to scalar motion stats.
 
 
 
 
1487
 
1488
  Magnitudes are reported in ORIGINAL image pixel space (multiplied by
1489
  src_h / downsampled_h) so they're comparable across calls regardless
@@ -1511,7 +1583,9 @@ def _neuflow_aggregate_flow(
1511
  # Dominant direction: angle of the mean flow vector
1512
  mean_fx = float(np.mean(fx_orig))
1513
  mean_fy = float(np.mean(fy_orig))
1514
- dominant_dir_deg = float((np.degrees(np.arctan2(-mean_fy, mean_fx)) + 360.0) % 360.0)
 
 
1515
 
1516
  # Direction consistency: |mean(unit_vectors)|
1517
  eps = 1e-6
@@ -1599,14 +1673,19 @@ def api_optical_flow(
1599
  "elapsed_s": round(time.monotonic() - started, 3),
1600
  }
1601
 
1602
- model = _neuflow_load_model()
1603
  results: list[dict[str, Any]] = []
1604
  for ts, img1, img2 in pairs:
1605
  src_h, src_w = img1.shape[:2]
1606
  t1, ds_dims = _neuflow_preprocess(img1, downsample_to)
1607
  t2, _ = _neuflow_preprocess(img2, downsample_to)
 
 
 
 
1608
  with torch.no_grad():
1609
- flow = model(t1, t2)
 
 
1610
  results.append(
1611
  _neuflow_aggregate_flow(
1612
  flow,
 
119
  print("[GPU worker] Falling back to HF Transformers Sam3VideoModel...")
120
  from transformers import Sam3VideoModel, Sam3VideoProcessor
121
 
122
+ _video_hf_processor = Sam3VideoProcessor.from_pretrained(
123
+ "facebook/sam3", token=HF_TOKEN
 
124
  )
125
+ _video_hf_model = Sam3VideoModel.from_pretrained(
126
+ "facebook/sam3", token=HF_TOKEN, torch_dtype=torch.bfloat16
127
+ ).to("cuda")
128
  print("[GPU worker] HF Sam3VideoModel loaded")
129
  return ("hf_sam3", (_video_hf_model, _video_hf_processor))
130
 
 
147
  _image_model = model
148
  _image_processor = Sam3Processor(_image_model)
149
  dtype_sample = next(model.parameters()).dtype
150
+ print(
151
+ f"[GPU worker] SAM 3 image model ready (first-param dtype={dtype_sample})"
152
+ )
153
  return _image_model, _image_processor
154
 
155
 
 
331
  video_storage_device="cpu",
332
  dtype=torch.bfloat16,
333
  )
334
+ inference_session = processor.add_text_prompt(
335
+ inference_session=inference_session, text=query
336
+ )
337
  outputs_per_frame: dict[int, dict[str, Any]] = {}
338
+ for model_outputs in model.propagate_in_video_iterator(
339
+ inference_session=inference_session
340
+ ):
341
  processed = processor.postprocess_outputs(inference_session, model_outputs)
342
  outputs_per_frame[model_outputs.frame_idx] = processed
343
 
344
+ visible_indices = [
345
+ fi
346
+ for fi, payload in outputs_per_frame.items()
347
+ if (payload.get("object_ids") or [])
348
+ ]
349
  visible_indices.sort()
350
 
351
+ fps_est = (
352
+ len(frames) / _video_duration_s(video_path)
353
+ if _video_duration_s(video_path) > 0
354
+ else SAMPLE_FPS
355
+ )
356
  tracked_segments: list[dict[str, float]] = []
357
  if visible_indices:
358
  current_start = visible_indices[0]
 
445
  for chunk in response.iter_bytes(chunk_size=256 * 1024):
446
  buf.write(chunk)
447
  if buf.tell() > MAX_IMAGE_BYTES:
448
+ raise ValueError(
449
+ f"Image exceeds {MAX_IMAGE_BYTES // (1024 * 1024)} MB limit"
450
+ )
451
  return buf.getvalue()
452
 
453
 
 
525
  masks = candidate
526
  break
527
  if masks is None:
528
+ print(
529
+ f"[GPU worker] mask extraction: no mask-like key in output (got {sorted(output.keys())})"
530
+ )
531
  if masks is None:
532
  return []
533
 
 
585
  prev = 1 - prev
586
  return {
587
  "size": [int(h), int(w)],
588
+ "counts": base64.b64encode(
589
+ ",".join(str(r) for r in runs).encode("ascii")
590
+ ).decode("ascii"),
591
  "format": "uncompressed_rle_b64",
592
  }
593
 
 
769
 
770
  return {
771
  "size": [int(img_h), int(img_w)],
772
+ "counts": base64.b64encode(
773
+ ",".join(str(r) for r in runs).encode("ascii")
774
+ ).decode("ascii"),
775
  "format": "bbox_rle_b64",
776
  }
777
 
 
946
  SAPIENS_INPUT_HEIGHT = 1024
947
  SAPIENS_INPUT_WIDTH = 768
948
 
949
+ SAPIENS_KEYPOINT_NAMES_PATH = os.path.join(
950
+ os.path.dirname(__file__), "goliath_keypoints.txt"
951
+ )
952
 
953
  # Worker-local model cache β€” separate from the SAM caches above so the
954
  # two model families don't compete for the same global slot.
 
963
  return _sapiens_worker_keypoint_names
964
  if os.path.exists(SAPIENS_KEYPOINT_NAMES_PATH):
965
  with open(SAPIENS_KEYPOINT_NAMES_PATH) as f:
966
+ names = [
967
+ line.strip() for line in f if line.strip() and not line.startswith("#")
968
+ ]
969
  if names:
970
  _sapiens_worker_keypoint_names = names
971
  return _sapiens_worker_keypoint_names
 
1073
  from PIL import Image
1074
 
1075
  orig_h, orig_w = image.shape[:2]
1076
+ pil = Image.fromarray(image).resize(
1077
+ (SAPIENS_INPUT_WIDTH, SAPIENS_INPUT_HEIGHT), Image.BILINEAR
1078
+ )
1079
  arr = np.array(pil).astype(np.float32) / 255.0
1080
  mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
1081
  std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
1082
  arr = (arr - mean) / std
1083
+ tensor = (
1084
+ torch.from_numpy(arr)
1085
+ .permute(2, 0, 1)
1086
+ .unsqueeze(0)
1087
+ .to("cuda", dtype=torch.float32)
1088
+ )
1089
  return tensor, (orig_h, orig_w)
1090
 
1091
 
 
1215
  subprocess.run(cmd, check=True, capture_output=True, timeout=30)
1216
  frame_bgr = cv2.imread(out_path)
1217
  if frame_bgr is not None:
1218
+ extracted_ff.append(
1219
+ (ts, cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
1220
+ )
1221
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1222
  print(f"[sapiens] ffmpeg seek failed for ts={ts}: {exc}")
1223
  continue
 
1264
  # ByteTrack drops rejected detections from its output, so output
1265
  # length ≀ input length, and order isn't preserved. Per-input-row
1266
  # bbox-equality matching is the only safe way to assign IDs.
1267
+ if (
1268
+ getattr(tracked, "tracker_id", None) is not None
1269
+ and getattr(tracked, "xyxy", None) is not None
1270
+ ):
1271
  for out_idx in range(len(tracked.tracker_id)):
1272
  tid = tracked.tracker_id[out_idx]
1273
  if tid is None:
 
1276
  for p in frame_persons:
1277
  if "id" in p:
1278
  continue # already matched to a tracker_id
1279
+ if (
1280
+ abs(p["bbox"]["x1"] - float(out_x1)) < 1.0
1281
+ and abs(p["bbox"]["y1"] - float(out_y1)) < 1.0
1282
+ ):
1283
  p["id"] = f"p_{int(tid)}"
1284
  break
1285
 
 
1312
  image_array = _sapiens_decode_image_np(raw)
1313
  h, w = image_array.shape[:2]
1314
 
1315
+ person_bboxes = _sapiens_detect_persons(
1316
+ image_array, detector_conf=detector_conf
1317
+ )
1318
  if not person_bboxes:
1319
  return {
1320
  "ok": True,
 
1328
  }
1329
 
1330
  pose_model = _sapiens_load_pose_model()
1331
+ persons = _sapiens_infer_pose_for_persons(
1332
+ pose_model, image_array, person_bboxes, confidence_threshold
1333
+ )
1334
  for j, p in enumerate(persons):
1335
  p["id"] = f"p_{j}"
1336
 
 
1410
 
1411
  for ts, image_array in frames:
1412
  h, w = image_array.shape[:2]
1413
+ person_bboxes = _sapiens_detect_persons(
1414
+ image_array, detector_conf=detector_conf
1415
+ )
1416
  if len(person_bboxes) >= SAPIENS_MAX_PERSONS_PER_FRAME:
1417
  any_capped = True
1418
+ persons = _sapiens_infer_pose_for_persons(
1419
+ pose_model, image_array, person_bboxes, confidence_threshold
1420
+ )
1421
  per_frame_persons.append(persons)
1422
  per_frame_meta.append({"timestamp_s": ts, "width": w, "height": h})
1423
 
 
1484
  NEUFLOW_DEFAULT_PAIR_GAP_S = 0.1
1485
  NEUFLOW_DEFAULT_DOWNSAMPLE = 384
1486
 
1487
+ # NeuFlow needs init_bhwd(B, H, W, device) called once per resolution.
1488
+ # Cache the (H, W) pair the model was last initialised at so we re-init
1489
+ # only when the request resolution actually changes.
1490
  _neuflow_worker_model: Any = None
1491
+ _neuflow_last_init_hw: tuple[int, int] | None = None
1492
 
1493
 
1494
+ def _neuflow_load_model(image_h: int, image_w: int) -> Any:
1495
+ """Load NeuFlow v2 from the vendored ./NeuFlow/ package + HF weights.
 
 
1496
 
1497
+ Calls init_bhwd whenever the requested (H, W) differs from the last
1498
+ init (NeuFlow allocates internal correlation/upsample buffers per
1499
+ resolution, so init must run before inference at any new size).
1500
+ """
1501
+ global _neuflow_worker_model, _neuflow_last_init_hw
1502
+ if _neuflow_worker_model is None:
1503
+ # Vendored class definition lives in ./NeuFlow/neuflow.py
1504
+ from NeuFlow.neuflow import NeuFlow # type: ignore[import-not-found]
1505
+
1506
+ print(f"[neuflow] Loading {NEUFLOW_REPO} into GPU memory...")
1507
+ model = NeuFlow.from_pretrained(NEUFLOW_REPO).to("cuda")
1508
+ model.eval()
1509
+ model.half() # NeuFlow's reference inference path runs fp16
1510
+ _neuflow_worker_model = model
1511
+ print("[neuflow] NeuFlow v2 ready on CUDA")
1512
+
1513
+ if _neuflow_last_init_hw != (image_h, image_w):
1514
+ _neuflow_worker_model.init_bhwd(1, image_h, image_w, "cuda")
1515
+ _neuflow_last_init_hw = (image_h, image_w)
1516
 
 
 
 
 
 
1517
  return _neuflow_worker_model
1518
 
1519
 
1520
  def _neuflow_preprocess(image: np.ndarray, max_dim: int) -> tuple[Any, tuple[int, int]]:
1521
+ """Downsample to max_dim on the long side; convert to (1,3,H,W) half tensor.
1522
+
1523
+ NeuFlow v2's reference inference takes raw uint8 BGR (no ImageNet
1524
+ normalisation) β†’ half tensor. NeuFlow also requires H and W to be
1525
+ multiples of 16 for its hierarchical pyramid; we floor-round.
1526
+ """
1527
  h, w = image.shape[:2]
1528
  scale = max_dim / max(h, w)
1529
  if scale < 1.0:
1530
  new_h, new_w = int(h * scale), int(w * scale)
 
1531
  else:
1532
+ new_h, new_w = h, w
1533
+ new_h = max(16, (new_h // 16) * 16)
1534
+ new_w = max(16, (new_w // 16) * 16)
1535
+ image_small = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
1536
+
1537
+ # cv2 default is BGR β€” match the reference infer_hf.py exactly (no
1538
+ # RGB conversion, no /255 scaling, no mean/std).
1539
+ tensor = (
1540
+ torch.from_numpy(image_small).permute(2, 0, 1).unsqueeze(0).half().to("cuda")
1541
+ )
1542
+ return tensor, (new_h, new_w)
1543
 
1544
 
1545
  def _neuflow_aggregate_flow(
 
1551
  pair_timestamp_s: float,
1552
  pair_gap_s: float,
1553
  ) -> dict[str, Any]:
1554
+ """Reduce a (2, H', W') flow tensor to scalar motion stats.
1555
+
1556
+ Caller must already strip the batch dim β€” NeuFlow's reference
1557
+ inference returns multi-scale flow as a list of (B, 2, H, W) tensors;
1558
+ the wrapper takes [-1][0] to get the highest-res un-batched flow.
1559
 
1560
  Magnitudes are reported in ORIGINAL image pixel space (multiplied by
1561
  src_h / downsampled_h) so they're comparable across calls regardless
 
1583
  # Dominant direction: angle of the mean flow vector
1584
  mean_fx = float(np.mean(fx_orig))
1585
  mean_fy = float(np.mean(fy_orig))
1586
+ dominant_dir_deg = float(
1587
+ (np.degrees(np.arctan2(-mean_fy, mean_fx)) + 360.0) % 360.0
1588
+ )
1589
 
1590
  # Direction consistency: |mean(unit_vectors)|
1591
  eps = 1e-6
 
1673
  "elapsed_s": round(time.monotonic() - started, 3),
1674
  }
1675
 
 
1676
  results: list[dict[str, Any]] = []
1677
  for ts, img1, img2 in pairs:
1678
  src_h, src_w = img1.shape[:2]
1679
  t1, ds_dims = _neuflow_preprocess(img1, downsample_to)
1680
  t2, _ = _neuflow_preprocess(img2, downsample_to)
1681
+ # init_bhwd is per-resolution; load_model takes (H, W) so it
1682
+ # re-inits when we switch sizes (typically constant per call,
1683
+ # but caller can mix resolutions across pairs in theory).
1684
+ model = _neuflow_load_model(ds_dims[0], ds_dims[1])
1685
  with torch.no_grad():
1686
+ # NeuFlow returns multi-scale flow; [-1] is highest-res,
1687
+ # [0] strips batch dim β†’ shape (2, H, W)
1688
+ flow = model(t1, t2)[-1][0]
1689
  results.append(
1690
  _neuflow_aggregate_flow(
1691
  flow,
requirements.txt CHANGED
@@ -45,9 +45,9 @@ ultralytics>=8.3.0
45
  supervision>=0.25.0
46
 
47
  # ── BUNDLED: NeuFlow v2 optical flow (Phase 2 spatial-tools) ─────────────
48
- # NeuFlow v2 doesn't ship as a pip package. The model class is in the
49
- # upstream repo and uses PyTorchModelHubMixin to load the weights from
50
- # Study-is-happy/neuflow-v2. May need post-deploy iteration if the
51
- # install path/import path differs at runtime β€” see _neuflow_load_model
52
- # in app.py for the dual-path fallback.
53
- git+https://github.com/neufieldrobotics/NeuFlow_v2.git
 
45
  supervision>=0.25.0
46
 
47
  # ── BUNDLED: NeuFlow v2 optical flow (Phase 2 spatial-tools) ─────────────
48
+ # NeuFlow v2 doesn't ship as a pip package or have a setup.py β€” its
49
+ # model class lives in the ./NeuFlow/ subdir which we vendor into this
50
+ # Space (copied from neufieldrobotics/NeuFlow_v2 commit 204b5e3 +
51
+ # loaded by app.py via `from NeuFlow.neuflow import NeuFlow`). Weights
52
+ # are pulled at first inference from Study-is-happy/neuflow-v2 via
53
+ # PyTorchModelHubMixin.from_pretrained. No additional pip dep needed.