File size: 27,626 Bytes
e2ac00e 4e3ab14 e2ac00e 4e3ab14 e2ac00e c4e47f4 e2ac00e 4e3ab14 e2ac00e d544d5d e2ac00e c4e47f4 be948dc e2ac00e c4e47f4 e2ac00e 4ede126 e2ac00e 4ede126 e2ac00e 4ede126 e2ac00e d544d5d c4e47f4 8ebf17a d138c19 8ebf17a 9d3b191 93754f2 9c32855 93754f2 9c32855 b6ebd4d 9c32855 e2ac00e b6ebd4d 9c32855 b6ebd4d 93754f2 e2ac00e 9c32855 e2ac00e 9c32855 e2ac00e 93754f2 e2ac00e 4e3ab14 b6ebd4d d544d5d c4e47f4 be948dc c4e47f4 be948dc c4e47f4 be948dc c4e47f4 be948dc c4e47f4 d138c19 8ebf17a 9d3b191 b6ebd4d e2ac00e 4e3ab14 e2ac00e 9c32855 e2ac00e 4e3ab14 9c32855 e2ac00e 93754f2 e2ac00e 4e3ab14 e2ac00e d138c19 9c32855 d138c19 e2ac00e 9c32855 e2ac00e 4e3ab14 e2ac00e c4e47f4 be948dc c4e47f4 be948dc c4e47f4 b6ebd4d d138c19 b6ebd4d 8ebf17a d138c19 8ebf17a d544d5d 9d3b191 b6ebd4d 8ebf17a 9d3b191 8ebf17a 9d3b191 b6ebd4d e2ac00e | 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 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 | """Gradio app: annotate an image with pascal_voc bounding boxes via pilbox.
Web-UI counterpart of ``annotate_cli.py`` β paste a list of objects (each with a
``boundingBox`` dict ``{x0, y0, x1, y1}``) and get the annotated image back.
"""
import json
import tempfile
from pathlib import Path
import gradio as gr
from loguru import logger
from PIL import ImageColor
import boxer
import ffmpret
import pilbox
import vidbox
ASSETS = Path(__file__).parent / "assets"
def _load_example():
"""Return ``(image_path, boxes_json_text)`` for the demo example.
The example assets (``example_0_in.jpg`` + ``example_0.json``) live in
``assets/`` but are git-ignored and not deployed to the Space, so both are
optional. When either is missing the app runs without a preloaded example.
Returns:
``(image_path, boxes_json_text)`` when both assets are present, else
``(None, "[]")``.
"""
image_path = ASSETS / "example_0_in.jpg"
boxes_path = ASSETS / "example_0.json"
if not (image_path.exists() and boxes_path.exists()):
logger.info("demo example assets not found in {}; running without one", ASSETS)
return None, "[]"
try:
return str(image_path), boxes_path.read_text()
except OSError as e:
logger.warning(f"could not read example assets: {e}")
return None, "[]"
EXAMPLE_IMAGE, EXAMPLE_JSON = _load_example()
def _example_mask():
"""Return the first object's base64 PNG mask from the demo JSON, if any.
Used to preload the Mask tab's example. Returns an empty string when the
example assets are absent (e.g. on the Space) or carry no mask.
"""
try:
objects = json.loads(EXAMPLE_JSON)
for obj in objects:
if obj.get("b64_mask"):
return obj["b64_mask"]
except (json.JSONDecodeError, AttributeError, TypeError):
pass
return ""
EXAMPLE_MASK = _example_mask()
def _load_video_example():
"""Return ``(video_path, detections_json_path)`` for the video demo example.
Both assets live in ``assets/`` but are git-ignored and not deployed to the
Space, so both are optional; returns ``(None, None)`` when either is absent.
"""
video_path = ASSETS / "17078229_3222904.mp4"
json_path = ASSETS / "17078229_3222904-SAM2_tiny_ZeroGPU-with_mask.json"
if not (video_path.exists() and json_path.exists()):
logger.info("video example assets not found in {}; running without one", ASSETS)
return None, None
return str(video_path), str(json_path)
EXAMPLE_VIDEO, EXAMPLE_VIDEO_JSON = _load_video_example()
def _load_crop_video_example():
"""Return ``(video_path, detections_json_path)`` for the Crop Video demo example.
Both assets are git-ignored/not deployed, so both are optional; returns
``(None, None)`` when either is absent.
"""
video_path = ASSETS / "17078229_3222904.mp4"
json_path = ASSETS / "17078229_3222904-VideoCrop-example.json"
if not (video_path.exists() and json_path.exists()):
logger.info(
"crop-video example assets not found in {}; running without one", ASSETS
)
return None, None
return str(video_path), str(json_path)
EXAMPLE_CROP_VIDEO, EXAMPLE_CROP_JSON = _load_crop_video_example()
def _load_mask_video_example():
"""Return ``(video_path, mask_json_path)`` for the Mask Video demo example.
Both assets are git-ignored/not deployed, so both are optional; returns
``(None, None)`` when either is absent.
"""
video_path = ASSETS / "17078229_3222904.mp4"
json_path = ASSETS / "17078229_3222904-player1box.json"
if not (video_path.exists() and json_path.exists()):
logger.info(
"mask-video example assets not found in {}; running without one", ASSETS
)
return None, None
return str(video_path), str(json_path)
EXAMPLE_MASK_VIDEO, EXAMPLE_MASK_JSON = _load_mask_video_example()
def annotate_image(
image,
boxes_json,
bbox_format,
label_key,
color_key,
mask_key,
mask_alpha,
width,
font_size,
):
"""Draw bounding boxes (and optional segmentation masks) onto an image and return the annotated image.
boxes_json is a JSON list of object dicts. Each object holds its box under a "boundingBox" key
as either {"x0", "y0", "x1", "y1"} or a list of four numbers; the four values are read in that
order (x0, y0, x1, y1 slots) and interpreted according to bbox_format, one of: "pascal_voc" =
[x0, y0, x1, y1] absolute pixels; "albumentations" = [x0, y0, x1, y1] normalized 0-1; "coco" =
[x0, y0, width, height] absolute pixels; "coco_normalized" = [x0, y0, width, height] normalized
0-1 β as documented at
https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats .
Whatever the input format, boxes are converted to pascal_voc absolute pixels before drawing. Each
object may also carry the label_key and color_key fields, plus a mask_key field holding a
base64-encoded PNG mask. When an object has a mask, it is drawn as a translucent colored overlay
beneath the box, using the SAME color as that object's box (both derived from color_key). The
output is the input image with every mask and box drawn on it.
Args:
image: The RGB image to annotate.
boxes_json: JSON text β a list of object dicts, each with a "boundingBox" (a {x0, y0, x1, y1} dict or a 4-number list, read in that order) and optional label/color/mask fields.
bbox_format: Box coordinate convention for the four boundingBox values: one of "pascal_voc", "albumentations", "coco", or "coco_normalized".
label_key: Name of the object field whose value is drawn as each box's text label.
color_key: Name of the object field used to color-group boxes and masks (each distinct value gets its own color).
mask_key: Name of the object field holding a base64-encoded PNG mask; leave empty to disable mask drawing.
mask_alpha: Mask overlay opacity from 0.0 (invisible) to 1.0 (solid color).
width: Box outline width in pixels.
font_size: Label font size in points.
"""
if image is None:
raise gr.Error("Please provide an input image.")
try:
objects = json.loads(boxes_json)
except json.JSONDecodeError as e:
raise gr.Error(f"Invalid JSON: {e}")
im_w, im_h = image.size
try:
converted = []
for obj in objects:
box = obj["boundingBox"]
coords = (
box[:4]
if isinstance(box, (list, tuple))
else (box["x0"], box["y0"], box["x1"], box["y1"])
)
converted.append(
{**obj, "boundingBox": boxer.to_pascal_voc(coords, bbox_format, im_w, im_h)}
)
except (ValueError, KeyError, TypeError) as e:
raise gr.Error(f"Could not read boxes: {e}")
font = pilbox.load_pil_font(size=int(font_size))
return pilbox.annotate(
image,
converted,
label_key=label_key,
color_key=color_key,
mask_key=mask_key,
mask_alpha=float(mask_alpha),
width=int(width),
font=font,
)
def crop_image(image, x0, y0, x1, y1):
"""Crop an image to the pascal_voc box and return only that region as a new image.
The crop box is given as absolute pixel coordinates in the "pascal_voc" format: (x0, y0) is
the top-left corner and (x1, y1) is the bottom-right corner, measured in pixels of the input
image (documented at
https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats ).
The box must be non-empty (x1 > x0 and y1 > y0) and lie fully within the image; otherwise an
error is returned. NOT normalized to 0-1 and NOT [x, y, width, height]. The output is the
cropped RGB image of size (x1 - x0) by (y1 - y0).
Args:
image: The RGB image to crop.
x0: Left edge of the crop box, in absolute pixels from the left.
y0: Top edge of the crop box, in absolute pixels from the top.
x1: Right edge of the crop box, in absolute pixels from the left; must be greater than x0.
y1: Bottom edge of the crop box, in absolute pixels from the top; must be greater than y0.
"""
if image is None:
raise gr.Error("Please provide an input image.")
try:
return pilbox.crop(image, x0, y0, x1, y1)
except ValueError as e:
raise gr.Error(str(e))
def _rgb_from_css(color: str):
"""Convert a CSS color string ("#rrggbb" or "rgba(r,g,b,a)") to an (r, g, b) tuple."""
color = (color or "#000000").strip()
if color.startswith("rgba") or color.startswith("rgb"):
nums = color[color.index("(") + 1 : color.index(")")].split(",")
return tuple(int(float(n)) for n in nums[:3])
return ImageColor.getrgb(color)
def mask_image(image, b64_mask, bg_color):
"""Cut out an image's foreground using a base64-encoded PNG mask and return it on a solid background.
The mask is a base64-encoded PNG string the SAME pixel size as the input image: pixels that are
non-zero (white) mark the foreground to keep, and zero (black) pixels are the background. The
output keeps the foreground pixels unchanged and replaces every background pixel with bg_color,
so the subject is "masked out" of its scene onto a flat backdrop. bg_color is a CSS hex color
string like "#000000" (the default, black); "#ff0000" would put the foreground on red.
Args:
image: The RGB image to mask.
b64_mask: Base64-encoded PNG mask, same width and height as image; non-zero pixels are the foreground to keep.
bg_color: Background fill as a CSS hex color string like "#rrggbb"; defaults to black "#000000".
"""
if image is None:
raise gr.Error("Please provide an input image.")
try:
rgb = _rgb_from_css(bg_color)
return pilbox.apply_mask(image, b64_mask, bg_rgb_tup=rgb)
except ValueError as e:
raise gr.Error(str(e))
def annotate_video(
video_path,
boxes_json_file,
bbox_format,
coord_keys,
label_key,
color_key,
mask_key,
mask_alpha,
width,
font_size,
text_overlay=None,
text_font_size=20,
text_y_position="bottom",
) -> str:
"""Draw per-frame bounding boxes (and optional masks) onto every frame of a video and return the annotated video.
Takes a video plus a detections JSON file β a flat JSON list of per-frame detection objects. Each
object holds a 0-based frame index under the "frame" key, the box coordinates under the four keys
named by coord_keys (default "x,y,w,h", read in that order), and optionally a label value, a color
value, and a base64-encoded PNG mask the same pixel size as the video frame. Every detection is
drawn on its frame; each distinct color_key value keeps ONE stable color across the whole video (so
a track id is one consistent color), and any mask is drawn as a translucent overlay beneath the box
in that same color. The output is a new silent video at the source resolution and frame rate with
all boxes, labels, and masks burned in. An optional text_overlay caption (e.g. a clip name or
camera id) can additionally be burned into every frame, horizontally centered at a chosen
vertical position and font size, drawn beneath the boxes/masks. The box coordinates are
interpreted per bbox_format, one of:
"pascal_voc" = [x0, y0, x1, y1] absolute pixels; "albumentations" = [x0, y0, x1, y1] normalized 0-1;
"coco" = [x0, y0, width, height] absolute pixels; "coco_normalized" = [x0, y0, width, height]
normalized 0-1 β as documented at
https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats .
Args:
video_path: The input video file to annotate.
boxes_json_file: A .json file holding a flat list of per-frame detection objects (see above).
bbox_format: Box coordinate convention: one of "pascal_voc", "albumentations", "coco", or "coco_normalized".
coord_keys: Comma-separated names of the four object keys holding the box values, in order (default "x,y,w,h").
label_key: Name of the object field whose value is drawn as each box's text label.
color_key: Name of the object field used to color-group boxes and masks; each distinct value gets one stable color across all frames.
mask_key: Name of the object field holding a base64-encoded PNG mask; leave empty to disable mask drawing.
mask_alpha: Mask overlay opacity from 0.0 (invisible) to 1.0 (solid color).
width: Box outline width in pixels.
font_size: Label font size in points.
text_overlay: Optional literal caption text burned into every frame of the output video; leave empty for no caption.
text_font_size: Font size of the text_overlay caption in points (independent of the box-label font_size).
text_y_position: Vertical placement of the text_overlay caption: "top", "middle", or "bottom".
"""
if video_path is None:
raise gr.Error("Please provide an input video.")
if not boxes_json_file:
raise gr.Error("Please provide a detections JSON file.")
try:
with open(boxes_json_file) as f:
detections = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise gr.Error(f"Could not read detections JSON: {e}")
out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
try:
return vidbox.annotate_video(
video_path,
detections,
out_path,
bbox_format=bbox_format,
coord_keys=tuple(k.strip() for k in coord_keys.split(",")),
label_key=label_key,
color_key=color_key,
mask_key=mask_key,
mask_alpha=float(mask_alpha),
width=int(width),
font_size=int(font_size),
text_overlay=(text_overlay or "").strip() or None,
text_font_size=int(text_font_size),
text_y_position=text_y_position,
)
except (ValueError, KeyError) as e:
raise gr.Error(str(e))
def crop_video(
video_path, boxes_json_file, bbox_format, coord_keys, mode, padding, gap_behavior
) -> str:
"""Crop a video to a subject that moves frame-to-frame, using a per-frame box JSON, and return the cropped video.
Takes a video plus a detections JSON file β a flat JSON list of per-frame detection objects, each with a
0-based frame index under the "frame" key and box coordinates under the four keys named by coord_keys
(default "x,y,w,h", read in that order). Any mask fields are IGNORED; only the boxes are used. Each frame
must carry at most one box β exact-duplicate rows collapse to one, but a frame holding two DIFFERENT boxes
is an error. Because a video needs a constant frame size, the output size is the per-axis maximum box size
(times padding, rounded to even, clamped to the frame) and every frame is cropped to that fixed size. With
mode "window" a fixed window is cropped from the original frame and re-centered on each frame's box, so the
subject is shown with its surrounding scene and the window pans to follow it; with mode "box_fit" each
frame is cropped exactly to its box, black-padded to the output aspect ratio (no stretching), and resized
to fill, so only the subject is shown. Frames with no detection are gaps: with "jump" the window centers on
the frame (window mode) or a black frame is emitted (box_fit); with "carry_forward" the previous output
frame is repeated. The box coordinates are interpreted per bbox_format, one of: "pascal_voc" =
[x0, y0, x1, y1] absolute pixels; "albumentations" = [x0, y0, x1, y1] normalized 0-1; "coco" =
[x0, y0, width, height] absolute pixels; "coco_normalized" = [x0, y0, width, height] normalized 0-1 β as
documented at https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats .
Args:
video_path: The input video file to crop.
boxes_json_file: A .json file holding a flat list of per-frame detection objects (see above); masks ignored.
bbox_format: Box coordinate convention: one of "pascal_voc", "albumentations", "coco", or "coco_normalized".
coord_keys: Comma-separated names of the four object keys holding the box values, in order (default "x,y,w,h").
mode: "window" (crop a panning window from the frame, keeping surrounding scene) or "box_fit" (crop to the box, letterbox, resize to fill β subject only).
padding: Multiplier that expands each box about its center before sizing and cropping (1.0 = no expansion).
gap_behavior: For frames with no detection: "jump" (center the window / emit a black frame) or "carry_forward" (repeat the previous output frame).
"""
if video_path is None:
raise gr.Error("Please provide an input video.")
if not boxes_json_file:
raise gr.Error("Please provide a detections JSON file.")
try:
with open(boxes_json_file) as f:
detections = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise gr.Error(f"Could not read detections JSON: {e}")
out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
try:
return vidbox.crop_video(
video_path,
detections,
out_path,
bbox_format=bbox_format,
coord_keys=tuple(k.strip() for k in coord_keys.split(",")),
mode=mode,
padding=float(padding),
gap_behavior=gap_behavior,
)
except (ValueError, KeyError) as e:
raise gr.Error(str(e))
def mask_video(video_path, mask_json_file, mask_key, bg_color, gap_behavior) -> str:
"""Mask a video frame-by-frame from a per-frame mask JSON β keep each frame's foreground over a solid background color β and return the masked video.
Takes a video plus a masks JSON file β a flat JSON list of per-frame objects, each with a 0-based frame index
under the "frame" key and a base64-encoded PNG mask under mask_key (default "mask_b64"). The mask must be the
SAME pixel size as the video frame: non-zero (white) pixels are the foreground to keep, zero (black) pixels are
background. Any bounding boxes in the JSON are IGNORED; only masks are used, and each frame may carry only one
mask (exact-duplicate rows are collapsed, but two different masks on a frame is an error). For each frame with a
mask the foreground is kept and every background pixel is replaced with bg_color; the output is a new silent
video at the source resolution and frame rate. Frames that have NO mask are handled by gap_behavior: "skip"
drops them (the output is shorter and jump-cuts over the gaps), while "fill" keeps them as frames painted
entirely with bg_color. bg_color is a CSS hex color like "#000000" (the default, black); it fills both the mask
background and the "fill" gap frames.
Args:
video_path: The input video file to mask.
mask_json_file: A .json file holding a flat list of per-frame mask objects (see above); bounding boxes ignored.
mask_key: Name of the object field holding a base64-encoded PNG mask, same size as the video frame.
bg_color: Background fill as a CSS hex color string like "#rrggbb"; defaults to black "#000000".
gap_behavior: For frames with no mask: "skip" (drop them) or "fill" (keep them, painted entirely with bg_color).
"""
if video_path is None:
raise gr.Error("Please provide an input video.")
if not mask_json_file:
raise gr.Error("Please provide a masks JSON file.")
try:
with open(mask_json_file) as f:
detections = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise gr.Error(f"Could not read masks JSON: {e}")
out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
try:
return vidbox.mask_video(
video_path,
detections,
out_path,
mask_key=mask_key,
bg_rgb_tup=_rgb_from_css(bg_color),
gap_behavior=gap_behavior,
)
except (ValueError, KeyError) as e:
raise gr.Error(str(e))
annotate_interface = gr.Interface(
fn=annotate_image,
inputs=[
gr.Image(type="pil", label="Input Image"),
gr.Code(
language="json",
label="Bounding Boxes (JSON)",
value=EXAMPLE_JSON,
),
gr.Dropdown(
choices=list(boxer.BBOX_FORMATS),
value="pascal_voc",
label="Box format",
),
gr.Textbox(value="object_id", label="Label key"),
gr.Textbox(value="object_id", label="Color key"),
gr.Textbox(value="b64_mask", label="Mask key"),
gr.Slider(0, 1, value=0.5, step=0.05, label="Mask opacity"),
gr.Slider(1, 10, value=3, step=1, label="Box width"),
gr.Slider(8, 60, value=20, step=1, label="Font size"),
],
outputs=gr.Image(type="pil", label="Annotated Image"),
examples=(
[
[
EXAMPLE_IMAGE,
EXAMPLE_JSON,
"pascal_voc",
"object_id",
"object_id",
"b64_mask",
0.5,
3,
20,
]
]
if EXAMPLE_IMAGE
else None
),
title="PILBox β Bounding Box Annotator",
description="Draw bounding boxes on an image (pascal_voc / albumentations / coco / coco_normalized) using numpy + Pillow.",
api_name="annotate",
)
annotate_video_interface = gr.Interface(
fn=annotate_video,
inputs=[
gr.Video(label="Input Video"),
gr.File(label="Detections JSON", file_types=[".json"], type="filepath"),
gr.Dropdown(
choices=list(boxer.BBOX_FORMATS),
value="coco_normalized",
label="Box format",
),
gr.Textbox(value="x,y,w,h", label="Coordinate keys (comma-separated)"),
gr.Textbox(value="track_id", label="Label key"),
gr.Textbox(value="track_id", label="Color key"),
gr.Textbox(value="mask_b64", label="Mask key"),
gr.Slider(0, 1, value=0.5, step=0.05, label="Mask opacity"),
gr.Slider(1, 10, value=3, step=1, label="Box width"),
gr.Slider(8, 60, value=20, step=1, label="Font size"),
gr.Textbox(value=None, label="Text overlay (optional)"),
gr.Slider(8, 60, value=20, step=1, label="Text overlay font size"),
gr.Dropdown(
choices=list(ffmpret.TEXT_Y_POSITIONS),
value="bottom",
label="Text overlay position",
),
],
outputs=gr.Video(label="Annotated Video"),
examples=(
[
[
EXAMPLE_VIDEO,
EXAMPLE_VIDEO_JSON,
"coco_normalized",
"x,y,w,h",
"track_id",
"track_id",
"mask_b64",
0.5,
3,
20,
None,
20,
"bottom",
]
]
if EXAMPLE_VIDEO and EXAMPLE_VIDEO_JSON
else None
),
title="PILBox β Video Annotator",
description="Draw per-frame bounding boxes and masks over a video (pascal_voc / albumentations / coco / coco_normalized).",
api_name="annotate_video",
)
crop_interface = gr.Interface(
fn=crop_image,
inputs=[
gr.Image(type="pil", label="Input Image"),
gr.Number(value=0, precision=0, label="x0 (left)"),
gr.Number(value=0, precision=0, label="y0 (top)"),
gr.Number(value=100, precision=0, label="x1 (right)"),
gr.Number(value=100, precision=0, label="y1 (bottom)"),
],
outputs=gr.Image(type="pil", label="Cropped Image"),
examples=([[EXAMPLE_IMAGE, 0, 0, 100, 100]] if EXAMPLE_IMAGE else None),
title="PILBox β Image Cropper",
description="Crop an image to a pascal_voc box (x0, y0, x1, y1) using numpy + Pillow.",
api_name="crop",
)
crop_video_interface = gr.Interface(
fn=crop_video,
inputs=[
gr.Video(label="Input Video"),
gr.File(label="Detections JSON", file_types=[".json"], type="filepath"),
gr.Dropdown(
choices=list(boxer.BBOX_FORMATS),
value="coco_normalized",
label="Box format",
),
gr.Textbox(value="x,y,w,h", label="Coordinate keys (comma-separated)"),
gr.Dropdown(
choices=list(vidbox.CROP_MODES), value="box_fit", label="Crop mode"
),
gr.Slider(1.0, 2.0, value=1.0, step=0.05, label="Padding (box expand factor)"),
gr.Dropdown(
choices=list(vidbox.GAP_BEHAVIORS),
value="jump",
label="Gap behavior (frames with no detection)",
),
],
outputs=gr.Video(label="Cropped Video"),
examples=(
[
[
EXAMPLE_CROP_VIDEO,
EXAMPLE_CROP_JSON,
"coco_normalized",
"x,y,w,h",
"window",
1.0,
"jump",
]
]
if EXAMPLE_CROP_VIDEO and EXAMPLE_CROP_JSON
else None
),
title="PILBox β Video Cropper",
description="Crop a video to a moving subject via a per-frame box JSON (tracking window; masks ignored).",
api_name="crop_video",
)
mask_interface = gr.Interface(
fn=mask_image,
inputs=[
gr.Image(type="pil", label="Input Image"),
gr.Textbox(lines=4, label="Mask (base64-encoded PNG)", value=EXAMPLE_MASK),
gr.ColorPicker(value="#000000", label="Background color"),
],
outputs=gr.Image(type="pil", label="Masked Image"),
examples=(
[[EXAMPLE_IMAGE, EXAMPLE_MASK, "#000000"]]
if EXAMPLE_IMAGE and EXAMPLE_MASK
else None
),
title="PILBox β Background Masker",
description="Cut out an image's foreground with a base64 PNG mask, over a solid background color.",
api_name="mask",
)
mask_video_interface = gr.Interface(
fn=mask_video,
inputs=[
gr.Video(label="Input Video"),
gr.File(label="Masks JSON", file_types=[".json"], type="filepath"),
gr.Textbox(value="mask_b64", label="Mask key"),
gr.ColorPicker(value="#000000", label="Background color"),
gr.Dropdown(
choices=list(vidbox.MASK_GAP_BEHAVIORS),
value="fill",
label="Frames with no mask",
),
],
outputs=gr.Video(label="Masked Video"),
examples=(
[[EXAMPLE_MASK_VIDEO, EXAMPLE_MASK_JSON, "mask_b64", "#000000", "skip"]]
if EXAMPLE_MASK_VIDEO and EXAMPLE_MASK_JSON
else None
),
title="PILBox β Video Masker",
description="Keep each frame's masked foreground over a solid background color, from a per-frame mask JSON (boxes ignored).",
api_name="mask_video",
)
app = gr.TabbedInterface(
[
annotate_interface,
annotate_video_interface,
crop_interface,
crop_video_interface,
mask_interface,
mask_video_interface,
],
["Annotate", "Annotate Video", "Crop", "Crop Video", "Mask", "Mask Video"],
title="PILBox",
)
if __name__ == "__main__":
app.launch(
mcp_server=True, app_kwargs={"docs_url": "/docs"} # FastAPI Swagger API Docs
)
|