File size: 15,218 Bytes
85300f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6ce26f
 
 
 
85300f6
 
b6ce26f
 
85300f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238f0f8
85300f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0ce351
85300f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce5396a
 
 
 
 
 
bcd1a57
ce5396a
85300f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0ce351
85300f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0ce351
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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
"""MJev-Spatial — one-forward-pass spatial decision demo.

Model: https://huggingface.co/Fr0zenCr4nE/MJev-Spatial-Qwen-3.5-4B
Paper: MJev-Spatial: System One Spatial Intelligence via Native Attention Matching
(arXiv 2610.22276). Inference follows the official scripts/predict.py path:
the model scores a finite candidate set with the language backbone's native
final-attention Q/K projections and returns the argmax decision — no text is generated.
"""

import os

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # noqa: E402  -- MUST precede torch / CUDA-touching imports
import json  # noqa: E402
import sys  # noqa: E402
import tempfile  # noqa: E402
import time  # noqa: E402
from pathlib import Path  # noqa: E402

import gradio as gr  # noqa: E402
import numpy as np  # noqa: E402
import torch  # noqa: E402
from PIL import Image, ImageDraw  # noqa: E402

ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "src"))

# Vendored inference runtime (Apache-2.0) from github.com/Fr0zenCrane/MJev-Spatial
from spatial_jev.kev import KevBuilder, build_model, make_processor, predict_batch  # noqa: E402
from spatial_jev.numeric_tasks import ANGLE_CODEBOOK, COUNT_CODEBOOK  # noqa: E402

CHECKPOINT = "Fr0zencr4nE/MJev-Spatial-Qwen-3.5-4B"
MAX_SIDE = 512  # keep encode time and sequence length comfortable on ZeroGPU

# ---------------------------------------------------------------------------
# Model loading (module scope, eager .to("cuda") — intercepted by the spaces hijack)
# ---------------------------------------------------------------------------

def snapshot_repo(repo_id):
    """Fetch the checkpoint into a local directory path (build_model needs a *directory*)."""
    from huggingface_hub import snapshot_download
    return Path(snapshot_download(repo_id))


ckpt_dir = snapshot_repo(CHECKPOINT)  # local dir; build_model requires a checkpoint *path*
config = json.loads((ckpt_dir / "config.json").read_text())
config.update(
    preserve_option_order=True,
    max_image_tokens=1024,
    max_sequence_length=max(8192, int(config.get("max_sequence_length", 4096))),
    inference_language_dtype="bf16",
)

print("[mjev] loading processor…", flush=True)
processor = make_processor(config)
print("[mjev] loading model…", flush=True)
builder = KevBuilder(processor, config)
MODEL = build_model(config, ckpt_dir, trainable=False)
MODEL = MODEL.to("cuda").eval()
print("[mjev] model ready", flush=True)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def load_image(image, max_side=MAX_SIDE):
    """PIL RGB image scaled so neon-style huge uploads don't blow the token budget."""
    if image is None:
        raise gr.Error("Please provide an image.")
    img = image if isinstance(image, Image.Image) else Image.open(image)
    img = img.convert("RGB")
    w, h = img.size
    scale = max_side / max(w, h)
    if scale < 1:
        img = img.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.Resampling.BILINEAR)
    return img


def parse_options(text):
    """One option per line; 'id | text' optional — falls back to the text as id."""
    options = []
    for line in (text or "").strip().splitlines():
        line = line.strip()
        if not line:
            continue
        if "|" in line:
            oid, otext = line.split("|", 1)
            oid, otext = oid.strip(), otext.strip() or oid.strip()
        else:
            oid = otext = line
        options.append({"id": oid, "text": otext})
    if len(options) < 2:
        raise gr.Error("Choice tasks need at least 2 options (one per line).")
    if len(options) > config["max_choices"]:
        raise gr.Error(f"Too many options — this checkpoint supports at most {config['max_choices']}.")
    return options


def draw_point(image, xy):
    """Overlay the predicted normalized point + crosshair on a copy of the image."""
    img = image.copy()
    w, h = img.size
    x, y = xy[0] * w, xy[1] * h
    draw = ImageDraw.Draw(img)
    r = max(6, int(min(w, h) * 0.02))
    for color, width in (((255, 255, 255), 5), ((255, 70, 0), 3)):
        draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=width)
        draw.line([x - 1.6 * r, y, x + 1.6 * r, y], fill=color, width=width)
        draw.line([x, y - 1.6 * r, x, y + 1.6 * r], fill=color, width=width)
    return img


def overlay_probability_grid(image, result):
    """Pointing: warm heatmap of the final-stage 9-way scores over the selected crop."""
    if "logits" not in result or result.get("pointing_method") != "visual_tokens":
        return None
    probs = np.asarray(result.get("probabilities", []), dtype=np.float64)
    if probs.size == 0:
        return None
    height, width = result.get("grid_hw", [1, 1])
    if probs.shape != (height, width):
        return None
    heat = np.zeros((height, width, 4), dtype=np.uint8)
    heat[..., 0] = 255
    heat[..., 3] = (probs / probs.max() * 255).astype(np.uint8) if probs.max() > 0 else 0
    heat_img = Image.fromarray(heat, "RGBA").resize(image.size, Image.Resampling.BILINEAR)
    out = image.copy()
    out.paste(heat_img, (0, 0), heat_img)
    return out


def describe_path(result, kind, option_map=None):
    """Human-readable trace of the decision path."""
    lines = []
    logits = result.get("logits") or []
    path = result.get("path") or []
    names = {
        "top-center": "top-center", "top-left": "top-left", "top-right": "top-right",
        "center": "center", "middle-left": "middle-left", "middle-right": "middle-right",
        "bottom-center": "bottom-center", "bottom-left": "bottom-left",
        "bottom-right": "bottom-right",
    }
    from spatial_jev.hierarchy import POINT_NAMES, decode_point, decode_scalar  # local import
    if kind == "choice":
        if option_map:
            lines.append(f"selected option: **{option_map[path[0]]}**" if path else "")
        elif result.get("prediction") is not None:
            lines.append(f"selected option: **{result['prediction']}**")
    elif kind == "scalar":
        if path:
            edges = config["scalar_codebook"]["edges_m"]
            if path[0] == 0:
                lines.append("coarse stage: exactly zero")
            elif path[0] == len(edges):
                lines.append(f"coarse stage: greater than {edges[-1]:g} m (overflow)")
            else:
                from spatial_jev.hierarchy import scalar_interval, scalar_fine_bins
                lo, hi = scalar_interval(path[0], config["scalar_codebook"])
                lines.append(f"coarse stage: interval {lo:.6g} – {hi:.6g} m")
                if len(path) > 1:
                    fine = scalar_fine_bins(path[0], config["scalar_codebook"])
                    stepw = (hi - lo) / fine
                    lines.append(f"fine stage: refined bin {path[1] + 1}/{fine} "
                                 f"≈ {lo + path[1] * stepw:.6g} – {lo + (path[1] + 1) * stepw:.6g} m")
            lines.append(f"decoded estimate: **{result['prediction']:.6g} m**")
    elif kind == "point":
        steps = ["whole image", *(f"zoom #{i}" for i in range(1, len(path)))]
        for stage, (choice, scores) in enumerate(zip(path, logits)):
            label = names.get(POINT_NAMES[choice], POINT_NAMES[choice])
            best = scores[choice] if stage < len(scores) and choice < len(scores) else float("nan")
            lines.append(f"stage {stage + 1} ({steps[stage] if stage < len(steps) else ''}): "
                         f"region **{label}** (score {best:.3f})")
        if kind == "point" and result.get("prediction"):
            x, y = result["prediction"]
            lines.append(f"decoded point: **({x:.3f}, {y:.3f})** normalized "
                         f"(origin top-left, x→right, y→down)")
    return "\n".join(f"- {l}" for l in lines if l)


# ---------------------------------------------------------------------------
# Inference endpoint
# ---------------------------------------------------------------------------

@spaces.GPU(duration=45)  # measured: 3.3s cold attach + 3-stage point, 0.1–0.8s warm
def run_spatial(image, task, question, options_text, quantity, unit, progress=gr.Progress()):
    """Answer a spatial question about an image in one forward pass per decision stage.

    Args:
        image: input image (PIL).
        task: one of "choice" | "scalar" | "point".
        question: the spatial question about the image.
        options_text: for choice tasks, one option per line ("id | text" accepted).
        quantity: for scalar tasks, what is being estimated (e.g. "distance").
        unit: scalar unit — "m", "count" or "deg".
        progress: Gradio progress bar.
    Returns:
        (answer markdown, decision-path markdown, annotated image, raw JSON result)
    """
    t0 = time.perf_counter()
    img = load_image(image)
    if not (question or "").strip():
        raise gr.Error("Please provide a question.")

    question = question.strip()
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "input.png"
        img.save(path)
        if task == "choice":
            space = {"kind": "choice", "options": parse_options(options_text)}
        elif task == "scalar":
            space = {"kind": "scalar", "quantity": (quantity or "value").strip() or "value",
                     "unit": unit}
        else:
            space = {"kind": "point", "coordinate_system": "normalized_xy", "num_points": 1}
        request = {"id": "demo-request", "input": {
            "media": [{"kind": "image", "uri": str(path)}],
            "question": question,
            "answer_space": space,
        }}
        result = predict_batch(MODEL, builder, [request], torch.device("cuda"))[0]

    prediction = result["prediction"]
    if task == "point":
        # Accept both [[x, y]] (list of points) and [x, y] (single point)
        if isinstance(prediction, (list, tuple)) and len(prediction) >= 1:
            inner = prediction if not isinstance(prediction[0], (list, tuple)) else prediction[0]
            if len(inner) != 2 or any(isinstance(v, (list, tuple)) for v in inner):
                inner = [v for v in inner if isinstance(v, (int, float))][:2]
            point = [float(inner[0]), float(inner[1])]
        else:
            raise gr.Error(f"Unexpected point prediction: {prediction!r}")
        overlay = draw_point(img, point)
    else:
        overlay = img.copy()

    if task == "choice":
        answer = str(prediction)
    elif task == "scalar":
        label = {"m": "m", "count": "", "deg": "°"}[unit]
        answer = f"{prediction:g}{label}" if unit != "count" else f"{int(prediction)} {quantity or 'objects'}"
    else:
        answer = f"point (x, y) = ({point[0]:.3f}, {point[1]:.3f})"

    trace = describe_path(result, task)
    seconds = time.perf_counter() - t0
    summary = f"## {answer}\n\n*(one forward pass per decision stage, {seconds:.1f}s)*"
    raw = json.dumps({"prediction": result.get("prediction"), "path": result.get("path"),
                      "logits": result.get("logits")}, ensure_ascii=False)
    return summary, trace, overlay, raw


# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------

CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

with gr.Blocks(title="MJev-Spatial") as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            "# 🧭 MJev-Spatial\n"
            "Spatial intelligence via **native attention matching** — a 4B VLM that "
            "answers spatial questions as a single candidate-scoring decision, with no "
            "generated answer tokens. [paper](https://arxiv.org/abs/2610.22276) · "
            "[code](https://github.com/Fr0zenCrane/MJev-Spatial) · "
            "[checkpoint](https://huggingface.co/Fr0zencr4nE/MJev-Spatial-Qwen-3.5-4B)"
        )

        with gr.Row():
            with gr.Column(scale=1):
                image_in = gr.Image(type="pil", label="Image", height=300)
                task = gr.Radio(
                    ["choice", "scalar", "point"], value="choice", label="Answer space",
                    info="choice: pick an option · scalar: estimate a value · point: locate a target",
                )
                question = gr.Textbox(
                    label="Question", placeholder="e.g. Where is the red square relative to the blue circle?",
                    value="Where is the red square relative to the blue circle?",
                )
                options_text = gr.Textbox(
                    label="Options (choice only) — one per line, 'id | text' optional",
                    value="left | left\nright | right", lines=3,
                )
                quantity = gr.Textbox(
                    label="Quantity (scalar only)", value="distance",
                    info="What is being estimated, e.g. distance, width, height.",
                )
                unit = gr.Radio(["m", "count", "deg"], value="m", label="Unit (scalar only)")
                run_btn = gr.Button("Decide", variant="primary")

            with gr.Column(scale=1):
                answer_md = gr.Markdown(label="Answer")
                annotated = gr.Image(label="Predicted point (pointing tasks)", height=300)
                trace_md = gr.Markdown("Decision path will appear here.")
                with gr.Accordion("Raw result (JSON)", open=False):
                    raw_json = gr.JSON(label="scores / path")

        gr.Examples(
            examples=[
                ["examples/images/scene.png", "choice",
                 "Where is the red square relative to the blue circle?",
                 "left | left\nright | right", "distance", "m"],
                ["examples/images/scene.png", "scalar",
                 "What is the approximate distance between the two objects?",
                 "", "distance", "m"],
                ["examples/images/scene.png", "point",
                 "Point to the blue circle.", "", "distance", "m"],
            ],
            inputs=[image_in, task, question, options_text, quantity, unit],
            outputs=[answer_md, trace_md, annotated, raw_json],
            fn=run_spatial,
            label="Official examples (scene.png from the MJev-Spatial repo)",
            cache_examples=True,
            cache_mode="lazy",
        )

        gr.Markdown(
            "Pointing decodes three 3×3 region decisions into a normalized point; the "
            "score of each stage's picked region is shown in the decision path. "
            "Scalar questions are answered through an interval hierarchy over meters "
            "(or an integer count / degree codebook)."
        )

    run_btn.click(
        run_spatial,
        inputs=[image_in, task, question, options_text, quantity, unit],
        outputs=[answer_md, trace_md, annotated, raw_json],
        api_name="decide",
    )

demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)