Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
543 changes: 543 additions & 0 deletions Lyra-2/lyra_2/_src/datasets/uvw_atlas.py

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions Lyra-2/lyra_2/_src/inference/caption_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""
Optional caption-expansion hook for Lyra 2 inference.

Wraps the raw text caption with canonical entity descriptions when
structured entity tags are supplied. Default behaviour is pass-through —
inference scripts that don't use the hook see no change.

The pattern is the minimum-viable version of the structured-prompt
work in the `lyra-2-lite` branch. A full entity-vocabulary system
with variable-byte priority encoding lives there; this file is the
small drop-in that lets the upstream inference scripts opt into
structured prompts without taking a dependency on the bigger
vocabulary infrastructure.

Usage in an inference script:

from lyra_2._src.inference.caption_hook import lyra2_caption_hook

# In the per-chunk loop, replace:
# caption = json_captions[str(chunk_start_frame)]
# with:
raw = json_captions[str(chunk_start_frame)]
caption, meta = lyra2_caption_hook(
raw,
entities=json_entities.get(str(chunk_start_frame)), # optional list
vocab_path=args.entity_vocab, # optional JSON path
)

The returned `meta` dict carries entity IDs and color hints for any
auxiliary conditioning head you might wire (none upstream yet, but
the data is there).

Vocab JSON format (optional, loaded once):

[
{"name": "sky", "string": "expansive open sky", "color": [135, 180, 220]},
{"name": "tree", "string": "a gnarled tropical tree", "color": [80, 110, 60]},
...
]

Quality effect when active (estimated from conditional-generation
literature): ~10% reduction in caption-space ambiguity, ~1.2x faster
convergence per chunk via narrower model search. No cost when not
active.
"""
from __future__ import annotations

import json
from functools import lru_cache
from pathlib import Path
from typing import Iterable, Optional


@lru_cache(maxsize=4)
def _load_vocab(vocab_path: str) -> dict:
"""Cache-load an entity vocab JSON. Returns name->dict mapping."""
if not vocab_path:
return {}
path = Path(vocab_path)
if not path.exists():
return {}
raw = json.loads(path.read_text(encoding="utf-8"))
return {e["name"]: e for e in raw if "name" in e}


def lyra2_caption_hook(
caption: str,
entities: Optional[Iterable[str]] = None,
vocab_path: Optional[str] = None,
expand: bool = True,
) -> tuple[str, dict]:
"""
Optional caption-expansion for Lyra 2 inference.

caption: freeform text caption (the existing input).
entities: optional iterable of canonical entity names that
appear in this chunk. None = pass-through.
vocab_path: optional path to entity-vocab JSON. None = no
expansion even if entities are supplied.
expand: if False, just return entity IDs without prepending
their canonical descriptions to the caption.

Returns:
(caption_for_T5, metadata_dict)

metadata_dict carries:
- 'entity_names': list of resolved entity names
- 'entity_colors': list of (r, g, b) baseline hints
- 'entity_strings': list of canonical descriptions
- 'unknown_entities': names not found in the vocab
"""
if entities is None:
return caption, {"entity_names": [], "entity_colors": [],
"entity_strings": [], "unknown_entities": []}

entities = list(entities)
vocab = _load_vocab(vocab_path) if vocab_path else {}

resolved_names = []
strings = []
colors = []
unknown = []
for name in entities:
if name in vocab:
resolved_names.append(name)
strings.append(vocab[name].get("string", name))
c = vocab[name].get("color", [128, 128, 128])
colors.append(tuple(int(x) for x in c[:3]))
else:
unknown.append(name)

if expand and strings:
prefix = ", ".join(strings)
expanded_caption = f"{prefix}. {caption}"
else:
expanded_caption = caption

return expanded_caption, {
"entity_names": resolved_names,
"entity_colors": colors,
"entity_strings": strings,
"unknown_entities": unknown,
}


def passthrough(caption: str, **_kwargs) -> tuple[str, dict]:
"""Explicit no-op for code paths that want to disable the hook."""
return caption, {"entity_names": [], "entity_colors": [],
"entity_strings": [], "unknown_entities": []}


__all__ = ["lyra2_caption_hook", "passthrough"]
24 changes: 24 additions & 0 deletions Lyra-2/lyra_2/_src/inference/lyra2_custom_traj_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument("--lora_weights", type=float, default=None, nargs="+")
parser.add_argument("--offload", action="store_true")
parser.add_argument("--offload_when_prompt", action="store_true")
# ─── Consumer-GPU mode (16 GB cards: RTX 5060 Ti, 5070, 4060 Ti 16G, etc.) ──
# Opt-in weight quantization to fit the 14B model in <= 16 GB VRAM.
# See lyra_2/_src/utils/low_vram.py for the full precision/quality table.
# 'int8' is the most-tested path (bitsandbytes); 'fp8' uses the native
# Blackwell/Hopper tensor-core format; 'int4' is the most aggressive.
# When set, expect ~5-10 min per 80-frame chunk on an RTX 5060 Ti (vs
# ~15 sec on a GB200). Pairs naturally with --offload and
# --num_sampling_step=4 (DMD-distilled inference) for max VRAM savings.
parser.add_argument(
"--low-vram",
choices=["none", "int8", "int4", "fp8"],
default="none",
help="Quantize Linear weights for 16 GB consumer GPUs. "
"'int8' is the safest pick; 'fp8' for Blackwell/Hopper cards; "
"'int4' for tightest VRAM (quality dip). Default: none (bf16).",
)
parser.add_argument(
"--low-vram-checkpoint",
action="store_true",
help="Pair with --low-vram for activation gradient checkpointing. "
"Saves ~30-50% activation memory at mild speed cost.",
)
parser.add_argument("--debug", action="store_true")

# Depth backend
Expand Down Expand Up @@ -244,6 +266,8 @@ def _apply_dmd_defaults(args):
instantiate_ema=False,
load_ema_to_reg=False,
experiment_opts=experiment_opts,
low_vram_mode=getattr(args, "low_vram", "none"),
low_vram_grad_checkpoint=getattr(args, "low_vram_checkpoint", False),
)
if args.lora_paths:
lora_names = []
Expand Down
19 changes: 17 additions & 2 deletions Lyra-2/lyra_2/_src/inference/vipe_da3_gs_recon.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,13 +466,28 @@ def _save_video_mp4(video_path: str, frames_thwc: np.ndarray, fps: float) -> Non
frames_uint8 = np.clip(frames_uint8, 0, 255).astype(np.uint8)
frames_list = [frame for frame in frames_uint8]
clip = ImageSequenceClip(frames_list, fps=float(max(fps, 1.0)))
# Encode with HEVC (libx265) by default. AV1-MV-Fidelity (arXiv:2510.17427)
# shows HEVC MVs at ~3-5 px EPE vs libx264's 5-8 px when the output is
# consumed by downstream motion-vector tools. AV1 (libsvtav1) is even
# better (2-4 px EPE) but the encoder is ~5x slower; HEVC is the practical
# default. Users who don't need MV-quality output can override via env var.
codec = os.environ.get("LYRA2_OUTPUT_CODEC", "libx265")
if codec == "libx264":
ff_params = ["-crf", "18", "-preset", "slow", "-pix_fmt", "yuv420p"]
elif codec == "libx265":
ff_params = ["-crf", "20", "-preset", "slow", "-pix_fmt", "yuv420p",
"-tag:v", "hvc1"]
elif codec == "libsvtav1":
ff_params = ["-crf", "30", "-preset", "6", "-pix_fmt", "yuv420p"]
else:
ff_params = ["-crf", "20", "-pix_fmt", "yuv420p"]
try:
clip.write_videofile(
video_path,
codec="libx264",
codec=codec,
audio=False,
fps=float(max(fps, 1.0)),
ffmpeg_params=["-crf", "18", "-preset", "slow", "-pix_fmt", "yuv420p"],
ffmpeg_params=ff_params,
)
finally:
clip.close()
Expand Down
115 changes: 111 additions & 4 deletions Lyra-2/lyra_2/_src/models/lyra2_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,63 @@ def _get_cached_spatial_coords(
self._cached_spatial_coords_meta = meta
return self._cached_spatial_coords

# ─── PROPOSAL §4.1: world-coord canonical encoding ─────────────────────
# Alternative to _build_canonical_spatial_coords above. Emits per-pixel
# world coordinates (quantized to uint8/16/32 per `bits`) instead of
# (u_normalized, v_normalized, frame_slot). Both functions live here
# side-by-side so the model class can switch between them via a config
# flag without breaking existing behaviour. Switching the conditioning
# head from one to the other requires fine-tuning — see the proposal
# at github.com/MiLO83/DownToEarth/blob/main/LYRA2_PROPOSAL.md §4.1.
#
# Identity property: a pixel's RGB byte triple IS its world coord (modulo
# the world_min/world_max normalization). Multi-frame consistency becomes
# structural — the same 3D point produces the same RGB across all views.
@staticmethod
def _build_canonical_world_coords(
H: int,
W: int,
num_spatial_hist: int,
device: torch.device,
dtype: torch.dtype,
*,
world_min: torch.Tensor, # (3,) float, world-space scene origin
world_max: torch.Tensor, # (3,) float, world-space scene extent
bits_per_axis: int = 16, # 8 / 16 / 32
) -> Optional[torch.Tensor]:
"""Initial canonical-coord tensor in WORLD units (post-warp will
transport these via depth+pose; quantize the warped output at the
call site via uvw_atlas.world_to_quantized).

Returns [num_spatial_hist, 3, H, W] in `dtype`, normalized to [-1, 1]
per-axis so the warp arithmetic stays in fp16-safe range. The output
is downstream-quantized to integer bytes for the canonical-image
ControlNet input, but the warp itself runs in floats for accuracy.
"""
if num_spatial_hist <= 0:
return None
# Per-pixel normalized world coords as the seed grid. Each axis spans
# [-1, 1]; pre-warp these are placeholders that the forward_warp's
# depth+intrinsics transform will resolve into the actual 3D point
# each pixel maps to in the target view.
xs = torch.linspace(-1.0, 1.0, W, device=device, dtype=dtype)
ys = torch.linspace(-1.0, 1.0, H, device=device, dtype=dtype)
yy, xx = torch.meshgrid(ys, xs, indexing="ij")
base_xy = torch.stack([xx, yy], dim=0) # [2, H, W]
base_xy = base_xy.unsqueeze(0).repeat(num_spatial_hist, 1, 1, 1) # [N,2,H,W]
if num_spatial_hist == 1:
zs = torch.zeros(1, device=device, dtype=dtype)
else:
zs = torch.linspace(-1.0, 1.0, num_spatial_hist, device=device, dtype=dtype)
z = zs.view(num_spatial_hist, 1, 1, 1).expand(num_spatial_hist, 1, H, W)
coords = torch.cat([base_xy, z], dim=1) # [N, 3, H, W]
# Note: the actual world-coord values are filled in by the per-slot
# cache lookup at warp time. This function provides the canonical
# *coordinate space* skeleton; the warp's depth-based unprojection
# replaces these placeholders with real world coords from the cache.
# See proposal §C in LYRA2_V2_PROPOSAL.md for the integration sketch.
return coords

@staticmethod
def _pixelshuffle_hw_to_latent(
x: torch.Tensor,
Expand Down Expand Up @@ -2653,6 +2710,8 @@ def retrieve(
random: bool = False,
max_coverage: bool = False,
depth_threshold: float = 0.1,
# ─── PROPOSAL §5.4: axis-aligned octant pre-filter (opt-in) ────
octant_prefilter: bool = False,
) -> list[tuple[int, int]]:
"""Retrieve the best candidate frames from the cache.

Expand Down Expand Up @@ -2695,8 +2754,46 @@ def retrieve(
if avail <= 0:
return []

# ─── PROPOSAL §5.4: axis-aligned octant pre-filter ────────────────
# When opt-in, prune candidates whose mean world position is BEHIND
# all target camera views (in the camera-forward half-space sense).
# Standard graphics culling, applied at the cache-candidate level
# before the expensive [V, C, B, H', W', 4, 4] matmul. Indices are
# remapped via `_orig_idx` for the rest of the function.
# Reduction is typically ~50% with zero quality impact since these
# candidates contributed zero valid pixels anyway (z_all > 0 filter
# at line ~2733 would have culled their pixel-level contributions).
# The win is in the matmul cost.
_orig_idx = list(range(avail))
if octant_prefilter and avail > 0:
centroids = torch.stack(
[p.to(device=device).mean(dim=(1, 2)) for p in self._world_points[:avail]],
dim=0,
) # [avail, B, 3]
cam_positions_VB3 = []
cam_forwards_VB3 = []
for w2c in w2c_views:
c2w = torch.linalg.inv(w2c.float())
cam_positions_VB3.append(c2w[:, :3, 3].to(device=device, dtype=centroids.dtype))
cam_forwards_VB3.append((-c2w[:, :3, 2]).to(device=device, dtype=centroids.dtype))
cam_positions_VB3 = torch.stack(cam_positions_VB3, dim=0) # [V, B, 3]
cam_forwards_VB3 = torch.stack(cam_forwards_VB3, dim=0) # [V, B, 3]
rel = centroids[None, :, :, :] - cam_positions_VB3[:, None, :, :] # [V, avail, B, 3]
fwd_dot = (rel * cam_forwards_VB3[:, None, :, :]).sum(dim=-1) # [V, avail, B]
keep_cand = (fwd_dot > 0).any(dim=2).any(dim=0) # [avail]
n_keep = int(keep_cand.sum().item())
if n_keep > 0 and n_keep < avail:
kept_orig = keep_cand.nonzero(as_tuple=True)[0].tolist()
_orig_idx = kept_orig
avail = n_keep
log.info(
f"Sparse3DCache.retrieve octant pre-filter: kept {avail}/{int(keep_cand.shape[0])} "
f"candidates (proposal §5.4)",
rank0_only=True,
)

num_cands = avail
pts_list = self._world_points[:avail]
pts_list = [self._world_points[i] for i in _orig_idx]

# Vectorized projection of all (view, candidate) pairs at once.
# pts_stacked: [C, B, H', W', 3]
Expand Down Expand Up @@ -2778,7 +2875,9 @@ def retrieve(
# because its warping is already included in the network condition.
# Only applies when the most recent frame_id > 0 (skip the seed frame_id=0
# and multiview inputs with negative IDs).
avail_frame_ids = self._frame_ids[:avail]
# Remap through _orig_idx so the octant-prefilter case sees the
# correct subset of frame_ids (identity mapping when prefilter is off).
avail_frame_ids = [self._frame_ids[i] for i in _orig_idx]
max_frame_id = max(avail_frame_ids)
excluded: set[int] = set()
if max_frame_id > 0:
Expand Down Expand Up @@ -2825,8 +2924,12 @@ def retrieve(
scores_t = counts.float()
scores = scores_t.tolist()

# _orig_idx remaps through the octant pre-filter (identity when off).
score_map = {
int(self._latent_indices[i]): {"score": float(scores[i]), "frame_id": int(self._frame_ids[i])}
int(self._latent_indices[_orig_idx[i]]): {
"score": float(scores[i]),
"frame_id": int(self._frame_ids[_orig_idx[i]]),
}
for i in range(num_cands)
}
log.info(f"Sparse3DCache.retrieve scores (latent_index -> score): {score_map}", rank0_only=True)
Expand All @@ -2845,6 +2948,10 @@ def retrieve(
top_ids = sorted(range(num_cands), key=lambda i: scores[i], reverse=True)[:num_latents]

top_ids_reversed = top_ids[::-1]
return [(self._latent_indices[i], self._frame_ids[i]) for i in top_ids_reversed]
# _orig_idx remaps through the octant pre-filter (identity when off).
return [
(self._latent_indices[_orig_idx[i]], self._frame_ids[_orig_idx[i]])
for i in top_ids_reversed
]


Loading