John Ho Claude Fable 5 commited on
Commit
be948dc
·
1 Parent(s): 00ea497

Expose text_overlay controls on the Annotate Video tab

Browse files

The optional caption from ffmpret.extract_frames is now reachable from
the app: annotate_video_interface gains a Text overlay textbox (default
empty = off) plus font-size slider and top/middle/bottom position
dropdown, passed through app.py -> vidbox.annotate_video ->
ffmpret.extract_frames. The caption is burned in at extraction time, so
it renders beneath the box/mask annotations. The annotate-video-file
CLI gains the matching --text-overlay/--text-font-size/
--text-y-position options, and the dropdown choices come from a new
ffmpret.TEXT_Y_POSITIONS constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. CLAUDE.md +15 -6
  2. app.py +24 -1
  3. ffmpret.py +5 -2
  4. tests/test_vidbox.py +18 -0
  5. vidbox.py +29 -3
CLAUDE.md CHANGED
@@ -75,7 +75,10 @@ commit the JSON.
75
  four `boxer.BBOX_FORMATS` (default `coco_normalized`), a comma-separated `coord_keys`
76
  textbox (default `"x,y,w,h"`), then `label_key` / `color_key` / `mask_key` (default
77
  `"mask_b64"`, matching the SAM2 example data) / `mask_alpha` / `width` / `font_size`
78
- mirroring the image Annotate tab. Output is a silent annotated video at source
 
 
 
79
  resolution/fps. Each distinct `color_key` value keeps one stable color across all frames.
80
  - **Crop** tab: `crop_image(...)` → `pilbox.crop` (`api_name="crop"`). Inputs are an image
81
  plus manual `gr.Number` fields `x0/y0/x1/y1` (pascal_voc box); output is the cropped
@@ -193,13 +196,15 @@ video and encoding frames back into one, via `ffmpeg-python`. Depends on `ffmpeg
193
  Raises (does not return `None`) when there's no video stream, so failures are legible to
194
  the callers that subscript the result.
195
  - `extract_frames(input_path, fps=8, max_short_edge=1080, write_timestamp=True,
196
- write_frame_num=True, output_dir=None, out_vid_path=None, text_font_size=20,
197
- text_y_position="bottom") -> list[PIL.Image]` decodes frames by piping ffmpeg `rawvideo`
 
198
  to stdout. Requested `fps` is capped to source fps; **`fps=None` skips resampling and
199
  extracts every native frame** (so output index i == source frame i — what `vidbox` needs
200
  for frame-accurate annotation). Frames may be scaled down so the short edge ≤
201
  `max_short_edge`. An optional `drawtext` overlay stamps timestamp/frame-number
202
- (`text_y_position` ∈ {top, middle, bottom}); it uses ffmpeg's default font. A
 
203
  `text_overlay` string (default `None`) is prefixed to that overlay line, and is drawn even
204
  when `write_timestamp=False`, so it works standalone. The overlay text is built by
205
  `_build_drawtext_text` and passed to drawtext via a temp **`textfile=`** (removed in a
@@ -242,8 +247,12 @@ the three existing modules). Keeps `pilbox` lite by living in its own module.
242
 
243
  - `annotate_video(video_path, detections, out_path, *, bbox_format="coco_normalized",
244
  coord_keys=("x","y","w","h"), frame_key="frame", label_key="track_id",
245
- color_key="track_id", mask_key="mask_b64", mask_alpha=0.5, width=3, font_size=20) ->
246
- out_path`. `detections` is a **flat** list of per-frame dicts (frame index under
 
 
 
 
247
  `frame_key`, box values under `coord_keys` read positionally per `bbox_format`, optional
248
  `label_key`/`color_key`/`mask_key`). It: probes metadata → extracts **every native frame**
249
  (`ffmpret.extract_frames(fps=None)`) → groups detections by frame → converts each box via
 
75
  four `boxer.BBOX_FORMATS` (default `coco_normalized`), a comma-separated `coord_keys`
76
  textbox (default `"x,y,w,h"`), then `label_key` / `color_key` / `mask_key` (default
77
  `"mask_b64"`, matching the SAM2 example data) / `mask_alpha` / `width` / `font_size`
78
+ mirroring the image Annotate tab, plus an optional `text_overlay` caption (a `gr.Textbox`,
79
+ default empty = off) with its own `text_font_size` slider and `text_y_position` dropdown
80
+ (`ffmpret.TEXT_Y_POSITIONS`, default `bottom`) — burned into every frame beneath the
81
+ annotations. Output is a silent annotated video at source
82
  resolution/fps. Each distinct `color_key` value keeps one stable color across all frames.
83
  - **Crop** tab: `crop_image(...)` → `pilbox.crop` (`api_name="crop"`). Inputs are an image
84
  plus manual `gr.Number` fields `x0/y0/x1/y1` (pascal_voc box); output is the cropped
 
196
  Raises (does not return `None`) when there's no video stream, so failures are legible to
197
  the callers that subscript the result.
198
  - `extract_frames(input_path, fps=8, max_short_edge=1080, write_timestamp=True,
199
+ write_frame_num=True, output_dir=None, out_vid_path=None, text_overlay=None,
200
+ text_font_size=20, text_y_position="bottom") -> list[PIL.Image]` decodes frames by piping
201
+ ffmpeg `rawvideo`
202
  to stdout. Requested `fps` is capped to source fps; **`fps=None` skips resampling and
203
  extracts every native frame** (so output index i == source frame i — what `vidbox` needs
204
  for frame-accurate annotation). Frames may be scaled down so the short edge ≤
205
  `max_short_edge`. An optional `drawtext` overlay stamps timestamp/frame-number
206
+ (`text_y_position` ∈ module constant `TEXT_Y_POSITIONS` = {top, middle, bottom}); it uses
207
+ ffmpeg's default font. A
208
  `text_overlay` string (default `None`) is prefixed to that overlay line, and is drawn even
209
  when `write_timestamp=False`, so it works standalone. The overlay text is built by
210
  `_build_drawtext_text` and passed to drawtext via a temp **`textfile=`** (removed in a
 
247
 
248
  - `annotate_video(video_path, detections, out_path, *, bbox_format="coco_normalized",
249
  coord_keys=("x","y","w","h"), frame_key="frame", label_key="track_id",
250
+ color_key="track_id", mask_key="mask_b64", mask_alpha=0.5, width=3, font_size=20,
251
+ text_overlay=None, text_font_size=20, text_y_position="bottom") ->
252
+ out_path`. The three `text_*` params pass through to `ffmpret.extract_frames` — an
253
+ optional caption burned into every frame at extraction time, i.e. beneath the box/mask
254
+ annotations (`font_size` is the pilbox label font; `text_font_size` the ffmpeg overlay
255
+ font). `detections` is a **flat** list of per-frame dicts (frame index under
256
  `frame_key`, box values under `coord_keys` read positionally per `bbox_format`, optional
257
  `label_key`/`color_key`/`mask_key`). It: probes metadata → extracts **every native frame**
258
  (`ffmpret.extract_frames(fps=None)`) → groups detections by frame → converts each box via
app.py CHANGED
@@ -13,6 +13,7 @@ from loguru import logger
13
  from PIL import ImageColor
14
 
15
  import boxer
 
16
  import pilbox
17
  import vidbox
18
 
@@ -261,6 +262,9 @@ def annotate_video(
261
  mask_alpha,
262
  width,
263
  font_size,
 
 
 
264
  ) -> str:
265
  """Draw per-frame bounding boxes (and optional masks) onto every frame of a video and return the annotated video.
266
 
@@ -271,7 +275,10 @@ def annotate_video(
271
  drawn on its frame; each distinct color_key value keeps ONE stable color across the whole video (so
272
  a track id is one consistent color), and any mask is drawn as a translucent overlay beneath the box
273
  in that same color. The output is a new silent video at the source resolution and frame rate with
274
- all boxes, labels, and masks burned in. The box coordinates are interpreted per bbox_format, one of:
 
 
 
275
  "pascal_voc" = [x0, y0, x1, y1] absolute pixels; "albumentations" = [x0, y0, x1, y1] normalized 0-1;
276
  "coco" = [x0, y0, width, height] absolute pixels; "coco_normalized" = [x0, y0, width, height]
277
  normalized 0-1 — as documented at
@@ -288,6 +295,9 @@ def annotate_video(
288
  mask_alpha: Mask overlay opacity from 0.0 (invisible) to 1.0 (solid color).
289
  width: Box outline width in pixels.
290
  font_size: Label font size in points.
 
 
 
291
  """
292
  if video_path is None:
293
  raise gr.Error("Please provide an input video.")
@@ -313,6 +323,9 @@ def annotate_video(
313
  mask_alpha=float(mask_alpha),
314
  width=int(width),
315
  font_size=int(font_size),
 
 
 
316
  )
317
  except (ValueError, KeyError) as e:
318
  raise gr.Error(str(e))
@@ -480,6 +493,13 @@ annotate_video_interface = gr.Interface(
480
  gr.Slider(0, 1, value=0.5, step=0.05, label="Mask opacity"),
481
  gr.Slider(1, 10, value=3, step=1, label="Box width"),
482
  gr.Slider(8, 60, value=20, step=1, label="Font size"),
 
 
 
 
 
 
 
483
  ],
484
  outputs=gr.Video(label="Annotated Video"),
485
  examples=(
@@ -495,6 +515,9 @@ annotate_video_interface = gr.Interface(
495
  0.5,
496
  3,
497
  20,
 
 
 
498
  ]
499
  ]
500
  if EXAMPLE_VIDEO and EXAMPLE_VIDEO_JSON
 
13
  from PIL import ImageColor
14
 
15
  import boxer
16
+ import ffmpret
17
  import pilbox
18
  import vidbox
19
 
 
262
  mask_alpha,
263
  width,
264
  font_size,
265
+ text_overlay=None,
266
+ text_font_size=20,
267
+ text_y_position="bottom",
268
  ) -> str:
269
  """Draw per-frame bounding boxes (and optional masks) onto every frame of a video and return the annotated video.
270
 
 
275
  drawn on its frame; each distinct color_key value keeps ONE stable color across the whole video (so
276
  a track id is one consistent color), and any mask is drawn as a translucent overlay beneath the box
277
  in that same color. The output is a new silent video at the source resolution and frame rate with
278
+ all boxes, labels, and masks burned in. An optional text_overlay caption (e.g. a clip name or
279
+ camera id) can additionally be burned into every frame, horizontally centered at a chosen
280
+ vertical position and font size, drawn beneath the boxes/masks. The box coordinates are
281
+ interpreted per bbox_format, one of:
282
  "pascal_voc" = [x0, y0, x1, y1] absolute pixels; "albumentations" = [x0, y0, x1, y1] normalized 0-1;
283
  "coco" = [x0, y0, width, height] absolute pixels; "coco_normalized" = [x0, y0, width, height]
284
  normalized 0-1 — as documented at
 
295
  mask_alpha: Mask overlay opacity from 0.0 (invisible) to 1.0 (solid color).
296
  width: Box outline width in pixels.
297
  font_size: Label font size in points.
298
+ text_overlay: Optional literal caption text burned into every frame of the output video; leave empty for no caption.
299
+ text_font_size: Font size of the text_overlay caption in points (independent of the box-label font_size).
300
+ text_y_position: Vertical placement of the text_overlay caption: "top", "middle", or "bottom".
301
  """
302
  if video_path is None:
303
  raise gr.Error("Please provide an input video.")
 
323
  mask_alpha=float(mask_alpha),
324
  width=int(width),
325
  font_size=int(font_size),
326
+ text_overlay=(text_overlay or "").strip() or None,
327
+ text_font_size=int(text_font_size),
328
+ text_y_position=text_y_position,
329
  )
330
  except (ValueError, KeyError) as e:
331
  raise gr.Error(str(e))
 
493
  gr.Slider(0, 1, value=0.5, step=0.05, label="Mask opacity"),
494
  gr.Slider(1, 10, value=3, step=1, label="Box width"),
495
  gr.Slider(8, 60, value=20, step=1, label="Font size"),
496
+ gr.Textbox(value=None, label="Text overlay (optional)"),
497
+ gr.Slider(8, 60, value=20, step=1, label="Text overlay font size"),
498
+ gr.Dropdown(
499
+ choices=list(ffmpret.TEXT_Y_POSITIONS),
500
+ value="bottom",
501
+ label="Text overlay position",
502
+ ),
503
  ],
504
  outputs=gr.Video(label="Annotated Video"),
505
  examples=(
 
515
  0.5,
516
  3,
517
  20,
518
+ None,
519
+ 20,
520
+ "bottom",
521
  ]
522
  ]
523
  if EXAMPLE_VIDEO and EXAMPLE_VIDEO_JSON
ffmpret.py CHANGED
@@ -17,6 +17,9 @@ logger.add(
17
  )
18
  app = typer.Typer(pretty_exceptions_show_locals=False)
19
 
 
 
 
20
 
21
  def parse_frame_name(fname: str):
22
  """return a tuple of frame_type and frame_index
@@ -229,8 +232,8 @@ def extract_frames(
229
  "bottom": "h-(2*lh)",
230
  }
231
  assert (
232
- text_y_position in y_position_map
233
- ), f"text_y_position must be one of {list(y_position_map)}, got {text_y_position!r}"
234
  text_y_expr = y_position_map[text_y_position]
235
 
236
  if output_dir:
 
17
  )
18
  app = typer.Typer(pretty_exceptions_show_locals=False)
19
 
20
+ # Vertical placements accepted by extract_frames' text_y_position.
21
+ TEXT_Y_POSITIONS = ("top", "middle", "bottom")
22
+
23
 
24
  def parse_frame_name(fname: str):
25
  """return a tuple of frame_type and frame_index
 
232
  "bottom": "h-(2*lh)",
233
  }
234
  assert (
235
+ text_y_position in TEXT_Y_POSITIONS
236
+ ), f"text_y_position must be one of {list(TEXT_Y_POSITIONS)}, got {text_y_position!r}"
237
  text_y_expr = y_position_map[text_y_position]
238
 
239
  if output_dir:
tests/test_vidbox.py CHANGED
@@ -88,6 +88,24 @@ def test_annotate_video_synthetic_coco_normalized(tiny_video, tmp_path):
88
  assert 8 <= int(meta["nb_frames"]) <= 12
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def test_crop_video_rejects_conflicting_boxes():
92
  # same frame, two DIFFERENT boxes -> conflict error (needs no ffmpeg: raises
93
  # during grouping, before extraction).
 
88
  assert 8 <= int(meta["nb_frames"]) <= 12
89
 
90
 
91
+ def test_annotate_video_with_text_overlay(tiny_video, tmp_path):
92
+ # the overlay params pass through to ffmpret.extract_frames (drawtext via
93
+ # textfile); output must still encode at source dims/frames.
94
+ detections = [
95
+ {"frame": 0, "track_id": 0, "x": 0.1, "y": 0.1, "w": 0.3, "h": 0.3},
96
+ ]
97
+ out = tmp_path / "overlaid.mp4"
98
+ result = vidbox.annotate_video(
99
+ tiny_video, detections, str(out), bbox_format="coco_normalized",
100
+ mask_key="",
101
+ text_overlay="cam-1", text_font_size=12, text_y_position="top",
102
+ )
103
+ assert Path(result).is_file()
104
+ meta = _probe(result)
105
+ assert meta["width"] == "64" and meta["height"] == "48"
106
+ assert 8 <= int(meta["nb_frames"]) <= 12
107
+
108
+
109
  def test_crop_video_rejects_conflicting_boxes():
110
  # same frame, two DIFFERENT boxes -> conflict error (needs no ffmpeg: raises
111
  # during grouping, before extraction).
vidbox.py CHANGED
@@ -26,7 +26,7 @@ from collections import defaultdict
26
  import typer
27
  from loguru import logger
28
  from PIL import Image, ImageColor
29
- from typing import Literal
30
 
31
  import boxer
32
  import ffmpret
@@ -63,12 +63,17 @@ def annotate_video(
63
  mask_alpha: float = 0.5,
64
  width: int = 3,
65
  font_size: int = 20,
 
 
 
66
  ) -> str:
67
  """Draw per-frame boxes and masks over a video and write the annotated result.
68
 
69
  Frames are extracted at native fps/resolution (so frame index ``i`` matches a
70
  detection's ``frame_key`` value, and full-frame masks line up), annotated with
71
  :func:`pilbox.annotate`, then re-encoded to a silent video at the source fps.
 
 
72
 
73
  Args:
74
  video_path: Path to the input video.
@@ -86,7 +91,15 @@ def annotate_video(
86
  pass ``""`` to disable masks.
87
  mask_alpha: Mask overlay opacity in ``[0, 1]``.
88
  width: Box outline width in pixels.
89
- font_size: Label font size in points.
 
 
 
 
 
 
 
 
90
 
91
  Returns:
92
  ``out_path``.
@@ -103,7 +116,14 @@ def annotate_video(
103
  org_w, org_h, fps = vmeta["width"], vmeta["height"], vmeta["fps"]
104
 
105
  # Extract every native frame so frame indices align with the detections.
106
- frames = ffmpret.extract_frames(video_path, fps=None, write_timestamp=False)
 
 
 
 
 
 
 
107
  if not frames:
108
  raise ValueError(f"no frames decoded from {video_path}")
109
 
@@ -437,6 +457,9 @@ def annotate_video_file(
437
  mask_alpha: float = 0.5,
438
  width: int = 3,
439
  font_size: int = 20,
 
 
 
440
  ) -> str:
441
  """Annotate ``video_path`` using detections from a JSON file; save to ``out_path``.
442
 
@@ -458,6 +481,9 @@ def annotate_video_file(
458
  mask_alpha=mask_alpha,
459
  width=width,
460
  font_size=font_size,
 
 
 
461
  )
462
 
463
 
 
26
  import typer
27
  from loguru import logger
28
  from PIL import Image, ImageColor
29
+ from typing import Literal, Optional
30
 
31
  import boxer
32
  import ffmpret
 
63
  mask_alpha: float = 0.5,
64
  width: int = 3,
65
  font_size: int = 20,
66
+ text_overlay: Optional[str] = None,
67
+ text_font_size: int = 20,
68
+ text_y_position: str = "bottom",
69
  ) -> str:
70
  """Draw per-frame boxes and masks over a video and write the annotated result.
71
 
72
  Frames are extracted at native fps/resolution (so frame index ``i`` matches a
73
  detection's ``frame_key`` value, and full-frame masks line up), annotated with
74
  :func:`pilbox.annotate`, then re-encoded to a silent video at the source fps.
75
+ An optional ``text_overlay`` label is burned in at extraction time, so it
76
+ renders beneath the box/mask annotations.
77
 
78
  Args:
79
  video_path: Path to the input video.
 
91
  pass ``""`` to disable masks.
92
  mask_alpha: Mask overlay opacity in ``[0, 1]``.
93
  width: Box outline width in pixels.
94
+ font_size: Label font size in points (the pilbox box-label font).
95
+ text_overlay: Optional literal text burned into every frame (beneath
96
+ the annotations); ``None``/empty disables it.
97
+ text_font_size: Font size of the ``text_overlay`` (the ffmpeg drawtext
98
+ font, distinct from ``font_size``). Only used when ``text_overlay``
99
+ is set.
100
+ text_y_position: Vertical placement of the ``text_overlay``; one of
101
+ :data:`ffmpret.TEXT_Y_POSITIONS` (``top``/``middle``/``bottom``).
102
+ Only used when ``text_overlay`` is set.
103
 
104
  Returns:
105
  ``out_path``.
 
116
  org_w, org_h, fps = vmeta["width"], vmeta["height"], vmeta["fps"]
117
 
118
  # Extract every native frame so frame indices align with the detections.
119
+ frames = ffmpret.extract_frames(
120
+ video_path,
121
+ fps=None,
122
+ write_timestamp=False,
123
+ text_overlay=text_overlay,
124
+ text_font_size=text_font_size,
125
+ text_y_position=text_y_position,
126
+ )
127
  if not frames:
128
  raise ValueError(f"no frames decoded from {video_path}")
129
 
 
457
  mask_alpha: float = 0.5,
458
  width: int = 3,
459
  font_size: int = 20,
460
+ text_overlay: Optional[str] = None,
461
+ text_font_size: int = 20,
462
+ text_y_position: str = "bottom",
463
  ) -> str:
464
  """Annotate ``video_path`` using detections from a JSON file; save to ``out_path``.
465
 
 
481
  mask_alpha=mask_alpha,
482
  width=width,
483
  font_size=font_size,
484
+ text_overlay=text_overlay,
485
+ text_font_size=text_font_size,
486
+ text_y_position=text_y_position,
487
  )
488
 
489