FeilongTang commited on
Commit
27a601e
·
1 Parent(s): 5c5ab4f

Rename to OneVision Patch Inspector + presets, charts, UI polish

Browse files

- Rename title in README and Hero block to "OneVision Patch Inspector".
- Run info JSON moved into a default-collapsed Accordion to keep the
output area tidy; a small summary blurb sits above it.
- Visualization mode and saliency signal Radios now use (label, value)
pairs so the meaning of "sbs" / "frame_diff" / etc. is visible.
- Quick presets: three buttons (Detail / Motion / Balanced) wire through
to atomically set saliency_signal + log + percentile + viz_mode.
- Charts panel: matplotlib gr.Plot with a log-y patch-score histogram
and a per-frame selected-count line, refreshed each run.
- CSS pass: bigger hero with subtle radial glow, accent-bullet card
headers, larger gradient Run button with hover lift, soft-gradient
preset chips, footer divider. Container widened to 1320px.
- requirements: matplotlib added (used for the charts panel).

Files changed (3) hide show
  1. README.md +2 -2
  2. app.py +219 -54
  3. requirements.txt +1 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: OneVision Encoder Codec View
3
  emoji: 🏆
4
  colorFrom: blue
5
  colorTo: indigo
@@ -9,7 +9,7 @@ python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
- short_description: For online video codec testing
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: OneVision Patch Inspector
3
  emoji: 🏆
4
  colorFrom: blue
5
  colorTo: indigo
 
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
+ short_description: Inspect which video patches a codec-style saliency picks
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py CHANGED
@@ -32,6 +32,9 @@ from typing import List, Tuple
32
  import cv2
33
  import gradio as gr
34
  import imageio_ffmpeg
 
 
 
35
  import numpy as np
36
 
37
 
@@ -315,6 +318,42 @@ def pack_canvas(
315
  return canvas, n
316
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  def process(
319
  video_path,
320
  sample_frames: int,
@@ -332,7 +371,7 @@ def process(
332
  progress=gr.Progress(track_tqdm=False),
333
  ):
334
  if not video_path:
335
- return None, None, "Please upload a video."
336
 
337
  t0 = time.time()
338
  progress(0.05, desc="Reading metadata")
@@ -342,7 +381,7 @@ def process(
342
  return None, None, json.dumps(
343
  {"error": "Could not read frame count.", "metadata": meta},
344
  indent=2, ensure_ascii=False,
345
- )
346
 
347
  progress(0.10, desc="Sampling frames")
348
  fps = float(meta.get("fps") or 0.0)
@@ -372,7 +411,7 @@ def process(
372
  return None, None, json.dumps(
373
  {"error": "Failed to decode frames.", "metadata": meta},
374
  indent=2, ensure_ascii=False,
375
- )
376
 
377
  progress(0.25, desc="smart_resize")
378
  resized = [smart_resize(f, int(max_pixels), int(patch_size)) for f in raw]
@@ -455,85 +494,164 @@ def process(
455
  + (" (log1p applied)" if score_log_scale else ""),
456
  "elapsed_sec": round(time.time() - t0, 2),
457
  }
 
 
 
458
  progress(1.0, desc="Done")
459
- return vis_path, canvas_path, json.dumps(info, indent=2, ensure_ascii=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
 
462
  CUSTOM_CSS = """
463
  :root, .gradio-container, .gradio-container.dark {
464
  --ovc-grad: linear-gradient(135deg, #4f46e5 0%, #2563eb 50%, #06b6d4 100%);
 
465
  }
466
- .gradio-container { max-width: 1280px !important; margin: 0 auto !important; }
 
 
467
  #ovc-hero {
468
  text-align: center;
469
- padding: 28px 16px 8px;
470
- border-radius: 16px;
471
- background: linear-gradient(180deg, rgba(79,70,229,0.08), rgba(6,182,212,0.04));
472
- margin-bottom: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
473
  }
474
  #ovc-hero h1 {
475
- font-size: 2.1rem;
476
- font-weight: 700;
477
  background: var(--ovc-grad);
478
  -webkit-background-clip: text;
479
  background-clip: text;
480
  color: transparent;
481
- margin: 0 0 6px;
482
- letter-spacing: -0.02em;
 
483
  }
484
  #ovc-hero p.tagline {
485
- font-size: 1.02rem;
486
  color: var(--body-text-color-subdued);
487
- margin: 0 auto 12px;
488
- max-width: 720px;
489
- line-height: 1.55;
490
  }
491
- #ovc-hero .pills { display:flex; flex-wrap:wrap; gap:6px; justify-content:center; margin-top:6px; }
492
  #ovc-hero .pill {
493
  font-size: 0.78rem;
494
  font-weight: 600;
495
- padding: 4px 10px;
496
  border-radius: 999px;
497
  color: #fff;
498
  background: var(--ovc-grad);
499
- opacity: 0.92;
 
500
  }
 
 
501
  .ovc-card {
502
- border-radius: 14px !important;
503
  padding: 14px 16px !important;
504
- border: 1px solid var(--border-color-primary) !important;
505
  background: var(--background-fill-primary) !important;
506
- box-shadow: 0 1px 2px rgba(0,0,0,0.04);
507
  }
508
  .ovc-card h3 {
509
- font-size: 0.86rem !important;
510
  font-weight: 700 !important;
511
  text-transform: uppercase;
512
- letter-spacing: 0.06em;
513
- color: var(--body-text-color-subdued) !important;
514
- margin: 0 0 8px !important;
 
 
 
 
 
 
 
 
515
  }
 
 
516
  #ovc-run button {
517
  width: 100%;
518
- height: 48px !important;
519
- font-size: 1.02rem !important;
520
- font-weight: 600 !important;
 
521
  background: var(--ovc-grad) !important;
522
  border: none !important;
523
  color: #fff !important;
524
- border-radius: 12px !important;
525
- box-shadow: 0 4px 14px rgba(37, 99, 235, 0.35);
526
- transition: transform 0.05s ease;
 
 
 
 
527
  }
528
- #ovc-run button:hover { transform: translateY(-1px); }
529
  #ovc-run button:active { transform: translateY(0); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  #ovc-footer {
531
  text-align: center;
532
  color: var(--body-text-color-subdued);
533
- font-size: 0.78rem;
534
- padding: 18px 8px 8px;
535
- margin-top: 10px;
 
 
 
 
 
 
536
  }
 
 
 
537
  """
538
 
539
  THEME = gr.themes.Soft(
@@ -550,16 +668,16 @@ THEME = gr.themes.Soft(
550
 
551
  HERO_HTML = """
552
  <div id="ovc-hero">
553
- <h1>OneVision Encoder Codec View</h1>
554
  <p class="tagline">
555
- Visualize which patches a codec-style saliency picks from your video,
556
  then pack them into the canvas LLaVA-OneVision2 consumes.
557
- Use it to inspect <i>where</i> the model is actually looking.
558
  </p>
559
  <div class="pills">
560
- <span class="pill">selection · heatmap · sbs</span>
561
- <span class="pill">gradient + motion signals</span>
562
- <span class="pill">canvas export</span>
563
  </div>
564
  </div>
565
  """
@@ -568,7 +686,7 @@ try:
568
  _GR_MAJOR = int(gr.__version__.split(".")[0])
569
  except Exception:
570
  _GR_MAJOR = 4
571
- _BLOCK_KW: dict = {"title": "OneVision Encoder Codec View"}
572
  _LAUNCH_KW: dict = {}
573
  if _GR_MAJOR >= 6:
574
  # In Gradio 6.0 these moved off Blocks(...) onto launch(...).
@@ -578,6 +696,19 @@ else:
578
  _BLOCK_KW["theme"] = THEME
579
  _BLOCK_KW["css"] = CUSTOM_CSS
580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  with gr.Blocks(**_BLOCK_KW) as demo:
582
  gr.HTML(HERO_HTML)
583
 
@@ -591,7 +722,7 @@ with gr.Blocks(**_BLOCK_KW) as demo:
591
  with gr.Group(elem_classes="ovc-card"):
592
  gr.Markdown("### Pipeline")
593
  viz_mode = gr.Radio(
594
- ["selection", "heatmap", "sbs"], value="selection",
595
  label="Visualization mode",
596
  )
597
  sample_frames = gr.Slider(
@@ -604,6 +735,18 @@ with gr.Blocks(**_BLOCK_KW) as demo:
604
  PATCH_CHOICES, value=14, label="Patch size (px)",
605
  )
606
 
 
 
 
 
 
 
 
 
 
 
 
 
607
  with gr.Accordion("Time window", open=False):
608
  with gr.Row():
609
  start_sec = gr.Number(value=0.0, precision=2, label="Start (s)")
@@ -614,18 +757,18 @@ with gr.Blocks(**_BLOCK_KW) as demo:
614
 
615
  with gr.Accordion("Saliency", open=False):
616
  saliency_signal = gr.Radio(
617
- ["gradient", "frame_diff", "combined"], value="gradient",
618
  label="Scoring signal",
619
- info="gradient = intra-frame Sobel · "
620
- "frame_diff = inter-frame motion · "
621
- "combined = 0.5 each.",
622
  )
623
  score_log_scale = gr.Checkbox(
624
- value=False, label="Apply log1p to scores",
 
 
625
  )
626
  bitcost_pct = gr.Slider(
627
  80.0, 99.9, value=99.0, step=0.1,
628
  label="Heatmap normalization percentile",
 
629
  )
630
 
631
  with gr.Accordion("Visual style", open=False):
@@ -652,6 +795,11 @@ with gr.Blocks(**_BLOCK_KW) as demo:
652
  vis_out = gr.Video(
653
  label="", show_label=False, autoplay=True, height=420,
654
  )
 
 
 
 
 
655
  with gr.Row():
656
  with gr.Column(scale=1):
657
  with gr.Group(elem_classes="ovc-card"):
@@ -661,10 +809,15 @@ with gr.Blocks(**_BLOCK_KW) as demo:
661
  )
662
  with gr.Column(scale=1):
663
  with gr.Group(elem_classes="ovc-card"):
664
- gr.Markdown("### Run info")
665
- info_out = gr.Code(
666
- label="", language="json", lines=14,
 
667
  )
 
 
 
 
668
 
669
  gr.HTML(
670
  '<div id="ovc-footer">'
@@ -674,6 +827,18 @@ with gr.Blocks(**_BLOCK_KW) as demo:
674
  '</div>'
675
  )
676
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  run_btn.click(
678
  process,
679
  inputs=[
@@ -682,7 +847,7 @@ with gr.Blocks(**_BLOCK_KW) as demo:
682
  start_sec, end_sec,
683
  saliency_signal, score_log_scale, bitcost_pct, fade_strength,
684
  ],
685
- outputs=[vis_out, canvas_out, info_out],
686
  )
687
 
688
 
 
32
  import cv2
33
  import gradio as gr
34
  import imageio_ffmpeg
35
+ import matplotlib
36
+ matplotlib.use("Agg")
37
+ import matplotlib.pyplot as plt
38
  import numpy as np
39
 
40
 
 
318
  return canvas, n
319
 
320
 
321
+ def make_charts(
322
+ grids: List[np.ndarray], masks: List[np.ndarray], saliency_signal: str,
323
+ ):
324
+ """Two side-by-side panels:
325
+ - patch score histogram across all sampled frames (log-y)
326
+ - selected patch count per sampled frame index"""
327
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8.6, 3.0), constrained_layout=True)
328
+ all_scores = np.concatenate([g.flatten().astype(np.float32) for g in grids])
329
+ ax1.hist(all_scores, bins=40, color="#4f46e5", alpha=0.85,
330
+ edgecolor="#312e81", linewidth=0.4)
331
+ ax1.set_yscale("log")
332
+ ax1.set_title(f"Patch score distribution · {saliency_signal}",
333
+ fontsize=10, color="#1e293b")
334
+ ax1.set_xlabel("score", fontsize=9)
335
+ ax1.set_ylabel("patches (log)", fontsize=9)
336
+ ax1.tick_params(axis="both", which="both", labelsize=8)
337
+ ax1.grid(True, alpha=0.25, linestyle="--")
338
+ ax1.spines[["top", "right"]].set_visible(False)
339
+
340
+ counts = [int(m.sum()) for m in masks]
341
+ xs = list(range(len(counts)))
342
+ ax2.plot(xs, counts, "o-", color="#06b6d4", linewidth=2.0,
343
+ markersize=5, markeredgecolor="#0e7490")
344
+ ax2.fill_between(xs, counts, alpha=0.15, color="#06b6d4")
345
+ ax2.set_title("Selected patches per sampled frame",
346
+ fontsize=10, color="#1e293b")
347
+ ax2.set_xlabel("sampled frame index", fontsize=9)
348
+ ax2.set_ylabel("# selected", fontsize=9)
349
+ ax2.tick_params(axis="both", which="both", labelsize=8)
350
+ ax2.grid(True, alpha=0.25, linestyle="--")
351
+ ax2.spines[["top", "right"]].set_visible(False)
352
+
353
+ fig.patch.set_facecolor("white")
354
+ return fig
355
+
356
+
357
  def process(
358
  video_path,
359
  sample_frames: int,
 
371
  progress=gr.Progress(track_tqdm=False),
372
  ):
373
  if not video_path:
374
+ return None, None, "Please upload a video.", None
375
 
376
  t0 = time.time()
377
  progress(0.05, desc="Reading metadata")
 
381
  return None, None, json.dumps(
382
  {"error": "Could not read frame count.", "metadata": meta},
383
  indent=2, ensure_ascii=False,
384
+ ), None
385
 
386
  progress(0.10, desc="Sampling frames")
387
  fps = float(meta.get("fps") or 0.0)
 
411
  return None, None, json.dumps(
412
  {"error": "Failed to decode frames.", "metadata": meta},
413
  indent=2, ensure_ascii=False,
414
+ ), None
415
 
416
  progress(0.25, desc="smart_resize")
417
  resized = [smart_resize(f, int(max_pixels), int(patch_size)) for f in raw]
 
494
  + (" (log1p applied)" if score_log_scale else ""),
495
  "elapsed_sec": round(time.time() - t0, 2),
496
  }
497
+ progress(0.95, desc="Building charts")
498
+ chart_fig = make_charts(grids, masks, saliency_signal)
499
+
500
  progress(1.0, desc="Done")
501
+ return (
502
+ vis_path, canvas_path,
503
+ json.dumps(info, indent=2, ensure_ascii=False),
504
+ chart_fig,
505
+ )
506
+
507
+
508
+ PRESETS = {
509
+ # (saliency_signal, score_log_scale, bitcost_pct, viz_mode)
510
+ "detail": ("gradient", False, 99.0, "heatmap"),
511
+ "motion": ("frame_diff", False, 95.0, "heatmap"),
512
+ "balanced": ("combined", True, 96.0, "sbs"),
513
+ }
514
+
515
+
516
+ def apply_preset(name: str):
517
+ sig, log_, pct, mode = PRESETS[name]
518
+ return sig, log_, pct, mode
519
 
520
 
521
  CUSTOM_CSS = """
522
  :root, .gradio-container, .gradio-container.dark {
523
  --ovc-grad: linear-gradient(135deg, #4f46e5 0%, #2563eb 50%, #06b6d4 100%);
524
+ --ovc-grad-soft: linear-gradient(135deg, rgba(79,70,229,0.10), rgba(6,182,212,0.10));
525
  }
526
+ .gradio-container { max-width: 1320px !important; margin: 0 auto !important; }
527
+
528
+ /* Hero */
529
  #ovc-hero {
530
  text-align: center;
531
+ padding: 36px 16px 18px;
532
+ border-radius: 18px;
533
+ background:
534
+ radial-gradient(120% 80% at 50% -10%, rgba(79,70,229,0.18), transparent 60%),
535
+ linear-gradient(180deg, rgba(79,70,229,0.06), rgba(6,182,212,0.03));
536
+ border: 1px solid rgba(99,102,241,0.18);
537
+ margin-bottom: 14px;
538
+ position: relative;
539
+ overflow: hidden;
540
+ }
541
+ #ovc-hero::after {
542
+ content: "";
543
+ position: absolute; inset: auto -20% -40% -20%;
544
+ height: 60%;
545
+ background: radial-gradient(60% 80% at 50% 0%, rgba(6,182,212,0.18), transparent 70%);
546
+ pointer-events: none;
547
  }
548
  #ovc-hero h1 {
549
+ font-size: 2.4rem;
550
+ font-weight: 800;
551
  background: var(--ovc-grad);
552
  -webkit-background-clip: text;
553
  background-clip: text;
554
  color: transparent;
555
+ margin: 0 0 8px;
556
+ letter-spacing: -0.025em;
557
+ line-height: 1.05;
558
  }
559
  #ovc-hero p.tagline {
560
+ font-size: 1.04rem;
561
  color: var(--body-text-color-subdued);
562
+ margin: 0 auto 14px;
563
+ max-width: 760px;
564
+ line-height: 1.6;
565
  }
566
+ #ovc-hero .pills { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:8px; }
567
  #ovc-hero .pill {
568
  font-size: 0.78rem;
569
  font-weight: 600;
570
+ padding: 5px 12px;
571
  border-radius: 999px;
572
  color: #fff;
573
  background: var(--ovc-grad);
574
+ opacity: 0.94;
575
+ box-shadow: 0 1px 6px rgba(79,70,229,0.25);
576
  }
577
+
578
+ /* Cards */
579
  .ovc-card {
580
+ border-radius: 16px !important;
581
  padding: 14px 16px !important;
582
+ border: 1px solid rgba(148,163,184,0.30) !important;
583
  background: var(--background-fill-primary) !important;
584
+ box-shadow: 0 1px 3px rgba(15,23,42,0.04);
585
  }
586
  .ovc-card h3 {
587
+ font-size: 0.78rem !important;
588
  font-weight: 700 !important;
589
  text-transform: uppercase;
590
+ letter-spacing: 0.08em;
591
+ color: #4f46e5 !important;
592
+ margin: 0 0 10px !important;
593
+ }
594
+ .ovc-card h3::before {
595
+ content: "";
596
+ display: inline-block;
597
+ width: 6px; height: 6px; border-radius: 50%;
598
+ background: var(--ovc-grad);
599
+ margin-right: 8px; vertical-align: middle;
600
+ transform: translateY(-1px);
601
  }
602
+
603
+ /* Run button */
604
  #ovc-run button {
605
  width: 100%;
606
+ height: 52px !important;
607
+ font-size: 1.05rem !important;
608
+ font-weight: 700 !important;
609
+ letter-spacing: 0.01em;
610
  background: var(--ovc-grad) !important;
611
  border: none !important;
612
  color: #fff !important;
613
+ border-radius: 14px !important;
614
+ box-shadow: 0 6px 18px rgba(37, 99, 235, 0.32);
615
+ transition: transform 0.06s ease, box-shadow 0.2s ease;
616
+ }
617
+ #ovc-run button:hover {
618
+ transform: translateY(-1px);
619
+ box-shadow: 0 8px 22px rgba(37, 99, 235, 0.40);
620
  }
 
621
  #ovc-run button:active { transform: translateY(0); }
622
+
623
+ /* Preset buttons */
624
+ .ovc-preset button {
625
+ background: var(--ovc-grad-soft) !important;
626
+ color: #4338ca !important;
627
+ border: 1px solid rgba(79,70,229,0.25) !important;
628
+ border-radius: 10px !important;
629
+ font-weight: 600 !important;
630
+ transition: all 0.15s ease;
631
+ }
632
+ .ovc-preset button:hover {
633
+ background: var(--ovc-grad) !important;
634
+ color: #fff !important;
635
+ border-color: transparent !important;
636
+ }
637
+
638
+ /* Footer */
639
  #ovc-footer {
640
  text-align: center;
641
  color: var(--body-text-color-subdued);
642
+ font-size: 0.80rem;
643
+ padding: 22px 8px 10px;
644
+ margin-top: 14px;
645
+ border-top: 1px solid rgba(148,163,184,0.18);
646
+ }
647
+ #ovc-footer code {
648
+ background: rgba(79,70,229,0.08);
649
+ padding: 1px 6px;
650
+ border-radius: 4px;
651
  }
652
+
653
+ /* Tighter spacing for sliders inside cards */
654
+ .ovc-card .gradio-slider { margin-bottom: 4px !important; }
655
  """
656
 
657
  THEME = gr.themes.Soft(
 
668
 
669
  HERO_HTML = """
670
  <div id="ovc-hero">
671
+ <h1>OneVision Patch Inspector</h1>
672
  <p class="tagline">
673
+ See which patches a codec-style saliency picks from your video,
674
  then pack them into the canvas LLaVA-OneVision2 consumes.
675
+ A visual lab for inspecting <i>where</i> the encoder &mdash; and the model &mdash; is actually looking.
676
  </p>
677
  <div class="pills">
678
+ <span class="pill">selection · heatmap · side-by-side</span>
679
+ <span class="pill">gradient + motion saliency</span>
680
+ <span class="pill">presets · charts · canvas export</span>
681
  </div>
682
  </div>
683
  """
 
686
  _GR_MAJOR = int(gr.__version__.split(".")[0])
687
  except Exception:
688
  _GR_MAJOR = 4
689
+ _BLOCK_KW: dict = {"title": "OneVision Patch Inspector"}
690
  _LAUNCH_KW: dict = {}
691
  if _GR_MAJOR >= 6:
692
  # In Gradio 6.0 these moved off Blocks(...) onto launch(...).
 
696
  _BLOCK_KW["theme"] = THEME
697
  _BLOCK_KW["css"] = CUSTOM_CSS
698
 
699
+
700
+ VIZ_CHOICES = [
701
+ ("Selection — kept patches in color, others fade to gray-white", "selection"),
702
+ ("Heatmap — full-frame JET overlay (blue=low, red=high)", "heatmap"),
703
+ ("Side-by-side — selection on the left, heatmap on the right", "sbs"),
704
+ ]
705
+ SIGNAL_CHOICES = [
706
+ ("Gradient — intra-frame Sobel (sharp edges, textures, text)", "gradient"),
707
+ ("Frame diff — inter-frame motion (movers, action)", "frame_diff"),
708
+ ("Combined — 0.5·gradient + 0.5·frame_diff (general purpose)", "combined"),
709
+ ]
710
+
711
+
712
  with gr.Blocks(**_BLOCK_KW) as demo:
713
  gr.HTML(HERO_HTML)
714
 
 
722
  with gr.Group(elem_classes="ovc-card"):
723
  gr.Markdown("### Pipeline")
724
  viz_mode = gr.Radio(
725
+ VIZ_CHOICES, value="selection",
726
  label="Visualization mode",
727
  )
728
  sample_frames = gr.Slider(
 
735
  PATCH_CHOICES, value=14, label="Patch size (px)",
736
  )
737
 
738
+ with gr.Group(elem_classes="ovc-card"):
739
+ gr.Markdown("### Quick presets")
740
+ with gr.Row(elem_classes="ovc-preset"):
741
+ btn_detail = gr.Button("Detail", size="sm")
742
+ btn_motion = gr.Button("Motion", size="sm")
743
+ btn_balanced = gr.Button("Balanced", size="sm")
744
+ gr.Markdown(
745
+ "<small><b>Detail</b>: gradient · pct=99 · heatmap &nbsp;|&nbsp; "
746
+ "<b>Motion</b>: frame_diff · pct=95 · heatmap &nbsp;|&nbsp; "
747
+ "<b>Balanced</b>: combined · log · pct=96 · sbs</small>"
748
+ )
749
+
750
  with gr.Accordion("Time window", open=False):
751
  with gr.Row():
752
  start_sec = gr.Number(value=0.0, precision=2, label="Start (s)")
 
757
 
758
  with gr.Accordion("Saliency", open=False):
759
  saliency_signal = gr.Radio(
760
+ SIGNAL_CHOICES, value="gradient",
761
  label="Scoring signal",
 
 
 
762
  )
763
  score_log_scale = gr.Checkbox(
764
+ value=False,
765
+ label="Apply log1p to scores",
766
+ info="Compresses dynamic range — brings up mid-energy patches.",
767
  )
768
  bitcost_pct = gr.Slider(
769
  80.0, 99.9, value=99.0, step=0.1,
770
  label="Heatmap normalization percentile",
771
+ info="Higher = harder to saturate red; lower = more vivid.",
772
  )
773
 
774
  with gr.Accordion("Visual style", open=False):
 
795
  vis_out = gr.Video(
796
  label="", show_label=False, autoplay=True, height=420,
797
  )
798
+
799
+ with gr.Group(elem_classes="ovc-card"):
800
+ gr.Markdown("### Score distribution & per-frame patch count")
801
+ chart_out = gr.Plot(label="", show_label=False)
802
+
803
  with gr.Row():
804
  with gr.Column(scale=1):
805
  with gr.Group(elem_classes="ovc-card"):
 
809
  )
810
  with gr.Column(scale=1):
811
  with gr.Group(elem_classes="ovc-card"):
812
+ gr.Markdown("### Run summary")
813
+ gr.Markdown(
814
+ "<small>The full per-run JSON is collapsed below "
815
+ "to keep things tidy. Click to expand.</small>"
816
  )
817
+ with gr.Accordion("Show full run info (JSON)", open=False):
818
+ info_out = gr.Code(
819
+ label="", language="json", lines=18,
820
+ )
821
 
822
  gr.HTML(
823
  '<div id="ovc-footer">'
 
827
  '</div>'
828
  )
829
 
830
+ # Preset wiring: each updates 4 controls atomically.
831
+ for btn, key in [
832
+ (btn_detail, "detail"),
833
+ (btn_motion, "motion"),
834
+ (btn_balanced, "balanced"),
835
+ ]:
836
+ btn.click(
837
+ lambda k=key: apply_preset(k),
838
+ inputs=None,
839
+ outputs=[saliency_signal, score_log_scale, bitcost_pct, viz_mode],
840
+ )
841
+
842
  run_btn.click(
843
  process,
844
  inputs=[
 
847
  start_sec, end_sec,
848
  saliency_signal, score_log_scale, bitcost_pct, fade_strength,
849
  ],
850
+ outputs=[vis_out, canvas_out, info_out, chart_out],
851
  )
852
 
853
 
requirements.txt CHANGED
@@ -2,4 +2,5 @@ opencv-python-headless>=4.8
2
  numpy>=1.24
3
  imageio>=2.34
4
  imageio-ffmpeg>=0.5
 
5
  Pillow>=10.0
 
2
  numpy>=1.24
3
  imageio>=2.34
4
  imageio-ffmpeg>=0.5
5
+ matplotlib>=3.7
6
  Pillow>=10.0