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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +371 -221
app.py CHANGED
@@ -1,156 +1,378 @@
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,82 +383,10 @@ with gr.Blocks(
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()
 
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
  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()