stevenlearns commited on
Commit
5670bcb
·
verified ·
1 Parent(s): 57e88d9

Upload folder using huggingface_hub

Browse files
Files changed (7) hide show
  1. .gitignore +11 -0
  2. LICENSE +21 -0
  3. README.md +144 -0
  4. caption.py +361 -0
  5. caption_config.json +8 -0
  6. install.sh +186 -0
  7. requirements.txt +8 -0
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+
6
+ # Virtual environment (created by install.sh)
7
+ .venv/
8
+
9
+ # OS / editor noise
10
+ .DS_Store
11
+ *.swp
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oohfixer.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Caption Tool
2
+
3
+ A small, friendly tool that writes text descriptions (captions) for every image
4
+ in a folder — all on your own computer.
5
+
6
+ - **No API key. No account. No internet** (after the first-time model download).
7
+ - **Private:** your images never leave your machine.
8
+ - **Lightweight:** runs on a GPU with as little as **2 GB of VRAM**.
9
+ - **Whole folders at once:** point it at a directory and it captions everything.
10
+
11
+ Made by [oohfixer.com](https://oohfixer.com).
12
+
13
+ ---
14
+
15
+ ## What it does
16
+
17
+ For every image it finds, Caption Tool writes a matching text file next to it:
18
+
19
+ ```
20
+ vacation.jpg → vacation.txt
21
+ cat.png → cat.txt
22
+ ```
23
+
24
+ That's it. The caption sits in the `.txt` file, ready to use for training
25
+ image models, organizing photos, or search.
26
+
27
+ ---
28
+
29
+ ## Install
30
+
31
+ You need **Python 3.13 or older** (3.14 doesn't work with this tool's
32
+ dependencies).
33
+
34
+ ### One command (recommended)
35
+
36
+ Open a terminal and run:
37
+
38
+ ```bash
39
+ curl -fsSL https://oohfixer.com/caption-tool/install.sh | bash
40
+ ```
41
+
42
+ This downloads the tool, installs what it needs in its own isolated
43
+ environment, and adds a **`captions`** command you can run from anywhere. When it
44
+ finishes, open a new terminal (or run `source ~/.bashrc`).
45
+
46
+ Then just type:
47
+
48
+ ```bash
49
+ captions
50
+ ```
51
+
52
+ ### Manual install
53
+
54
+ If you'd rather set it up by hand:
55
+
56
+ 1. Download this folder and open a terminal in it.
57
+ 2. Install the requirements:
58
+
59
+ ```bash
60
+ pip install -r requirements.txt
61
+ ```
62
+
63
+ 3. Run it:
64
+
65
+ ```bash
66
+ python caption.py
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Use it
72
+
73
+ Just run it:
74
+
75
+ ```bash
76
+ python caption.py
77
+ ```
78
+
79
+ It will ask you for a folder of images, show the settings, and caption them.
80
+ Sit back — a progress bar shows how far it's gotten.
81
+
82
+ ### Want to skip the questions?
83
+
84
+ If you already know what you want, you can do it in one line:
85
+
86
+ ```bash
87
+ python caption.py --path "/path/to/your/images"
88
+ ```
89
+
90
+ Useful extras:
91
+
92
+ | Option | What it does |
93
+ | --- | --- |
94
+ | `--path "FOLDER"` | Caption this folder right away (no prompt). |
95
+ | `--force` | Re-caption images that already have a `.txt` file. |
96
+ | `--model florence2-base` | Pick a different model (see below). |
97
+ | `--max-new-tokens 512` | Allow longer captions. |
98
+
99
+ ---
100
+
101
+ ## Choosing a model
102
+
103
+ When you run it, you can pick which AI model writes the captions. Bigger = more
104
+ detailed, but uses more VRAM.
105
+
106
+ | Model | Size | Best for |
107
+ | --- | --- | --- |
108
+ | **florence2-large-ft** (default) | ~1.5 GB | Best quality. Needs a normal GPU. |
109
+ | florence2-large | ~1.5 GB | Slightly less accurate. |
110
+ | **florence2-base** | ~0.45 GB | **2 GB GPUs**, or when you want it fast. |
111
+
112
+ If your card only has 2 GB of VRAM, choose **florence2-base**.
113
+
114
+ ---
115
+
116
+ ## Prefix and suffix (optional)
117
+
118
+ If you're captioning images to train your own image model, you may want a
119
+ **trigger word** on every caption (for example, your subject's name). Set a
120
+ **prefix** and the tool adds it to the front of every caption automatically.
121
+ A **suffix** does the same at the end.
122
+
123
+ Leave them empty if you just want plain descriptions.
124
+
125
+ ---
126
+
127
+ ## Settings
128
+
129
+ Settings are stored in `caption_config.json` next to the script. You can edit
130
+ that file directly, or change things from the tool's menu. The defaults:
131
+
132
+ - Skip images that already have a caption (so re-running is safe).
133
+ - At least ~15 words per caption (it re-captions anything shorter).
134
+ - 256 token cap on caption length.
135
+
136
+ ---
137
+
138
+ ## Questions
139
+
140
+ - **Does it need to be online?** Only the very first time, to download the
141
+ model. After that, everything runs offline.
142
+ - **Where do my images go?** Nowhere. They're processed locally and the caption
143
+ is saved next to each image.
144
+ - **What image types?** jpg, png, webp, bmp, tiff.
caption.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Caption Tool — caption entire folders of images locally.
4
+
5
+ Runs 100% on your machine. No API key, no internet after the first model
6
+ download. Works on GPUs with as little as 2 GB of VRAM (use Florence-2-base).
7
+
8
+ For every image it writes a matching <image>.txt caption file next to it.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ import warnings
17
+
18
+ warnings.filterwarnings("ignore")
19
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
20
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
21
+
22
+ from pathlib import Path
23
+
24
+ from rich.console import Console
25
+ from rich.panel import Panel
26
+ from rich.prompt import Prompt, Confirm
27
+ from rich.progress import (
28
+ Progress,
29
+ SpinnerColumn,
30
+ BarColumn,
31
+ TextColumn,
32
+ TimeElapsedColumn,
33
+ TaskProgressColumn,
34
+ )
35
+ from rich.live import Live
36
+ from rich.table import Table
37
+ from rich.text import Text
38
+
39
+ console = Console()
40
+
41
+ CONFIG_PATH = Path(__file__).parent / "caption_config.json"
42
+
43
+ # Model choices. Sizes are approximate fp16 GPU footprint.
44
+ MODELS = {
45
+ "florence2-large-ft": ("microsoft/Florence-2-large-ft", "~1.5 GB — best quality (recommended)"),
46
+ "florence2-large": ("microsoft/Florence-2-large", "~1.5 GB — base, slightly less accurate"),
47
+ "florence2-base": ("microsoft/Florence-2-base", "~0.45 GB — fast, great for 2 GB GPUs"),
48
+ }
49
+
50
+ DEFAULT_CONFIG = {
51
+ "model": "microsoft/Florence-2-large-ft",
52
+ "prefix": "",
53
+ "suffix": "",
54
+ "max_new_tokens": 256,
55
+ "min_words": 15,
56
+ "skip_existing": True,
57
+ }
58
+
59
+
60
+ def _model_family(model_id: str) -> str:
61
+ return "florence2" if "florence" in model_id.lower() else "unknown"
62
+
63
+
64
+ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"}
65
+
66
+
67
+ def load_config() -> dict:
68
+ if CONFIG_PATH.exists():
69
+ with open(CONFIG_PATH) as f:
70
+ cfg = json.load(f)
71
+ return {**DEFAULT_CONFIG, **cfg}
72
+ return DEFAULT_CONFIG.copy()
73
+
74
+
75
+ def save_config(cfg: dict):
76
+ with open(CONFIG_PATH, "w") as f:
77
+ json.dump(cfg, f, indent=2)
78
+
79
+
80
+ def find_images(path: Path) -> list[Path]:
81
+ if path.is_file():
82
+ if path.suffix.lower() in IMAGE_EXTENSIONS:
83
+ return [path]
84
+ return []
85
+ return sorted(p for p in path.rglob("*") if p.suffix.lower() in IMAGE_EXTENSIONS)
86
+
87
+
88
+ def load_model(model_id: str):
89
+ """Load the captioning model onto the GPU, or CPU if no GPU is present."""
90
+ import torch
91
+ from transformers import AutoProcessor, AutoModelForCausalLM
92
+
93
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
94
+ device_label = (
95
+ f"CUDA ({torch.cuda.get_device_name(0)})" if device.type == "cuda" else "CPU"
96
+ )
97
+ dtype = torch.float16 if device.type == "cuda" else torch.float32
98
+ family = _model_family(model_id)
99
+
100
+ with console.status(f"[bold cyan]Loading [white]{model_id}[/white] on {device_label}...", spinner="dots"):
101
+ processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
102
+ model = AutoModelForCausalLM.from_pretrained(
103
+ model_id, torch_dtype=dtype, trust_remote_code=True
104
+ ).to(device)
105
+ model.eval()
106
+
107
+ return (processor, model, device, dtype, family), device_label
108
+
109
+
110
+ def _generate_caption(processor, model, img, device, dtype, max_new_tokens: int, task: str) -> str:
111
+ """Run one Florence-2 caption generation for the given task prompt."""
112
+ import torch
113
+
114
+ inputs = processor(text=task, images=img, return_tensors="pt")
115
+ inputs = {k: v.to(device) for k, v in inputs.items()}
116
+ if "pixel_values" in inputs:
117
+ inputs["pixel_values"] = inputs["pixel_values"].to(dtype)
118
+ output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens, num_beams=3)
119
+ raw = processor.batch_decode(output_ids, skip_special_tokens=False)[0]
120
+ parsed = processor.post_process_generation(raw, task=task, image_size=(img.width, img.height))
121
+ return parsed[task].strip()
122
+
123
+
124
+ def caption_image(model_bundle, image_path: Path, prefix: str, suffix: str,
125
+ max_new_tokens: int, min_words: int = 0) -> str:
126
+ import torch
127
+ from PIL import Image
128
+
129
+ processor, model, device, dtype, family = model_bundle
130
+ img = Image.open(image_path).convert("RGB")
131
+
132
+ with torch.no_grad():
133
+ caption = _generate_caption(
134
+ processor, model, img, device, dtype, max_new_tokens, "<MORE_DETAILED_CAPTION>"
135
+ )
136
+
137
+ # Florence-2 has no native minimum length, so re-run with a stronger
138
+ # prompt if the first caption came out too short.
139
+ if min_words and len(caption.split()) < min_words:
140
+ stronger = (
141
+ "Describe this image in extensive detail for a training caption. "
142
+ "Cover the subject, clothing, hair, lighting, pose, expression, "
143
+ "background, and camera angle."
144
+ )
145
+ caption = _generate_caption(
146
+ processor, model, img, device, dtype, max_new_tokens, stronger
147
+ )
148
+
149
+ parts = [p for p in (prefix.strip(), caption, suffix.strip()) if p]
150
+ return ", ".join(parts) if prefix or suffix else caption
151
+
152
+
153
+ def select_model(current: str) -> str:
154
+ console.print("\n[bold]Available models:[/bold]")
155
+ table = Table(show_header=False, box=None, padding=(0, 2))
156
+ for key, (model_id, desc) in MODELS.items():
157
+ marker = "→" if model_id == current else " "
158
+ table.add_row(f"[cyan]{marker} {key}[/cyan]", model_id, f"[dim]{desc}[/dim]")
159
+ console.print(table)
160
+
161
+ choice = Prompt.ask(
162
+ "\nModel shortname or full HF model ID",
163
+ default=next((k for k, (m, _) in MODELS.items() if m == current), current),
164
+ )
165
+ if choice in MODELS:
166
+ return MODELS[choice][0]
167
+ return choice # allow any Hugging Face model ID
168
+
169
+
170
+ def parse_args():
171
+ p = argparse.ArgumentParser(description="Caption Tool — local batch image captioner.")
172
+ p.add_argument("--path", help="Image folder or file (skips the path prompt)")
173
+ p.add_argument("--model", help="Override the model (shortname or HF ID)")
174
+ p.add_argument("--max-new-tokens", type=int, help="Override max new tokens")
175
+ p.add_argument("--force", action="store_true", help="Re-caption even if a .txt already exists")
176
+ return p.parse_args()
177
+
178
+
179
+ def _hsl_hex(hue: float) -> str:
180
+ """Hue (0-360) → hex color string, full saturation, mid lightness."""
181
+ hue %= 360
182
+ s, l = 1.0, 0.55
183
+ c = (1 - abs(2 * l - 1)) * s
184
+ hp = hue / 60
185
+ x = c * (1 - abs((hp % 2) - 1))
186
+ m = l - c / 2
187
+ if hp < 1:
188
+ r, g, b = c, x, 0
189
+ elif hp < 2:
190
+ r, g, b = x, c, 0
191
+ elif hp < 3:
192
+ r, g, b = 0, c, x
193
+ elif hp < 4:
194
+ r, g, b = 0, x, c
195
+ elif hp < 5:
196
+ r, g, b = x, 0, c
197
+ else:
198
+ r, g, b = c, 0, x
199
+ R = int((r + m) * 255)
200
+ G = int((g + m) * 255)
201
+ B = int((b + m) * 255)
202
+ return f"#{R:02x}{G:02x}{B:02x}"
203
+
204
+
205
+ def _header_text(offset: int) -> "Text":
206
+ """Build the title panel content with a rainbow-shimmer oohfixer.com."""
207
+ t = Text()
208
+ t.append("Caption Tool", style="bold white")
209
+ t.append("\n")
210
+ t.append("Caption entire image folders locally — no API key required", style="dim")
211
+ t.append("\n")
212
+ for i, ch in enumerate("oohfixer.com"):
213
+ hue = (i * 18 + offset) % 360
214
+ t.append(ch, style=f"bold {_hsl_hex(hue)}")
215
+ return t
216
+
217
+
218
+ def show_header():
219
+ """Print the title. Animate a rainbow shimmer on a real terminal; static otherwise."""
220
+ panel = lambda off: Panel(_header_text(off), style="bold blue", expand=False) # noqa: E731
221
+ if console.is_terminal:
222
+ with Live(console=console, refresh_per_second=24, transient=False) as live:
223
+ for frame in range(40):
224
+ live.update(panel(frame * 14))
225
+ time.sleep(0.03)
226
+ else:
227
+ console.print(panel(0))
228
+
229
+
230
+ def main():
231
+ args = parse_args()
232
+
233
+ show_header()
234
+
235
+ cfg = load_config()
236
+
237
+ # --- Path input (use --path if given) ---
238
+ if args.path:
239
+ target = Path(args.path).expanduser().resolve()
240
+ else:
241
+ raw = Prompt.ask("\n[bold]Image folder or file path[/bold]")
242
+ target = Path(raw).expanduser().resolve()
243
+
244
+ if not target.exists():
245
+ console.print(f"[red]✗ Path not found:[/red] {target}")
246
+ sys.exit(1)
247
+
248
+ images = find_images(target)
249
+ if not images:
250
+ console.print("[red]✗ No images found.[/red]")
251
+ sys.exit(1)
252
+
253
+ # CLI / config overrides
254
+ if args.model:
255
+ cfg["model"] = args.model
256
+ if args.max_new_tokens:
257
+ cfg["max_new_tokens"] = args.max_new_tokens
258
+ if args.force:
259
+ cfg["skip_existing"] = False
260
+
261
+ # Filter already-captioned unless skipping is disabled
262
+ pending = images
263
+ if cfg["skip_existing"]:
264
+ pending = [p for p in images if not p.with_suffix(".txt").exists()]
265
+ skipped = len(images) - len(pending)
266
+ else:
267
+ skipped = 0
268
+
269
+ console.print(f"\n[green]✓[/green] Found [bold]{len(images)}[/bold] image(s)", end="")
270
+ if skipped:
271
+ console.print(f" [dim]({skipped} already captioned, skipping)[/dim]", end="")
272
+ console.print()
273
+
274
+ if not pending:
275
+ console.print("[yellow]All images already have captions. Run with --force to redo them.[/yellow]")
276
+ sys.exit(0)
277
+
278
+ # --- Settings summary + optional changes (skip prompts when --path given) ---
279
+ console.print(Panel(
280
+ f"[cyan]Model:[/cyan] {cfg['model']}\n"
281
+ f"[cyan]Prefix:[/cyan] {cfg['prefix'] or '[dim](none)[/dim]'}\n"
282
+ f"[cyan]Suffix:[/cyan] {cfg['suffix'] or '[dim](none)[/dim]'}\n"
283
+ f"[cyan]Max tokens:[/cyan] {cfg['max_new_tokens']}\n"
284
+ f"[cyan]Min words:[/cyan] {cfg['min_words']} (0 = no minimum)\n"
285
+ f"[cyan]Skip existing:[/cyan] {cfg['skip_existing']}",
286
+ title="[bold]Current Settings[/bold]",
287
+ expand=False,
288
+ ))
289
+
290
+ # Only offer to change settings when running interactively (no --path).
291
+ if not args.path and Confirm.ask("Change settings?", default=False):
292
+ cfg["model"] = select_model(cfg["model"])
293
+ cfg["prefix"] = Prompt.ask("Caption prefix (trigger word, etc.)", default=cfg["prefix"])
294
+ cfg["suffix"] = Prompt.ask("Caption suffix", default=cfg["suffix"])
295
+ cfg["max_new_tokens"] = int(Prompt.ask("Max new tokens", default=str(cfg["max_new_tokens"])))
296
+ cfg["min_words"] = int(Prompt.ask("Min words (re-caption if shorter)", default=str(cfg["min_words"])))
297
+ cfg["skip_existing"] = Confirm.ask("Skip already-captioned images?", default=cfg["skip_existing"])
298
+ save_config(cfg)
299
+ console.print("[dim]Settings saved.[/dim]")
300
+
301
+ proceed = args.path or Confirm.ask(f"\nCaption [bold]{len(pending)}[/bold] image(s)?", default=True)
302
+ if not proceed:
303
+ console.print("[dim]Aborted.[/dim]")
304
+ sys.exit(0)
305
+
306
+ # --- Load model ---
307
+ console.print()
308
+ try:
309
+ model_bundle, device_label = load_model(cfg["model"])
310
+ except Exception as e:
311
+ console.print(f"[red]✗ Failed to load model:[/red] {e}")
312
+ sys.exit(1)
313
+
314
+ console.print(f"[green]✓[/green] Model ready on [bold]{device_label}[/bold]\n")
315
+
316
+ # --- Caption loop ---
317
+ errors = []
318
+ done = 0
319
+
320
+ with Progress(
321
+ SpinnerColumn(),
322
+ TextColumn("[bold cyan]{task.description}"),
323
+ BarColumn(),
324
+ TaskProgressColumn(),
325
+ TimeElapsedColumn(),
326
+ console=console,
327
+ transient=False,
328
+ ) as progress:
329
+ task = progress.add_task("Captioning...", total=len(pending))
330
+
331
+ for img_path in pending:
332
+ progress.update(task, description=f"[bold cyan]{img_path.name}")
333
+ try:
334
+ caption = caption_image(
335
+ model_bundle, img_path,
336
+ cfg["prefix"], cfg["suffix"], cfg["max_new_tokens"], cfg["min_words"]
337
+ )
338
+ txt_path = img_path.with_suffix(".txt")
339
+ txt_path.write_text(caption, encoding="utf-8")
340
+ done += 1
341
+ except Exception as e:
342
+ errors.append((img_path.name, str(e)))
343
+ progress.advance(task)
344
+
345
+ # --- Summary ---
346
+ console.print()
347
+ if errors:
348
+ console.print(f"[green]✓ {done} captioned[/green] [red]✗ {len(errors)} failed[/red]")
349
+ for name, err in errors:
350
+ console.print(f" [red]{name}:[/red] {err}")
351
+ else:
352
+ console.print(Panel(
353
+ f"[bold green]✓ {done} image(s) captioned successfully[/bold green]\n"
354
+ f"[dim]Output: .txt files alongside each image[/dim]",
355
+ style="green",
356
+ expand=False,
357
+ ))
358
+
359
+
360
+ if __name__ == "__main__":
361
+ main()
caption_config.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "microsoft/Florence-2-large-ft",
3
+ "prefix": "",
4
+ "suffix": "",
5
+ "max_new_tokens": 256,
6
+ "min_words": 15,
7
+ "skip_existing": true
8
+ }
install.sh ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Caption Tool installer
4
+ #
5
+ # One-command install (host this file, then):
6
+ # curl -fsSL https://oohfixer.com/caption-tool/install.sh | bash
7
+ #
8
+ # Local install (run from this folder):
9
+ # bash install.sh
10
+ #
11
+ # Environment overrides:
12
+ # CAPTION_TOOL_BASE -> base URL the files are served from (download mode)
13
+ # CAPTION_TOOL_DIR -> install location (default ~/.local/share/caption_tool)
14
+
15
+ set -euo pipefail
16
+
17
+ BASE_URL="${CAPTION_TOOL_BASE:-https://oohfixer.com/caption-tool}"
18
+ INSTALL_DIR="${CAPTION_TOOL_DIR:-$HOME/.local/share/caption_tool}"
19
+ BIN_DIR="$HOME/.local/bin"
20
+ VENV="$INSTALL_DIR/.venv"
21
+
22
+ info() { printf '%s\n' "$1"; }
23
+ err() { printf 'Error: %s\n' "$1" >&2; }
24
+
25
+ # Ask a yes/no question on the terminal so it works even when this script is
26
+ # piped in via curl (where stdin is the script itself, not the keyboard).
27
+ prompt_yes_no() {
28
+ local ans
29
+ if [ -r /dev/tty ]; then
30
+ read -r -p "$1 [y/N] " ans < /dev/tty || ans=""
31
+ else
32
+ read -r -p "$1 [y/N] " ans || ans=""
33
+ fi
34
+ case "$ans" in
35
+ y|Y|yes|YES|Yes) return 0 ;;
36
+ *) return 1 ;;
37
+ esac
38
+ }
39
+
40
+ # Best-effort install of Python 3.13 via the system package manager.
41
+ install_python() {
42
+ local os_id=""
43
+ if [ -f /etc/os-release ]; then
44
+ # shellcheck disable=SC1091
45
+ os_id="$(. /etc/os-release && echo "$ID")"
46
+ fi
47
+
48
+ case "$os_id" in
49
+ ubuntu|debian|linuxmint|pop|raspbian|kali)
50
+ sudo apt-get update
51
+ if ! sudo apt-get install -y python3.13 python3.13-venv; then
52
+ sudo apt-get install -y software-properties-common
53
+ sudo add-apt-repository -y ppa:deadsnakes/ppa
54
+ sudo apt-get update
55
+ sudo apt-get install -y python3.13 python3.13-venv
56
+ fi
57
+ ;;
58
+ fedora|rhel|centos|rocky|almalinux)
59
+ sudo dnf install -y python3.13
60
+ ;;
61
+ arch|manjaro)
62
+ err "Arch-based systems do not provide older Python easily."
63
+ info "Install python-3.13 from the AUR, then re-run this installer."
64
+ return 1
65
+ ;;
66
+ *)
67
+ if command -v brew >/dev/null 2>&1; then
68
+ brew install python@3.13
69
+ else
70
+ err "Could not detect your OS package manager."
71
+ info "Install Python 3.13 manually (https://www.python.org/downloads/), then re-run."
72
+ return 1
73
+ fi
74
+ ;;
75
+ esac
76
+ }
77
+
78
+ # --- 1. Locate or install a suitable Python --------------------------------
79
+ PYBIN=""
80
+ if command -v python3 >/dev/null 2>&1; then
81
+ PYVER="$(python3 --version 2>&1 | sed 's/Python //')"
82
+ PYMAJ="$(printf '%s' "$PYVER" | cut -d. -f1)"
83
+ PYMIN="$(printf '%s' "$PYVER" | cut -d. -f2)"
84
+ if [ "$PYMAJ" -gt 3 ] || { [ "$PYMAJ" -eq 3 ] && [ "$PYMIN" -ge 14 ]; }; then
85
+ if prompt_yes_no "Python $PYVER is installed, but Caption Tool needs 3.13 or older. Install Python 3.13 now?"; then
86
+ install_python || true
87
+ PYBIN="$(command -v python3.13 || true)"
88
+ fi
89
+ else
90
+ PYBIN="$(command -v python3)"
91
+ fi
92
+ else
93
+ if prompt_yes_no "Python 3 was not found. Install Python 3.13 now?"; then
94
+ install_python || true
95
+ PYBIN="$(command -v python3.13 || command -v python3 || true)"
96
+ fi
97
+ fi
98
+
99
+ if [ -z "$PYBIN" ]; then
100
+ err "A Python 3.13 (or older) interpreter is required."
101
+ info "Install it, then re-run: bash install.sh"
102
+ exit 1
103
+ fi
104
+
105
+ # Re-verify the chosen interpreter is acceptable.
106
+ PYVER="$("$PYBIN" --version 2>&1 | sed 's/Python //')"
107
+ PYMAJ="$(printf '%s' "$PYVER" | cut -d. -f1)"
108
+ PYMIN="$(printf '%s' "$PYVER" | cut -d. -f2)"
109
+ if [ "$PYMAJ" -gt 3 ] || { [ "$PYMAJ" -eq 3 ] && [ "$PYMIN" -ge 14 ]; }; then
110
+ err "Python $PYVER is still too new. Caption Tool requires 3.13 or older."
111
+ exit 6
112
+ fi
113
+
114
+ # --- 2. Locate the source files (local copy or download) --------------------
115
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-.}")" && pwd)"
116
+ if [ -f "$SCRIPT_DIR/caption.py" ]; then
117
+ SRC_MODE="local"
118
+ SRC="$SCRIPT_DIR"
119
+ else
120
+ SRC_MODE="download"
121
+ SRC="$BASE_URL"
122
+ fi
123
+
124
+ # --- 3. Create install dir + virtual environment ----------------------------
125
+ info "Installing Caption Tool"
126
+ info " Location: $INSTALL_DIR"
127
+ info ""
128
+
129
+ info "Step 1/5 Preparing the isolated Python environment..."
130
+ mkdir -p "$INSTALL_DIR"
131
+ if [ ! -d "$VENV" ]; then
132
+ "$PYBIN" -m venv "$VENV"
133
+ fi
134
+
135
+ # --- 4. Fetch the tool files -------------------------------------------------
136
+ info "Step 2/5 Getting the tool files..."
137
+ if [ "$SRC_MODE" = "local" ]; then
138
+ cp "$SRC/caption.py" "$INSTALL_DIR/"
139
+ cp "$SRC/caption_config.json" "$INSTALL_DIR/"
140
+ cp "$SRC/requirements.txt" "$INSTALL_DIR/"
141
+ else
142
+ curl -fsSL "$SRC/caption.py" -o "$INSTALL_DIR/caption.py"
143
+ curl -fsSL "$SRC/caption_config.json" -o "$INSTALL_DIR/caption_config.json"
144
+ curl -fsSL "$SRC/requirements.txt" -o "$INSTALL_DIR/requirements.txt"
145
+ fi
146
+
147
+ # --- 5. Install dependencies -------------------------------------------------
148
+ info "Step 3/5 Installing dependencies (this can take a few minutes)..."
149
+ "$VENV/bin/pip" install --quiet --upgrade pip
150
+ "$VENV/bin/pip" install --quiet -r "$INSTALL_DIR/requirements.txt"
151
+
152
+ # --- 6. Create the global 'captions' command ---------------------------------
153
+ info "Step 4/5 Creating the 'captions' command..."
154
+ mkdir -p "$BIN_DIR"
155
+ cat > "$BIN_DIR/captions" <<EOF
156
+ #!/usr/bin/env bash
157
+ exec "$VENV/bin/python" "$INSTALL_DIR/caption.py" "\$@"
158
+ EOF
159
+ chmod +x "$BIN_DIR/captions"
160
+
161
+ # --- 7. Make sure ~/.local/bin is on PATH ------------------------------------
162
+ NEED_RESTART=0
163
+ if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
164
+ RC="$HOME/.bashrc"
165
+ case "${SHELL:-}" in
166
+ *zsh) RC="$HOME/.zshrc" ;;
167
+ esac
168
+ if [ -f "$RC" ] && ! grep -q 'caption_tool_path' "$RC"; then
169
+ printf '\nexport PATH="%s:$PATH" # caption_tool_path\n' "$BIN_DIR" >> "$RC"
170
+ fi
171
+ NEED_RESTART=1
172
+ fi
173
+
174
+ # --- 8. Done -----------------------------------------------------------------
175
+ info "Step 5/5 Done."
176
+ info ""
177
+ info "Caption Tool is installed."
178
+ if [ "$NEED_RESTART" = "1" ]; then
179
+ info "Run the next line (or open a new terminal) to use 'captions' anywhere:"
180
+ info " source $RC"
181
+ else
182
+ info "The command 'captions' is ready to use now."
183
+ fi
184
+ info ""
185
+ info "Start captioning: captions"
186
+ info "Or point at a folder: captions --path \"/path/to/images\""
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Caption Tool — local image captioner
2
+ # Needs Python 3.13 or older. Python 3.14 breaks the tokenizer build.
3
+
4
+ torch>=2.1.0
5
+ torchvision>=0.20.0
6
+ transformers==4.46.3
7
+ Pillow>=10.0.0
8
+ rich>=13.0.0