chore: deploy private lightloom build
Browse files- app.py +130 -0
- requirements.txt +4 -0
- src/lightloom/core/config.py +11 -0
- src/lightloom/export/ldi.py +134 -0
- src/lightloom/vision/world_seer.py +162 -0
app.py
CHANGED
|
@@ -1470,6 +1470,136 @@ def scroll_live_api(session: str = "", text: str = "", audio_b64: str = "", lang
|
|
| 1470 |
yield {"stage": stage, "error": str(exc)[:200], "session": sess}
|
| 1471 |
|
| 1472 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1473 |
@spaces.GPU(duration=120)
|
| 1474 |
def _introspect_klein_on_gpu() -> dict[str, Any]:
|
| 1475 |
"""Report the klein pipeline's call signature + LoRA/embeds capabilities, so we can
|
|
|
|
| 1470 |
yield {"stage": stage, "error": str(exc)[:200], "session": sess}
|
| 1471 |
|
| 1472 |
|
| 1473 |
+
@spaces.GPU(duration=170)
|
| 1474 |
+
def _postprocess_world(session: str, lang: str = "en"):
|
| 1475 |
+
"""Director's Cut POST-PROCESS (Tier-1). 100% SEPARATE from the live painter: never calls
|
| 1476 |
+
_scroll_live_phrase, only READS the finished section-*.webp off disk, fail-isolated. Stitches the panorama,
|
| 1477 |
+
lets the MiniCPM-V-4.6 "Art Director" name it from the PIXELS (env-gated, try/except center-fallback to the
|
| 1478 |
+
live text title), then renders a gentle clamped depth-parallax pan-through to world.mp4 (served by the
|
| 1479 |
+
existing /frames-scroll route). torchvision is required by the seer's processor (in requirements.txt)."""
|
| 1480 |
+
import math
|
| 1481 |
+
|
| 1482 |
+
import numpy as np
|
| 1483 |
+
import torch
|
| 1484 |
+
from PIL import Image
|
| 1485 |
+
|
| 1486 |
+
out_dir = _SCROLL_ROOT / session
|
| 1487 |
+
strips = (
|
| 1488 |
+
sorted(out_dir.glob("section-*.webp"), key=lambda p: int(re.match(r"section-(\d+)", p.stem).group(1)))
|
| 1489 |
+
if out_dir.is_dir()
|
| 1490 |
+
else []
|
| 1491 |
+
)
|
| 1492 |
+
if not strips:
|
| 1493 |
+
yield {"stage": "postprocess_error", "error": "no_strips"}
|
| 1494 |
+
return
|
| 1495 |
+
# 1) STITCH the finished strips into ONE panorama
|
| 1496 |
+
imgs = [Image.open(p).convert("RGB") for p in strips]
|
| 1497 |
+
h0 = max(i.height for i in imgs)
|
| 1498 |
+
w0 = sum(i.width for i in imgs)
|
| 1499 |
+
pano = Image.new("RGB", (w0, h0))
|
| 1500 |
+
x = 0
|
| 1501 |
+
for im in imgs:
|
| 1502 |
+
pano.paste(im, (x, 0))
|
| 1503 |
+
x += im.width
|
| 1504 |
+
pano.save(out_dir / "panorama.webp", "WEBP", quality=92)
|
| 1505 |
+
yield {"stage": "stitched", "w": w0, "h": h0}
|
| 1506 |
+
|
| 1507 |
+
# 2) ART DIRECTOR (MiniCPM-V-4.6) — env-gated, try/except CENTER fallback so it can NEVER break the MP4
|
| 1508 |
+
state = _live_state(out_dir)
|
| 1509 |
+
seer = {
|
| 1510 |
+
"title": (state.get("title") or "").strip(),
|
| 1511 |
+
"caption": "",
|
| 1512 |
+
"field_notes": [],
|
| 1513 |
+
"focal_points": [{"x": 0.5, "y": 0.5, "label": ""}],
|
| 1514 |
+
}
|
| 1515 |
+
if os.getenv("LIGHTLOOM_WORLD_SEER", "1") == "1":
|
| 1516 |
+
try:
|
| 1517 |
+
from lightloom.paint.prompt import scrub_unsafe
|
| 1518 |
+
from lightloom.vision.world_seer import load_world_seer, see_world
|
| 1519 |
+
|
| 1520 |
+
proc, vmodel = load_world_seer()
|
| 1521 |
+
see_in = pano.copy()
|
| 1522 |
+
see_in.thumbnail((1536, 1536)) # bound the VLM token count
|
| 1523 |
+
seer = see_world(proc, vmodel, see_in, seed=len(strips))
|
| 1524 |
+
seer["title"] = scrub_unsafe(seer.get("title") or "")[0]
|
| 1525 |
+
seer["caption"] = scrub_unsafe(seer.get("caption") or "")[0]
|
| 1526 |
+
except Exception as exc: # noqa: BLE001 - naming is optional; never block the keepsake MP4
|
| 1527 |
+
yield {"stage": "seer_skipped", "error": str(exc)[:160]}
|
| 1528 |
+
yield {
|
| 1529 |
+
"stage": "titled",
|
| 1530 |
+
"title": seer["title"],
|
| 1531 |
+
"caption": seer["caption"],
|
| 1532 |
+
"field_notes": seer["field_notes"],
|
| 1533 |
+
}
|
| 1534 |
+
|
| 1535 |
+
# 3) RAW float32 depth on the (downscaled) panorama -> one near/far scale (NOT the live 8-bit PNG)
|
| 1536 |
+
from lightloom.depth.depth import estimate_depth, load_depth_pipeline
|
| 1537 |
+
from lightloom.export.ldi import encode_frames_ffmpeg, normalize_disparity, warp_layer
|
| 1538 |
+
|
| 1539 |
+
depth_pipe = load_depth_pipeline()
|
| 1540 |
+
render_h = 540
|
| 1541 |
+
render_w = max(2, int(round(w0 * render_h / h0)))
|
| 1542 |
+
render_w -= render_w % 2
|
| 1543 |
+
render_pano = pano.resize((render_w, render_h))
|
| 1544 |
+
raw_d = estimate_depth(depth_pipe, render_pano, normalize=False)
|
| 1545 |
+
nd = normalize_disparity(np.asarray(raw_d, dtype=np.float32))
|
| 1546 |
+
|
| 1547 |
+
# 4) RENDER: a 16:9 window PANS across the wide panorama (ease-in-out) with a gentle depth-parallax
|
| 1548 |
+
# breathing -> "fly through your world". Tier-1: single plane, border-fill on the tiny gaps, no FLUX heal.
|
| 1549 |
+
img_full = torch.from_numpy(np.asarray(render_pano, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0).to("cuda")
|
| 1550 |
+
nd_full = torch.from_numpy(nd[None, None].astype(np.float32)).to("cuda")
|
| 1551 |
+
win_w = min(render_w, int(round(render_h * 16 / 9)))
|
| 1552 |
+
win_w -= win_w % 2
|
| 1553 |
+
travel = max(0, render_w - win_w)
|
| 1554 |
+
n_frames = 144
|
| 1555 |
+
frames_dir = out_dir / "frames"
|
| 1556 |
+
frames_dir.mkdir(parents=True, exist_ok=True)
|
| 1557 |
+
try:
|
| 1558 |
+
for k in range(n_frames):
|
| 1559 |
+
t = k / (n_frames - 1) if n_frames > 1 else 0.0
|
| 1560 |
+
ease = 0.5 - 0.5 * math.cos(math.pi * t) # ease the pan
|
| 1561 |
+
x0 = int(round(ease * travel))
|
| 1562 |
+
win_img = img_full[:, :, :, x0 : x0 + win_w]
|
| 1563 |
+
win_nd = nd_full[:, :, :, x0 : x0 + win_w]
|
| 1564 |
+
dolly = 0.05 * (0.5 - 0.5 * math.cos(2 * math.pi * t)) # subtle parallax breathing
|
| 1565 |
+
with torch.inference_mode():
|
| 1566 |
+
warped, _gap = warp_layer(win_img, win_nd, 0.0, 0.0, dolly, padding_mode="border")
|
| 1567 |
+
frame = warped[0].clamp(0, 1).mul(255).byte().cpu().numpy().transpose(1, 2, 0)
|
| 1568 |
+
Image.fromarray(frame).save(frames_dir / f"frame-{k:04d}.webp", "WEBP", quality=88)
|
| 1569 |
+
if k % 36 == 0:
|
| 1570 |
+
yield {"stage": "rendering", "frame": k, "total": n_frames}
|
| 1571 |
+
yield {"stage": "encoding"}
|
| 1572 |
+
encode_frames_ffmpeg(frames_dir, out_dir / "world.mp4", fps=24, height=1080)
|
| 1573 |
+
finally:
|
| 1574 |
+
for f in frames_dir.glob("frame-*.webp"):
|
| 1575 |
+
try:
|
| 1576 |
+
f.unlink()
|
| 1577 |
+
except OSError:
|
| 1578 |
+
pass
|
| 1579 |
+
yield {
|
| 1580 |
+
"stage": "ready",
|
| 1581 |
+
"video": f"/frames-scroll/{session}/world.mp4",
|
| 1582 |
+
"title": seer["title"],
|
| 1583 |
+
"caption": seer["caption"],
|
| 1584 |
+
}
|
| 1585 |
+
|
| 1586 |
+
|
| 1587 |
+
@app.api(name="postprocess", concurrency_id="gpu", concurrency_limit=1, stream_every=0.05)
|
| 1588 |
+
def postprocess_api(session: str = "", lang: str = "en") -> dict:
|
| 1589 |
+
sess = (session or "").strip()
|
| 1590 |
+
if not re.fullmatch(r"[a-f0-9]{6,32}", sess):
|
| 1591 |
+
yield {"stage": "postprocess_error", "error": "bad_session"}
|
| 1592 |
+
return
|
| 1593 |
+
try:
|
| 1594 |
+
for event in _postprocess_world(sess, lang or "en"):
|
| 1595 |
+
event.setdefault("session", sess)
|
| 1596 |
+
yield event
|
| 1597 |
+
except Exception as exc: # noqa: BLE001 - quota guard; CANNOT affect the live painter (separate endpoint)
|
| 1598 |
+
message = str(exc).lower()
|
| 1599 |
+
stage = "quota_exceeded" if any(t in message for t in ("gpu", "quota", "zerogpu", "exceeded")) else "postprocess_error"
|
| 1600 |
+
yield {"stage": stage, "error": str(exc)[:200], "session": sess}
|
| 1601 |
+
|
| 1602 |
+
|
| 1603 |
@spaces.GPU(duration=120)
|
| 1604 |
def _introspect_klein_on_gpu() -> dict[str, Any]:
|
| 1605 |
"""Report the klein pipeline's call signature + LoRA/embeds capabilities, so we can
|
requirements.txt
CHANGED
|
@@ -7,6 +7,10 @@ pydantic==2.12.5
|
|
| 7 |
numpy==2.2.6
|
| 8 |
Pillow==12.2.0
|
| 9 |
torch==2.11.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
transformers==5.11.0
|
| 11 |
diffusers @ git+https://github.com/huggingface/diffusers.git@784fa62652fb2719d415830f918fc32a49ecc7a1
|
| 12 |
peft>=0.14
|
|
|
|
| 7 |
numpy==2.2.6
|
| 8 |
Pillow==12.2.0
|
| 9 |
torch==2.11.0
|
| 10 |
+
# torchvision is REQUIRED by the MiniCPM-V-4.6 "Art Director" processor (the AutoProcessor pulls a
|
| 11 |
+
# MiniCPMV4_6VideoProcessor that requires_backends torchvision) — verified by the F0 smoke-load. 0.26.0
|
| 12 |
+
# matches torch 2.11.0. Post-process (Director's Cut) only; the live painter never imports it.
|
| 13 |
+
torchvision==0.26.0
|
| 14 |
transformers==5.11.0
|
| 15 |
diffusers @ git+https://github.com/huggingface/diffusers.git@784fa62652fb2719d415830f918fc32a49ecc7a1
|
| 16 |
peft>=0.14
|
src/lightloom/core/config.py
CHANGED
|
@@ -120,6 +120,17 @@ MODEL_REFS: dict[str, ModelRef] = {
|
|
| 120 |
notes="Runtime via transformers on GPU (LlamaForCausalLM). G3: llama.cpp CPU "
|
| 121 |
"measured ~1 tok/s on the ZeroGPU Space regardless of threads.",
|
| 122 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
"director_gguf": ModelRef(
|
| 124 |
role="Director (local llama.cpp GGUF)",
|
| 125 |
repo_id="openbmb/MiniCPM5-1B-GGUF",
|
|
|
|
| 120 |
notes="Runtime via transformers on GPU (LlamaForCausalLM). G3: llama.cpp CPU "
|
| 121 |
"measured ~1 tok/s on the ZeroGPU Space regardless of threads.",
|
| 122 |
),
|
| 123 |
+
"world_seer": ModelRef(
|
| 124 |
+
role="Art Director / world-seer (Director's Cut post-process)",
|
| 125 |
+
repo_id="openbmb/MiniCPM-V-4.6",
|
| 126 |
+
revision=None, # pin after the on-Space smoke-load (F0 PASSED locally on transformers 5.11.0)
|
| 127 |
+
license="apache-2.0",
|
| 128 |
+
params=1_300_000_000,
|
| 129 |
+
runtime=False, # POST-PROCESS only — loaded at session-end, NEVER on the live painter slot
|
| 130 |
+
notes="MiniCPM-V-4.6 vision-language (MiniCPMV4_6ForConditionalGeneration). Native in transformers 5.11 "
|
| 131 |
+
"(no trust_remote_code) but REQUIRES torchvision (the AutoProcessor pulls a video processor). Names + "
|
| 132 |
+
"describes the FINISHED panorama and aims the keepsake fly-through camera. F0 smoke-load PASSED local.",
|
| 133 |
+
),
|
| 134 |
"director_gguf": ModelRef(
|
| 135 |
role="Director (local llama.cpp GGUF)",
|
| 136 |
repo_id="openbmb/MiniCPM5-1B-GGUF",
|
src/lightloom/export/ldi.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layered-Depth-Image (LDI) Ken Burns fly-through helpers for the Director's Cut keepsake MP4.
|
| 2 |
+
|
| 3 |
+
Runs in the session-end POST-PROCESS endpoint over the FINISHED panorama + its re-estimated float32 depth —
|
| 4 |
+
NEVER on the live painter path. Pure torch / numpy / PIL, and a SYSTEM-ffmpeg encode (ffmpeg is in
|
| 5 |
+
packages.txt -> ZERO new pip deps). The grid_sample warp runs on GPU at runtime; the depth-normalize /
|
| 6 |
+
layer-split / camera-path / grid math here is pure CPU and unit-tested.
|
| 7 |
+
|
| 8 |
+
Design constraints (verified):
|
| 9 |
+
- Depth source = `estimate_depth(..., normalize=False)` RAW float32 disparity (near=high), NOT the live
|
| 10 |
+
8-bit `section-*-depth.png` (session-renormalized -> wrong-scaled for back-projection).
|
| 11 |
+
- Motion is GENTLE and HARD-CLAMPED (the `stage-scroll.js` PARALLAX=0 scar): one focal point can never
|
| 12 |
+
widen the camera past the envelope, so disocclusion gaps stay small and FLUX heals them in-style.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import subprocess
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def normalize_disparity(disp: Any, lo_pct: float = 2.0, hi_pct: float = 98.0) -> Any:
|
| 23 |
+
"""Robustly normalize RAW float32 disparity to [0,1] (1 = nearest) via percentiles, so ONE near/far scale
|
| 24 |
+
spans the whole panorama. Returns a float32 array; a flat input maps to all-zeros."""
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
d = np.asarray(disp, dtype=np.float32)
|
| 28 |
+
lo, hi = np.percentile(d, lo_pct), np.percentile(d, hi_pct)
|
| 29 |
+
if hi - lo < 1e-6:
|
| 30 |
+
return np.zeros_like(d)
|
| 31 |
+
return np.clip((d - lo) / (hi - lo), 0.0, 1.0).astype(np.float32)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def depth_to_layers(norm_disp: Any, n_layers: int = 3) -> list:
|
| 35 |
+
"""Split normalized disparity [0,1] into ``n_layers`` boolean masks, NEAR (index 0) -> FAR, equal-width
|
| 36 |
+
bands. Every pixel lands in exactly one layer; the disoccludable background is the union of farther
|
| 37 |
+
layers behind each nearer one."""
|
| 38 |
+
import numpy as np
|
| 39 |
+
|
| 40 |
+
n = max(1, int(n_layers))
|
| 41 |
+
d = np.asarray(norm_disp, dtype=np.float32)
|
| 42 |
+
edges = np.linspace(0.0, 1.0, n + 1) # edges[0]=0 .. edges[n]=1
|
| 43 |
+
masks = []
|
| 44 |
+
for i in range(n): # i=0 -> nearest band (highest disparity)
|
| 45 |
+
hi = edges[n - i]
|
| 46 |
+
lo = edges[n - i - 1]
|
| 47 |
+
if i == n - 1: # farthest band is inclusive at the bottom so disparity==0 is covered
|
| 48 |
+
masks.append((d <= hi) & (d >= lo))
|
| 49 |
+
else:
|
| 50 |
+
masks.append((d <= hi) & (d > lo))
|
| 51 |
+
return masks
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def clamped_camera_path(
|
| 55 |
+
n_frames: int,
|
| 56 |
+
focal_x: float = 0.5,
|
| 57 |
+
focal_y: float = 0.5,
|
| 58 |
+
max_shift: float = 0.06,
|
| 59 |
+
max_dolly: float = 0.10,
|
| 60 |
+
) -> list:
|
| 61 |
+
"""A gentle, CLAMPED dolly-in + small lateral path (ease-in-out), softly biased toward (focal_x, focal_y).
|
| 62 |
+
Returns ``n_frames`` ``(shift_x, shift_y, dolly)`` with amplitudes HARD-bounded by max_shift/max_dolly so
|
| 63 |
+
one focal point can never widen motion past the gentle envelope (the PARALLAX=0 scar). Frame 0 is the
|
| 64 |
+
rest pose (0,0,0)."""
|
| 65 |
+
import numpy as np
|
| 66 |
+
|
| 67 |
+
n = max(1, int(n_frames))
|
| 68 |
+
t = np.linspace(0.0, 1.0, n)
|
| 69 |
+
ease = 0.5 - 0.5 * np.cos(np.pi * t) # smooth 0..1 ease-in-out, ease[0]=0
|
| 70 |
+
bx = (min(1.0, max(0.0, float(focal_x))) - 0.5) * 2.0 # focal bias in [-1,1]
|
| 71 |
+
by = (min(1.0, max(0.0, float(focal_y))) - 0.5) * 2.0
|
| 72 |
+
sx = bx * max_shift * ease
|
| 73 |
+
sy = by * (max_shift * 0.5) * ease # vertical motion is half the horizontal envelope
|
| 74 |
+
dolly = max_dolly * ease
|
| 75 |
+
return [(float(sx[i]), float(sy[i]), float(dolly[i])) for i in range(n)]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_sample_grid(h: int, w: int) -> Any:
|
| 79 |
+
"""Base identity sampling grid in [-1,1] for ``torch.nn.functional.grid_sample`` — shape (1, h, w, 2),
|
| 80 |
+
last dim = (x, y)."""
|
| 81 |
+
import torch
|
| 82 |
+
|
| 83 |
+
ys = torch.linspace(-1.0, 1.0, h)
|
| 84 |
+
xs = torch.linspace(-1.0, 1.0, w)
|
| 85 |
+
gy, gx = torch.meshgrid(ys, xs, indexing="ij")
|
| 86 |
+
return torch.stack((gx, gy), dim=-1).unsqueeze(0)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def warp_layer(
|
| 90 |
+
img_chw: Any,
|
| 91 |
+
norm_disp_hw: Any,
|
| 92 |
+
shift_x: float,
|
| 93 |
+
shift_y: float,
|
| 94 |
+
dolly: float,
|
| 95 |
+
padding_mode: str = "border",
|
| 96 |
+
) -> tuple:
|
| 97 |
+
"""Depth-displaced backward warp of one RGB layer (GPU at runtime). ``img_chw`` (1,3,H,W) in [0,1],
|
| 98 |
+
``norm_disp_hw`` (1,1,H,W) in [0,1]. Returns ``(warped (1,3,H,W), gap_mask (1,1,H,W))`` where gap_mask=1
|
| 99 |
+
marks disoccluded pixels (sampled from outside the source). Tier-1 uses ``padding_mode="border"`` so the
|
| 100 |
+
tiny clamped-motion gaps are filled by the edge (no black / no heal); Tier-2 LDI uses the gap_mask to FLUX-
|
| 101 |
+
heal the disoccluded background. The gap_mask is computed from grid bounds, independent of padding_mode."""
|
| 102 |
+
import torch
|
| 103 |
+
import torch.nn.functional as F
|
| 104 |
+
|
| 105 |
+
_, _, h, w = img_chw.shape
|
| 106 |
+
base = build_sample_grid(h, w).to(img_chw.device)
|
| 107 |
+
# parallax: nearer pixels (high disparity) move MORE; dolly scales the whole field outward.
|
| 108 |
+
disp = norm_disp_hw[0, 0]
|
| 109 |
+
off_x = disp * shift_x + base[0, :, :, 0] * dolly
|
| 110 |
+
off_y = disp * shift_y + base[0, :, :, 1] * dolly
|
| 111 |
+
grid = base.clone()
|
| 112 |
+
grid[0, :, :, 0] = base[0, :, :, 0] + off_x
|
| 113 |
+
grid[0, :, :, 1] = base[0, :, :, 1] + off_y
|
| 114 |
+
warped = F.grid_sample(img_chw, grid, mode="bilinear", padding_mode=padding_mode, align_corners=True)
|
| 115 |
+
inb = ((grid[..., 0].abs() <= 1.0) & (grid[..., 1].abs() <= 1.0)).float().unsqueeze(1)
|
| 116 |
+
gap = 1.0 - inb
|
| 117 |
+
return warped, gap
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def encode_frames_ffmpeg(frames_dir: Any, out_path: Any, fps: int = 25, height: int = 1080) -> Path:
|
| 121 |
+
"""Encode ``frame-*.webp`` in ``frames_dir`` into an H.264 yuv420p MP4 via the SYSTEM ffmpeg
|
| 122 |
+
(packages.txt) — zero new pip deps. Scales to ``height`` p (width auto, even). Raises
|
| 123 |
+
``subprocess.CalledProcessError`` if ffmpeg fails."""
|
| 124 |
+
d = Path(frames_dir)
|
| 125 |
+
out = Path(out_path)
|
| 126 |
+
cmd = [
|
| 127 |
+
"ffmpeg", "-y", "-framerate", str(int(fps)),
|
| 128 |
+
"-pattern_type", "glob", "-i", str(d / "frame-*.webp"),
|
| 129 |
+
"-vf", f"scale=-2:{int(height)}:flags=lanczos,format=yuv420p",
|
| 130 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
| 131 |
+
str(out),
|
| 132 |
+
]
|
| 133 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 134 |
+
return out
|
src/lightloom/vision/world_seer.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniCPM-V-4.6 "Art Director" world-seer (Director's Cut POST-PROCESS only — NEVER the live painter path).
|
| 2 |
+
|
| 3 |
+
Reads the FINISHED painted panorama at session end and names + describes it from the ACTUAL pixels, and flags
|
| 4 |
+
the focal regions that aim the keepsake fly-through camera. This is the Best-MiniCPM-Build contribution.
|
| 5 |
+
|
| 6 |
+
F0 smoke-load VERIFIED on transformers 5.11.0 (local, 2026-06-14): `AutoModelForImageTextToText` loads
|
| 7 |
+
natively (class MiniCPMV4_6ForConditionalGeneration, 1.30B, NO trust_remote_code); the 2-call processor form +
|
| 8 |
+
greedy decode returned vision-grounded JSON in ~2.7s warm. REQUIRES `torchvision` (the AutoProcessor pulls a
|
| 9 |
+
MiniCPMV4_6VideoProcessor) -> must be pinned in requirements.txt; adding it forces ONE full Space rebuild.
|
| 10 |
+
|
| 11 |
+
Parameter contribution: openbmb/MiniCPM-V-4.6 ~1.30B (register in the ledger). Loading/inference run under
|
| 12 |
+
`@spaces.GPU` in the session-end endpoint; `parse_world_json`/`_coerce` are pure CPU and unit-tested.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import re
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
WORLD_SEER_MODEL = "openbmb/MiniCPM-V-4.6"
|
| 22 |
+
|
| 23 |
+
_SEER_PROMPT = (
|
| 24 |
+
"You are looking at a finished painted panorama — one continuous imagined world. "
|
| 25 |
+
"Reply with EXACTLY ONE JSON object and NOTHING else, in this shape:\n"
|
| 26 |
+
'{"title": "a 2-4 word evocative world name in Title Case",'
|
| 27 |
+
' "caption": "one short evocative sentence about the world",'
|
| 28 |
+
' "field_notes": ["3-5 short noun phrases naming things actually visible"],'
|
| 29 |
+
' "focal_points": [{"x": 0.0-1.0, "y": 0.0-1.0, "label": "the most striking thing here"}]}\n'
|
| 30 |
+
"x,y are the fractional centre of each striking subject (0,0 = top-left). List 1-3 focal_points."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
_FALLBACK_TITLES = (
|
| 34 |
+
"A World Unfolding",
|
| 35 |
+
"Where the Light Remembers",
|
| 36 |
+
"The Long Bright Drift",
|
| 37 |
+
"A Quiet Country",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
_SEER_CACHE: tuple[Any, Any] | None = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_world_seer() -> tuple[Any, Any]:
|
| 44 |
+
"""Load MiniCPM-V-4.6 on CUDA (bf16), CACHED at module scope. GPU only — call inside the session-end
|
| 45 |
+
`@spaces.GPU` post-process endpoint, NEVER on the live painter path. Returns ``(processor, model)``.
|
| 46 |
+
Native in transformers 5.11 (no trust_remote_code); requires torchvision (the AutoProcessor pulls a
|
| 47 |
+
video processor)."""
|
| 48 |
+
global _SEER_CACHE
|
| 49 |
+
if _SEER_CACHE is not None:
|
| 50 |
+
return _SEER_CACHE
|
| 51 |
+
import torch
|
| 52 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 53 |
+
|
| 54 |
+
processor = AutoProcessor.from_pretrained(WORLD_SEER_MODEL)
|
| 55 |
+
model = (
|
| 56 |
+
AutoModelForImageTextToText.from_pretrained(WORLD_SEER_MODEL, dtype=torch.bfloat16)
|
| 57 |
+
.to("cuda")
|
| 58 |
+
.eval()
|
| 59 |
+
)
|
| 60 |
+
_SEER_CACHE = (processor, model)
|
| 61 |
+
return _SEER_CACHE
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _iter_json_objects(text: str):
|
| 65 |
+
"""Yield every top-level balanced-brace ``{...}`` object in ``text``. MiniCPM-V sometimes emits several
|
| 66 |
+
separate JSON objects (e.g. ``{"title":..}{"caption":..}``) instead of one; ``parse_world_json`` merges
|
| 67 |
+
them. String-aware so a brace inside a quoted value never miscounts the depth."""
|
| 68 |
+
depth = 0
|
| 69 |
+
start = -1
|
| 70 |
+
in_str = False
|
| 71 |
+
esc = False
|
| 72 |
+
for i, ch in enumerate(text):
|
| 73 |
+
if in_str:
|
| 74 |
+
if esc:
|
| 75 |
+
esc = False
|
| 76 |
+
elif ch == "\\":
|
| 77 |
+
esc = True
|
| 78 |
+
elif ch == '"':
|
| 79 |
+
in_str = False
|
| 80 |
+
continue
|
| 81 |
+
if ch == '"':
|
| 82 |
+
in_str = True
|
| 83 |
+
elif ch == "{":
|
| 84 |
+
if depth == 0:
|
| 85 |
+
start = i
|
| 86 |
+
depth += 1
|
| 87 |
+
elif ch == "}":
|
| 88 |
+
if depth > 0:
|
| 89 |
+
depth -= 1
|
| 90 |
+
if depth == 0 and start >= 0:
|
| 91 |
+
yield text[start : i + 1]
|
| 92 |
+
start = -1
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def parse_world_json(text: str) -> dict:
|
| 96 |
+
"""Tolerantly parse the seer decode: merge every balanced-brace JSON object found (the model may split
|
| 97 |
+
fields across several ``{}``), keeping the FIRST value seen for each key, ignoring unparseable fragments."""
|
| 98 |
+
merged: dict = {}
|
| 99 |
+
for blob in _iter_json_objects(text or ""):
|
| 100 |
+
try:
|
| 101 |
+
obj = json.loads(blob)
|
| 102 |
+
except Exception: # noqa: BLE001 - skip a malformed fragment, keep the rest
|
| 103 |
+
continue
|
| 104 |
+
if isinstance(obj, dict):
|
| 105 |
+
for key, value in obj.items():
|
| 106 |
+
if key not in merged:
|
| 107 |
+
merged[key] = value
|
| 108 |
+
return merged
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _coerce(merged: dict, seed: int = 0) -> dict:
|
| 112 |
+
"""Fill every field with a safe default so the post-process always has a usable, clean result (a VLM
|
| 113 |
+
failure can never blank or break the keepsake)."""
|
| 114 |
+
title = re.sub(r"[\"'“”]", "", str(merged.get("title") or "")).strip()
|
| 115 |
+
title = re.sub(r"[.;:!?,]+$", "", title).strip()
|
| 116 |
+
if len(title.split()) > 6: # runaway -> first 6 words
|
| 117 |
+
title = " ".join(title.split()[:6])
|
| 118 |
+
if not title or not re.search(r"[A-Za-z]", title):
|
| 119 |
+
title = _FALLBACK_TITLES[abs(seed) % len(_FALLBACK_TITLES)]
|
| 120 |
+
title = title[:60]
|
| 121 |
+
|
| 122 |
+
caption = re.sub(r"\s+", " ", str(merged.get("caption") or "")).strip()[:200]
|
| 123 |
+
|
| 124 |
+
notes: list[str] = []
|
| 125 |
+
if isinstance(merged.get("field_notes"), list):
|
| 126 |
+
for note in merged["field_notes"]:
|
| 127 |
+
cleaned = re.sub(r"\s+", " ", str(note)).strip()[:60]
|
| 128 |
+
if cleaned:
|
| 129 |
+
notes.append(cleaned)
|
| 130 |
+
notes = notes[:5]
|
| 131 |
+
|
| 132 |
+
focal: list[dict] = []
|
| 133 |
+
if isinstance(merged.get("focal_points"), list):
|
| 134 |
+
for fp in merged["focal_points"]:
|
| 135 |
+
if not isinstance(fp, dict):
|
| 136 |
+
continue
|
| 137 |
+
try:
|
| 138 |
+
x = min(1.0, max(0.0, float(fp.get("x", 0.5))))
|
| 139 |
+
y = min(1.0, max(0.0, float(fp.get("y", 0.5))))
|
| 140 |
+
except (TypeError, ValueError):
|
| 141 |
+
continue
|
| 142 |
+
label = re.sub(r"\s+", " ", str(fp.get("label") or "")).strip()[:60]
|
| 143 |
+
focal.append({"x": x, "y": y, "label": label})
|
| 144 |
+
focal = focal[:3] or [{"x": 0.5, "y": 0.5, "label": ""}] # CENTER fallback: the camera always has a target
|
| 145 |
+
|
| 146 |
+
return {"title": title, "caption": caption, "field_notes": notes, "focal_points": focal}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def see_world(processor: Any, model: Any, panorama: Any, max_new_tokens: int = 192, seed: int = 0) -> dict:
|
| 150 |
+
"""Read the finished panorama (PIL.Image) -> ``{title, caption, field_notes, focal_points}``. Uses the
|
| 151 |
+
F0-verified 2-call form (``apply_chat_template(tokenize=False)`` -> ``processor(images=, text=)`` ->
|
| 152 |
+
``generate``); greedy decode (``do_sample=False``) was verified to emit cleanly. ALWAYS returns a coerced
|
| 153 |
+
dict (never raises on bad JSON) so a VLM failure cannot break the post-process."""
|
| 154 |
+
import torch
|
| 155 |
+
|
| 156 |
+
messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": _SEER_PROMPT}]}]
|
| 157 |
+
text = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
| 158 |
+
inputs = processor(images=[panorama], text=text, return_tensors="pt").to(model.device)
|
| 159 |
+
with torch.inference_mode():
|
| 160 |
+
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
|
| 161 |
+
decoded = processor.batch_decode(out[:, inputs["input_ids"].shape[1] :], skip_special_tokens=True)[0]
|
| 162 |
+
return _coerce(parse_world_json(decoded), seed=seed)
|