trojblue commited on
Commit
d8004fd
·
verified ·
1 Parent(s): 5859393

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +221 -371
app.py CHANGED
@@ -1,378 +1,156 @@
1
- import io
2
- import json
3
- import struct
4
- import zlib
5
- from typing import List, Dict, Any, Optional, Union
6
 
7
- import gradio as gr
8
- from PIL import Image, PngImagePlugin
9
-
10
- # -------- THEME (similar to your example) --------
11
- theme = gr.themes.Soft(primary_hue="indigo", secondary_hue="violet", radius_size="lg")
12
-
13
- # =================================================
14
- # ========== PNG Text Chunk Reader (tab 1) ========
15
- # =================================================
16
-
17
- PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
18
-
19
-
20
- def _parse_png_text_chunks(data: bytes) -> List[Dict[str, Any]]:
21
- """
22
- Parse PNG chunks and extract tEXt, zTXt, and iTXt entries.
23
- """
24
- if not data.startswith(PNG_SIGNATURE):
25
- raise ValueError("Not a PNG file.")
26
-
27
- pos = len(PNG_SIGNATURE)
28
- out = []
29
-
30
- while pos + 8 <= len(data):
31
- # Read chunk length and type
32
- length = struct.unpack(">I", data[pos:pos+4])[0]
33
- ctype = data[pos+4:pos+8]
34
- pos += 8
35
-
36
- if pos + length + 4 > len(data):
37
- break
38
-
39
- cdata = data[pos:pos+length]
40
- pos += length
41
-
42
- # Skip CRC (4 bytes)
43
- pos += 4
44
-
45
- if ctype == b"tEXt":
46
- # Latin-1: key\0value
47
- try:
48
- null_idx = cdata.index(b"\x00")
49
- key = cdata[:null_idx].decode("latin-1", "replace")
50
- text = cdata[null_idx+1:].decode("latin-1", "replace")
51
- out.append({"type": "tEXt", "keyword": key, "text": text})
52
- except Exception:
53
- pass
54
-
55
- elif ctype == b"zTXt":
56
- # key\0compression_method(1) + compressed data
57
- try:
58
- null_idx = cdata.index(b"\x00")
59
- key = cdata[:null_idx].decode("latin-1", "replace")
60
- method = cdata[null_idx+1:null_idx+2]
61
- comp = cdata[null_idx+2:]
62
- if method == b"\x00": # zlib/deflate
63
- text = zlib.decompress(comp).decode("latin-1", "replace")
64
- out.append({"type": "zTXt", "keyword": key, "text": text})
65
- except Exception:
66
- pass
67
-
68
- elif ctype == b"iTXt":
69
- # UTF-8: key\0flag(1)\0method(1)\0lang\0translated\0text
70
- try:
71
- i0 = cdata.index(b"\x00")
72
- key = cdata[:i0].decode("latin-1", "replace")
73
- comp_flag = cdata[i0+1:i0+2]
74
- comp_method = cdata[i0+2:i0+3]
75
- rest = cdata[i0+3:]
76
-
77
- i1 = rest.index(b"\x00")
78
- language_tag = rest[:i1].decode("ascii", "replace")
79
- rest2 = rest[i1+1:]
80
-
81
- i2 = rest2.index(b"\x00")
82
- translated_keyword = rest2[:i2].decode("utf-8", "replace")
83
- text_bytes = rest2[i2+1:]
84
-
85
- if comp_flag == b"\x01" and comp_method == b"\x00":
86
- text = zlib.decompress(text_bytes).decode("utf-8", "replace")
87
- else:
88
- text = text_bytes.decode("utf-8", "replace")
89
-
90
- out.append({
91
- "type": "iTXt",
92
- "keyword": key,
93
- "language_tag": language_tag,
94
- "translated_keyword": translated_keyword,
95
- "text": text,
96
- })
97
- except Exception:
98
- pass
99
-
100
- if ctype == b"IEND":
101
- break
102
-
103
- return out
104
-
105
-
106
- def read_png_info(file_obj) -> Dict[str, Any]:
107
- """
108
- Given an uploaded file (path or file-like), return structured PNG text info.
109
- Also surface Pillow's .info (which often contains 'parameters').
110
- """
111
- if hasattr(file_obj, "read"):
112
- data = file_obj.read()
113
- else:
114
- with open(file_obj, "rb") as f:
115
- data = f.read()
116
-
117
- chunks = _parse_png_text_chunks(data)
118
-
119
- try:
120
- img = Image.open(io.BytesIO(data))
121
- pil_info = dict(img.info)
122
- for k, v in list(pil_info.items()):
123
- if isinstance(v, (bytes, bytearray)):
124
- try:
125
- pil_info[k] = v.decode("utf-8", "replace")
126
- except Exception:
127
- pil_info[k] = repr(v)
128
- elif isinstance(v, PngImagePlugin.PngInfo):
129
- pil_info[k] = "PngInfo(...)"
130
- except Exception as e:
131
- pil_info = {"_error": f"Pillow failed to open PNG: {e}"}
132
-
133
- response = {
134
- "found_text_chunks": chunks,
135
- "pil_info": pil_info,
136
- "quick_fields": {
137
- "parameters": next((c["text"] for c in chunks if c.get("keyword") == "parameters"), pil_info.get("parameters")),
138
- "Software": next((c["text"] for c in chunks if c.get("keyword") == "Software"), pil_info.get("Software")),
139
- },
140
- }
141
- return response
142
-
143
-
144
- def infer_png_text(file):
145
- if file is None:
146
- return {"error": "Please upload a PNG file."}
147
- try:
148
- return read_png_info(file.name if hasattr(file, "name") else file)
149
- except Exception as e:
150
- return {"error": str(e)}
151
-
152
-
153
- # =================================================
154
- # ========== NovelAI LSB Reader (tab 2) ===========
155
- # =================================================
156
-
157
- # (User-provided logic, lightly wrapped for Gradio.)
158
- import numpy as np
159
- import gzip
160
  from pathlib import Path
161
- from io import BytesIO
162
-
163
- def _pack_lsb_bytes(alpha: np.ndarray) -> np.ndarray:
164
- """
165
- Pack the least significant bits (LSB) from an image's alpha channel into bytes.
166
- """
167
- alpha = alpha.T.reshape((-1,))
168
- alpha = alpha[:(alpha.shape[0] // 8) * 8]
169
- alpha = np.bitwise_and(alpha, 1)
170
- alpha = alpha.reshape((-1, 8))
171
- alpha = np.packbits(alpha, axis=1)
172
- return alpha
173
-
174
-
175
- class LSBReader:
176
- """
177
- Utility class for reading hidden data from an image's alpha channel using LSB encoding.
178
- """
179
- def __init__(self, data: np.ndarray):
180
- self.data = _pack_lsb_bytes(data[..., -1])
181
- self.pos = 0
182
-
183
- def read_bytes(self, n: int) -> bytearray:
184
- """Read `n` bytes from the bitstream."""
185
- n_bytes = self.data[self.pos:self.pos + n]
186
- self.pos += n
187
- return bytearray(n_bytes.flatten().tolist())
188
-
189
- def read_int32(self) -> Optional[int]:
190
- """Read a 4-byte big-endian integer from the bitstream."""
191
- bytes_list = self.read_bytes(4)
192
- return int.from_bytes(bytes_list, 'big') if len(bytes_list) == 4 else None
193
-
194
-
195
- def _extract_nai_metadata_from_image(image: Image.Image) -> dict:
196
- """
197
- Extract embedded metadata from a PNG image generated by NovelAI.
198
- """
199
- image_array = np.array(image.convert("RGBA"))
200
- if image_array.shape[-1] != 4 or len(image_array.shape) != 3:
201
- raise ValueError("Image must be in RGBA format")
202
-
203
- reader = LSBReader(image_array)
204
- magic = "stealth_pngcomp"
205
- if reader.read_bytes(len(magic)).decode("utf-8", "replace") != magic:
206
- raise ValueError("Invalid magic number (not NovelAI stealth payload)")
207
-
208
- bit_len = reader.read_int32()
209
- if bit_len is None or bit_len <= 0:
210
- raise ValueError("Invalid payload length")
211
-
212
- json_len = bit_len // 8
213
- compressed_json = reader.read_bytes(json_len)
214
- json_data = json.loads(gzip.decompress(bytes(compressed_json)).decode("utf-8"))
215
-
216
- if "Comment" in json_data and isinstance(json_data["Comment"], str):
217
- try:
218
- json_data["Comment"] = json.loads(json_data["Comment"])
219
- except Exception:
220
- # Leave as-is if not valid JSON
221
- pass
222
-
223
- return json_data
224
-
225
-
226
- def extract_nai_metadata(image: Union[Image.Image, str, Path]) -> dict:
227
- if isinstance(image, (str, Path)):
228
- image = Image.open(image)
229
- elif not isinstance(image, Image.Image):
230
- raise ValueError("Input must be a file path (string/Path) or a PIL Image")
231
- return _extract_nai_metadata_from_image(image)
232
-
233
-
234
- def extract_nai_caption_from_hf_img(hf_img: dict) -> Optional[str]:
235
- image_bytes = hf_img['bytes']
236
- pil_image = Image.open(BytesIO(image_bytes))
237
- metadata = extract_nai_metadata(pil_image)
238
- return metadata.get('Description')
239
-
240
-
241
- def infer_nai(image: Optional[Image.Image]):
242
- if image is None:
243
- return None, {"error": "Please upload a PNG with alpha channel (RGBA)."}
244
- try:
245
- meta = extract_nai_metadata(image)
246
- description = meta.get("Description")
247
- return description, meta
248
- except Exception as e:
249
- return None, {"error": str(e)}
250
-
251
-
252
- # =================================================
253
- # =========== Similarity Metrics (tab 3) ===========
254
- # =================================================
255
-
256
- def _load_rgb_image(path: Union[str, Path]) -> np.ndarray:
257
- """Load an image file as RGB uint8 numpy array."""
258
- img = Image.open(path).convert("RGB")
259
- return np.array(img, dtype=np.uint8)
260
-
261
-
262
- def _pixel_metrics(img_a: np.ndarray, img_b: np.ndarray) -> Dict[str, float]:
263
- """Compute basic pixel-wise similarity metrics between two RGB images."""
264
- if img_a.shape != img_b.shape:
265
- raise ValueError(f"Image size mismatch: {img_a.shape} vs {img_b.shape}")
266
-
267
- diff = img_a.astype(np.float32) - img_b.astype(np.float32)
268
- abs_diff = np.abs(diff)
269
-
270
- mse = float(np.mean(diff ** 2))
271
- mae = float(np.mean(abs_diff))
272
- max_abs = float(np.max(abs_diff))
273
 
274
- pixel_match = float(np.mean(img_a == img_b))
275
- pixel_diff_pct = float(100.0 * (1.0 - pixel_match))
276
-
277
- if mse == 0.0:
278
- psnr = float("inf")
279
- else:
280
- psnr = float(20.0 * np.log10(255.0 / np.sqrt(mse)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  return {
283
- "pixel_diff_pct": pixel_diff_pct,
284
- "pixel_match": pixel_match,
285
- "mse": mse,
286
- "mae": mae,
287
- "max_abs": max_abs,
288
- "psnr": psnr,
289
  }
290
 
291
-
292
- def compute_similarity_report(files: Optional[List[str]]) -> str:
293
- if not files or len(files) < 2:
294
- return "Upload at least two images to compare (first file is treated as base)."
295
-
296
- try:
297
- images: Dict[str, np.ndarray] = {}
298
- base_name = None
299
- base_img = None
300
-
301
- for idx, file_path in enumerate(files):
302
- name = Path(file_path).name
303
- images[name] = _load_rgb_image(file_path)
304
- if idx == 0:
305
- base_name = name
306
- base_img = images[name]
307
-
308
- if base_name is None or base_img is None:
309
- return "Failed to load base image."
310
-
311
- metrics: Dict[str, Dict[str, float]] = {}
312
-
313
- # Base vs others
314
- for name, img in images.items():
315
- if name == base_name:
316
- continue
317
- metrics[f"{base_name}_vs_{name}"] = _pixel_metrics(base_img, img)
318
-
319
- # Pairwise among non-base images
320
- other_keys = [k for k in images.keys() if k != base_name]
321
- for i in range(len(other_keys)):
322
- for j in range(i + 1, len(other_keys)):
323
- k1, k2 = other_keys[i], other_keys[j]
324
- metrics[f"{k1}_vs_{k2}"] = _pixel_metrics(images[k1], images[k2])
325
-
326
- lines = [
327
- "=== similarity metrics ===",
328
- f"Base image: {base_name}",
329
- ]
330
- for name, vals in metrics.items():
331
- lines.append(
332
- (
333
- f"{name}: pixel_diff_pct={vals['pixel_diff_pct']:.6f}%, "
334
- f"pixel_match={vals['pixel_match']:.6f}, mse={vals['mse']:.6e}, "
335
- f"mae={vals['mae']:.6e}, max_abs={vals['max_abs']:.6e}, "
336
- f"psnr={vals['psnr']:.2f}dB"
337
- )
338
  )
339
-
340
- lines.append("\nMetrics (JSON):")
341
- lines.append(json.dumps(metrics, indent=2))
342
-
343
- return "\n".join(lines)
344
- except Exception as exc: # pragma: no cover - handled for UI
345
- return f"Error computing metrics: {exc}"
346
-
347
-
348
- # =================================================
349
- # =============== Gradio App (two tabs) ===========
350
- # =================================================
351
-
352
- with gr.Blocks(title="PNG Tools — ImageInfo & NovelAI Reader", theme=theme, analytics_enabled=False) as demo:
353
- gr.Markdown("# PNG Tools\nTwo utilities: PNG text-chunk metadata and NovelAI LSB metadata.")
354
-
355
- with gr.Tabs():
356
- with gr.Tab("PNG ImageInfo Reader"):
357
- with gr.Row():
358
- inp_png = gr.File(label="PNG file", file_types=[".png"])
359
- out_png = gr.JSON(label="pngImageInfo")
360
- inp_png.change(fn=infer_png_text, inputs=inp_png, outputs=out_png)
361
- gr.Markdown("Tip: Stable Diffusion ‘parameters’ often appear under a **tEXt** chunk with keyword `parameters`.")
362
-
363
- with gr.Tab("NovelAI Reader"):
364
- with gr.Row():
365
- nai_img = gr.Image(label="Upload PNG (RGBA preferred)", type="pil", height=360)
366
- with gr.Row():
367
- nai_btn = gr.Button("Extract NovelAI Metadata", variant="primary")
368
- with gr.Row():
369
- nai_desc = gr.Textbox(label="Description (if present)", lines=4)
370
- nai_json = gr.JSON(label="Decoded NovelAI JSON")
371
-
372
- nai_btn.click(fn=infer_nai, inputs=nai_img, outputs=[nai_desc, nai_json])
373
-
374
- with gr.Tab("Similarity Metrics"):
375
- gr.Markdown("Upload multiple images; the first file is treated as the base for comparisons.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  files_in = gr.Files(
377
  label="Image files",
378
  # Explicit list ensures WebP acceptance across Gradio builds
@@ -383,10 +161,82 @@ with gr.Blocks(title="PNG Tools — ImageInfo & NovelAI Reader", theme=theme, an
383
  type="filepath",
384
  interactive=True,
385
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  with gr.Row():
387
- metrics_btn = gr.Button("Compute Similarity", variant="primary")
388
- metrics_out = gr.Textbox(label="Similarity report", lines=14, show_copy_button=True)
389
- metrics_btn.click(fn=compute_similarity_report, inputs=files_in, outputs=metrics_out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
  if __name__ == "__main__":
392
- demo.launch()
 
1
+ # annotate_concat_demo.py
2
+ # pip install -U gradio pillow
 
 
 
3
 
4
+ import os
5
+ import time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from pathlib import Path
7
+ from typing import List, Tuple, Optional
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ import gradio as gr
10
+ from PIL import Image, ImageOps
11
+
12
+ # Your existing implementations are assumed available:
13
+ from unibox.utils.image_utils import (
14
+ concatenate_images_horizontally,
15
+ add_annotation,
16
+ )
17
+
18
+ # ------------------------- helpers -------------------------
19
+
20
+ def _norm_path(f) -> Optional[str]:
21
+ if f is None:
22
+ return None
23
+ if isinstance(f, (str, Path)):
24
+ return str(f)
25
+ if hasattr(f, "name"):
26
+ return str(getattr(f, "name"))
27
+ if isinstance(f, dict):
28
+ return str(f.get("name") or f.get("path") or "")
29
+ return str(f)
30
+
31
+ def _load_images(files) -> List[Tuple[Image.Image, str]]:
32
+ out: List[Tuple[Image.Image, str]] = []
33
+ for f in (files or []):
34
+ p = _norm_path(f)
35
+ if not p:
36
+ continue
37
+ im = Image.open(p)
38
+ # auto-orient, ensure RGB; supports PNG/JPEG/WebP/GIF/BMP/TIFF…
39
+ im = ImageOps.exif_transpose(im).convert("RGB")
40
+ out.append((im, os.path.basename(p)))
41
+ return out
42
 
43
+ def _parse_descriptions(text: str, n: int):
44
+ lines = (text or "").splitlines()
45
+ if len(lines) > n:
46
+ return None, f"Too many description lines ({len(lines)}) for {n} image(s). Provide ≤ one per image."
47
+ lines = lines + [""] * (max(0, n - len(lines))) # pad with blanks
48
+ return lines[:n], None
49
+
50
+ def _build_stats(files, desc_text: str) -> dict:
51
+ pairs = _load_images(files)
52
+ n = len(pairs)
53
+ lines, err = _parse_descriptions(desc_text, n) if n > 0 else ((desc_text or "").splitlines(), None)
54
+ mapping = {}
55
+ for i, (_, fname) in enumerate(pairs):
56
+ mapping[fname] = (lines[i] if isinstance(lines, list) and i < len(lines) else "")
57
  return {
58
+ "num_images": n,
59
+ "num_descriptions": len((desc_text or "").splitlines()),
60
+ "mapping": mapping,
61
+ **({"error": err} if err else {}),
 
 
62
  }
63
 
64
+ # --------------------- core actions ------------------------
65
+
66
+ def concatenate_with_annotations(
67
+ files,
68
+ desc_text: str,
69
+ max_height: int,
70
+ position: str,
71
+ alignment: str,
72
+ size_adj: str,
73
+ ):
74
+ logs = []
75
+ out_img = None
76
+ out_file = None
77
+
78
+ pairs = _load_images(files)
79
+ if not pairs:
80
+ logs.append("ERROR: Please upload at least one image.")
81
+ return out_img, out_file, "\n".join(logs), _build_stats(files, desc_text)
82
+
83
+ lines, err = _parse_descriptions(desc_text, len(pairs))
84
+ if err:
85
+ logs.append(f"ERROR: {err}")
86
+ return out_img, out_file, "\n".join(logs), _build_stats(files, desc_text)
87
+
88
+ # For left/right, alignment must be center (matches add_annotation behavior)
89
+ if position in ("left", "right"):
90
+ alignment = "center"
91
+
92
+ annotated = []
93
+ for (im, fname), line in zip(pairs, lines):
94
+ if line.strip():
95
+ im2 = add_annotation(
96
+ pil_image=im,
97
+ annotation=line,
98
+ position=position,
99
+ alignment=alignment,
100
+ size=size_adj,
 
 
 
 
 
 
 
 
 
 
101
  )
102
+ annotated.append(im2)
103
+ logs.append(f"Annotated: {fname}")
104
+ else:
105
+ annotated.append(im)
106
+ logs.append(f"Skipped (no description): {fname}")
107
+
108
+ started = time.time()
109
+ merged = concatenate_images_horizontally(annotated, max_height=max_height)
110
+ if merged is None:
111
+ logs.append("ERROR: Concatenation produced no result.")
112
+ return None, None, "\n".join(logs), _build_stats(files, desc_text)
113
+
114
+ # Save JPEG with required name
115
+ out_dir = Path("outputs")
116
+ out_dir.mkdir(parents=True, exist_ok=True)
117
+ stamp = time.strftime("%Y%m%d_%H%M%S")
118
+ out_name = f"concatenate_{stamp}.jpg"
119
+ out_path = out_dir / out_name
120
+ merged.save(str(out_path), format="JPEG", quality=95, optimize=True)
121
+
122
+ w, h = merged.size
123
+ size_bytes = out_path.stat().st_size
124
+ latency = time.time() - started
125
+ logs.append(f"Output: {out_name} — {w}×{h}px — {size_bytes} bytes — {latency:.3f}s")
126
+
127
+ return merged, str(out_path), "\n".join(logs), _build_stats(files, desc_text)
128
+
129
+ def check_stats_only(files, desc_text: str, *_):
130
+ stats = _build_stats(files, desc_text)
131
+ log = f"Images: {stats.get('num_images', 0)}; Description lines: {stats.get('num_descriptions', 0)}"
132
+ if "error" in stats:
133
+ log += f"\nERROR: {stats['error']}"
134
+ return None, None, log, stats
135
+
136
+ # ----------------------- UI wiring -------------------------
137
+
138
+ theme = gr.themes.Monochrome(primary_hue="slate", radius_size="sm")
139
+
140
+ with gr.Blocks(
141
+ title="Annotated Concatenation — Demo",
142
+ theme=theme,
143
+ analytics_enabled=False,
144
+ ) as demo:
145
+
146
+ gr.Markdown("# Annotate & Concatenate Images")
147
+ gr.Markdown(
148
+ "Upload images (PNG/JPEG/WebP…), add one description per line (blank = skip), "
149
+ "and concatenate horizontally. The output JPEG is named `concatenate_{timestamp}.jpg`."
150
+ )
151
+
152
+ with gr.Row(variant="panel"):
153
+ with gr.Column(scale=2):
154
  files_in = gr.Files(
155
  label="Image files",
156
  # Explicit list ensures WebP acceptance across Gradio builds
 
161
  type="filepath",
162
  interactive=True,
163
  )
164
+ desc_in = gr.Textbox(
165
+ label="Descriptions (one per line; blank lines allowed to skip)",
166
+ placeholder="e.g.\nLeft image label\n\nRight image label",
167
+ lines=8,
168
+ )
169
+ max_h = gr.Number(
170
+ label="Max height (px) for concatenated image",
171
+ value=1024,
172
+ precision=0,
173
+ minimum=64,
174
+ interactive=True,
175
+ )
176
+
177
+ # Folded by default
178
+ with gr.Accordion("Annotation settings", open=False):
179
+ pos = gr.Dropdown(
180
+ label="Position",
181
+ choices=["top", "bottom", "left", "right"],
182
+ value="bottom",
183
+ )
184
+ align = gr.Radio(
185
+ label="Alignment (applies to top/bottom)",
186
+ choices=["left", "center", "right"],
187
+ value="center",
188
+ )
189
+ size_adj = gr.Radio(
190
+ label="Text size",
191
+ choices=["default", "larger", "smaller", "smallest", "largest"],
192
+ value="default",
193
+ )
194
+
195
+ def _toggle_align(p):
196
+ return gr.update(value="center", interactive=False) if p in ("left", "right") else gr.update(interactive=True)
197
+
198
+ pos.change(_toggle_align, inputs=[pos], outputs=[align])
199
+
200
  with gr.Row():
201
+ concat_btn = gr.Button("Concatenate image", variant="primary")
202
+ stats_btn = gr.Button("Check stats")
203
+
204
+ with gr.Column(scale=3):
205
+ out_img = gr.Image(
206
+ label="Concatenated image (preview)",
207
+ interactive=False,
208
+ format="jpeg",
209
+ show_download_button=False,
210
+ )
211
+ download_file = gr.File(
212
+ label="Download JPEG (named as saved)",
213
+ interactive=False,
214
+ height=72, # compact
215
+ )
216
+
217
+ with gr.Accordion("Logs", open=False):
218
+ logs_out = gr.Textbox(
219
+ label="Info / Errors",
220
+ lines=10,
221
+ interactive=False,
222
+ )
223
+
224
+ with gr.Accordion("Stats", open=False):
225
+ stats_out = gr.JSON(label="Counts and current filename→description mapping")
226
+
227
+ concat_btn.click(
228
+ concatenate_with_annotations,
229
+ inputs=[files_in, desc_in, max_h, pos, align, size_adj],
230
+ outputs=[out_img, download_file, logs_out, stats_out],
231
+ api_name="concatenate",
232
+ )
233
+
234
+ stats_btn.click(
235
+ check_stats_only,
236
+ inputs=[files_in, desc_in, max_h, pos, align, size_adj],
237
+ outputs=[out_img, download_file, logs_out, stats_out],
238
+ api_name="check_stats",
239
+ )
240
 
241
  if __name__ == "__main__":
242
+ demo.queue(max_size=8).launch()