File size: 13,330 Bytes
5e5d775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
"""
Multi-scale (N-channel) power-transform depth encoding, designed -- like
`multiscale_disparity_torch.py` -- to reuse pretrained RGB VAEs without wasting
channel capacity.  This is the power-transform counterpart of the multi-scale
disparity codec.

With three channels and the recommended preset (lam = -3, c = (2, 6, 18)):

    y_R = 1 - 2 / (1 + d / (|lam| * c_1))^2     c_1 = 2    (near)
    y_G = 1 - 2 / (1 + d / (|lam| * c_2))^2     c_2 = 6    (mid)
    y_B = 1 - 2 / (1 + d / (|lam| * c_3))^2     c_3 = 18   (far)

Each channel is an independent power-transform bijection at a different
scale.  Decoding inverts each channel and blends the three depth estimates,

    w_i = (1 - y_i^2)^2,        d_hat = sum_i w_i * d_i / sum_i w_i.

Why (1 - y_i^2)^2?
------------------
For the disparity codec, this weight happens to be exactly proportional to
d^2 / Var(d_hat), which minimises AbsRel.  For the power transform that
identity is no longer exact -- the AbsRel-optimal weight is

    w_i^opt  proportional to  (1 - y_i)^2 * (sqrt(2) - sqrt(1 - y_i))^2.

In our 5-dataset VAE-noise simulation (sigma_y in [0.01, 0.05]) the two weights
were within 0.5%, so we keep the simpler (1 - y^2)^2 as the default and expose
the optimal form as an opt-in (`weight = "opt"`).

Why c = (2, 6, 18) ?
--------------------
Power transform's tail decays as d^-3 (vs d^-2 for disparity), so each channel
covers a *narrower* band of depth.  Empirically, ratio r = 3 between adjacent
c's outperforms r = 4 (which we use for disparity) by ~5% AbsRel; r = 2 over-
laps too much, r = 4 leaves precision dips between channels.

In our 5-dataset experiment (NYU, ETH3D, DIODE, Sintel, KITTI), this preset
gives ~10% lower AbsRel than 3-channel disparity (2, 8, 32) under the same
VAE noise level -- at the cost of a slightly less-clean optimal weight.
"""

from __future__ import annotations

from typing import Literal, Optional, Sequence, Tuple, Union

import torch
from torch import Tensor

from .power_transform import depth_to_signed_unit, signed_unit_to_depth


# ---------------------------------------------------------------------------
# Default preset.  Picked from VAE-noise sweeps over NYU + ETH3D + DIODE +
# Sintel + KITTI;  see the docstring for the rationale.
# ---------------------------------------------------------------------------

DEFAULT_LAM: float = -3.0
DEFAULT_C_RGB: Tuple[float, float, float] = (2.0, 6.0, 18.0)

WeightKind = Literal["iyy2", "opt"]


# ---------------------------------------------------------------------------
# Functional API
# ---------------------------------------------------------------------------

def multiscale_power_encode(
    d: Tensor,                                      # shape (..., 1, H, W) or (..., H, W)
    c_values: Sequence[float] = DEFAULT_C_RGB,
    lam: float = DEFAULT_LAM,
    mask: Optional[Tensor] = None,
    invalid_fill: float = 0.0,
    return_mask: bool = False,
    channel_dim: int = -3,
) -> Union[Tensor, Tuple[Tensor, Tensor]]:
    """Encode depth to an N-channel power-transform tensor.

    Args:
        d            : depth tensor with a singleton channel dim (or no channel
                       dim);  any leading batch dims are fine.
        c_values     : per-channel scale parameters.  len == output channels.
        lam          : shared shape parameter for all channels;  must be < -1.
        mask         : optional bool mask, broadcastable to `d`.
        invalid_fill : value at invalid pixels (in [-1, 1) space).
        return_mask  : also return the effective valid mask.
        channel_dim  : where to stack the N channels (default -3, so a (..., 1,
                       H, W) input becomes (..., N, H, W)).

    Returns:
        y : same as `d` but with N channels on `channel_dim`, in [-1, 1)^N.
    """
    if lam >= -1.0:
        raise ValueError(f"lam must be < -1, got {lam}")
    if any(c <= 0 for c in c_values):
        raise ValueError(f"all c must be > 0, got {tuple(c_values)}")

    # Collapse a singleton channel dim on d, if present, so per-channel
    # encoders see a clean (..., H, W) tensor.
    if d.dim() >= -channel_dim and d.shape[channel_dim] == 1:
        d_bare = d.squeeze(channel_dim)
        if mask is not None and mask.shape == d.shape:
            mask_bare: Optional[Tensor] = mask.squeeze(channel_dim)
        else:
            mask_bare = mask
    else:
        d_bare = d
        mask_bare = mask

    channels = []
    valid_per_ch = []
    for c in c_values:
        y_i, v_i = depth_to_signed_unit(
            d_bare, lam=lam, c=float(c),
            mask=mask_bare, invalid_fill=invalid_fill, return_mask=True,
        )
        channels.append(y_i)
        valid_per_ch.append(v_i)
    y = torch.stack(channels, dim=channel_dim)
    valid = torch.stack(valid_per_ch, dim=channel_dim)

    if return_mask:
        return y, valid
    return y


def multiscale_power_decode(
    y: Tensor,                                      # shape (..., N, H, W)
    c_values: Sequence[float] = DEFAULT_C_RGB,
    lam: float = DEFAULT_LAM,
    inf_cap: Optional[float] = 1e4,
    eps_boundary: float = 1e-4,
    weight: WeightKind = "iyy2",
    channel_dim: int = -3,
) -> Tensor:
    """Decode an N-channel power-transform tensor back to single-channel depth.

    Each channel is inverted independently using `signed_unit_to_depth`
    and the final estimate is the confidence-weighted average,

        d_hat = sum_i w_i * d_i / sum_i w_i

    Args:
        y            : encoded tensor, shape (..., N, H, W).
        c_values     : per-channel scale parameters.
        lam          : shared shape parameter, must match the encoder.
        inf_cap      : clamp on the final depth (None disables).
        eps_boundary : keep |y| <= 1 - eps so the inverse stays finite.
        weight       : "iyy2" -> w = (1 - y^2)^2  (cheap, near-optimal)
                       "opt"  -> AbsRel-optimal weight derived for lam = -3.
        channel_dim  : channel axis (default -3).
    """
    if y.shape[channel_dim] != len(c_values):
        raise ValueError(
            f"y has {y.shape[channel_dim]} channels but "
            f"c_values has {len(c_values)} entries"
        )

    y_c = y.clamp(min=-1.0 + eps_boundary, max=1.0 - eps_boundary)

    # Per-channel depth estimates, one slice at a time so each call to
    # `signed_unit_to_depth` sees the right `c` (lam is shared).
    chans = []
    for i, c in enumerate(c_values):
        y_i = y_c.select(channel_dim, i)
        d_i = signed_unit_to_depth(y_i, lam=lam, c=float(c), inf_cap=None)
        chans.append(d_i)
    d_per_ch = torch.stack(chans, dim=channel_dim)               # (..., N, H, W)

    # --- confidence weights --------------------------------------------------
    if weight == "iyy2":
        w = (1.0 - y_c.pow(2)).pow(2)
    elif weight == "opt":
        # w_i^opt  proportional to  (1 - y)^2 * (sqrt(2) - sqrt(1 - y))^2
        # Derived from  w  proportional to  d^2 / Var(d_hat)  with lam = -3.
        if lam != -3.0:
            raise NotImplementedError(
                "weight='opt' is currently only derived for lam = -3"
            )
        one_my = (1.0 - y_c).clamp(min=eps_boundary)
        w = one_my.pow(2) * (2.0 ** 0.5 - one_my.sqrt()).pow(2)
    else:
        raise ValueError(f"unknown weight kind {weight!r}")

    w = w.clamp(min=1e-12)
    d_hat = (w * d_per_ch).sum(dim=channel_dim) / w.sum(dim=channel_dim)

    if inf_cap is not None:
        d_hat = d_hat.clamp(max=inf_cap)
    return d_hat


# ---------------------------------------------------------------------------
# Container class (parallels DepthMultiScaleDisparityTransform)
# ---------------------------------------------------------------------------

class DepthMultiScalePowerTransform:
    """N-channel power-transform codec.  Plain container, not an nn.Module.

    Example
    -------
    >>> codec = DepthMultiScalePowerTransform()             # lam=-3, c=(2,6,18)
    >>> y     = codec.encode(depth, mask=mask)              # (..., 3, H, W)
    >>> d_hat = codec.decode(y_pred)                        # (..., H, W)
    """

    def __init__(
        self,
        c: Sequence[float] = DEFAULT_C_RGB,
        lam: float = DEFAULT_LAM,
        inf_cap: Optional[float] = 1e4,
        invalid_fill: float = 0.0,
        weight: WeightKind = "iyy2",
        channel_dim: int = -3,
    ):
        if lam >= -1.0:
            raise ValueError(f"lam must be < -1, got {lam}")
        self.c_values = tuple(float(_c) for _c in c)
        if any(_c <= 0 for _c in self.c_values):
            raise ValueError(f"all c must be > 0, got {self.c_values}")

        self.lam = float(lam)
        self.inf_cap = inf_cap
        self.invalid_fill = float(invalid_fill)
        self.weight: WeightKind = weight
        self.channel_dim = channel_dim

    @property
    def num_channels(self) -> int:
        return len(self.c_values)

    def encode(self, d, mask=None, return_mask=False):
        return multiscale_power_encode(
            d, c_values=self.c_values, lam=self.lam, mask=mask,
            invalid_fill=self.invalid_fill, return_mask=return_mask,
            channel_dim=self.channel_dim,
        )

    def decode(self, y):
        return multiscale_power_decode(
            y, c_values=self.c_values, lam=self.lam,
            inf_cap=self.inf_cap, weight=self.weight,
            channel_dim=self.channel_dim,
        )

    def __call__(self, d, valid_mask=None, return_mask=False):
        return self.encode(d, mask=valid_mask, return_mask=return_mask)

    def __repr__(self):
        return (
            f"DepthMultiScalePowerTransform("
            f"lam={self.lam:.4f}, c={self.c_values}, "
            f"weight={self.weight!r}, inf_cap={self.inf_cap}, "
            f"invalid_fill={self.invalid_fill})"
        )


# ---------------------------------------------------------------------------
# Self-test: round-trip + VAE-noise comparison vs single-channel and disparity
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import math

    torch.manual_seed(0)

    codec = DepthMultiScalePowerTransform()
    print(codec)

    # Round-trip sanity (no noise) -- weighted blend converges to true depth
    # because each channel's inverse is exact.
    d = torch.rand(2, 1, 64, 64, dtype=torch.float64) * 200.0
    y = codec(d)
    d_hat = codec.decode(y)
    print(f"round-trip max err  : {(d.squeeze(1) - d_hat).abs().max():.2e} m")
    print(f"y shape             : {tuple(y.shape)}")
    print(f"y range per channel : "
          f"R [{y[:, 0].min():+.3f}, {y[:, 0].max():+.3f}]  "
          f"G [{y[:, 1].min():+.3f}, {y[:, 1].max():+.3f}]  "
          f"B [{y[:, 2].min():+.3f}, {y[:, 2].max():+.3f}]")

    # ---- VAE-noise comparison vs single-channel power & 3-ch disparity ------

    def lognormal(mean_m, sigma, n=200_000, cap=None):
        x = (torch.randn(n) * sigma + math.log(mean_m)).exp()
        return x if cap is None else x.clamp(0.1, cap)

    datasets = {
        "NYU":    lognormal(2.5,  0.5, cap=10.0),
        "ETH3D":  lognormal(5.0,  0.8, cap=70.0),
        "DIODE":  lognormal(8.0,  1.2, cap=350.0),
        "Sintel": lognormal(10.0, 0.6, cap=100.0),
        "KITTI":  lognormal(15.0, 0.7, cap=80.0),
    }

    # 3-ch disparity (c = 2, 8, 32) for an apples-to-apples baseline.
    from multiscale_disparity_torch import DepthMultiScaleDisparityTransform
    disp_codec  = DepthMultiScaleDisparityTransform()                 # (2, 8, 32)
    pwr_codec   = DepthMultiScalePowerTransform()                     # (2, 6, 18)
    pwr_codec_o = DepthMultiScalePowerTransform(weight="opt")         # AbsRel-optimal

    print("\nVAE-noise AbsRel (sigma_y = 0.01):")
    print(f"{'dataset':<8}  {'3-ch disp':>10}  {'3-ch pwr (1-y2)2':>18}  "
          f"{'3-ch pwr opt':>14}  {'pwr vs disp':>12}")
    print("-" * 72)
    sigma = 0.01
    for ds_name, d_arr in datasets.items():
        d_arr = d_arr.reshape(1, 1, -1, 1)

        y_d = disp_codec.encode(d_arr) + torch.randn(
            1, 3, d_arr.shape[-2], 1) * sigma
        ar_d = ((disp_codec.decode(y_d) - d_arr.squeeze(1)).abs()
                / d_arr.squeeze(1)).mean().item()

        y_p = pwr_codec.encode(d_arr) + torch.randn(
            1, 3, d_arr.shape[-2], 1) * sigma
        ar_p = ((pwr_codec.decode(y_p) - d_arr.squeeze(1)).abs()
                / d_arr.squeeze(1)).mean().item()

        y_po = pwr_codec_o.encode(d_arr) + torch.randn(
            1, 3, d_arr.shape[-2], 1) * sigma
        ar_po = ((pwr_codec_o.decode(y_po) - d_arr.squeeze(1)).abs()
                 / d_arr.squeeze(1)).mean().item()

        imp = (ar_d - ar_p) / ar_d * 100.0
        print(f"{ds_name:<8}  {ar_d:>10.4f}  {ar_p:>18.4f}  {ar_po:>14.4f}  "
              f"{imp:>11.1f}%")

    # ---- Per-channel inspection at one specific depth ------------------------
    d_pt = torch.tensor([[[1.0, 6.0, 30.0, 100.0]]])               # (1, 1, 4)
    y_pt = pwr_codec.encode(d_pt.unsqueeze(-1))                    # (1, 3, 4, 1)
    print("\nPer-channel y at d = [1, 6, 30, 100] m  (c = (2, 6, 18)):")
    for i, c in enumerate(pwr_codec.c_values):
        row = y_pt[0, i, :, 0].tolist()
        print(f"   ch{i} (c={c:>4.1f})  y = "
              + "  ".join(f"{v:+.3f}" for v in row))