File size: 9,158 Bytes
f4b848e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13b4736
 
f4b848e
 
13b4736
 
f4b848e
 
 
 
 
 
 
 
 
 
 
 
 
 
13b4736
 
 
 
f4b848e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# SPDX-License-Identifier: Apache-2.0
"""Host-side pre/post-processing for the MoGe-2 server.

Two jobs, both pure torch/numpy/PIL on the host:

* ``fit_to_canvas``: put ANY client image onto the single canonical canvas the
  decoder trace was captured for (default 1920x1080, the README's validated
  setting: ``num_tokens=1800`` -> 32x57 token grid). ``fit="pad"`` keeps the
  aspect ratio (letterbox, centred, neutral gray) so the geometry stays faithful;
  ``fit="stretch"`` squashes the image onto the canvas.
* ``canvas_to_camera`` + ``to_original``: the tail of the upstream
  ``MoGeModel.infer()`` (moge/model/v2.py) -- recover focal + z-shift from the
  affine point map, build normalized intrinsics, apply the metric scale -- then
  crop the letterbox region away, resize to the client's original resolution,
  re-project points from the resized depth (``force_projection``) and apply the
  validity mask.

Imports of the vendored ``moge`` / ``utils3d`` happen inside the functions:
importing ``tt_moge`` puts them on ``sys.path`` and their import chain pulls in
cv2/scipy, so nothing here runs at module import.
"""
from __future__ import annotations

import math
from typing import Dict, Optional, Tuple

import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image

Region = Tuple[int, int, int, int]  # x0, y0, x1, y1 on the canvas (pixels)

PAD_GRAY = (128, 128, 128)


def token_grid(width: int, height: int, num_tokens: int) -> Tuple[int, int]:
    """(base_h, base_w) MoGe uses for an image of this size -- mirrors MoGeModel.forward."""
    aspect_ratio = width / height
    return round((num_tokens / aspect_ratio) ** 0.5), round((num_tokens * aspect_ratio) ** 0.5)


def fit_to_canvas(im: Image.Image, canvas: Tuple[int, int], fit: str,
                  contiguous: bool = True) -> Tuple[torch.Tensor, Region]:
    """RGB PIL image -> (float32 [1,3,Hc,Wc] in [0,1], region the image occupies).

    ``canvas`` is (width, height). ``contiguous=False`` returns the channels-last view (same values,
    no NCHW copy; the C++ input prep reads any strides).
    """
    cw, ch = canvas
    if fit == "stretch":
        pasted = im.resize((cw, ch), Image.BILINEAR)
        region: Region = (0, 0, cw, ch)
    elif fit == "pad":
        s = min(cw / im.width, ch / im.height)
        rw, rh = max(1, round(im.width * s)), max(1, round(im.height * s))
        x0, y0 = (cw - rw) // 2, (ch - rh) // 2
        pasted = Image.new("RGB", (cw, ch), PAD_GRAY)
        pasted.paste(im.resize((rw, rh), Image.BILINEAR), (x0, y0))
        region = (x0, y0, x0 + rw, y0 + rh)
    else:
        raise ValueError(f"unknown fit mode {fit!r}")
    # one pass: float32(u8) / 255 in float32 (== np.asarray(pasted, np.float32) / 255.0, bit for bit)
    arr = np.true_divide(np.asarray(pasted), np.float32(255.0), dtype=np.float32)
    x = torch.from_numpy(arr).permute(2, 0, 1)[None]
    return (x.contiguous() if contiguous else x), region


def region_mask(canvas: Tuple[int, int], region: Region) -> torch.Tensor:
    """bool [Hc, Wc]: True inside the pasted image, False on the letterbox bars."""
    cw, ch = canvas
    m = torch.zeros(ch, cw, dtype=torch.bool)
    x0, y0, x1, y1 = region
    m[y0:y1, x0:x1] = True
    return m


@torch.inference_mode()
def canvas_to_camera(
    out: Dict[str, torch.Tensor],
    valid: Optional[torch.Tensor],
    fov_x: Optional[float] = None,
) -> Dict[str, torch.Tensor]:
    """``MoGeModel.infer()``'s recovery step on one canvas-sized TtMoGe output.

    ``out`` is the TtMoGe / MoGeModel.forward dict (batch 1): ``points`` [1,H,W,3]
    affine, ``normal`` [1,H,W,3], ``mask`` [1,H,W] sigmoid, ``metric_scale`` [1].
    ``valid`` (bool [H,W]) restricts the focal/shift solve to real (non-letterbox)
    pixels. Returns canvas-resolution tensors, metric-scaled, UNMASKED:
    ``depth`` [H,W], ``points`` [H,W,3] (affine map shifted into camera space --
    the exact upstream output when force_projection=False), ``normal`` [H,W,3],
    ``mask`` bool [H,W], ``intrinsics`` [3,3] normalized (cx=cy=0.5), plus scalars
    ``focal``, ``shift``, ``metric_scale``.
    """
    from moge.utils.geometry_torch import recover_focal_shift
    import utils3d

    points = out["points"][0].float().clone()      # (H, W, 3)
    normal = out["normal"][0].float()
    mask_prob = out["mask"][0].float()
    metric_scale = out["metric_scale"].float().reshape(-1)[0]
    height, width = points.shape[:2]
    aspect_ratio = width / height

    mask_binary = mask_prob > 0.5
    if valid is not None:
        mask_binary &= valid

    p, m = points[None], mask_binary[None]
    if fov_x is None:
        focal, shift = recover_focal_shift(p, m)
    else:
        focal = torch.tensor(
            [aspect_ratio / (1 + aspect_ratio ** 2) ** 0.5 / math.tan(math.radians(fov_x) / 2)],
            dtype=p.dtype,
        )
        _, shift = recover_focal_shift(p, m, focal=focal)
    focal = focal.reshape(-1)[0]
    shift = shift.reshape(-1)[0]

    fx = focal / 2 * (1 + aspect_ratio ** 2) ** 0.5 / aspect_ratio
    fy = focal / 2 * (1 + aspect_ratio ** 2) ** 0.5
    half = torch.tensor(0.5, dtype=p.dtype)
    intrinsics = utils3d.pt.intrinsics_from_focal_center(fx[None], fy[None], half, half)[0]

    points[..., 2] += shift
    mask_binary &= points[..., 2] > 0          # negative depth should never happen; drop it if it does
    depth = points[..., 2].clone()

    points *= metric_scale
    depth *= metric_scale
    return {
        "depth": depth, "points": points, "normal": normal, "mask": mask_binary,
        "intrinsics": intrinsics, "focal": focal, "shift": shift, "metric_scale": metric_scale,
    }


def fov_x_to_canvas(fov_x_deg: float, canvas: Tuple[int, int], region: Region) -> float:
    """A client's horizontal FoV (of ITS image) -> the horizontal FoV of the padded canvas.

    The image occupies ``region`` (width rw) on a canvas of width cw with the same focal
    length in pixels, so tan(fov_c/2) = tan(fov/2) * cw / rw. Identity for ``fit="stretch"``.
    """
    cw = canvas[0]
    rw = region[2] - region[0]
    return math.degrees(2 * math.atan(math.tan(math.radians(fov_x_deg) / 2) * cw / rw))


def crop_intrinsics(intrinsics: torch.Tensor, canvas: Tuple[int, int], region: Region) -> torch.Tensor:
    """Normalized canvas intrinsics -> normalized intrinsics of the cropped region.

    Normalized intrinsics are invariant to a uniform resize, so the result also holds
    for the client's original image (the region has its aspect ratio).
    """
    cw, ch = canvas
    x0, y0, x1, y1 = region
    rw, rh = x1 - x0, y1 - y0
    k = intrinsics.clone()
    k[0, 0] = intrinsics[0, 0] * cw / rw
    k[1, 1] = intrinsics[1, 1] * ch / rh
    k[0, 2] = (intrinsics[0, 2] * cw - x0) / rw
    k[1, 2] = (intrinsics[1, 2] * ch - y0) / rh
    return k


def _resize_map(t: torch.Tensor, size: Tuple[int, int], mode: str) -> torch.Tensor:
    """[H,W] or [H,W,C] -> same rank at ``size`` (height, width)."""
    squeeze = t.dim() == 2
    x = t[None, None] if squeeze else t.permute(2, 0, 1)[None]
    kw = {} if mode == "nearest" else {"align_corners": False}
    y = F.interpolate(x, size, mode=mode, **kw)
    return y[0, 0] if squeeze else y[0].permute(1, 2, 0)


@torch.inference_mode()
def to_original(
    cam: Dict[str, torch.Tensor],
    canvas: Tuple[int, int],
    region: Region,
    size: Tuple[int, int],
    force_projection: bool = True,
    apply_mask: bool = True,
) -> Dict[str, torch.Tensor]:
    """Crop the letterbox away, resize to the original (height, width), finish infer().

    Returns ``depth`` [H,W] f32 (metres), ``points`` [H,W,3] f32 (camera space, metres),
    ``normal`` [H,W,3] f32 (unit), ``mask`` bool [H,W], ``intrinsics`` [3,3] normalized.
    With ``apply_mask`` invalid pixels get depth/points = +inf and normal = 0, exactly
    like the upstream ``infer()``.
    """
    import utils3d

    x0, y0, x1, y1 = region
    sl = (slice(y0, y1), slice(x0, x1))
    depth = _resize_map(cam["depth"][sl], size, "bilinear")
    normal = _resize_map(cam["normal"][sl], size, "bilinear")
    normal = F.normalize(normal, dim=-1)
    mask = _resize_map(cam["mask"][sl].float(), size, "nearest") > 0.5
    intrinsics = crop_intrinsics(cam["intrinsics"], canvas, region)

    if force_projection:
        points = utils3d.pt.depth_map_to_point_map(depth, intrinsics)
    else:
        points = _resize_map(cam["points"][sl], size, "bilinear")

    mask &= torch.isfinite(depth) & (depth > 0)
    if apply_mask:
        inf = torch.tensor(float("inf"), dtype=depth.dtype)
        depth = torch.where(mask, depth, inf)
        points = torch.where(mask[..., None], points, inf)
        normal = torch.where(mask[..., None], normal, torch.zeros_like(normal))

    return {"depth": depth, "points": points, "normal": normal, "mask": mask, "intrinsics": intrinsics}


def fov_deg(intrinsics: torch.Tensor) -> Tuple[float, float]:
    """Horizontal / vertical FoV in degrees from normalized intrinsics."""
    fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
    return math.degrees(2 * math.atan(0.5 / fx)), math.degrees(2 * math.atan(0.5 / fy))