Efradeca commited on
Commit
85ede0d
·
verified ·
1 Parent(s): 6cb2147

chore: deploy private lightloom build

Browse files
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)