pilbox / app.py
John Ho
Expose text_overlay controls on the Annotate Video tab
be948dc
Raw
History Blame Contribute Delete
27.6 kB
"""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
)