Image-to-3DGS / utils /image_utils.py
dgarch424's picture
Upload 20 files
bcf1530 verified
Raw
History Blame Contribute Delete
10.2 kB
"""
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))