yaekobB commited on
Commit
8b4fe4f
·
0 Parent(s):

Initial commit: BLIP I mage captioning demo

Browse files
Files changed (4) hide show
  1. .gitignore +6 -0
  2. README.md +10 -0
  3. app.py +437 -0
  4. requirements.txt +12 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.csv
4
+ *.json
5
+ *.zip
6
+ outputs_captioned/
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Multimodal Image Captioning with BLIP (Demo)
3
+ sdk: gradio
4
+ python_version: "3.10"
5
+ app_file: app.py
6
+ tags: [image-captioning, blip, gradio, portfolio]
7
+ ---
8
+
9
+ This Space hosts an interactive demo of my fine-tuned BLIP model for image captioning.
10
+ Upload images, generate captions, and download results (CSV/JSON/ZIP).
app.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # BLIP Captioning — Pro Demo (CPU, Gradio v5)
3
+ # ------------------------------------------------------------
4
+ # UI POLISH + flexible model source (local or Hub).
5
+ # - Logic unchanged: same presets, generation, rendering, downloads.
6
+ # - Loads model from HF Hub if MODEL_ID_OR_PATH is set; else uses local ./blip_caption_model/final
7
+ # - Optional HF_TOKEN (for private models) is supported but not required for public models.
8
+ # ============================================================
9
+
10
+ import os
11
+ import re
12
+ import io
13
+ import json
14
+ import zipfile
15
+ from typing import Dict, List, Tuple, Optional
16
+
17
+ import pandas as pd
18
+ import torch
19
+ from PIL import Image, ImageDraw, ImageFont
20
+ import gradio as gr
21
+ from transformers import (
22
+ BlipForConditionalGeneration,
23
+ BlipProcessor,
24
+ AutoTokenizer,
25
+ GenerationConfig,
26
+ __version__ as TF_VER,
27
+ )
28
+
29
+ # =======================
30
+ # Global Configuration
31
+ # =======================
32
+
33
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
34
+
35
+ HERE = os.path.dirname(__file__)
36
+ # Flexible source:
37
+ # - On Spaces, set MODEL_ID_OR_PATH="your-username/blip-caption-model"
38
+ # - Locally, leave unset to use the fine-tuned weights in ./blip_caption_model/final
39
+ FINAL_DIR = os.getenv(
40
+ "MODEL_ID_OR_PATH",
41
+ os.path.join(HERE, "blip_caption_model", "final")
42
+ )
43
+ BASE_ID = "Salesforce/blip-image-captioning-base"
44
+
45
+ # Optional token (only needed if your model repo is private)
46
+ HF_TOKEN = os.getenv("HF_TOKEN", None)
47
+
48
+ device = torch.device("cpu")
49
+ torch.set_num_threads(max(1, (os.cpu_count() or 2) - 1))
50
+
51
+ # =======================
52
+ # Load Model & Processor
53
+ # =======================
54
+
55
+ print("🔧 torch:", torch.__version__, "| transformers:", TF_VER)
56
+ print("🔄 Loading model from:", FINAL_DIR)
57
+
58
+ # Load model + (optional) generation config
59
+ model = BlipForConditionalGeneration.from_pretrained(FINAL_DIR, token=HF_TOKEN)
60
+ try:
61
+ model.generation_config = GenerationConfig.from_pretrained(FINAL_DIR, token=HF_TOKEN)
62
+ except Exception:
63
+ pass
64
+
65
+ # Prefer hub processor (fast if torchvision is installed); fall back to saved preprocessor
66
+ try:
67
+ processor = BlipProcessor.from_pretrained(BASE_ID, use_fast=True)
68
+ print("ℹ️ Processor: hub:", BASE_ID)
69
+ except Exception as e:
70
+ print("⚠️ Hub processor failed; using saved preprocessor. Reason:", e)
71
+ processor = BlipProcessor.from_pretrained(FINAL_DIR, token=HF_TOKEN)
72
+
73
+ # Kaggle-style preprocessing (224 + center-crop)
74
+ processor.image_processor.size = {"height": 224, "width": 224}
75
+ if hasattr(processor.image_processor, "do_center_crop"):
76
+ processor.image_processor.do_center_crop = True
77
+ if hasattr(processor.image_processor, "crop_size"):
78
+ processor.image_processor.crop_size = {"height": 224, "width": 224}
79
+
80
+ # =======================
81
+ # Tokenizer Alignment
82
+ # =======================
83
+
84
+ def _lm_head_rows(m: BlipForConditionalGeneration) -> int:
85
+ try:
86
+ return m.text_decoder.cls.predictions.decoder.weight.shape[0]
87
+ except Exception:
88
+ return m.get_input_embeddings().weight.shape[0]
89
+
90
+ lm_rows = _lm_head_rows(model)
91
+
92
+ def load_tokenizer_matching_head(model_head_rows: int) -> Tuple[AutoTokenizer, List[str]]:
93
+ # Try hub tokenizer first (often matches base), else fall back to saved FINAL_DIR
94
+ try:
95
+ tok = AutoTokenizer.from_pretrained(BASE_ID)
96
+ print("ℹ️ Tokenizer: hub:", BASE_ID)
97
+ if len(tok) == model_head_rows:
98
+ return tok, []
99
+ else:
100
+ print(f"⚠️ Hub tokenizer size {len(tok)} != LM head {model_head_rows}.")
101
+ except Exception as e:
102
+ print("⚠️ Hub tokenizer failed; trying saved. Reason:", e)
103
+
104
+ tok = AutoTokenizer.from_pretrained(FINAL_DIR, token=HF_TOKEN)
105
+ print("ℹ️ Tokenizer: FINAL_DIR")
106
+ extra: List[str] = []
107
+ if len(tok) < model_head_rows:
108
+ need = model_head_rows - len(tok)
109
+ extra = [f"<extra_tok_{i}>" for i in range(need)]
110
+ tok.add_tokens(extra)
111
+ tok.add_special_tokens({"additional_special_tokens": extra})
112
+ print(f"ℹ️ Added {len(extra)} dummy *special* tokens to match LM head.")
113
+ return tok, extra
114
+
115
+ tokenizer, EXTRA_TOKENS = load_tokenizer_matching_head(lm_rows)
116
+
117
+ # Pad/eos config + left padding (decoder-only-friendly)
118
+ if tokenizer.pad_token is None:
119
+ tokenizer.pad_token = tokenizer.eos_token
120
+ tokenizer.padding_side = "left"
121
+ model.config.pad_token_id = tokenizer.pad_token_id
122
+ model.generation_config.pad_token_id = tokenizer.pad_token_id
123
+ if tokenizer.eos_token_id is not None:
124
+ model.generation_config.eos_token_id = tokenizer.eos_token_id
125
+
126
+ processor.tokenizer = tokenizer
127
+
128
+ # If we added dummy specials, ban them during generation
129
+ BAD_WORDS_IDS: Optional[List[List[int]]] = None
130
+ if EXTRA_TOKENS:
131
+ bad = [tokenizer.convert_tokens_to_ids(t) for t in EXTRA_TOKENS]
132
+ BAD_WORDS_IDS = [[i] for i in bad if i is not None]
133
+
134
+ def _strip_extra_tokens(text: str) -> str:
135
+ if not EXTRA_TOKENS:
136
+ return text
137
+ return re.sub(r"\s*<extra_tok_\d+>\s*", " ", text).strip()
138
+
139
+ model.to(device).eval()
140
+ print("✅ Ready. Device:", device)
141
+ print("📏 Inference image size:", getattr(processor.image_processor, "size", None))
142
+ print(f"🧪 vocab check -> lm_head rows: {lm_rows} | tokenizer size: {len(tokenizer)}")
143
+
144
+ # =======================
145
+ # Decoding Presets
146
+ # =======================
147
+
148
+ BASE_ARGS = dict(
149
+ min_length=5,
150
+ no_repeat_ngram_size=2,
151
+ early_stopping=True,
152
+ do_sample=False,
153
+ )
154
+
155
+ PRESETS: Dict[str, Dict] = {
156
+ "Quality": dict(num_beams=5, max_length=35, length_penalty=1.05, **BASE_ARGS),
157
+ "Balanced": dict(num_beams=3, max_length=32, length_penalty=1.0, **BASE_ARGS),
158
+ "Fast (CPU)":dict(num_beams=1, max_length=28, length_penalty=1.0, **BASE_ARGS),
159
+ }
160
+
161
+ # =======================
162
+ # Caption Rendering (Image)
163
+ # =======================
164
+
165
+ def _get_font(size: int) -> ImageFont.FreeTypeFont:
166
+ try:
167
+ return ImageFont.truetype("arial.ttf", size)
168
+ except Exception:
169
+ try:
170
+ import PIL
171
+ fp = os.path.join(os.path.dirname(PIL.__file__), "fonts", "DejaVuSans.ttf")
172
+ return ImageFont.truetype(fp, size)
173
+ except Exception:
174
+ return ImageFont.load_default()
175
+
176
+ def _wrap_lines(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> List[str]:
177
+ words = text.split()
178
+ lines: List[str] = []
179
+ line = ""
180
+ for w in words:
181
+ trial = (line + " " + w).strip()
182
+ if draw.textlength(trial, font=font) <= max_width:
183
+ line = trial
184
+ else:
185
+ if line:
186
+ lines.append(line)
187
+ line = w
188
+ if line:
189
+ lines.append(line)
190
+ return lines
191
+
192
+ def render_captioned_image(src_path: str, caption: str, out_dir: str, font_size_ratio: float = 0.045) -> str:
193
+ img = Image.open(src_path).convert("RGB")
194
+ W, H = img.size
195
+
196
+ font_size = max(14, int(W * font_size_ratio))
197
+ font = _get_font(font_size)
198
+ padding = int(font_size * 0.6)
199
+
200
+ draw_tmp = ImageDraw.Draw(img)
201
+ lines = _wrap_lines(draw_tmp, caption, font, max_width=W - 2 * padding)
202
+
203
+ ascent, descent = font.getmetrics()
204
+ line_h = ascent + descent
205
+ text_h = line_h * len(lines)
206
+ box_h = text_h + 2 * padding
207
+
208
+ new_img = Image.new("RGB", (W, H + box_h), "white")
209
+ new_img.paste(img, (0, 0))
210
+
211
+ draw = ImageDraw.Draw(new_img)
212
+ y = H + padding
213
+ for l in lines:
214
+ draw.text((padding, y), l, font=font, fill=(0, 0, 0))
215
+ y += line_h
216
+
217
+ os.makedirs(out_dir, exist_ok=True)
218
+ base = os.path.basename(src_path)
219
+ name, _ = os.path.splitext(base)
220
+ out_path = os.path.join(out_dir, f"{name}_captioned.jpg")
221
+ new_img.save(out_path, quality=95)
222
+ return out_path
223
+
224
+ # =======================
225
+ # Captioning Functions
226
+ # =======================
227
+
228
+ @torch.no_grad()
229
+ def caption_one(path: str, beams: int, maxlen: int, lenpen: float) -> str:
230
+ img = Image.open(path).convert("RGB")
231
+ batch = processor(images=img, return_tensors="pt").to(device)
232
+
233
+ gen_kwargs = dict(
234
+ num_beams=int(beams),
235
+ max_length=int(maxlen),
236
+ length_penalty=float(lenpen),
237
+ **BASE_ARGS,
238
+ )
239
+ if BAD_WORDS_IDS is not None:
240
+ gen_kwargs["bad_words_ids"] = BAD_WORDS_IDS
241
+
242
+ ids = model.generate(pixel_values=batch["pixel_values"], **gen_kwargs)
243
+ text = tokenizer.decode(ids[0], skip_special_tokens=True)
244
+ return _strip_extra_tokens(text)
245
+
246
+ def caption_many(paths: List[str],
247
+ preset: str,
248
+ beams: int,
249
+ maxlen: int,
250
+ lenpen: float,
251
+ export_captioned: bool):
252
+ p = PRESETS[preset]
253
+ beams = int(beams or p["num_beams"])
254
+ maxlen = int(maxlen or p["max_length"])
255
+ lenpen = float(lenpen or p["length_penalty"])
256
+
257
+ rows, gallery = [], []
258
+ captioned_paths, captioned_names = [], []
259
+ cap_map: Dict[str, str] = {}
260
+
261
+ out_img_dir = os.path.abspath("outputs_captioned")
262
+ for pth in (paths or []):
263
+ try:
264
+ cap = caption_one(pth, beams, maxlen, lenpen)
265
+ name = os.path.basename(pth)
266
+ rows.append({"image": name, "caption": cap})
267
+ gallery.append((pth, cap))
268
+ if export_captioned:
269
+ cpath = render_captioned_image(pth, cap, out_img_dir)
270
+ captioned_paths.append(cpath)
271
+ display_name = os.path.basename(cpath)
272
+ captioned_names.append(display_name)
273
+ cap_map[display_name] = cpath
274
+ except Exception as e:
275
+ rows.append({"image": os.path.basename(pth) if pth else "unknown",
276
+ "caption": f"[error] {e}"})
277
+
278
+ df = pd.DataFrame(rows)
279
+ csv_path = os.path.abspath("demo_captions.csv")
280
+ json_path = os.path.abspath("demo_captions.json")
281
+ df.to_csv(csv_path, index=False)
282
+ with open(json_path, "w", encoding="utf-8") as f:
283
+ json.dump(rows, f, ensure_ascii=False, indent=2)
284
+
285
+ zip_path = None
286
+ if export_captioned and captioned_paths:
287
+ zip_path = os.path.abspath("captioned_images.zip")
288
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
289
+ for cp in captioned_paths:
290
+ zf.write(cp, arcname=os.path.basename(cp))
291
+
292
+ one_dd_choices = captioned_names
293
+ multi_cb_choices = captioned_names
294
+
295
+ return (
296
+ gallery, df, csv_path, json_path, zip_path,
297
+ gr.update(choices=one_dd_choices, value=(one_dd_choices[0] if one_dd_choices else None)),
298
+ gr.update(choices=multi_cb_choices, value=[]),
299
+ cap_map
300
+ )
301
+
302
+ # =======================
303
+ # Download Helpers
304
+ # =======================
305
+
306
+ def download_one(selected_name: Optional[str], cap_state: Dict[str, str]) -> Optional[str]:
307
+ if not selected_name or not cap_state:
308
+ return None
309
+ return cap_state.get(selected_name)
310
+
311
+ def download_multi(selected_names: Optional[List[str]], cap_state: Dict[str, str]) -> Optional[str]:
312
+ if not selected_names or not cap_state:
313
+ return None
314
+ sel_paths = [cap_state[n] for n in selected_names if n in cap_state]
315
+ if not sel_paths:
316
+ return None
317
+ out_zip = os.path.abspath("captioned_selection.zip")
318
+ with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
319
+ for p in sel_paths:
320
+ zf.write(p, arcname=os.path.basename(p))
321
+ return out_zip
322
+
323
+ # =======================
324
+ # Gradio UI (Green theme)
325
+ # =======================
326
+
327
+ from gradio.themes.base import Base
328
+ from gradio.themes.utils import colors
329
+
330
+ THEME = Base(
331
+ primary_hue=colors.green, # ✅ action buttons, toggles, sliders → green
332
+ secondary_hue=colors.gray,
333
+ )
334
+
335
+ CUSTOM_CSS = """
336
+ h1, h2, h3, h4, h5, h6 {
337
+ color: #228B22 !important; /* forest green headings */
338
+ }
339
+ #gallery .grid-wrap .label {
340
+ background: rgba(255,255,255,0.9);
341
+ border-radius: 10px;
342
+ padding: 6px 10px;
343
+ font-size: 0.95rem;
344
+ line-height: 1.25rem;
345
+ }
346
+ footer, .disclaimer {
347
+ color: #555;
348
+ font-size: 0.9rem;
349
+ }
350
+ """
351
+
352
+ with gr.Blocks(title="Multimodal Image Captioning with BLIP", theme=THEME, css=CUSTOM_CSS) as demo:
353
+ gr.Markdown(
354
+ """
355
+ # 🖼️ Multimodal Image Captioning with BLIP (Demo)
356
+ Upload one or many images and generate captions using a fine-tuned BLIP model.
357
+
358
+ **How it works**
359
+ 1. **Upload** JPG/PNG/WebP/BMP images
360
+ 2. Pick a **Preset** (Quality / Balanced / Fast)
361
+ 3. *(Optional)* Tune **Advanced** settings
362
+ 4. Click **Generate Captions** → view **gallery & table**
363
+ 5. **Download** CSV/JSON and **captioned images** (all / single / selected)
364
+ """.strip()
365
+ )
366
+
367
+ with gr.Row():
368
+ with gr.Column(scale=6):
369
+ uploader = gr.File(
370
+ label="Upload image(s)",
371
+ file_types=[".jpg", ".jpeg", ".png", ".bmp", ".webp"],
372
+ file_count="multiple",
373
+ type="filepath",
374
+ )
375
+ gr.Markdown("_Supported formats: JPG, PNG, WebP, BMP. Images resized to 224×224 for inference._")
376
+ with gr.Column(scale=4):
377
+ preset = gr.Radio(
378
+ choices=list(PRESETS.keys()),
379
+ value="Quality",
380
+ label="Preset",
381
+ )
382
+ with gr.Accordion("Advanced settings (optional)", open=False):
383
+ beams = gr.Slider(1, 8, value=PRESETS["Quality"]["num_beams"], step=1, label="num_beams")
384
+ maxlen = gr.Slider(16, 64, value=PRESETS["Quality"]["max_length"], step=1, label="max_length")
385
+ lenpen = gr.Slider(0.8, 1.5, value=PRESETS["Quality"]["length_penalty"], step=0.05, label="length_penalty")
386
+ export_chk = gr.Checkbox(value=True, label="Also save captioned images")
387
+ gr.Markdown("_Generates copies with captions rendered for download (ZIP / per-image)._")
388
+ run = gr.Button("🚀 Generate Captions", variant="primary")
389
+
390
+ with gr.Row():
391
+ gallery = gr.Gallery(label="Results (original image + caption below)", elem_id="gallery", columns=2, height="auto")
392
+ table = gr.Dataframe(label="Captions Table", interactive=False)
393
+
394
+ with gr.Row():
395
+ csv_out = gr.File(label="📥 Download CSV")
396
+ json_out = gr.File(label="📥 Download JSON")
397
+ zip_all = gr.File(label="📦 Download ALL captioned (.zip)")
398
+
399
+ gr.Markdown("### Download individual / selected captioned images")
400
+ with gr.Row():
401
+ with gr.Column():
402
+ dl_one_dd = gr.Dropdown(choices=[], value=None, label="Pick a captioned image")
403
+ dl_one_btn = gr.Button("⬇️ Download selected image")
404
+ dl_one_file = gr.File(label="Selected captioned image")
405
+ with gr.Column():
406
+ dl_multi_cb = gr.CheckboxGroup(choices=[], label="Select multiple captioned images")
407
+ dl_multi_btn = gr.Button("📦 Zip & download selected")
408
+ dl_multi_zip = gr.File(label="Selected captioned images (.zip)")
409
+
410
+ gr.Markdown(
411
+ """
412
+ <div class="disclaimer">
413
+ <strong>Notes.</strong><br>
414
+ • Captions are deterministic with the same settings (beam search, no sampling).<br>
415
+ • Preprocessing is fixed at 224×224 + center-crop to match training.<br>
416
+ • Extra special tokens (if any) are banned during generation and stripped from outputs.
417
+ </div>
418
+ """,
419
+ elem_classes=["disclaimer"]
420
+ )
421
+
422
+ CAP_STATE = gr.State({})
423
+
424
+ def _dispatch(files, preset, beams, maxlen, lenpen, export_chk):
425
+ return caption_many(files, preset, beams, maxlen, lenpen, export_chk)
426
+
427
+ run.click(
428
+ _dispatch,
429
+ inputs=[uploader, preset, beams, maxlen, lenpen, export_chk],
430
+ outputs=[gallery, table, csv_out, json_out, zip_all, dl_one_dd, dl_multi_cb, CAP_STATE],
431
+ )
432
+
433
+ dl_one_btn.click(download_one, inputs=[dl_one_dd, CAP_STATE], outputs=[dl_one_file])
434
+ dl_multi_btn.click(download_multi, inputs=[dl_multi_cb, CAP_STATE], outputs=[dl_multi_zip])
435
+
436
+ if __name__ == "__main__":
437
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch==2.8.0+cpu
3
+ # torchvision==0.19.0+cpu # optional if you want the "fast" image processor
4
+
5
+ transformers==4.56.0
6
+ huggingface-hub>=0.34.0,<1.0
7
+ tokenizers==0.22.0
8
+ safetensors>=0.4.3
9
+
10
+ gradio==5.27.0
11
+ pillow>=10.0.0
12
+ pandas>=2.3.0