FeilongTang commited on
Commit
2f7884e
·
1 Parent(s): 0c2f8f4

Fix cumulative patches chart sampling baselines

Browse files
Files changed (1) hide show
  1. app.py +150 -86
app.py CHANGED
@@ -8,11 +8,12 @@ high local complexity = roughly what the encoder would spend bits on).
8
 
9
  Pipeline (mirrors codec_tools/pipeline/process_video_bitcost_readiness.py):
10
  1. Uniformly sample N frames from the input video.
11
- 2. smart_resize each frame so dims are multiples of `patch` and the
12
- total pixel count <= max_pixels.
13
  3. Slice every frame into a patch grid; score each patch by its
14
  Sobel gradient magnitude mean.
15
- 4. Pick the top-K highest-scoring patches per frame.
 
16
  5. Render a "selection visualization" video: kept patches stay in
17
  full color, dropped patches are faded to a gray-white wash so the
18
  viewer can see exactly which patches the codec stage chose.
@@ -21,7 +22,6 @@ Pipeline (mirrors codec_tools/pipeline/process_video_bitcost_readiness.py):
21
  """
22
 
23
  import json
24
- import math
25
  import os
26
  import shutil
27
  import subprocess
@@ -48,7 +48,7 @@ DEMO_PRESET = (
48
  DEMO_VIDEO_PATH, # video_in
49
  16, # sample_frames
50
  14, # patch_size
51
- 1024, # total_patches
52
  150000, # max_pixels
53
  "sbs", # viz_mode
54
  0.55, # heatmap_alpha
@@ -63,16 +63,22 @@ DEMO_PRESET = (
63
 
64
 
65
  def smart_resize(frame: np.ndarray, max_pixels: int, factor: int) -> np.ndarray:
66
- """Resize so h,w are multiples of `factor` and h*w <= max_pixels."""
67
- h, w = frame.shape[:2]
68
- pixels = h * w
69
- if pixels > max_pixels:
70
- scale = math.sqrt(max_pixels / pixels)
71
- h = max(factor, int(h * scale))
72
- w = max(factor, int(w * scale))
73
- h = max(factor, (h // factor) * factor)
74
- w = max(factor, (w // factor) * factor)
75
- return cv2.resize(frame, (w, h), interpolation=cv2.INTER_AREA)
 
 
 
 
 
 
76
 
77
 
78
  def sample_frame_ids(total: int, n: int) -> List[int]:
@@ -83,6 +89,27 @@ def sample_frame_ids(total: int, n: int) -> List[int]:
83
  return [int(round(i)) for i in np.linspace(0, total - 1, n)]
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def decode_frames(video_path: str, frame_ids: List[int]) -> List[np.ndarray]:
87
  cap = cv2.VideoCapture(video_path)
88
  if not cap.isOpened():
@@ -200,8 +227,10 @@ def topk_mask(score: np.ndarray, k: int) -> np.ndarray:
200
  return np.ones_like(score, dtype=np.uint8)
201
  if k <= 0:
202
  return np.zeros_like(score, dtype=np.uint8)
203
- thresh = np.partition(flat, -k)[-k]
204
- return (score >= thresh).astype(np.uint8)
 
 
205
 
206
 
207
  def global_topk_masks(
@@ -218,15 +247,17 @@ def global_topk_masks(
218
  arr = np.stack(grids, axis=0).astype(np.float32) # [N, hb, wb]
219
  N, hb, wb = arr.shape
220
  flat = arr.reshape(-1)
221
- if total_k >= flat.size:
 
222
  masks = [np.ones((hb, wb), dtype=np.uint8) for _ in range(N)]
223
  return masks, int(flat.size)
224
- if total_k <= 0:
225
  return [np.zeros((hb, wb), dtype=np.uint8) for _ in range(N)], 0
226
- thresh = np.partition(flat, -total_k)[-total_k]
227
- bool_mask = (arr >= thresh)
228
- actual = int(bool_mask.sum())
229
- return [bool_mask[i].astype(np.uint8) for i in range(N)], actual
 
230
 
231
 
232
  def build_dynamic_groups(
@@ -323,14 +354,35 @@ def grouped_topk_masks(
323
  cursor = end + 1
324
 
325
  num_groups = max(1, len(groups))
326
- per_group_budget = max(1, int(total_k) // num_groups)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
  # Initialize empty masks, then fill per-group selections.
329
  out_masks = [np.zeros(g.shape, dtype=np.uint8) for g in grids]
330
  actual_total = 0
331
- for (s, e) in groups:
332
  sub = grids[s:e + 1]
333
- sub_masks, sub_actual = global_topk_masks(sub, per_group_budget)
334
  for i, sm in enumerate(sub_masks):
335
  out_masks[s + i] = sm
336
  actual_total += sub_actual
@@ -573,7 +625,9 @@ def pack_canvases_per_group(
573
  def make_charts(
574
  grids: List[np.ndarray],
575
  masks: List[np.ndarray],
576
- frame_ids: List[int],
 
 
577
  fps: float,
578
  total_duration_sec: float,
579
  total_patches_budget: int,
@@ -582,14 +636,13 @@ def make_charts(
582
  gop_label: str = "global",
583
  ):
584
  """One overlaid step chart: cumulative patches selected vs time, for
585
- the codec saliency curve and a uniform-sampling baseline at the same
586
- total budget.
587
 
588
  X = time (s)
589
  Y = cumulative count of selected patches
590
- Both curves end near the budget (codec: == total selected; uniform:
591
- n_uniform_frames × grid_size, budget). The codec curve rises in
592
- bursts where saliency is high; uniform rises in equal steps."""
593
  fig, ax = plt.subplots(figsize=(9.2, 3.6), constrained_layout=True)
594
 
595
  fps_safe = float(fps) if fps and fps > 0 else 25.0
@@ -598,8 +651,9 @@ def make_charts(
598
  else:
599
  hb = wb = 1
600
  grid_size = hb * wb
 
601
  duration = float(total_duration_sec) if total_duration_sec and total_duration_sec > 0 else (
602
- (max(frame_ids) / fps_safe) if frame_ids else 1.0
603
  )
604
 
605
  # ─── Build step curves ──────────────────────────────────────────────
@@ -615,27 +669,22 @@ def make_charts(
615
  xx.append(duration); yy.append(prev)
616
  return xx, yy
617
 
618
- times = [fid / fps_safe for fid in frame_ids]
619
  counts = [int(m.sum()) for m in masks]
620
  codec_cum = list(np.cumsum(counts)) if counts else []
621
  codec_total = int(codec_cum[-1]) if codec_cum else 0
622
  xx_c, yy_c = _step(times, codec_cum)
623
 
624
- # Uniform baseline: same N frames as codec (at the same timestamps),
625
- # but the patch budget is split equally across them. Both curves now
626
- # reach the same budget — what differs is *which* patches each method
627
- # picks within each frame (saliency vs equal-allocation).
628
- n_uniform = len(times) if times else 1
629
  budget_int = int(total_patches_budget)
630
- if n_uniform > 0 and budget_int > 0:
631
- base = budget_int // n_uniform
632
- rem = budget_int - base * n_uniform
633
- uni_per_step = [base + (1 if i < rem else 0) for i in range(n_uniform)]
634
- else:
635
- uni_per_step = []
636
  uni_cum = list(np.cumsum(uni_per_step)) if uni_per_step else []
637
  uni_total = int(uni_cum[-1]) if uni_cum else 0
638
- uni_times = times if times else [duration * 0.5]
639
  xx_u, yy_u = _step(uni_times, uni_cum)
640
 
641
  # ─── Plot ───────────────────────────────────────────────────────────
@@ -650,17 +699,22 @@ def make_charts(
650
  else:
651
  codec_lbl = f"Codec · {saliency_signal} ({codec_total:,} patches)"
652
  if uni_per_step:
653
- u_per = uni_per_step[0]
654
- u_extra = sum(1 for x in uni_per_step if x != u_per)
655
- if u_extra == 0:
656
- uni_lbl = f"Uniform baseline ({uni_total:,} total · {u_per}/frame)"
657
- else:
658
- uni_lbl = (
659
- f"Uniform baseline ({uni_total:,} total · "
660
- f"~{budget_int // max(1, n_uniform)}/frame, ±1)"
661
- )
 
 
662
  else:
663
- uni_lbl = f"Uniform baseline ({uni_total:,} patches)"
 
 
 
664
 
665
  ax.fill_between(xx_c, yy_c, step=None, alpha=0.12, color="#4f46e5")
666
  ax.plot(xx_c, yy_c, color="#4f46e5", linewidth=2.2, label=codec_lbl)
@@ -838,12 +892,16 @@ def process(
838
 
839
  hb, wb = grids[0].shape
840
  grid_size = int(grids[0].shape[0] * grids[0].shape[1]) if grids else 0
841
- # Uniform baseline samples the SAME number of frames as codec, evenly
842
- # spaced in time; the budget is split equally across them.
843
- n_uniform = max(1, len(fids))
844
- uniform_per_frame = (
845
- int(int(total_patches)) // n_uniform if n_uniform > 0 else 0
 
 
846
  )
 
 
847
  info = {
848
  "input": meta,
849
  "params": {
@@ -872,22 +930,25 @@ def process(
872
  "frame_window": {
873
  "first_decoded": int(f_start),
874
  "last_decoded": int(f_end),
875
- "actual_frame_ids": [int(x) for x in fids],
 
876
  },
877
  "codec_per_frame_patches": [int(m.sum()) for m in masks],
878
  "uniform_baseline": {
879
- "frames": int(n_uniform),
880
- "patches_per_frame": int(uniform_per_frame),
881
- "total_patches": int(uniform_per_frame * n_uniform),
 
 
 
 
 
882
  "explanation": (
883
- "Same N frames as codec, evenly spaced in time. The patch "
884
- "budget is split equally per frame ({budget} ÷ {n} = "
885
- "{per}); the codec, by contrast, concentrates the same "
886
- "budget on high-saliency patches across those frames."
887
- ).format(
888
- budget=int(total_patches),
889
- n=int(n_uniform),
890
- per=int(uniform_per_frame),
891
  ),
892
  },
893
  "resized_frame_size": f"{tw}x{th}",
@@ -916,7 +977,8 @@ def process(
916
  progress(0.95, desc="Building charts")
917
  duration_sec = (total / fps) if fps > 0 else 0.0
918
  chart_fig = make_charts(
919
- grids, masks, fids, fps, duration_sec,
 
920
  int(total_patches), saliency_signal,
921
  groups=groups, gop_label=gop_resolved,
922
  )
@@ -1337,12 +1399,14 @@ with gr.Blocks(**_BLOCK_KW) as demo:
1337
  4, 64, value=16, step=1, label="Sampled frames",
1338
  )
1339
  top_k = gr.Slider(
1340
- 64, 8192, value=1024, step=32,
1341
  label="Total patches budget (whole video)",
1342
- info="The single budget shared across the whole video. "
1343
- "The codec saliency picks these patches GLOBALLY "
1344
- "high-energy frames may contribute many, low-energy "
1345
- "frames may contribute zero.",
 
 
1346
  )
1347
  patch_size = gr.Radio(
1348
  PATCH_CHOICES, value=14, label="Patch size (px)",
@@ -1422,13 +1486,13 @@ with gr.Blocks(**_BLOCK_KW) as demo:
1422
  with gr.Group(elem_classes="ovc-card ovc-card-primary"):
1423
  gr.Markdown("### Cumulative patches over time")
1424
  gr.Markdown(
1425
- "<small>Same number of sampled frames and the same total "
1426
- "patch budget for both methods. <b>Indigo</b>: codec "
1427
- "saliency — rises in bursts where the frames carry more "
1428
- "information. <b>Cyan (dashed)</b>: uniform baseline "
1429
- "the same budget split equally per frame, so each step "
1430
- "has the same height. Both curves end exactly at the "
1431
- "dotted <b>budget</b> reference line.</small>"
1432
  )
1433
  chart_out = gr.Plot(label="", show_label=False)
1434
 
 
8
 
9
  Pipeline (mirrors codec_tools/pipeline/process_video_bitcost_readiness.py):
10
  1. Uniformly sample N frames from the input video.
11
+ 2. Resize each sampled frame to a fixed square patch grid driven by
12
+ `patch_size`.
13
  3. Slice every frame into a patch grid; score each patch by its
14
  Sobel gradient magnitude mean.
15
+ 4. Pick the top-K highest-scoring patches under the selected GOP
16
+ grouping.
17
  5. Render a "selection visualization" video: kept patches stay in
18
  full color, dropped patches are faded to a gray-white wash so the
19
  viewer can see exactly which patches the codec stage chose.
 
22
  """
23
 
24
  import json
 
25
  import os
26
  import shutil
27
  import subprocess
 
48
  DEMO_VIDEO_PATH, # video_in
49
  16, # sample_frames
50
  14, # patch_size
51
+ 3136, # total_patches (= 16 * 14^2)
52
  150000, # max_pixels
53
  "sbs", # viz_mode
54
  0.55, # heatmap_alpha
 
63
 
64
 
65
  def smart_resize(frame: np.ndarray, max_pixels: int, factor: int) -> np.ndarray:
66
+ """Resize each frame to a square patch grid.
67
+
68
+ The demo uses `factor` as both:
69
+ - patch size in pixels
70
+ - patches per side in the resized frame
71
+
72
+ So patch_size=14 means:
73
+ - each patch is 14 x 14 pixels
74
+ - each frame is resized to 14 x 14 patches
75
+ - each frame therefore contributes 14^2 = 196 patch slots
76
+
77
+ `max_pixels` is kept for API compatibility with earlier revisions, but
78
+ the frame token count is now controlled by `factor` directly.
79
+ """
80
+ side_px = int(factor) * int(factor)
81
+ return cv2.resize(frame, (side_px, side_px), interpolation=cv2.INTER_AREA)
82
 
83
 
84
  def sample_frame_ids(total: int, n: int) -> List[int]:
 
89
  return [int(round(i)) for i in np.linspace(0, total - 1, n)]
90
 
91
 
92
+ def split_budget_evenly(total_k: int, n_parts: int) -> List[int]:
93
+ total = max(0, int(total_k))
94
+ n = max(0, int(n_parts))
95
+ if n == 0:
96
+ return []
97
+ base, rem = divmod(total, n)
98
+ return [base + (1 if i < rem else 0) for i in range(n)]
99
+
100
+
101
+ def sample_window_frame_ids(start: int, end: int, n: int) -> List[int]:
102
+ start_i = int(start)
103
+ end_i = int(end)
104
+ count = max(0, int(n))
105
+ if end_i < start_i or count <= 0:
106
+ return []
107
+ total = end_i - start_i + 1
108
+ if count >= total:
109
+ return list(range(start_i, end_i + 1))
110
+ return [start_i + x for x in sample_frame_ids(total, count)]
111
+
112
+
113
  def decode_frames(video_path: str, frame_ids: List[int]) -> List[np.ndarray]:
114
  cap = cv2.VideoCapture(video_path)
115
  if not cap.isOpened():
 
227
  return np.ones_like(score, dtype=np.uint8)
228
  if k <= 0:
229
  return np.zeros_like(score, dtype=np.uint8)
230
+ out = np.zeros(flat.size, dtype=np.uint8)
231
+ keep_idx = np.argpartition(flat, -k)[-k:]
232
+ out[keep_idx] = 1
233
+ return out.reshape(score.shape)
234
 
235
 
236
  def global_topk_masks(
 
247
  arr = np.stack(grids, axis=0).astype(np.float32) # [N, hb, wb]
248
  N, hb, wb = arr.shape
249
  flat = arr.reshape(-1)
250
+ k = int(total_k)
251
+ if k >= flat.size:
252
  masks = [np.ones((hb, wb), dtype=np.uint8) for _ in range(N)]
253
  return masks, int(flat.size)
254
+ if k <= 0:
255
  return [np.zeros((hb, wb), dtype=np.uint8) for _ in range(N)], 0
256
+ mask_flat = np.zeros(flat.size, dtype=np.uint8)
257
+ keep_idx = np.argpartition(flat, -k)[-k:]
258
+ mask_flat[keep_idx] = 1
259
+ bool_mask = mask_flat.reshape(N, hb, wb)
260
+ return [bool_mask[i].astype(np.uint8) for i in range(N)], k
261
 
262
 
263
  def build_dynamic_groups(
 
354
  cursor = end + 1
355
 
356
  num_groups = max(1, len(groups))
357
+ target_k = max(0, int(total_k))
358
+
359
+ capacities = [
360
+ sum(int(g.size) for g in grids[s:e + 1])
361
+ for (s, e) in groups
362
+ ]
363
+ alloc = split_budget_evenly(target_k, num_groups)
364
+
365
+ leftover = 0
366
+ for i, cap in enumerate(capacities):
367
+ if alloc[i] > cap:
368
+ leftover += alloc[i] - cap
369
+ alloc[i] = cap
370
+ while leftover > 0:
371
+ progressed = False
372
+ for i, cap in enumerate(capacities):
373
+ if alloc[i] < cap and leftover > 0:
374
+ alloc[i] += 1
375
+ leftover -= 1
376
+ progressed = True
377
+ if not progressed:
378
+ break
379
 
380
  # Initialize empty masks, then fill per-group selections.
381
  out_masks = [np.zeros(g.shape, dtype=np.uint8) for g in grids]
382
  actual_total = 0
383
+ for (s, e), group_k in zip(groups, alloc):
384
  sub = grids[s:e + 1]
385
+ sub_masks, sub_actual = global_topk_masks(sub, group_k)
386
  for i, sm in enumerate(sub_masks):
387
  out_masks[s + i] = sm
388
  actual_total += sub_actual
 
625
  def make_charts(
626
  grids: List[np.ndarray],
627
  masks: List[np.ndarray],
628
+ codec_frame_ids: List[int],
629
+ uniform_frame_ids: List[int],
630
+ uniform_requested_frames: int,
631
  fps: float,
632
  total_duration_sec: float,
633
  total_patches_budget: int,
 
636
  gop_label: str = "global",
637
  ):
638
  """One overlaid step chart: cumulative patches selected vs time, for
639
+ the codec saliency curve and a uniform full-frame sampling baseline.
 
640
 
641
  X = time (s)
642
  Y = cumulative count of selected patches
643
+ The codec curve rises in bursts where saliency is high; the uniform
644
+ baseline rises in equal steps because every sampled full frame
645
+ contributes one complete patch grid."""
646
  fig, ax = plt.subplots(figsize=(9.2, 3.6), constrained_layout=True)
647
 
648
  fps_safe = float(fps) if fps and fps > 0 else 25.0
 
651
  else:
652
  hb = wb = 1
653
  grid_size = hb * wb
654
+ all_frame_ids = list(codec_frame_ids) + list(uniform_frame_ids)
655
  duration = float(total_duration_sec) if total_duration_sec and total_duration_sec > 0 else (
656
+ (max(all_frame_ids) / fps_safe) if all_frame_ids else 1.0
657
  )
658
 
659
  # ─── Build step curves ──────────────────────────────────────────────
 
669
  xx.append(duration); yy.append(prev)
670
  return xx, yy
671
 
672
+ times = [fid / fps_safe for fid in codec_frame_ids]
673
  counts = [int(m.sum()) for m in masks]
674
  codec_cum = list(np.cumsum(counts)) if counts else []
675
  codec_total = int(codec_cum[-1]) if codec_cum else 0
676
  xx_c, yy_c = _step(times, codec_cum)
677
 
678
+ # Uniform baseline: evenly sample COMPLETE frames from the same time
679
+ # window, no codec saliency involved. Each sampled frame contributes a
680
+ # whole patch grid.
 
 
681
  budget_int = int(total_patches_budget)
682
+ requested_uniform = max(0, int(uniform_requested_frames))
683
+ n_uniform = len(uniform_frame_ids)
684
+ uni_per_step = [grid_size for _ in uniform_frame_ids]
 
 
 
685
  uni_cum = list(np.cumsum(uni_per_step)) if uni_per_step else []
686
  uni_total = int(uni_cum[-1]) if uni_cum else 0
687
+ uni_times = [fid / fps_safe for fid in uniform_frame_ids]
688
  xx_u, yy_u = _step(uni_times, uni_cum)
689
 
690
  # ─── Plot ───────────────────────────────────────────────────────────
 
699
  else:
700
  codec_lbl = f"Codec · {saliency_signal} ({codec_total:,} patches)"
701
  if uni_per_step:
702
+ unused = max(0, budget_int - uni_total)
703
+ frame_part = (
704
+ f"{n_uniform}/{requested_uniform} frames fit budget"
705
+ if requested_uniform != n_uniform else f"{n_uniform} frames"
706
+ )
707
+ uni_lbl = (
708
+ f"Uniform full frames ({frame_part} · {grid_size}/frame · "
709
+ f"{uni_total:,} total"
710
+ + (f" · {unused:,} budget unused" if unused else "")
711
+ + ")"
712
+ )
713
  else:
714
+ uni_lbl = (
715
+ f"Uniform full frames (0/{requested_uniform} frames fit budget "
716
+ f"{budget_int:,}; need {grid_size} patches/frame)"
717
+ )
718
 
719
  ax.fill_between(xx_c, yy_c, step=None, alpha=0.12, color="#4f46e5")
720
  ax.plot(xx_c, yy_c, color="#4f46e5", linewidth=2.2, label=codec_lbl)
 
892
 
893
  hb, wb = grids[0].shape
894
  grid_size = int(grids[0].shape[0] * grids[0].shape[1]) if grids else 0
895
+ # Uniform full-frame sampling baseline: evenly sample complete frames
896
+ # from the same time window, independent of codec saliency.
897
+ requested_budget = int(total_patches)
898
+ uniform_requested_frames = len(fids)
899
+ uniform_frame_count = min(
900
+ uniform_requested_frames,
901
+ requested_budget // max(1, grid_size),
902
  )
903
+ uniform_frame_ids = sample_window_frame_ids(f_start, f_end, uniform_frame_count)
904
+ uniform_total = int(len(uniform_frame_ids) * grid_size)
905
  info = {
906
  "input": meta,
907
  "params": {
 
930
  "frame_window": {
931
  "first_decoded": int(f_start),
932
  "last_decoded": int(f_end),
933
+ "codec_frame_ids": [int(x) for x in fids],
934
+ "uniform_full_frame_ids": [int(x) for x in uniform_frame_ids],
935
  },
936
  "codec_per_frame_patches": [int(m.sum()) for m in masks],
937
  "uniform_baseline": {
938
+ "mode": "uniform_full_frame_sampling",
939
+ "requested_frames": int(uniform_requested_frames),
940
+ "frames": int(len(uniform_frame_ids)),
941
+ "patches_per_frame": int(grid_size),
942
+ "frame_ids": [int(x) for x in uniform_frame_ids],
943
+ "requested_budget": requested_budget,
944
+ "unused_budget": int(max(0, requested_budget - uniform_total)),
945
+ "total_patches": uniform_total,
946
  "explanation": (
947
+ "Uniformly sample complete frames from the same time window. "
948
+ f"The baseline targets the same sampled-frame count as codec "
949
+ f"({uniform_requested_frames}), but each full frame costs "
950
+ f"{grid_size} patches, so only {len(uniform_frame_ids)} full "
951
+ "frames may fit inside the requested budget."
 
 
 
952
  ),
953
  },
954
  "resized_frame_size": f"{tw}x{th}",
 
977
  progress(0.95, desc="Building charts")
978
  duration_sec = (total / fps) if fps > 0 else 0.0
979
  chart_fig = make_charts(
980
+ grids, masks, fids, uniform_frame_ids, uniform_requested_frames,
981
+ fps, duration_sec,
982
  int(total_patches), saliency_signal,
983
  groups=groups, gop_label=gop_resolved,
984
  )
 
1399
  4, 64, value=16, step=1, label="Sampled frames",
1400
  )
1401
  top_k = gr.Slider(
1402
+ 16, 16384, value=3136, step=16,
1403
  label="Total patches budget (whole video)",
1404
+ info="Default = sample_frames x patch_size^2 "
1405
+ "(16 x 14^2 = 3136). The uniform baseline spends "
1406
+ "this budget on evenly sampled complete frames; the "
1407
+ "codec path spends it on saliency-selected patches. If "
1408
+ "budget < sample_frames x patch_size^2, the full-frame "
1409
+ "baseline will use fewer frames than codec.",
1410
  )
1411
  patch_size = gr.Radio(
1412
  PATCH_CHOICES, value=14, label="Patch size (px)",
 
1486
  with gr.Group(elem_classes="ovc-card ovc-card-primary"):
1487
  gr.Markdown("### Cumulative patches over time")
1488
  gr.Markdown(
1489
+ "<small><b>Indigo</b>: codec method selects patches "
1490
+ "within frames according to saliency, so the curve rises "
1491
+ "in bursts. <b>Cyan (dashed)</b>: uniform full-frame "
1492
+ "sampling evenly samples complete frames from the same "
1493
+ "time window, targeting the same sampled-frame count as "
1494
+ "codec when the budget allows. Each step is one full "
1495
+ "patch grid. The dotted line marks the requested budget.</small>"
1496
  )
1497
  chart_out = gr.Plot(label="", show_label=False)
1498