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
    )