File size: 8,381 Bytes
762d5e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd46c49
762d5e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""HaMeR image features: the MANO decoder token of each hand, one pair per frame.

The hand crop (_crop_hand) is adapted from HaMeR's hamer/datasets/utils.py
(https://github.com/geopavlakos/hamer, MIT License, Copyright (c) 2023 UC Regents, Georgios
Pavlakos), see THIRD_PARTY_NOTICES.md.
"""

from pathlib import Path

import cv2
import numpy as np
import torch
from hamer.configs import get_config
from hamer.models import HAMER
from torch.utils.data import DataLoader
from tqdm import tqdm


def hand_boxes_from_keypoints(kp2d, threshold, min_kpts=4):
    """Per-frame boxes around the confident keypoints of one hand.

    Args:
        kp2d: (L, J, 3) hand keypoints [x, y, score]
        threshold: keypoints with score > threshold are used
        min_kpts: frames with fewer confident keypoints get an all-zero box (= no hand)
    Returns:
        (L, 4) boxes [x1, y1, x2, y2]
    """
    x, y = kp2d[..., 0], kp2d[..., 1]
    mask = kp2d[..., 2] > threshold
    inf = x.new_tensor(float("inf"))
    boxes = torch.stack(
        [
            torch.where(mask, x, inf).amin(dim=1),
            torch.where(mask, y, inf).amin(dim=1),
            torch.where(mask, x, -inf).amax(dim=1),
            torch.where(mask, y, -inf).amax(dim=1),
        ],
        dim=1,
    )
    valid = mask.sum(dim=1) >= min_kpts
    return torch.where(valid.unsqueeze(1), boxes, torch.zeros_like(boxes))


class HamerHandFeatures:
    """Per-frame HaMeR tokens (1024-d) of both hands, given their bounding boxes.

    The token (the MANO decoder output consumed by HaMeR's regression heads) is read with a
    forward hook. Hands are cropped like in HaMeR's demo; the left hand is mirrored, as HaMeR
    only models right hands. Frames without a hand box get an all-black crop.

    Args:
        data_dir: HaMeR's data directory (from its hamer_demo_data download): contains
            hamer_ckpts/checkpoints/hamer.ckpt, hamer_ckpts/model_config.yaml,
            data/mano_mean_params.npz, and the MANO model data/mano/MANO_RIGHT.pkl
        fp16: run the network under float16 autocast (~1.9x faster, tokens nearly identical)
    """

    TOKEN_DIM = 1024

    def __init__(self, data_dir, fp16=True, batch_size=16, num_workers=8, device="cuda"):
        data_dir = Path(data_dir)
        cfg = get_config(str(data_dir / "hamer_ckpts" / "model_config.yaml"))
        cfg.defrost()
        cfg.MANO.MODEL_PATH = str(data_dir / "data" / "mano")
        cfg.MANO.MEAN_PARAMS = str(data_dir / "data" / "mano_mean_params.npz")
        if cfg.MODEL.BACKBONE.TYPE == "vit" and "BBOX_SHAPE" not in cfg.MODEL:
            cfg.MODEL.BBOX_SHAPE = [192, 256]
        cfg.MODEL.BACKBONE.pop("PRETRAINED_WEIGHTS", None)
        cfg.freeze()
        self.cfg = cfg
        # like hamer.models.load_hamer, without the renderer; built on the GPU: initializing the
        # weights on the CPU takes ~2 s, and the checkpoint replaces them anyway
        with torch.device(device):
            self.model = HAMER.load_from_checkpoint(
                str(data_dir / "hamer_ckpts" / "checkpoints" / "hamer.ckpt"),
                strict=False,
                cfg=cfg,
                init_renderer=False,
                map_location="cpu",  # ZeroGPU: deserialize on CPU; .to(device) handles the move
            )
        self.model.eval().to(device)
        self.fp16, self.batch_size, self.num_workers, self.device = (
            fp16, batch_size, num_workers, device
        )  # fmt: skip
        self._tokens = None
        self.model.mano_head.transformer.register_forward_hook(self._store_tokens)

    def _store_tokens(self, _module, _inputs, output):
        self._tokens = output.squeeze(1)

    @torch.no_grad()
    def __call__(self, frame_dir, hand_bbx_xyxy):
        """frame_dir with %06d.jpg frames, hand_bbx_xyxy (L, 2, 4) [left, right] boxes
        -> (L, 2, 1024) tokens"""
        dataset = _FrameDataset(Path(frame_dir), hand_bbx_xyxy, self.cfg)
        loader = DataLoader(
            dataset,
            batch_size=self.batch_size,
            shuffle=False,
            num_workers=self.num_workers,
            pin_memory=True,
        )
        tokens = torch.zeros((len(dataset), 2, self.TOKEN_DIM), dtype=torch.float32)
        i = 0
        with tqdm(total=len(dataset), desc="HaMeR hand features", unit="frame") as pbar:
            for crops in loader:
                bs = crops.shape[0]
                tokens[i : i + bs] = (
                    self._forward(crops.reshape(-1, 3, 256, 256))
                    .reshape(bs, 2, self.TOKEN_DIM)
                    .cpu()
                )
                i += bs
                pbar.update(bs)
        return tokens

    def _forward(self, crops):
        """(B, 3, 256, 256) normalized crops -> (B, 1024) tokens"""
        with torch.autocast(self.device, dtype=torch.float16, enabled=self.fp16):
            # HaMeR's ViT backbone sees the central 256x192 of the square crop
            feats = self.model.backbone(crops[:, :, :, 32:-32].to(self.device))
            self.model.mano_head(feats)  # the hook stores the tokens
        return self._tokens.float()


class _FrameDataset(torch.utils.data.Dataset):
    """Normalized (2, 3, S, S) [left, right] hand crops per frame."""

    def __init__(self, frame_dir, hand_bbx_xyxy, cfg):
        self.frame_dir, self.hand_bbx_xyxy = frame_dir, hand_bbx_xyxy
        self.img_size = cfg.MODEL.IMAGE_SIZE
        self.mean = 255.0 * np.array(cfg.MODEL.IMAGE_MEAN)
        self.std = 255.0 * np.array(cfg.MODEL.IMAGE_STD)
        self.bbox_shape = cfg.MODEL.BBOX_SHAPE

    def __len__(self):
        return len(self.hand_bbx_xyxy)

    def __getitem__(self, idx):
        path = self.frame_dir / f"{idx:06d}.jpg"
        img = cv2.imread(str(path))
        if img is None:
            raise FileNotFoundError(f"Could not read frame {path}")

        boxes = self.hand_bbx_xyxy[idx].numpy()
        center = (boxes[:, 2:4] + boxes[:, 0:2]) / 2.0
        scale = 2.0 * (boxes[:, 2:4] - boxes[:, 0:2]) / 200.0  # box enlarged 2x, in 200 px

        crops = []
        for i in range(2):  # left, right
            if scale[i, 0] == 0 or scale[i, 1] == 0:  # no hand in this frame
                crops.append(np.zeros((1, 3, self.img_size, self.img_size), dtype=np.float32))
                continue
            bbox_size = _expand_to_aspect_ratio(scale[i] * 200, self.bbox_shape).max()
            patch = _crop_hand(
                img.copy(), center[i, 0], center[i, 1], bbox_size, self.img_size, flip=i == 0
            )
            patch = patch[:, :, ::-1].transpose(2, 0, 1).astype(np.float32)  # BGR HWC -> RGB CHW
            for c in range(min(img.shape[2], 3)):
                patch[c] = (patch[c] - self.mean[c]) / self.std[c]
            crops.append(patch[None])
        return np.concatenate(crops, axis=0)


def _expand_to_aspect_ratio(size_wh, target_aspect_ratio):
    """Grow a (w, h) box size to the target (w, h) aspect ratio."""
    w, h = size_wh
    w_t, h_t = target_aspect_ratio
    if h / w < h_t / w_t:
        return np.array([w, max(w * h_t / w_t, h)])
    return np.array([max(h * w_t / h_t, w), h])


def _crop_hand(img, c_x, c_y, bbox_size, patch_size, flip):
    """Square crop of size bbox_size around (c_x, c_y), resized to patch_size (optionally
    mirrored), with black borders."""
    if flip:
        img = img[:, ::-1, :]
        c_x = img.shape[1] - c_x - 1
    src_center = np.array([c_x, c_y], dtype=np.float64)
    src_down = np.array([0, bbox_size * 0.5], dtype=np.float32)
    src_right = np.array([bbox_size * 0.5, 0], dtype=np.float32)
    dst_center = np.array([patch_size * 0.5, patch_size * 0.5], dtype=np.float32)
    dst_down = np.array([0, patch_size * 0.5], dtype=np.float32)
    dst_right = np.array([patch_size * 0.5, 0], dtype=np.float32)

    src = np.zeros((3, 2), dtype=np.float32)
    src[0, :] = src_center
    src[1, :] = src_center + src_down
    src[2, :] = src_center + src_right
    dst = np.zeros((3, 2), dtype=np.float32)
    dst[0, :] = dst_center
    dst[1, :] = dst_center + dst_down
    dst[2, :] = dst_center + dst_right
    trans = cv2.getAffineTransform(np.float32(src), np.float32(dst))

    return cv2.warpAffine(
        img,
        trans,
        (int(patch_size), int(patch_size)),
        flags=cv2.INTER_LINEAR,
        borderMode=cv2.BORDER_CONSTANT,
        borderValue=0,
    )