vlm-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vlm_toolkit/__init__.py +0 -0
- vlm_toolkit/bbox.py +62 -0
- vlm_toolkit/cmd/__init__.py +0 -0
- vlm_toolkit/cmd/caption_images.py +44 -0
- vlm_toolkit/cmd/detect_logo.py +42 -0
- vlm_toolkit/cmd/main.py +87 -0
- vlm_toolkit/cmd/remove_logo.py +155 -0
- vlm_toolkit/cmd/upload_dataset.py +53 -0
- vlm_toolkit/cmd/view_vlm_output.py +135 -0
- vlm_toolkit/cmd/vlm_process.py +51 -0
- vlm_toolkit/cmd/vlm_server.py +48 -0
- vlm_toolkit/hf_dataset.py +77 -0
- vlm_toolkit/img_utils.py +54 -0
- vlm_toolkit/logo/__init__.py +7 -0
- vlm_toolkit/logo/base.py +14 -0
- vlm_toolkit/logo/gray.py +29 -0
- vlm_toolkit/logo/opencv.py +35 -0
- vlm_toolkit/models.py +20 -0
- vlm_toolkit/prompts/caption.txt +1 -0
- vlm_toolkit/prompts/detect_logo.txt +1 -0
- vlm_toolkit/prompts/inat_classify.txt +24 -0
- vlm_toolkit/py.typed +0 -0
- vlm_toolkit/results.py +17 -0
- vlm_toolkit/templates/item.html +9 -0
- vlm_toolkit/templates/viewer.html +66 -0
- vlm_toolkit/util.py +10 -0
- vlm_toolkit/vlm.py +97 -0
- vlm_toolkit/vlm_process.py +196 -0
- vlm_toolkit/vlm_server.py +173 -0
- vlm_toolkit-0.1.0.dist-info/METADATA +264 -0
- vlm_toolkit-0.1.0.dist-info/RECORD +34 -0
- vlm_toolkit-0.1.0.dist-info/WHEEL +4 -0
- vlm_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
- vlm_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
vlm_toolkit/__init__.py
ADDED
|
File without changes
|
vlm_toolkit/bbox.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from PIL import Image, ImageDraw
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_bboxes(output: str) -> list[dict]:
|
|
8
|
+
"""Parse bbox_2d/label objects from VLM output, unwrapping markdown code blocks."""
|
|
9
|
+
# strip markdown code blocks if present
|
|
10
|
+
output = output.strip()
|
|
11
|
+
if output.startswith("```"):
|
|
12
|
+
match = re.search(r"```(?:json)?\s*\n(.*?)\n```", output, re.DOTALL)
|
|
13
|
+
if match:
|
|
14
|
+
output = match.group(1)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
bboxes = json.loads(output)
|
|
18
|
+
return bboxes if isinstance(bboxes, list) else []
|
|
19
|
+
except json.JSONDecodeError:
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def scale_bbox(
|
|
24
|
+
bbox_coords: list[int], width: int, height: int
|
|
25
|
+
) -> tuple[int, int, int, int]:
|
|
26
|
+
"""Scale a bbox from 1000x1000 coords to pixels, adding a 15% buffer."""
|
|
27
|
+
x1, y1, x2, y2 = bbox_coords
|
|
28
|
+
# convert from 1000-based coordinates to actual pixel coordinates
|
|
29
|
+
scaled_x1 = x1 * width / 1000
|
|
30
|
+
scaled_y1 = y1 * height / 1000
|
|
31
|
+
scaled_x2 = x2 * width / 1000
|
|
32
|
+
scaled_y2 = y2 * height / 1000
|
|
33
|
+
|
|
34
|
+
# add 15% buffer in all directions
|
|
35
|
+
bbox_width = scaled_x2 - scaled_x1
|
|
36
|
+
bbox_height = scaled_y2 - scaled_y1
|
|
37
|
+
|
|
38
|
+
buffer_x = bbox_width * 0.15
|
|
39
|
+
buffer_y = bbox_height * 0.15
|
|
40
|
+
|
|
41
|
+
# expand bbox and clamp to image boundaries
|
|
42
|
+
scaled_x1 = max(0, int(scaled_x1 - buffer_x))
|
|
43
|
+
scaled_y1 = max(0, int(scaled_y1 - buffer_y))
|
|
44
|
+
scaled_x2 = min(width, int(scaled_x2 + buffer_x))
|
|
45
|
+
scaled_y2 = min(height, int(scaled_y2 + buffer_y))
|
|
46
|
+
|
|
47
|
+
return scaled_x1, scaled_y1, scaled_x2, scaled_y2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_mask_from_bboxes(width: int, height: int, bboxes: list[dict]) -> Image.Image:
|
|
51
|
+
"""Create a mask from bounding boxes."""
|
|
52
|
+
mask = Image.new("RGB", (width, height), color="black")
|
|
53
|
+
draw = ImageDraw.Draw(mask)
|
|
54
|
+
|
|
55
|
+
for bbox in bboxes:
|
|
56
|
+
if "bbox_2d" in bbox:
|
|
57
|
+
# scale bbox from 1000x1000 to actual image dimensions
|
|
58
|
+
x1, y1, x2, y2 = scale_bbox(bbox["bbox_2d"], width, height)
|
|
59
|
+
# draw white rectangle where logo is
|
|
60
|
+
draw.rectangle([x1, y1, x2, y2], fill="white")
|
|
61
|
+
|
|
62
|
+
return mask
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from vlm_toolkit.models import DEFAULT_MODEL, SUGGESTED_MODELS
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
|
|
10
|
+
PROMPT_FILE = files("vlm_toolkit") / "prompts" / "caption.txt"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def main(
|
|
15
|
+
folder: Path = typer.Argument(..., help="Folder containing images to caption"),
|
|
16
|
+
output: Path = typer.Option(None, help="Output JSON file"),
|
|
17
|
+
model: str = typer.Option(DEFAULT_MODEL, help="Hugging Face model name"),
|
|
18
|
+
batch_size: int = typer.Option(1, help="Number of images to process in parallel"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Generate captions for all images in a folder using a VLM."""
|
|
21
|
+
# deferred so `vlm --help` doesn't pay for the torch import
|
|
22
|
+
from vlm_toolkit.vlm_process import vlm_process
|
|
23
|
+
|
|
24
|
+
prompt = PROMPT_FILE.read_text()
|
|
25
|
+
|
|
26
|
+
if output is None:
|
|
27
|
+
output = folder / "captions.json"
|
|
28
|
+
|
|
29
|
+
vlm_process(
|
|
30
|
+
folder=folder,
|
|
31
|
+
output=output,
|
|
32
|
+
prompt=prompt,
|
|
33
|
+
model=model,
|
|
34
|
+
batch_size=batch_size,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
main.__doc__ = (
|
|
39
|
+
f"Generate captions for all images in a folder using a VLM.\n\n{SUGGESTED_MODELS}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
app()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from vlm_toolkit.models import DEFAULT_MODEL, SUGGESTED_MODELS
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
|
|
10
|
+
PROMPT_FILE = files("vlm_toolkit") / "prompts" / "detect_logo.txt"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def main(
|
|
15
|
+
folder: Path = typer.Argument(..., help="Folder containing images to check"),
|
|
16
|
+
output: Path = typer.Option(None, help="Output JSON file"),
|
|
17
|
+
model: str = typer.Option(DEFAULT_MODEL, help="HF model"),
|
|
18
|
+
batch_size: int = typer.Option(8, help="Number of images to process in parallel"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Detect logos with a VLM."""
|
|
21
|
+
# deferred so `vlm --help` doesn't pay for the torch import
|
|
22
|
+
from vlm_toolkit.vlm_process import vlm_process
|
|
23
|
+
|
|
24
|
+
prompt = PROMPT_FILE.read_text()
|
|
25
|
+
|
|
26
|
+
if output is None:
|
|
27
|
+
output = folder / "logo_bbox_output.json"
|
|
28
|
+
|
|
29
|
+
vlm_process(
|
|
30
|
+
folder=folder,
|
|
31
|
+
output=output,
|
|
32
|
+
prompt=prompt,
|
|
33
|
+
model=model,
|
|
34
|
+
batch_size=batch_size,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
main.__doc__ = f"Detect logos with a VLM.\n\n{SUGGESTED_MODELS}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
app()
|
vlm_toolkit/cmd/main.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The `vlm` entry point, dispatching to the per-command Typer apps."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
# name -> (module under vlm_toolkit.cmd, one-line help shown by `vlm --help`)
|
|
10
|
+
COMMANDS = {
|
|
11
|
+
"process": ("vlm_process", "Run a VLM over a folder of images locally"),
|
|
12
|
+
"server": ("vlm_server", "Run a folder through an OpenAI-compatible endpoint"),
|
|
13
|
+
"caption-images": ("caption_images", "Caption images with the captioning prompt"),
|
|
14
|
+
"detect-logo": ("detect_logo", "Find logos and watermarks with a VLM"),
|
|
15
|
+
"remove-logo": ("remove_logo", "Erase detected boxes by inpainting"),
|
|
16
|
+
"view-output": ("view_vlm_output", "Render a run as an HTML page"),
|
|
17
|
+
"upload-dataset": ("upload_dataset", "Publish a run to the Hugging Face Hub"),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# top-level module -> the extra that provides it, for a readable error when a
|
|
21
|
+
# command is run against a base install
|
|
22
|
+
EXTRAS = {
|
|
23
|
+
"accelerate": "local",
|
|
24
|
+
"kernels": "local",
|
|
25
|
+
"xgrammar": "local",
|
|
26
|
+
"torch": "local",
|
|
27
|
+
"torchvision": "local",
|
|
28
|
+
"transformers": "local",
|
|
29
|
+
"cv2": "logo",
|
|
30
|
+
"numpy": "logo",
|
|
31
|
+
"datasets": "hub",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LazyGroup(click.Group):
|
|
36
|
+
"""Import a subcommand's module only when that subcommand actually runs.
|
|
37
|
+
|
|
38
|
+
Keeps `vlm --help` and the light commands usable without the optional heavy
|
|
39
|
+
extras (torch, opencv, datasets) installed.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
43
|
+
return list(COMMANDS)
|
|
44
|
+
|
|
45
|
+
def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
|
|
46
|
+
if name not in COMMANDS:
|
|
47
|
+
return None
|
|
48
|
+
module = importlib.import_module(f"vlm_toolkit.cmd.{COMMANDS[name][0]}")
|
|
49
|
+
command = typer.main.get_command(module.app)
|
|
50
|
+
command.short_help = COMMANDS[name][1]
|
|
51
|
+
# typer vendors its own click, so its commands aren't click.Command
|
|
52
|
+
# subclasses; click only ever duck-types what it gets back from here
|
|
53
|
+
return cast(click.Command, command)
|
|
54
|
+
|
|
55
|
+
def invoke(self, ctx: click.Context) -> object:
|
|
56
|
+
# typer's Exit and Abort are unrelated to click's, so without this a
|
|
57
|
+
# plain click group lets them escape as unhandled exceptions - which
|
|
58
|
+
# turns `--help` and every `raise typer.Exit(1)` into a traceback-ish 1
|
|
59
|
+
try:
|
|
60
|
+
return super().invoke(ctx)
|
|
61
|
+
except typer.Exit as e:
|
|
62
|
+
raise click.exceptions.Exit(e.exit_code) from e
|
|
63
|
+
except typer.Abort as e:
|
|
64
|
+
raise click.exceptions.Abort() from e
|
|
65
|
+
except ModuleNotFoundError as e:
|
|
66
|
+
extra = EXTRAS.get(e.name or "")
|
|
67
|
+
if extra is None:
|
|
68
|
+
raise
|
|
69
|
+
raise click.ClickException(
|
|
70
|
+
f"`vlm {ctx.invoked_subcommand}` needs the '{extra}' extra.\n"
|
|
71
|
+
f"Install it with: pip install 'vlm-toolkit[{extra}]'"
|
|
72
|
+
) from e
|
|
73
|
+
|
|
74
|
+
def format_commands(self, ctx: click.Context, formatter) -> None: # type: ignore[no-untyped-def]
|
|
75
|
+
# use the static help text, so listing commands imports nothing
|
|
76
|
+
with formatter.section("Commands"):
|
|
77
|
+
formatter.write_dl([(name, help) for name, (_, help) in COMMANDS.items()])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.group(cls=LazyGroup, context_settings={"help_option_names": ["-h", "--help"]})
|
|
81
|
+
@click.version_option(package_name="vlm-toolkit")
|
|
82
|
+
def cli() -> None:
|
|
83
|
+
"""Batch image processing with vision language models."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
cli()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from PIL import Image
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from vlm_toolkit.bbox import parse_bboxes
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from vlm_toolkit.logo import LogoRemover
|
|
16
|
+
|
|
17
|
+
app = typer.Typer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RemovalMethod(str, Enum):
|
|
21
|
+
"""Logo removal methods."""
|
|
22
|
+
|
|
23
|
+
GRAY = "gray"
|
|
24
|
+
OPENCV = "opencv"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def process_single_image(
|
|
28
|
+
image_path: Path, bboxes: list, output_path: Path, method: RemovalMethod
|
|
29
|
+
) -> tuple[bool, str, Path | str]:
|
|
30
|
+
"""Process a single image (used for parallel processing)."""
|
|
31
|
+
# deferred so `vlm --help` works without the logo extra installed
|
|
32
|
+
from vlm_toolkit.logo import GrayLogoRemover, OpenCVLogoRemover
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
remover: "LogoRemover"
|
|
36
|
+
if method == RemovalMethod.OPENCV:
|
|
37
|
+
remover = OpenCVLogoRemover()
|
|
38
|
+
else:
|
|
39
|
+
remover = GrayLogoRemover()
|
|
40
|
+
|
|
41
|
+
image = Image.open(image_path).convert("RGB")
|
|
42
|
+
result = remover.remove(image, bboxes)
|
|
43
|
+
result.save(output_path)
|
|
44
|
+
|
|
45
|
+
return (True, image_path.name, output_path)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return (False, image_path.name, str(e))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def main(
|
|
52
|
+
bboxes: Path = typer.Argument(
|
|
53
|
+
..., help="JSON file with bounding boxes (output from vlm-process)"
|
|
54
|
+
),
|
|
55
|
+
output_dir: Path = typer.Argument(None, help="Output directory for cleaned images"),
|
|
56
|
+
method: RemovalMethod = typer.Option(
|
|
57
|
+
RemovalMethod.OPENCV, help="Inpainting method"
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Remove logos from images using inpainting.
|
|
61
|
+
|
|
62
|
+
Expects a JSON file where each entry has an 'output' field containing
|
|
63
|
+
bounding boxes in the format:
|
|
64
|
+
[{"bbox_2d": [x1, y1, x2, y2], "label": "logo"}]
|
|
65
|
+
|
|
66
|
+
Methods:
|
|
67
|
+
- gray: Replace with gray rectangles (instant, simple)
|
|
68
|
+
- opencv: Fast, lightweight inpainting using OpenCV (recommended for simple cases)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
if not bboxes.exists():
|
|
72
|
+
print(f"Error: {bboxes} does not exist", flush=True)
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
# default output directory to same location as input JSON
|
|
76
|
+
if output_dir is None:
|
|
77
|
+
output_dir = bboxes.parent / f"{bboxes.stem}_cleaned"
|
|
78
|
+
|
|
79
|
+
output_dir.mkdir(exist_ok=True, parents=True)
|
|
80
|
+
|
|
81
|
+
# load detections from JSON lines file
|
|
82
|
+
detections = []
|
|
83
|
+
json_dir = bboxes.parent
|
|
84
|
+
with open(bboxes) as f:
|
|
85
|
+
for line in f:
|
|
86
|
+
if line.strip():
|
|
87
|
+
detections.append(json.loads(line))
|
|
88
|
+
|
|
89
|
+
print(f"Loaded {len(detections)} detections", flush=True)
|
|
90
|
+
|
|
91
|
+
# parse bboxes and filter out images without any
|
|
92
|
+
images_to_clean = []
|
|
93
|
+
for detection in detections:
|
|
94
|
+
bboxes_list = parse_bboxes(detection["output"])
|
|
95
|
+
if bboxes_list:
|
|
96
|
+
detection["bboxes"] = bboxes_list
|
|
97
|
+
# resolve relative path
|
|
98
|
+
rel_path = Path(detection["file_name"])
|
|
99
|
+
detection["abs_path"] = (json_dir / rel_path).resolve()
|
|
100
|
+
images_to_clean.append(detection)
|
|
101
|
+
|
|
102
|
+
print(f"Found {len(images_to_clean)} images with logos to remove", flush=True)
|
|
103
|
+
|
|
104
|
+
# prepare tasks for parallel processing
|
|
105
|
+
tasks = []
|
|
106
|
+
for detection in images_to_clean:
|
|
107
|
+
image_path = detection["abs_path"]
|
|
108
|
+
bbox_list = detection["bboxes"]
|
|
109
|
+
output_path = output_dir / f"{image_path.name}"
|
|
110
|
+
tasks.append((image_path, bbox_list, output_path, method))
|
|
111
|
+
|
|
112
|
+
# process images in parallel
|
|
113
|
+
num_workers = os.cpu_count()
|
|
114
|
+
print(
|
|
115
|
+
f"Processing {len(images_to_clean)} images with {num_workers} workers...",
|
|
116
|
+
flush=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
results = []
|
|
120
|
+
errors = []
|
|
121
|
+
with ProcessPoolExecutor(max_workers=num_workers) as executor:
|
|
122
|
+
futures = {
|
|
123
|
+
executor.submit(
|
|
124
|
+
process_single_image, img_path, bbox_list, out_path, method
|
|
125
|
+
): (
|
|
126
|
+
img_path,
|
|
127
|
+
len(bbox_list),
|
|
128
|
+
)
|
|
129
|
+
for img_path, bbox_list, out_path, method in tasks
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# process results as they complete with progress bar
|
|
133
|
+
for future in tqdm(
|
|
134
|
+
as_completed(futures), total=len(futures), desc="Processing"
|
|
135
|
+
):
|
|
136
|
+
img_path, num_bboxes = futures[future]
|
|
137
|
+
success, name, result_or_error = future.result()
|
|
138
|
+
|
|
139
|
+
if success:
|
|
140
|
+
results.append(result_or_error)
|
|
141
|
+
else:
|
|
142
|
+
errors.append((name, result_or_error))
|
|
143
|
+
|
|
144
|
+
# report any errors
|
|
145
|
+
if errors:
|
|
146
|
+
print(f"\nErrors encountered:", flush=True)
|
|
147
|
+
for name, error in errors:
|
|
148
|
+
print(f" ✗ {name}: {error}", flush=True)
|
|
149
|
+
|
|
150
|
+
print(f"Processed {len(results)}/{len(images_to_clean)} images", flush=True)
|
|
151
|
+
print(f"Cleaned images saved to: {output_dir}", flush=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
app()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command()
|
|
9
|
+
def main(
|
|
10
|
+
results_file: Path = typer.Argument(..., help="JSONL output from vlm-process"),
|
|
11
|
+
repo_id: str = typer.Argument(
|
|
12
|
+
..., help="Target dataset repo, e.g. user/my-dataset"
|
|
13
|
+
),
|
|
14
|
+
private: bool = typer.Option(True, help="Create the dataset repo as private"),
|
|
15
|
+
split: str = typer.Option("train", help="Split name to upload as"),
|
|
16
|
+
token: str = typer.Option(
|
|
17
|
+
None, help="Hugging Face token (defaults to HF_TOKEN or a cached login)"
|
|
18
|
+
),
|
|
19
|
+
card: Path = typer.Option(
|
|
20
|
+
None, help="Markdown file to attach as the dataset card (README.md)"
|
|
21
|
+
),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Upload a vlm-process run to the Hugging Face Hub as an image dataset.
|
|
24
|
+
|
|
25
|
+
Images are embedded in the dataset, not referenced by path, so the result is
|
|
26
|
+
self-contained and works in the Hub's dataset viewer. --card attaches a
|
|
27
|
+
README, preserving the dataset_info metadata the upload generates.
|
|
28
|
+
"""
|
|
29
|
+
# deferred so `vlm --help` works without the hub extra installed
|
|
30
|
+
from vlm_toolkit.hf_dataset import upload
|
|
31
|
+
|
|
32
|
+
if not results_file.exists():
|
|
33
|
+
print(f"Error: {results_file} does not exist", flush=True)
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
|
|
36
|
+
# check before uploading, so a typo doesn't surface after a long transfer
|
|
37
|
+
if card is not None and not card.exists():
|
|
38
|
+
print(f"Error: {card} does not exist", flush=True)
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
url = upload(
|
|
42
|
+
results_file,
|
|
43
|
+
repo_id,
|
|
44
|
+
private=private,
|
|
45
|
+
split=split,
|
|
46
|
+
token=token,
|
|
47
|
+
card_file=card,
|
|
48
|
+
)
|
|
49
|
+
print(f"Uploaded to {url}", flush=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
app()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Generate HTML visualization of VLM output."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import http.server
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import socketserver
|
|
8
|
+
import webbrowser
|
|
9
|
+
from html import escape
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from string import Template
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from vlm_toolkit.results import load_results
|
|
19
|
+
|
|
20
|
+
app = typer.Typer()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _ViewerHandler(http.server.SimpleHTTPRequestHandler):
|
|
24
|
+
"""Serve the generated page at / and images from the served root."""
|
|
25
|
+
|
|
26
|
+
page = b""
|
|
27
|
+
|
|
28
|
+
def do_GET(self) -> None:
|
|
29
|
+
if self.path in ("/", "/index.html"):
|
|
30
|
+
self.send_response(200)
|
|
31
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
32
|
+
self.send_header("Content-Length", str(len(self.page)))
|
|
33
|
+
self.end_headers()
|
|
34
|
+
self.wfile.write(self.page)
|
|
35
|
+
return
|
|
36
|
+
super().do_GET()
|
|
37
|
+
|
|
38
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _render(items: list[dict], srcs: list[str]) -> str:
|
|
43
|
+
"""Render the viewer page, using srcs as the img src for each item."""
|
|
44
|
+
templates = files("vlm_toolkit") / "templates"
|
|
45
|
+
page = Template((templates / "viewer.html").read_text())
|
|
46
|
+
item_template = Template((templates / "item.html").read_text())
|
|
47
|
+
|
|
48
|
+
# model output is untrusted text, so escape everything going into the markup
|
|
49
|
+
blocks = [
|
|
50
|
+
item_template.substitute(
|
|
51
|
+
src=escape(src, quote=True),
|
|
52
|
+
name=escape(item["abs_path"].name),
|
|
53
|
+
output=escape(item["output"]),
|
|
54
|
+
)
|
|
55
|
+
for item, src in zip(items, srcs)
|
|
56
|
+
]
|
|
57
|
+
return page.substitute(items="".join(blocks))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _web_root(items: list[dict], json_dir: Path) -> tuple[Path, list[str]]:
|
|
61
|
+
"""Pick a server root containing every image, and URLs relative to it."""
|
|
62
|
+
paths = [item["abs_path"] for item in items]
|
|
63
|
+
root = Path(os.path.commonpath([json_dir.resolve(), *paths]))
|
|
64
|
+
return root, [quote(str(p.relative_to(root))) for p in paths]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _lan_ip() -> str:
|
|
68
|
+
"""Best guess at this machine's outward-facing address, for the printed URL."""
|
|
69
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
70
|
+
try:
|
|
71
|
+
s.connect(("8.8.8.8", 80)) # no packets sent, just picks the route
|
|
72
|
+
return s.getsockname()[0]
|
|
73
|
+
except OSError:
|
|
74
|
+
return socket.gethostname()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _serve(items: list[dict], input_json: Path, host: str, port: int) -> None:
|
|
78
|
+
"""Serve the viewer, rooted at the common ancestor of the JSON and its images."""
|
|
79
|
+
root, srcs = _web_root(items, input_json.parent)
|
|
80
|
+
|
|
81
|
+
_ViewerHandler.page = _render(items, srcs).encode()
|
|
82
|
+
handler = functools.partial(_ViewerHandler, directory=str(root))
|
|
83
|
+
|
|
84
|
+
socketserver.TCPServer.allow_reuse_address = True
|
|
85
|
+
with socketserver.TCPServer((host, port), handler) as httpd:
|
|
86
|
+
local = f"http://{'127.0.0.1' if host in ('0.0.0.0', '') else host}:{port}"
|
|
87
|
+
print(f"Serving {root} (Ctrl-C to stop)", flush=True)
|
|
88
|
+
print(f" local: {local}", flush=True)
|
|
89
|
+
if host in ("0.0.0.0", ""):
|
|
90
|
+
print(f" network: http://{_lan_ip()}:{port}", flush=True)
|
|
91
|
+
print(" reachable by anyone who can route to this host", flush=True)
|
|
92
|
+
webbrowser.open(local)
|
|
93
|
+
try:
|
|
94
|
+
httpd.serve_forever()
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
print("\nStopped", flush=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def main(
|
|
101
|
+
input_json: Path = typer.Argument(..., help="JSON file with VLM output"),
|
|
102
|
+
serve: bool = typer.Option(
|
|
103
|
+
False, "--serve", help="Serve over HTTP instead of writing an HTML file"
|
|
104
|
+
),
|
|
105
|
+
host: str = typer.Option(
|
|
106
|
+
"127.0.0.1", help="Address to bind; use 0.0.0.0 to accept external connections"
|
|
107
|
+
),
|
|
108
|
+
port: int = typer.Option(8000, help="Port to serve on"),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Generate HTML visualization of VLM output.
|
|
111
|
+
|
|
112
|
+
Writes an HTML file next to the input and opens it, or with --serve hosts it
|
|
113
|
+
on localhost instead, which is what you want when the images live on a
|
|
114
|
+
remote machine. --host 0.0.0.0 accepts external connections, which serves the
|
|
115
|
+
image directory to the network with no authentication.
|
|
116
|
+
"""
|
|
117
|
+
if not input_json.exists():
|
|
118
|
+
print(f"Error: {input_json} does not exist", flush=True)
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
items = load_results(input_json)
|
|
122
|
+
|
|
123
|
+
if serve:
|
|
124
|
+
_serve(items, input_json, host, port)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
html = _render(items, [str(item["abs_path"]) for item in items])
|
|
128
|
+
output_path = Path(str(input_json) + ".html")
|
|
129
|
+
output_path.write_text(html)
|
|
130
|
+
print(f"Generated {output_path}", flush=True)
|
|
131
|
+
webbrowser.open(f"file://{output_path.absolute()}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
app()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from vlm_toolkit.models import DEFAULT_MODEL, DEFAULT_PROMPT, SUGGESTED_MODELS
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def main(
|
|
12
|
+
folder: Path = typer.Argument(..., help="Folder containing images to process"),
|
|
13
|
+
output: Path = typer.Option(None, help="Output JSON file"),
|
|
14
|
+
prompt: str = typer.Option(DEFAULT_PROMPT, help="Prompt for the VLM"),
|
|
15
|
+
prompt_file: Path = typer.Option(
|
|
16
|
+
None, help="File containing prompt (overrides --prompt)"
|
|
17
|
+
),
|
|
18
|
+
model: str = typer.Option(DEFAULT_MODEL, help="HF model"),
|
|
19
|
+
batch_size: int = typer.Option(8, help="Number of images to process in parallel"),
|
|
20
|
+
max_dim: int = typer.Option(
|
|
21
|
+
None, help="Maximum dimension for image resizing (default: 1024)"
|
|
22
|
+
),
|
|
23
|
+
schema: Path = typer.Option(
|
|
24
|
+
None, help="JSON schema file for constrained decoding (uses xgrammar)"
|
|
25
|
+
),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Process images using a Vision Language Model."""
|
|
28
|
+
# deferred so `vlm --help` doesn't pay for the torch import
|
|
29
|
+
from vlm_toolkit.vlm_process import vlm_process
|
|
30
|
+
|
|
31
|
+
if prompt_file is not None:
|
|
32
|
+
prompt = prompt_file.read_text().strip()
|
|
33
|
+
|
|
34
|
+
schema_str = schema.read_text() if schema is not None else None
|
|
35
|
+
|
|
36
|
+
vlm_process(
|
|
37
|
+
folder=folder,
|
|
38
|
+
output=output,
|
|
39
|
+
prompt=prompt,
|
|
40
|
+
model=model,
|
|
41
|
+
batch_size=batch_size,
|
|
42
|
+
max_dim=max_dim,
|
|
43
|
+
schema=schema_str,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
main.__doc__ = f"Process images using a Vision Language Model.\n\n{SUGGESTED_MODELS}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
app()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from vlm_toolkit.vlm_server import (
|
|
6
|
+
DEFAULT_BASE_URL,
|
|
7
|
+
DEFAULT_MODEL,
|
|
8
|
+
DEFAULT_PROMPT,
|
|
9
|
+
vlm_server_process,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def main(
|
|
17
|
+
folder: Path = typer.Argument(..., help="Folder containing images to process"),
|
|
18
|
+
output: Path = typer.Option(None, help="Output JSONL file"),
|
|
19
|
+
prompt: str = typer.Option(DEFAULT_PROMPT, help="Prompt for the VLM"),
|
|
20
|
+
prompt_file: Path = typer.Option(
|
|
21
|
+
None, help="File containing prompt (overrides --prompt)"
|
|
22
|
+
),
|
|
23
|
+
base_url: str = typer.Option(DEFAULT_BASE_URL, help="Server base URL"),
|
|
24
|
+
model: str = typer.Option(DEFAULT_MODEL, help="Model name to send in requests"),
|
|
25
|
+
max_dim: int = typer.Option(
|
|
26
|
+
None, help="Maximum dimension for image resizing (default: 1024)"
|
|
27
|
+
),
|
|
28
|
+
max_tokens: int = typer.Option(512, help="Max tokens to generate"),
|
|
29
|
+
concurrency: int = typer.Option(8, help="Number of concurrent requests"),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Process images via an OpenAI-compatible vision endpoint."""
|
|
32
|
+
if prompt_file is not None:
|
|
33
|
+
prompt = prompt_file.read_text().strip()
|
|
34
|
+
|
|
35
|
+
vlm_server_process(
|
|
36
|
+
folder=folder,
|
|
37
|
+
output=output,
|
|
38
|
+
prompt=prompt,
|
|
39
|
+
base_url=base_url,
|
|
40
|
+
model=model,
|
|
41
|
+
max_dim=max_dim,
|
|
42
|
+
max_tokens=max_tokens,
|
|
43
|
+
concurrency=concurrency,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
app()
|