Michaelqaz Cursor commited on
Commit
c0e7e4d
·
1 Parent(s): c3c6897

Add showcase cases, auto-captioning, and drop no-translation pose

Browse files

- Reorder examples: iStock rice-field motorbike first; add two OmniWorld
character scenes (misty forest, horse trail) in a separate "with characters"
group, per the release curation.
- Move the examples above the Advanced settings so they are visible and
clickable without scrolling.
- Auto-generate a scene prompt with Qwen2-VL-2B when the description is left
blank, instead of erroring, so an image-only request still gets a caption.
The model is held on CPU and only lifted onto the GPU while captioning.
- Remove the pan_right preset (pure in-place rotation, zero translation), the
only preset with no visible camera translation.

Co-authored-by: Cursor <cursoragent@cursor.com>

.gitattributes CHANGED
@@ -38,3 +38,6 @@ diffsynth/tokenizer_configs/kolors/tokenizer/vocab.txt filter=lfs diff=lfs merge
38
  examples/ai-airmountains.jpg filter=lfs diff=lfs merge=lfs -text
39
  examples/ai-middleages.jpg filter=lfs diff=lfs merge=lfs -text
40
  examples/ai-valley.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
38
  examples/ai-airmountains.jpg filter=lfs diff=lfs merge=lfs -text
39
  examples/ai-middleages.jpg filter=lfs diff=lfs merge=lfs -text
40
  examples/ai-valley.jpg filter=lfs diff=lfs merge=lfs -text
41
+ examples/istock-motorbike-rice-field.jpg filter=lfs diff=lfs merge=lfs -text
42
+ examples/omni-misty-forest.jpg filter=lfs diff=lfs merge=lfs -text
43
+ examples/omni-horse-trail.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -25,8 +25,9 @@ tags:
25
  [TencentARC/SCoPE](https://huggingface.co/TencentARC/SCoPE) retrofits **Wan2.2-I2V-A14B** with
26
  *Sightline-Coordinate Positional Encoding*: Plücker camera rays are normalised, gated and injected
27
  directly into the DiT's self-attention queries and keys, so an explicit 3D camera path steers the
28
- generated shot. Give it a first frame, a caption and a camera move, and it returns an 81-frame,
29
- 832×480, 16 fps clip.
 
30
 
31
  * Paper / project page: <https://visual-ai.github.io/scope/>
32
  * Reference code: <https://github.com/TencentARC/SCoPE>
@@ -40,9 +41,10 @@ height=480, width=832, num_frames=81, sigma_shift=5.0, switch_DiT_boundary=0.9,
40
  camera_cfg_scale=1.0, lock_first_frame=False, tiled=False)`.
41
 
42
  Camera paths are OpenCV **camera-to-world** matrices of shape `[81, 3, 4]`, expressed relative to
43
- the first frame, which is the format SCoPE was trained on. The 17 presets in `trajectories/` are
44
  taken from the release's own `examples/` trajectory set (re-expressed relative to frame 0), and the
45
- "camera motion scale" slider rescales only their translation component.
 
46
 
47
  ## Deviations from the reference configuration
48
 
@@ -63,6 +65,8 @@ control itself is architectural and unaffected by the shorter schedule.
63
 
64
  ## Credits
65
 
66
- Model, code and example images © the SCoPE authors (Apache-2.0). The three showcase images in
67
- `examples/` are the AI-generated cases shipped in the SCoPE repository's `examples/` directory,
68
- together with their original captions and `x_fov`.
 
 
 
25
  [TencentARC/SCoPE](https://huggingface.co/TencentARC/SCoPE) retrofits **Wan2.2-I2V-A14B** with
26
  *Sightline-Coordinate Positional Encoding*: Plücker camera rays are normalised, gated and injected
27
  directly into the DiT's self-attention queries and keys, so an explicit 3D camera path steers the
28
+ generated shot. Give it a first frame and a camera move, and it returns an 81-frame,
29
+ 832×480, 16 fps clip. A caption is used for the scene content; if you leave it blank, a small
30
+ vision-language model (`Qwen/Qwen2-VL-2B-Instruct`) writes one from your first frame automatically.
31
 
32
  * Paper / project page: <https://visual-ai.github.io/scope/>
33
  * Reference code: <https://github.com/TencentARC/SCoPE>
 
41
  camera_cfg_scale=1.0, lock_first_frame=False, tiled=False)`.
42
 
43
  Camera paths are OpenCV **camera-to-world** matrices of shape `[81, 3, 4]`, expressed relative to
44
+ the first frame, which is the format SCoPE was trained on. The 16 presets in `trajectories/` are
45
  taken from the release's own `examples/` trajectory set (re-expressed relative to frame 0), and the
46
+ "camera motion scale" slider rescales only their translation component. Every preset carries a real
47
+ translation; pure in-place rotations were dropped because their effect is hard to see.
48
 
49
  ## Deviations from the reference configuration
50
 
 
65
 
66
  ## Credits
67
 
68
+ Model, code and example images © the SCoPE authors (Apache-2.0). The showcase images in
69
+ `examples/` are drawn from the SCoPE release: three AI-generated scenes, one iStock aerial clip
70
+ (rice-field motorbike), and two OmniWorld character scenes (kept in their own "with characters"
71
+ group). Automatic prompting uses [`Qwen/Qwen2-VL-2B-Instruct`](https://huggingface.co/Qwen/Qwen2-VL-2B-Instruct)
72
+ (Apache-2.0).
app.py CHANGED
@@ -43,6 +43,7 @@ from torchao.quantization import ( # noqa: E402
43
  Int8WeightOnlyConfig,
44
  quantize_,
45
  )
 
46
 
47
  from diffsynth.data.video import save_video # noqa: E402
48
  from diffsynth.models import ModelManager # noqa: E402
@@ -60,6 +61,14 @@ HERE = Path(__file__).resolve().parent
60
  REPO_ID = "TencentARC/SCoPE"
61
  LORA_REPO = "lightx2v/Wan2.2-Lightning"
62
  LORA_SUBDIR = "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1"
 
 
 
 
 
 
 
 
63
  WORK_DIR = Path(os.environ.get("SCOPE_WEIGHT_DIR", "/tmp/scope-weights"))
64
  DEVICE = "cuda"
65
 
@@ -79,7 +88,6 @@ PRESETS: list[tuple[str, str]] = [
79
  ("Dolly out — pull straight back", "dolly_out"),
80
  ("Truck left — slide sideways to the left", "truck_left"),
81
  ("Truck right — slide sideways to the right", "truck_right"),
82
- ("Pan right — rotate in place, no translation", "pan_right"),
83
  ("Orbit left — arc around the subject", "orbit_left"),
84
  ("Crane up + forward — rise while pushing in", "crane_up_fwd"),
85
  ("Snake forward — weaving push-in", "snake_fwd"),
@@ -299,6 +307,57 @@ def build_pipeline() -> SCoPEPipeline:
299
  PIPE = build_pipeline()
300
 
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  # --------------------------------------------------------------------------------------
303
  # Inference
304
  # --------------------------------------------------------------------------------------
@@ -338,7 +397,11 @@ def estimate_duration(
338
  # Measured on ZeroGPU (fp8 experts, 832x480x81): 4 steps -> 67.5s, 8 steps -> 124s,
339
  # i.e. ~14.1s per sampling step over ~11s of fixed text-encode/VAE cost. Keep a ~15%
340
  # margin and nothing more, so a default 4-step run stays inside the free 120s quota.
341
- return int(math.ceil(1.15 * (11.0 + 14.1 * int(steps))))
 
 
 
 
342
 
343
 
344
  @spaces.GPU(duration=estimate_duration)
@@ -355,8 +418,14 @@ def generate(
355
  ):
356
  first_frame = prepare_image(image)
357
  prompt = (prompt or "").strip()
 
358
  if not prompt:
359
- raise gr.Error("Please describe the scene — SCoPE needs a caption for the content.")
 
 
 
 
 
360
 
361
  used_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
362
  poses = load_trajectory(trajectory, motion_scale)
@@ -398,6 +467,8 @@ def generate(
398
  f"{int(steps)} steps · seed {used_seed} · {PRESET_LABELS.get(trajectory, trajectory)} "
399
  f"· motion x{motion_scale:g} · {elapsed:.0f}s"
400
  )
 
 
401
  return output.name, used_seed, status
402
 
403
 
@@ -458,6 +529,16 @@ def preview_path(trajectory: str, motion_scale: float) -> Image.Image:
458
  # --------------------------------------------------------------------------------------
459
 
460
  EXAMPLES = [
 
 
 
 
 
 
 
 
 
 
461
  [
462
  str(HERE / "examples" / "ai-airmountains.jpg"),
463
  "A vast sky filled with multiple floating islands of different sizes, suspended above a "
@@ -490,6 +571,27 @@ EXAMPLES = [
490
  ],
491
  ]
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  CSS = """
494
  #col-container { margin: 0 auto; max-width: 1180px; }
495
  .dark .gradio-container { color: var(--body-text-color); }
@@ -505,8 +607,9 @@ with gr.Blocks() as demo:
505
  rays are normalised, gated and injected straight into the DiT's self-attention
506
  queries/keys, so a real 3D camera path drives the generated shot.
507
 
508
- Drop in an image, describe the scene, and pick a camera move — you get an
509
- 81-frame, 832x480, 16 fps clip that follows that trajectory.
 
510
  """
511
  )
512
  with gr.Row():
@@ -516,8 +619,9 @@ with gr.Blocks() as demo:
516
  )
517
  prompt_input = gr.Textbox(
518
  label="Scene description",
519
- placeholder="Describe what is in the image and what should happen…",
520
  lines=4,
 
521
  )
522
  trajectory_input = gr.Dropdown(
523
  label="Camera move",
@@ -536,6 +640,27 @@ with gr.Blocks() as demo:
536
  )
537
  status_output = gr.Markdown()
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  with gr.Accordion("Advanced settings", open=False):
540
  with gr.Row():
541
  steps_input = gr.Slider(
@@ -571,16 +696,6 @@ with gr.Blocks() as demo:
571
  )
572
  randomize_input = gr.Checkbox(label="Randomize seed", value=True)
573
 
574
- gr.Examples(
575
- examples=EXAMPLES,
576
- inputs=[image_input, prompt_input, trajectory_input],
577
- outputs=[video_output, seed_input, status_output],
578
- fn=generate,
579
- cache_examples=True,
580
- cache_mode="lazy",
581
- label="Examples (AI-generated showcase scenes from the SCoPE release)",
582
- )
583
-
584
  gr.Markdown(
585
  """
586
  **Notes** · Camera paths are OpenCV camera-to-world matrices `[81, 3, 4]` relative to
 
43
  Int8WeightOnlyConfig,
44
  quantize_,
45
  )
46
+ from transformers import AutoProcessor, Qwen2VLForConditionalGeneration # noqa: E402
47
 
48
  from diffsynth.data.video import save_video # noqa: E402
49
  from diffsynth.models import ModelManager # noqa: E402
 
61
  REPO_ID = "TencentARC/SCoPE"
62
  LORA_REPO = "lightx2v/Wan2.2-Lightning"
63
  LORA_SUBDIR = "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1"
64
+ # A small vision-language model used to auto-write a caption when the user leaves the
65
+ # scene description blank — SCoPE always needs a caption for the content it should draw.
66
+ CAPTIONER_REPO = "Qwen/Qwen2-VL-2B-Instruct"
67
+ CAPTION_INSTRUCTION = (
68
+ "Write one vivid paragraph describing this image for a video generation model. "
69
+ "Cover the main subjects, the setting, materials and colours, and the lighting. "
70
+ "Do not mention the camera, camera motion, shot type, or the word 'image'."
71
+ )
72
  WORK_DIR = Path(os.environ.get("SCOPE_WEIGHT_DIR", "/tmp/scope-weights"))
73
  DEVICE = "cuda"
74
 
 
88
  ("Dolly out — pull straight back", "dolly_out"),
89
  ("Truck left — slide sideways to the left", "truck_left"),
90
  ("Truck right — slide sideways to the right", "truck_right"),
 
91
  ("Orbit left — arc around the subject", "orbit_left"),
92
  ("Crane up + forward — rise while pushing in", "crane_up_fwd"),
93
  ("Snake forward — weaving push-in", "snake_fwd"),
 
307
  PIPE = build_pipeline()
308
 
309
 
310
+ # --------------------------------------------------------------------------------------
311
+ # Optional prompt writer — Qwen2-VL captions the first frame when no prompt is given.
312
+ # Downloaded and held on CPU at build time; only lifted onto the GPU for the few seconds
313
+ # it is actually needed, then evicted so it never competes with the two 14B experts.
314
+ # --------------------------------------------------------------------------------------
315
+
316
+
317
+ def build_captioner() -> tuple[Qwen2VLForConditionalGeneration, AutoProcessor]:
318
+ started = time.time()
319
+ processor = AutoProcessor.from_pretrained(
320
+ CAPTIONER_REPO, min_pixels=256 * 28 * 28, max_pixels=768 * 28 * 28
321
+ )
322
+ model = Qwen2VLForConditionalGeneration.from_pretrained(
323
+ CAPTIONER_REPO, torch_dtype=torch.bfloat16
324
+ )
325
+ model.requires_grad_(False)
326
+ model.eval()
327
+ print(f"[SCoPE] caption model ready in {time.time() - started:.0f}s", flush=True)
328
+ return model, processor
329
+
330
+
331
+ CAPTIONER, CAPTION_PROCESSOR = build_captioner()
332
+
333
+
334
+ def autocaption(image: Image.Image) -> str:
335
+ """Describe `image` with Qwen2-VL so a promptless request still has scene content."""
336
+ messages = [
337
+ {
338
+ "role": "user",
339
+ "content": [{"type": "image"}, {"type": "text", "text": CAPTION_INSTRUCTION}],
340
+ }
341
+ ]
342
+ text = CAPTION_PROCESSOR.apply_chat_template(
343
+ messages, tokenize=False, add_generation_prompt=True
344
+ )
345
+ inputs = CAPTION_PROCESSOR(text=[text], images=[image], return_tensors="pt")
346
+ CAPTIONER.to(DEVICE)
347
+ try:
348
+ inputs = inputs.to(DEVICE)
349
+ with torch.inference_mode():
350
+ generated = CAPTIONER.generate(**inputs, max_new_tokens=220, do_sample=False)
351
+ trimmed = generated[:, inputs["input_ids"].shape[1] :]
352
+ caption = CAPTION_PROCESSOR.batch_decode(
353
+ trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=True
354
+ )[0].strip()
355
+ finally:
356
+ CAPTIONER.to("cpu")
357
+ torch.cuda.empty_cache()
358
+ return caption
359
+
360
+
361
  # --------------------------------------------------------------------------------------
362
  # Inference
363
  # --------------------------------------------------------------------------------------
 
397
  # Measured on ZeroGPU (fp8 experts, 832x480x81): 4 steps -> 67.5s, 8 steps -> 124s,
398
  # i.e. ~14.1s per sampling step over ~11s of fixed text-encode/VAE cost. Keep a ~15%
399
  # margin and nothing more, so a default 4-step run stays inside the free 120s quota.
400
+ budget = 11.0 + 14.1 * int(steps)
401
+ if not (prompt or "").strip():
402
+ # Lifting Qwen2-VL onto the GPU and captioning the frame adds a fixed overhead.
403
+ budget += 12.0
404
+ return int(math.ceil(1.15 * budget))
405
 
406
 
407
  @spaces.GPU(duration=estimate_duration)
 
418
  ):
419
  first_frame = prepare_image(image)
420
  prompt = (prompt or "").strip()
421
+ autocaptioned = False
422
  if not prompt:
423
+ # SCoPE needs a caption for the content, so write one from the frame instead of
424
+ # failing on an empty prompt.
425
+ prompt = autocaption(first_frame)
426
+ autocaptioned = True
427
+ if not prompt:
428
+ raise gr.Error("Could not caption the image automatically — please add a description.")
429
 
430
  used_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
431
  poses = load_trajectory(trajectory, motion_scale)
 
467
  f"{int(steps)} steps · seed {used_seed} · {PRESET_LABELS.get(trajectory, trajectory)} "
468
  f"· motion x{motion_scale:g} · {elapsed:.0f}s"
469
  )
470
+ if autocaptioned:
471
+ status += f"\n\n*Auto-generated prompt (Qwen2-VL):* {prompt}"
472
  return output.name, used_seed, status
473
 
474
 
 
529
  # --------------------------------------------------------------------------------------
530
 
531
  EXAMPLES = [
532
+ [
533
+ str(HERE / "examples" / "istock-motorbike-rice-field.jpg"),
534
+ "A serene rural landscape in soft early-morning light with long shadows. A dirt road "
535
+ "winds through a lush green area; on the left, dense clusters of tall trees including palms "
536
+ "and a few small houses with red roofs, and on the right a vast expanse of green rice "
537
+ "paddies neatly divided into sections. A motorcycle travels along the dirt road, flanked by "
538
+ "utility poles with wires running along them. The overall atmosphere is peaceful and "
539
+ "idyllic, with greenery and natural elements dominating the scene.",
540
+ "orbit_left",
541
+ ],
542
  [
543
  str(HERE / "examples" / "ai-airmountains.jpg"),
544
  "A vast sky filled with multiple floating islands of different sizes, suspended above a "
 
571
  ],
572
  ]
573
 
574
+ # Scenes that contain a person / character. Kept in their own group so the showcase above
575
+ # stays character-free, per the release curation.
576
+ CHARACTER_EXAMPLES = [
577
+ [
578
+ str(HERE / "examples" / "omni-misty-forest.jpg"),
579
+ "A character in red armor and a straw hat progresses along a forest path, their steps "
580
+ "deliberate as they navigate over stones and through patches of grass. The environment is "
581
+ "a misty forest with ancient stone structures on the left and moss-covered cliffs on the "
582
+ "right. The dense fog and surrounding greenery contribute to a mysterious ambiance.",
583
+ "orbit_left",
584
+ ],
585
+ [
586
+ str(HERE / "examples" / "omni-horse-trail.jpg"),
587
+ "A character dressed in dark attire rides a white horse steadily along a rugged dirt path "
588
+ "that meanders through rocky terrain interspersed with patches of grass and shrubs. To the "
589
+ "left, a wooden fence lines the trail. In the distance, the landscape opens up to reveal "
590
+ "rolling hills covered in vegetation and distant mountains under a bright sky.",
591
+ "crane_up_fwd",
592
+ ],
593
+ ]
594
+
595
  CSS = """
596
  #col-container { margin: 0 auto; max-width: 1180px; }
597
  .dark .gradio-container { color: var(--body-text-color); }
 
607
  rays are normalised, gated and injected straight into the DiT's self-attention
608
  queries/keys, so a real 3D camera path drives the generated shot.
609
 
610
+ Drop in an image, pick a camera move, and (optionally) describe the scene — you get
611
+ an 81-frame, 832x480, 16 fps clip that follows that trajectory. Leave the description
612
+ blank and a small vision-language model writes one from your image automatically.
613
  """
614
  )
615
  with gr.Row():
 
619
  )
620
  prompt_input = gr.Textbox(
621
  label="Scene description",
622
+ placeholder="Describe what is in the image (leave empty to auto-caption it)",
623
  lines=4,
624
+ info="Optional — if left blank, Qwen2-VL writes a caption from your image.",
625
  )
626
  trajectory_input = gr.Dropdown(
627
  label="Camera move",
 
640
  )
641
  status_output = gr.Markdown()
642
 
643
+ gr.Examples(
644
+ examples=EXAMPLES,
645
+ inputs=[image_input, prompt_input, trajectory_input],
646
+ outputs=[video_output, seed_input, status_output],
647
+ fn=generate,
648
+ cache_examples=True,
649
+ cache_mode="lazy",
650
+ label="Examples — click a row to load its image, prompt and camera move",
651
+ examples_per_page=8,
652
+ )
653
+ gr.Examples(
654
+ examples=CHARACTER_EXAMPLES,
655
+ inputs=[image_input, prompt_input, trajectory_input],
656
+ outputs=[video_output, seed_input, status_output],
657
+ fn=generate,
658
+ cache_examples=True,
659
+ cache_mode="lazy",
660
+ label="Examples with characters",
661
+ examples_per_page=8,
662
+ )
663
+
664
  with gr.Accordion("Advanced settings", open=False):
665
  with gr.Row():
666
  steps_input = gr.Slider(
 
696
  )
697
  randomize_input = gr.Checkbox(label="Randomize seed", value=True)
698
 
 
 
 
 
 
 
 
 
 
 
699
  gr.Markdown(
700
  """
701
  **Notes** · Camera paths are OpenCV camera-to-world matrices `[81, 3, 4]` relative to
trajectories/pan_right.npy → examples/istock-motorbike-rice-field.jpg RENAMED
File without changes
examples/omni-horse-trail.jpg ADDED

Git LFS Details

  • SHA256: c30e5ec94736745579a69e9a5fa9b7c6ba7d83fb2012cab92bde9b06e760defc
  • Pointer size: 131 Bytes
  • Size of remote file: 357 kB
examples/omni-misty-forest.jpg ADDED

Git LFS Details

  • SHA256: a6d0775a56dd7e147a2d8b71e0aba3fecce19944936e93fb96c8699cb8394617
  • Pointer size: 131 Bytes
  • Size of remote file: 150 kB