File size: 9,937 Bytes
728fc83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
utils/image_utils.py
────────────────────
PIL ↔ numpy ↔ torch conversion helpers, plus depth-map visualisation
and PLY export utilities shared across pipeline stages.
"""

from __future__ import annotations

import io
import logging
from pathlib import Path
from typing import Union

import numpy as np
import torch
from PIL import Image

logger = logging.getLogger(__name__)

# ── Type aliases ──────────────────────────────────────────────────────────────
ArrayLike = Union[np.ndarray, torch.Tensor, Image.Image]


# ── Basic conversions ─────────────────────────────────────────────────────────

def to_pil(img: ArrayLike, mode: str = "RGB") -> Image.Image:
    """Convert any array-like to a PIL Image."""
    if isinstance(img, Image.Image):
        return img.convert(mode)
    if isinstance(img, torch.Tensor):
        img = img.detach().cpu().numpy()
    if img.dtype != np.uint8:
        # Assume [0,1] float → [0,255] uint8
        img = np.clip(img, 0.0, 1.0)
        img = (img * 255).astype(np.uint8)
    if img.ndim == 2:
        return Image.fromarray(img, mode="L").convert(mode)
    if img.ndim == 3 and img.shape[0] in (1, 3, 4):
        # CHW → HWC
        img = np.transpose(img, (1, 2, 0))
    return Image.fromarray(img).convert(mode)


def to_numpy(img: ArrayLike) -> np.ndarray:
    """Return HWC uint8 numpy array."""
    if isinstance(img, np.ndarray):
        return img
    if isinstance(img, Image.Image):
        return np.array(img)
    if isinstance(img, torch.Tensor):
        t = img.detach().cpu()
        if t.ndim == 4:
            t = t.squeeze(0)
        if t.shape[0] in (1, 3, 4):
            t = t.permute(1, 2, 0)
        arr = t.numpy()
        if arr.dtype != np.uint8:
            arr = np.clip(arr, 0.0, 1.0)
            arr = (arr * 255).astype(np.uint8)
        return arr
    raise TypeError(f"Unsupported type: {type(img)}")


def resize_image(img: Image.Image, max_side: int = 1024) -> Image.Image:
    """Resize image so longest side ≤ max_side, preserving aspect ratio."""
    w, h = img.size
    if max(w, h) <= max_side:
        return img
    scale = max_side / max(w, h)
    new_w, new_h = int(w * scale), int(h * scale)
    # Make dimensions divisible by 8 (required by most diffusion models)
    new_w = (new_w // 8) * 8
    new_h = (new_h // 8) * 8
    return img.resize((new_w, new_h), Image.LANCZOS)


# ── Depth map utilities ───────────────────────────────────────────────────────

def normalise_depth(depth: np.ndarray) -> np.ndarray:
    """Normalise a raw depth array to [0, 1] float32."""
    d = depth.astype(np.float32)
    dmin, dmax = d.min(), d.max()
    if dmax - dmin < 1e-8:
        return np.zeros_like(d)
    return (d - dmin) / (dmax - dmin)


def depth_to_colormap(depth: np.ndarray, colormap: str = "inferno") -> Image.Image:
    """Convert a float depth array to a false-colour PIL image for display."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.cm as cm

    norm = normalise_depth(depth)
    # get_cmap() was removed in Matplotlib 3.9; use matplotlib.colormaps instead
    try:
        cmap = matplotlib.colormaps[colormap]
    except (AttributeError, KeyError):
        cmap = cm.get_cmap(colormap)   # fallback for Matplotlib < 3.7
    coloured = (cmap(norm)[:, :, :3] * 255).astype(np.uint8)
    return Image.fromarray(coloured)


def depth_to_uint16(depth: np.ndarray) -> Image.Image:
    """
    Return a normalised depth map as an 8-bit greyscale PIL image.

    Gradio's gr.Image (type='pil') only renders mode 'L' or 'RGB' correctly —
    mode 'I' (32-bit int) displays as blank white, and mode 'I;16' crashes
    fromarray().  For the Gradio display panel we only need visual fidelity,
    so 8-bit 'L' is sufficient and always renders correctly.

    For a true lossless 16-bit PNG download, call save_depth_uint16() instead.
    """
    norm = normalise_depth(depth)
    uint8 = (norm * 255).astype(np.uint8)
    return Image.fromarray(uint8, mode="L")


# ── PLY / point cloud I/O ────────────────────────────────────────────────────

def rgbd_to_pointcloud(
    rgb: np.ndarray,
    depth: np.ndarray,
    fx: float = 500.0,
    fy: float = 500.0,
    cx: float | None = None,
    cy: float | None = None,
    depth_scale: float = 1.0,
    max_depth: float = 10.0,
    fg_mask: np.ndarray | None = None,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Back-project an RGBD pair into a coloured point cloud.

    Parameters
    ----------
    rgb          : HxWx3 uint8
    depth        : HxW float32  (normalised [0,1])
    fx, fy       : focal lengths in pixels
    cx, cy       : principal point  (defaults to image centre)
    depth_scale  : multiply normalised depth by this to get pseudo-metres
    max_depth    : clip depth values beyond this distance
    fg_mask      : optional HxW float32 in [0,1] (or bool) matte from the
                   background-removal stage. When supplied, this is the
                   foreground/background split (thresholded at 0.5) instead
                   of the near-black brightness heuristic — the matte from a
                   real segmentation model is far more reliable than
                   "is this pixel dark".

    Returns
    -------
    points : Nx3 float32   (X,Y,Z in camera space)
    colors : Nx3 uint8     (R,G,B)
    """
    h, w = depth.shape[:2]
    cx = cx if cx is not None else w / 2.0
    cy = cy if cy is not None else h / 2.0

    d = depth.astype(np.float32)

    # ── Outlier removal before back-projection ────────────────────────────────
    # Monocular depth maps are noisy at sky/background regions. Clip to the
    # 2nd-95th percentile so extreme depth values don't produce streaking fans.
    # Sky pixels in MiDaS typically map to the highest normalised depth values.
    d_lo, d_hi = np.percentile(d, 2), np.percentile(d, 95)
    d = np.clip(d, d_lo, d_hi)
    d_range = d_hi - d_lo
    if d_range > 1e-6:
        d = (d - d_lo) / d_range   # re-normalise to [0,1] after clipping

    d = d * depth_scale

    # Build pixel grids
    u = np.arange(w, dtype=np.float32)
    v = np.arange(h, dtype=np.float32)
    uu, vv = np.meshgrid(u, v)

    # Back-project
    x = (uu - cx) * d / fx
    y = (vv - cy) * d / fy
    z = d

    # Foreground/background split. Prefer the segmentation matte from the
    # background-removal stage (accurate, subject-aware); fall back to the
    # brightness heuristic only when no matte is available (e.g. background
    # removal was disabled for this run).
    if fg_mask is not None:
        fg = fg_mask
        if fg.shape[:2] != (h, w):
            fg_pil = Image.fromarray(fg.astype(np.float32), mode="F").resize((w, h), Image.BILINEAR)
            fg = np.array(fg_pil, dtype=np.float32)
        keep_mask = fg > 0.5
        if not keep_mask.any():
            # The matte found no foreground at all (blank/ambiguous image, or a
            # segmentation miss). Silently returning an empty point cloud would
            # be a worse outcome than just not masking — fall back to keeping
            # everything so the reconstruction stage still has something to work with.
            logger.warning("Foreground matte is empty (no pixels above threshold) — skipping matte-based masking for this frame.")
            keep_mask = np.ones((h, w), dtype=bool)
    else:
        brightness = rgb.mean(axis=2)
        keep_mask = brightness >= 8  # reject near-black — typically featureless sky/background

    mask = (z > 0.01) & (z < max_depth) & keep_mask
    points = np.stack([x[mask], y[mask], z[mask]], axis=-1)
    colors = rgb[mask]

    return points.astype(np.float32), colors.astype(np.uint8)


def save_ply(path: Union[str, Path], points: np.ndarray, colors: np.ndarray) -> None:
    """Write a coloured point cloud to a binary PLY file."""
    from plyfile import PlyData, PlyElement

    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    n = len(points)
    vertex = np.zeros(
        n,
        dtype=[
            ("x", "f4"), ("y", "f4"), ("z", "f4"),
            ("red", "u1"), ("green", "u1"), ("blue", "u1"),
        ],
    )
    vertex["x"] = points[:, 0]
    vertex["y"] = points[:, 1]
    vertex["z"] = points[:, 2]
    vertex["red"]   = colors[:, 0]
    vertex["green"] = colors[:, 1]
    vertex["blue"]  = colors[:, 2]

    el = PlyElement.describe(vertex, "vertex")
    PlyData([el], byte_order="<").write(str(path))
    logger.info("Saved PLY  →  %s  (%d points)", path, n)


def load_ply(path: Union[str, Path]) -> tuple[np.ndarray, np.ndarray]:
    """Load a coloured PLY and return (points Nx3, colors Nx3)."""
    from plyfile import PlyData

    plydata = PlyData.read(str(path))
    v = plydata["vertex"]
    points = np.stack([v["x"], v["y"], v["z"]], axis=-1).astype(np.float32)
    colors = np.stack([v["red"], v["green"], v["blue"]], axis=-1).astype(np.uint8)
    return points, colors


# ── File helpers ──────────────────────────────────────────────────────────────

def pil_to_bytes(img: Image.Image, fmt: str = "PNG") -> bytes:
    buf = io.BytesIO()
    img.save(buf, format=fmt)
    return buf.getvalue()


def bytes_to_pil(data: bytes) -> Image.Image:
    return Image.open(io.BytesIO(data))