JS6969 commited on
Commit
d3f75c2
·
verified ·
1 Parent(s): aa3281b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +3 -896
app.py CHANGED
@@ -1,178 +1,5 @@
1
  import os, io, csv, time, json, hashlib, base64, zipfile, re
2
- from typing import List, Any, Tuple, Dict
3
-
4
- import spaces
5
- import gradio as gr
6
- from PIL import Image
7
- import torch
8
- from transformers import LlavaForConditionalGeneration, AutoProcessor
9
-
10
- # ────────────────────────────────────────────────────────
11
- # Paths & caches
12
- # ────────────────────────────────────────────────────────
13
- os.environ.setdefault("HF_HOME", "/home/user/.cache/huggingface")
14
- os.makedirs(os.environ["HF_HOME"], exist_ok=True)
15
-
16
- APP_DIR = os.getcwd()
17
- SESSION_FILE = "/tmp/session.json"
18
- SETTINGS_FILE = "/tmp/cf_settings.json"
19
- JOURNAL_FILE = "/tmp/cf_journal.json"
20
- TXT_EXPORT_DIR = "/tmp/cf_txt"
21
- THUMB_CACHE = os.path.expanduser("~/.cache/captionforge/thumbs")
22
- os.makedirs(THUMB_CACHE, exist_ok=True)
23
- os.makedirs(TXT_EXPORT_DIR, exist_ok=True)
24
-
25
- # ────────────────────────────────────────────────────────
26
- # Model setup
27
- # ────────────────────────────────────────────────────────
28
- MODEL_PATH = "fancyfeast/llama-joycaption-beta-one-hf-llava"
29
-
30
- def _detect_gpu():
31
- if torch.cuda.is_available():
32
- p = torch.cuda.get_device_properties(0)
33
- return "cuda", int(p.total_memory/(1024**3)), p.name
34
- return "cpu", 0, "CPU"
35
-
36
- BACKEND, VRAM_GB, GPU_NAME = _detect_gpu()
37
- DTYPE = torch.bfloat16 if BACKEND == "cuda" else torch.float32
38
- MAX_SIDE_CAP = 1024 if BACKEND == "cuda" else 640
39
- DEVICE = "cuda" if BACKEND == "cuda" else "cpu"
40
-
41
- processor = AutoProcessor.from_pretrained(MODEL_PATH)
42
- model = LlavaForConditionalGeneration.from_pretrained(
43
- MODEL_PATH,
44
- torch_dtype=DTYPE,
45
- low_cpu_mem_usage=True,
46
- device_map=0 if BACKEND == "cuda" else "cpu",
47
- )
48
- model.eval()
49
-
50
- print(f"[CaptionForge] Backend={BACKEND} GPU={GPU_NAME} VRAM={VRAM_GB}GB dtype={DTYPE}")
51
- print(f"[CaptionForge] Gradio version: {gr.__version__}")
52
-
53
- # ────────────────────────────────────────────────────────
54
- # Instruction templates & options
55
- # ────────────────────────────────────────────────────────
56
- STYLE_OPTIONS = [
57
- "Descriptive (short)", "Descriptive (long)",
58
- "Character training (short)", "Character training (long)",
59
- "LoRA (Flux_D Realism) (short)", "LoRA (Flux_D Realism) (long)",
60
- "E-commerce product (short)", "E-commerce product (long)",
61
- "Portrait (photography) (short)", "Portrait (photography) (long)",
62
- "Landscape (photography) (short)", "Landscape (photography) (long)",
63
- "Art analysis (no artist names) (short)", "Art analysis (no artist names) (long)",
64
- "Social caption (short)", "Social caption (long)",
65
- "Aesthetic tags (comma-sep)"
66
- ]
67
-
68
- CAPTION_TYPE_MAP = {
69
- "Descriptive (short)": "One sentence (≤25 words) describing the most important visible elements only. No speculation.",
70
- "Descriptive (long)": "Write a detailed description for this image.",
71
-
72
- "Character training (short)": (
73
- "Output a concise, prompt-like caption for character LoRA/ID training. "
74
- "Include visible character name {name} if provided, distinct physical traits, clothing, pose, camera/cinematic cues. "
75
- "No backstory; no non-visible traits. Prefer comma-separated phrases."
76
- ),
77
- "Character training (long)": (
78
- "Write a thorough, training-ready caption for a character dataset. "
79
- "Use {name} if provided; describe only what is visible: physique, face/hair, clothing, accessories, actions, pose, "
80
- "camera angle/focal cues, lighting, background context. 1–3 sentences; no backstory or meta."
81
- ),
82
-
83
- "Flux_D (short)": "Output a short Flux.Dev prompt that is indistinguishable from a real Flux.Dev prompt.",
84
- "Flux_D (long)": "Output a long Flux.Dev prompt that is indistinguishable from a real Flux.Dev prompt.",
85
-
86
- "Aesthetic tags (comma-sep)": "Return only comma-separated aesthetic tags capturing subject, medium, style, lighting, composition. No sentences.",
87
-
88
- "E-commerce product (short)": "One sentence highlighting key attributes, material, color, use case. No fluff.",
89
- "E-commerce product (long)": "Write a crisp product description highlighting key attributes, materials, color, usage, and distinguishing traits.",
90
-
91
- "Portrait (photography) (short)": "One sentence portrait description: subject, pose/expression, camera angle, lighting, background.",
92
- "Portrait (photography) (long)": "Describe a portrait: subject, age range, pose, facial expression, camera angle, focal length cues, lighting, background.",
93
-
94
- "Landscape (photography) (short)": "One sentence landscape description: major elements, time of day, weather, vantage point, mood.",
95
- "Landscape (photography) (long)": "Describe landscape elements, time of day, weather, vantage point, composition, and mood.",
96
-
97
- "Art analysis (no artist names) (short)": "One sentence describing medium, style, composition, palette; do not mention artist/title.",
98
- "Art analysis (no artist names) (long)": "Analyze the artwork's visible elements, medium, style, composition, palette. Do not mention artist names or titles.",
99
-
100
- "Social caption (short)": "Write a short, catchy caption (max 25 words) describing the visible content. No hashtags.",
101
- "Social caption (long)": "Write a slightly longer, engaging caption (≤50 words) describing the visible content. No hashtags."
102
- }
103
-
104
- EXTRA_CHOICES = [
105
- "Do NOT include information about people/characters that cannot be changed (like ethnicity, gender, etc), but do still include changeable attributes (like hair style).",
106
- "Do NOT include information about whether there is a watermark or not.",
107
- "Do NOT use any ambiguous language.",
108
- "ONLY describe the most important elements of the image.",
109
- "Include information about the ages of any people/characters when applicable.",
110
- "Explicitly specify the vantage height (eye-level, low-angle worm’s-eye, bird’s-eye, drone, rooftop, etc.).",
111
- "Focus captions only on clothing/fashion details.",
112
- "Focus on setting, scenery, and context; ignore subject details.",
113
- "ONLY describe the subject’s pose, movement, or action. Do NOT mention appearance, clothing, or setting.",
114
- "Do NOT include anything sexual; keep it PG.",
115
- "Include synonyms/alternate phrasing to diversify training set.",
116
- "ALWAYS arrange caption elements in the order → Subject, Clothing/Accessories, Action/Pose, Setting/Environment, Lighting/Camera/Style.",
117
- "Do NOT mention the image's resolution.",
118
- "Include information about depth, lighting, and camera angle.",
119
- "Include information on composition (rule of thirds, symmetry, leading lines, etc).",
120
- "Specify the depth of field and whether the background is in focus or blurred.",
121
- "If applicable, mention the likely use of artificial or natural lighting sources.",
122
- "If it is a work of art, do not include the artist's name or the title of the work.",
123
- "Identify the image orientation (portrait, landscape, or square) if obvious.",
124
- ]
125
- NAME_OPTION = "If there is a person/character in the image you must refer to them as {name}."
126
-
127
- # ────────────────────────────────────────────────────────
128
- # Helpers (hashing, thumbs, resize)
129
- # ────────────────────────────────────────────────────────
130
- def sha1_file(path: str) -> str:
131
- h = hashlib.sha1()
132
- with open(path, "rb") as f:
133
- for chunk in iter(lambda: f.read(8192), b""):
134
- h.update(chunk)
135
- return h.hexdigest()
136
-
137
- def thumb_path(sha1: str, max_side=96) -> str:
138
- return os.path.join(THUMB_CACHE, f"{sha1}_{max_side}.jpg")
139
-
140
- def ensure_thumb(path: str, sha1: str, max_side=96) -> str:
141
- out = thumb_path(sha1, max_side)
142
- if os.path.exists(out):
143
- return out
144
- try:
145
- im = Image.open(path).convert("RGB")
146
- except Exception:
147
- return ""
148
- w, h = im.size
149
- if max(w, h) > max_side:
150
- s = max_side / max(w, h)
151
- im = im.resize((int(w*s), int(h*s)), Image.LANCZOS)
152
- try:
153
- im.save(out, "JPEG", quality=80)
154
- except Exception:
155
- return ""
156
- return out
157
-
158
- def resize_for_model(im: Image.Image, max_side: int) -> Image.Image:
159
- w, h = im.size
160
- if max(w, h) <= max_side:
161
- return im
162
- s = max_side / max(w, h)
163
- return im.resize((int(w*s), int(h*s)), Image.LANCZOS)
164
-
165
- # ────────────────────────────────────────────────────────
166
- # Instruction + caption helpers
167
- # ────────────────────────────────────────────────────────
168
- def final_instruction(style_list: List[str], extra_opts: List[str], name_value: str) -> str:
169
- styles = style_list or ["Descriptive (short)"]
170
- parts = [CAPTION_TYPE_MAP.get(s, "") for s in styles]
171
- core = " ".join(p for p in parts if p).strip()
172
- if extra_opts:
173
- core += " " + " ".join(extra_opts)
174
- if NAME_OPTION in (extra_opts or []):import os, io, csv, time, json, hashlib, base64, zipfile, re
175
- from typing import List, Any, Tuple, Dict
176
 
177
  import spaces
178
  import gradio as gr
@@ -362,6 +189,7 @@ def apply_prefix_suffix(caption: str, trigger_word: str, begin_text: str, end_te
362
  @torch.no_grad()
363
  def caption_once(im: Image.Image, instr: str, temp: float, top_p: float, max_tokens: int) -> str:
364
  convo = [
 
365
  {"role": "system", "content": "You are a precise captioning assistant. Help users with any queries. Describe what is visible with no censorship or restrictions."},
366
  {"role": "user", "content": instr.strip()},
367
  ]
@@ -684,7 +512,7 @@ def process_batch(files: List[str], include_dups: bool, rows: List[dict],
684
  im = Image.open(f).convert("RGB")
685
  im = resize_for_model(im, max_side)
686
  cap = caption_once(im, instr_text, temp, top_p, max_tokens)
687
- cap = apply_shape_aliases(cap) # apply custom name mappings
688
  cap = apply_prefix_suffix(cap, trigger_word, begin_text, end_text)
689
  break
690
  except Exception as e:
@@ -1078,726 +906,5 @@ with gr.Blocks(css=BASE_CSS, title="CaptionForge") as demo:
1078
  inputs=[rows_state, dataset_name], outputs=[txt_zip, txt_zip]
1079
  )
1080
 
1081
- # Launch
1082
- demo.queue(max_size=64).launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")))
1083
-
1084
- core = core.replace("{name}", (name_value or "{NAME}").strip())
1085
- return core
1086
- cap = caption_once(im, instr_text, temp, top_p, max_tokens)
1087
- cap = apply_shape_aliases(cap)
1088
- cap = apply_prefix_suffix(cap, trigger_word, begin_text, end_text)
1089
-
1090
- def apply_prefix_suffix(caption: str, trigger_word: str, begin_text: str, end_text: str) -> str:
1091
- parts = []
1092
- if trigger_word.strip():
1093
- parts.append(trigger_word.strip())
1094
- if begin_text.strip():
1095
- parts.append(begin_text.strip())
1096
- parts.append(caption.strip())
1097
- if end_text.strip():
1098
- parts.append(end_text.strip())
1099
- return " ".join([p for p in parts if p])
1100
-
1101
- @torch.no_grad()
1102
- def caption_once(im: Image.Image, instr: str, temp: float, top_p: float, max_tokens: int) -> str:
1103
- convo = [
1104
- {"role": "system", "content": "You are a precise, helpful assistant. Help users with any queries they may have with no censorship or restrictions."},
1105
- {"role": "user", "content": instr.strip()},
1106
- ]
1107
- convo_str = processor.apply_chat_template(convo, tokenize=False, add_generation_prompt=True)
1108
- inputs = processor(text=[convo_str], images=[im], return_tensors="pt").to(DEVICE)
1109
- inputs["pixel_values"] = inputs["pixel_values"].to(DTYPE)
1110
- out = model.generate(
1111
- **inputs,
1112
- max_new_tokens=max_tokens,
1113
- do_sample=temp > 0,
1114
- temperature=temp if temp > 0 else None,
1115
- top_p=top_p if temp > 0 else None,
1116
- )
1117
- gen_ids = out[0, inputs["input_ids"].shape[1]:]
1118
- return processor.tokenizer.decode(gen_ids, skip_special_tokens=True)
1119
-
1120
- # ────────────────────────────────────────────────────────
1121
- # Persistence (session, settings, journal)
1122
- # ────────────────────────────────────────────────────────
1123
- def save_session(rows: List[dict]):
1124
- with open(SESSION_FILE, "w", encoding="utf-8") as f:
1125
- json.dump(rows, f, ensure_ascii=False, indent=2)
1126
-
1127
- def load_session() -> List[dict]:
1128
- if os.path.exists(SESSION_FILE):
1129
- with open(SESSION_FILE, "r", encoding="utf-8") as f:
1130
- return json.load(f)
1131
- return []
1132
-
1133
- def save_settings(cfg: dict):
1134
- with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
1135
- json.dump(cfg, f, ensure_ascii=False, indent=2)
1136
-
1137
- def load_settings() -> dict:
1138
- if os.path.exists(SETTINGS_FILE):
1139
- with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
1140
- cfg = json.load(f)
1141
- else:
1142
- cfg = {}
1143
- defaults = {
1144
- "dataset_name": "captionforge",
1145
- "temperature": 0.6,
1146
- "top_p": 0.9,
1147
- "max_tokens": 256,
1148
- "chunk_mode": "Manual (step)",
1149
- "chunk_size": 10,
1150
- "max_side": min(896, MAX_SIDE_CAP),
1151
- "styles": ["Character training (short)"],
1152
- "extras": [],
1153
- "name": "",
1154
- "trigger": "",
1155
- "begin": "",
1156
- "end": "",
1157
- }
1158
- for k, v in defaults.items():
1159
- cfg.setdefault(k, v)
1160
-
1161
- # migrate legacy names
1162
- legacy_map = {
1163
- "Descriptive": "Descriptive (short)",
1164
- "LoRA (Flux_D Realism)": "LoRA (Flux_D Realism) (short)",
1165
- "Portrait (photography)": "Portrait (photography) (short)",
1166
- "Landscape (photography)": "Landscape (photography) (short)",
1167
- "Art analysis (no artist names)": "Art analysis (no artist names) (short)",
1168
- "E-commerce product": "E-commerce product (short)",
1169
- }
1170
- styles = cfg.get("styles") or []
1171
- migrated = []
1172
- for s in styles if isinstance(styles, list) else [styles]:
1173
- migrated.append(legacy_map.get(s, s))
1174
- migrated = [s for s in migrated if s in STYLE_OPTIONS]
1175
- if not migrated:
1176
- migrated = ["Descriptive (short)"]
1177
- cfg["styles"] = migrated
1178
- return cfg
1179
-
1180
- def save_journal(data: dict):
1181
- with open(JOURNAL_FILE, "w", encoding="utf-8") as f:
1182
- json.dump(data, f, ensure_ascii=False, indent=2)
1183
-
1184
- def load_journal() -> dict:
1185
- if os.path.exists(JOURNAL_FILE):
1186
- with open(JOURNAL_FILE, "r", encoding="utf-8") as f:
1187
- return json.load(f)
1188
- return {}
1189
-
1190
- # ────────────────────────────────────────────────────────
1191
- # Logo loader
1192
- # ────────────────────────────────────────────────────────
1193
- def logo_b64_img() -> str:
1194
- candidates = [
1195
- os.path.join(APP_DIR, "captionforge-logo.png"),
1196
- "/home/user/app/captionforge-logo.png",
1197
- "captionforge-logo.png",
1198
- ]
1199
- for p in candidates:
1200
- if os.path.exists(p):
1201
- with open(p, "rb") as f:
1202
- b64 = base64.b64encode(f.read()).decode("ascii")
1203
- return f"<img src='data:image/png;base64,{b64}' alt='CaptionForge' class='cf-logo'>"
1204
- return ""
1205
-
1206
- # ────────────────────────────────────────────────────────
1207
- # Import / Export helpers
1208
- # ────────────────────────────────────────────────────────
1209
- def sanitize_basename(s: str) -> str:
1210
- s = (s or "").strip() or "captionforge"
1211
- return re.sub(r"[^A-Za-z0-9._-]+", "_", s)[:120]
1212
-
1213
- def ts_stamp() -> str:
1214
- return time.strftime("%Y%m%d_%H%M%S")
1215
-
1216
- def export_csv(rows: List[dict], basename: str) -> str:
1217
- base = sanitize_basename(basename)
1218
- out = f"/tmp/{base}_{ts_stamp()}.csv"
1219
- with open(out, "w", newline="", encoding="utf-8") as f:
1220
- w = csv.writer(f)
1221
- w.writerow(["sha1", "filename", "caption"])
1222
- for r in rows:
1223
- w.writerow([r.get("sha1",""), r.get("filename",""), r.get("caption","")])
1224
- return out
1225
-
1226
- def export_excel(rows: List[dict], basename: str) -> str:
1227
- try:
1228
- from openpyxl import Workbook
1229
- from openpyxl.drawing.image import Image as XLImage
1230
- except Exception as e:
1231
- raise RuntimeError("Excel export requires 'openpyxl' in requirements.txt.") from e
1232
- base = sanitize_basename(basename)
1233
- out = f"/tmp/{base}_{ts_stamp()}.xlsx"
1234
- wb = Workbook(); ws = wb.active; ws.title = "CaptionForge"
1235
- ws.append(["image", "filename", "caption"])
1236
- ws.column_dimensions["A"].width = 24
1237
- ws.column_dimensions["B"].width = 42
1238
- ws.column_dimensions["C"].width = 100
1239
- r_i = 2
1240
- for r in rows:
1241
- fn = r.get("filename",""); cap = r.get("caption","")
1242
- ws.cell(row=r_i, column=2, value=fn)
1243
- ws.cell(row=r_i, column=3, value=cap)
1244
- img_path = r.get("thumb_path") or r.get("path")
1245
- if img_path and os.path.exists(img_path):
1246
- try:
1247
- ximg = XLImage(img_path); ws.add_image(ximg, f"A{r_i}"); ws.row_dimensions[r_i].height = 110
1248
- except Exception:
1249
- pass
1250
- r_i += 1
1251
- wb.save(out); return out
1252
-
1253
- def export_txt_zip(rows: List[dict], basename: str) -> str:
1254
- base = sanitize_basename(basename)
1255
- for name in os.listdir(TXT_EXPORT_DIR):
1256
- try: os.remove(os.path.join(TXT_EXPORT_DIR, name))
1257
- except Exception: pass
1258
- used: Dict[str,int] = {}
1259
- for r in rows:
1260
- orig = r.get("filename") or r.get("sha1") or "item"
1261
- stem = re.sub(r"\.[A-Za-z0-9]+$", "", orig)
1262
- stem = sanitize_basename(stem)
1263
- if stem in used:
1264
- used[stem] += 1
1265
- stem = f"{stem}_{used[stem]}"
1266
- else:
1267
- used[stem] = 0
1268
- txt_path = os.path.join(TXT_EXPORT_DIR, f"{stem}.txt")
1269
- with open(txt_path, "w", encoding="utf-8") as f:
1270
- f.write(r.get("caption", ""))
1271
- zpath = f"/tmp/{base}_{ts_stamp()}_txt.zip"
1272
- with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
1273
- for name in os.listdir(TXT_EXPORT_DIR):
1274
- if name.endswith(".txt"):
1275
- z.write(os.path.join(TXT_EXPORT_DIR, name), arcname=name)
1276
- return zpath
1277
-
1278
- def import_csv_jsonl(path: str, rows: List[dict]) -> List[dict]:
1279
- """Merge captions from CSV/JSONL into current rows by sha1→filename, updating only 'caption'."""
1280
- if not path or not os.path.exists(path):
1281
- return rows or []
1282
- rows = rows or []
1283
- by_sha = {r.get("sha1"): r for r in rows if r.get("sha1")}
1284
- by_fn = {r.get("filename"): r for r in rows if r.get("filename")}
1285
-
1286
- def _apply(item: dict):
1287
- if not isinstance(item, dict):
1288
- return
1289
- cap = (item.get("caption") or "").strip()
1290
- if not cap:
1291
- return
1292
- sha = (item.get("sha1") or "").strip()
1293
- fn = (item.get("filename") or "").strip()
1294
- tgt = by_sha.get(sha) if sha else None
1295
- if not tgt and fn:
1296
- tgt = by_fn.get(fn)
1297
- if tgt is not None:
1298
- prev = tgt.get("caption", "")
1299
- if cap != prev:
1300
- tgt.setdefault("history", []).append(prev)
1301
- tgt["caption"] = cap
1302
-
1303
- ext = os.path.splitext(path)[1].lower()
1304
- try:
1305
- if ext == ".csv":
1306
- with open(path, "r", encoding="utf-8") as f:
1307
- for row in csv.DictReader(f):
1308
- _apply(row)
1309
- elif ext in (".jsonl", ".ndjson"):
1310
- with open(path, "r", encoding="utf-8") as f:
1311
- for line in f:
1312
- line = line.strip()
1313
- if not line:
1314
- continue
1315
- try:
1316
- obj = json.loads(line)
1317
- except Exception:
1318
- continue
1319
- _apply(obj)
1320
- else:
1321
- with open(path, "r", encoding="utf-8") as f:
1322
- data = json.load(f)
1323
- if isinstance(data, list):
1324
- for obj in data:
1325
- _apply(obj)
1326
- except Exception as e:
1327
- print("[CaptionForge] Import merge failed:", e)
1328
- save_session(rows)
1329
- return rows
1330
-
1331
- # ────────────────────────────────────────────────────────
1332
- # Batching / processing
1333
- # ────────────────────────────────────────────────────────
1334
- def adaptive_defaults(vram_gb: int) -> Tuple[int, int]:
1335
- if vram_gb >= 40: return 24, min(1024, MAX_SIDE_CAP)
1336
- if vram_gb >= 24: return 16, min(1024, MAX_SIDE_CAP)
1337
- if vram_gb >= 12: return 10, 896
1338
- if vram_gb >= 8: return 6, 768
1339
- return 4, 640
1340
-
1341
- def warmup_if_needed(temp: float, top_p: float, max_tokens: int, max_side: int):
1342
- try:
1343
- im = Image.new("RGB", (min(128, max_side), min(128, max_side)), (127,127,127))
1344
- _ = caption_once(im, "Warm up.", temp=0.0, top_p=top_p, max_tokens=min(16, max_tokens))
1345
- except Exception as e:
1346
- print("[CaptionForge] Warm-up skipped:", e)
1347
-
1348
- @spaces.GPU()
1349
- @torch.no_grad()
1350
- def process_batch(files: List[str], include_dups: bool, rows: List[dict],
1351
- instr_text: str, temp: float, top_p: float, max_tokens: int,
1352
- trigger_word: str, begin_text: str, end_text: str,
1353
- mode: str, chunk_size: int, max_side: int, step_once: bool,
1354
- progress=gr.Progress(track_tqdm=True)) -> Tuple[List[dict], List[str], List[str]]:
1355
- if torch.cuda.is_available():
1356
- torch.cuda.empty_cache()
1357
- files = files or []
1358
- if not files: return rows, [], []
1359
-
1360
- auto_mode = mode == "Auto"
1361
- if auto_mode:
1362
- chunk_size, max_side = adaptive_defaults(VRAM_GB)
1363
-
1364
- sha_seen = {r.get("sha1") for r in rows}
1365
- f_infos = []
1366
- for f in files:
1367
- try:
1368
- s = sha1_file(f)
1369
- except Exception:
1370
- continue
1371
- if (not include_dups) and (s in sha_seen):
1372
- continue
1373
- f_infos.append((f, os.path.basename(f), s))
1374
- if not f_infos: return rows, [], []
1375
-
1376
- warmup_if_needed(temp, top_p, max_tokens, max_side)
1377
-
1378
- chunks = [f_infos[i:i+chunk_size] for i in range(0, len(f_infos), chunk_size)]
1379
- if step_once:
1380
- chunks = chunks[:1]
1381
-
1382
- error_log: List[str] = []
1383
- remaining: List[str] = []
1384
-
1385
- for ci, chunk in enumerate(chunks):
1386
- for (f, fname, sha) in progress.tqdm(chunk, desc=f"Chunk {ci+1}/{len(chunks)}"):
1387
- thumb = ensure_thumb(f, sha, 96)
1388
- attempt = 0
1389
- cap = None
1390
- while attempt < 2:
1391
- attempt += 1
1392
- try:
1393
- im = Image.open(f).convert("RGB")
1394
- im = resize_for_model(im, max_side)
1395
- cap = caption_once(im, instr_text, temp, top_p, max_tokens)
1396
- cap = apply_prefix_suffix(cap, trigger_word, begin_text, end_text)
1397
- break
1398
- except Exception as e:
1399
- error_log.append(f"{fname} ({sha[:8]}): {type(e).__name__}: {e} [attempt {attempt}]")
1400
- rows.append({
1401
- "sha1": sha, "filename": fname, "path": f, "thumb_path": thumb,
1402
- "caption": cap or "", "history": []
1403
- })
1404
- save_session(rows)
1405
-
1406
- if step_once and len(f_infos) > len(chunks[0]):
1407
- remaining = [fi[0] for fi in f_infos[len(chunks[0]):]]
1408
- save_journal({"remaining_files": remaining})
1409
- return rows, remaining, error_log
1410
-
1411
- # ────────────────────────────────────────────────────────
1412
- # UI
1413
- # ────────────────────────────────────────────────────────
1414
- BASE_CSS = """
1415
- :root{--galleryW:50%;--tableW:50%;}
1416
- .gradio-container{max-width:100%!important}
1417
- .cf-hero{
1418
- display:flex; align-items:center; justify-content:center; gap:16px;
1419
- margin:4px 0 12px; text-align:center;
1420
- }
1421
- .cf-hero > div { text-align:center; }
1422
- .cf-logo{height:calc(3.25rem + 3 * 1.1rem + 18px);width:auto;object-fit:contain}
1423
- .cf-title{margin:0;font-size:3.25rem;line-height:1;letter-spacing:.2px}
1424
- .cf-sub{margin:6px 0 0;font-size:1.1rem;color:#cfd3da}
1425
- .cf-row{display:flex;gap:12px}
1426
- .cf-col-gallery{flex:0 0 var(--galleryW)}
1427
- .cf-col-table{flex:0 0 var(--tableW)}
1428
- /* Shared scroll look */
1429
- .cf-scroll{max-height:70vh; overflow-y:auto; border:1px solid #e6e6e6; border-radius:10px; padding:8px}
1430
- /* Uniform sizes */
1431
- #cfGal .grid > div { height: 96px; }
1432
- /* Hide SHA1 in table (first column) */
1433
- #cfTable table thead tr th:first-child { display:none; }
1434
- #cfTable table tbody tr td:first-child { display:none; }
1435
- """
1436
-
1437
-
1438
- with gr.Blocks(css=BASE_CSS, title="CaptionForge") as demo:
1439
- settings = load_settings()
1440
- settings["styles"] = [s for s in settings.get("styles", []) if s in STYLE_OPTIONS] or ["Character training (short)"]
1441
-
1442
- header = gr.HTML(value="""
1443
- <div class="cf-hero">
1444
- %s
1445
- <div>
1446
- <h1 class="cf-title">CaptionForge</h1>
1447
- <div class="cf-sub">Batch captioning</div>
1448
- <div class="cf-sub">Scrollable editor & autosave</div>
1449
- <div class="cf-sub">CSV / Excel / TXT export</div>
1450
- </div>
1451
- </div>
1452
- <hr>
1453
- """ % logo_b64_img())
1454
-
1455
- with gr.Tabs():
1456
- with gr.Tab("Captioner"):
1457
- # (keep all your existing main controls here)
1458
- ...
1459
- with gr.Tab("Shape Aliases"):
1460
- enable_aliases = gr.Checkbox(label="Enable shape alias replacements", value=True)
1461
- alias_table = gr.Dataframe(
1462
- headers=["shape (literal token)", "name to insert"],
1463
- datatype=["str","str"],
1464
- row_count=(1, "dynamic"),
1465
- wrap=True,
1466
- interactive=True
1467
- )
1468
- save_aliases_btn = gr.Button("Save aliases")
1469
- save_status = gr.Markdown()
1470
-
1471
- init_rows, init_enabled = get_shape_alias_rows()
1472
- enable_aliases.value = init_enabled
1473
- alias_table.value = init_rows
1474
-
1475
- save_aliases_btn.click(
1476
- set_shape_alias_rows,
1477
- inputs=[enable_aliases, alias_table],
1478
- outputs=[save_status]
1479
- )
1480
-
1481
- # ===== Controls (top)
1482
- with gr.Group():
1483
- with gr.Row():
1484
- with gr.Column(scale=2):
1485
- style_checks = gr.CheckboxGroup(
1486
- choices=STYLE_OPTIONS,
1487
- value=settings.get("styles", ["Character training (short)"]),
1488
- label="Caption style (choose one or combine)"
1489
- )
1490
- with gr.Accordion("Extra options", open=True):
1491
- extra_opts = gr.CheckboxGroup(
1492
- choices=[NAME_OPTION] + EXTRA_CHOICES,
1493
- value=settings.get("extras", []),
1494
- label=None
1495
- )
1496
- with gr.Accordion("Name & Prefix/Suffix", open=True):
1497
- name_input = gr.Textbox(label="Person / Character Name", value=settings.get("name", ""))
1498
- trig = gr.Textbox(label="Trigger word")
1499
- add_start = gr.Textbox(label="Add text to start")
1500
- add_end = gr.Textbox(label="Add text to end")
1501
-
1502
- with gr.Column(scale=1):
1503
- instruction_preview = gr.Textbox(label="Model Instructions", lines=14)
1504
- dataset_name = gr.Textbox(label="Dataset name (used for export file titles)", value=settings.get("dataset_name", "captionforge"))
1505
-
1506
- with gr.Row():
1507
- chunk_mode = gr.Radio(
1508
- choices=["Auto", "Manual (all at once)", "Manual (step)"],
1509
- value=settings.get("chunk_mode", "Manual (step)"),
1510
- label="Batch mode"
1511
- )
1512
- chunk_size = gr.Slider(1, 50, settings.get("chunk_size", 10), step=1, label="Chunk size")
1513
- max_side = gr.Slider(256, MAX_SIDE_CAP, settings.get("max_side", min(896, MAX_SIDE_CAP)), step=32, label="Max side (resize)")
1514
-
1515
- # Auto-refresh instruction & persist key controls
1516
- def _refresh_instruction(styles, extra, name_value, trigv, begv, endv):
1517
- instr = final_instruction(styles or ["Character training (short)"], extra or [], name_value)
1518
- cfg = load_settings()
1519
- cfg.update({
1520
- "styles": styles or ["Character training (short)"],
1521
- "extras": extra or [],
1522
- "name": name_value,
1523
- "trigger": trigv, "begin": begv, "end": endv
1524
- })
1525
- save_settings(cfg)
1526
- return instr
1527
-
1528
- for comp in [style_checks, extra_opts, name_input, trig, add_start, add_end]:
1529
- comp.change(_refresh_instruction, inputs=[style_checks, extra_opts, name_input, trig, add_start, add_end], outputs=[instruction_preview])
1530
-
1531
- # ===== File inputs & actions
1532
- with gr.Accordion("Uploaded images", open=True) as uploads_acc:
1533
- input_files = gr.File(label="Drop images", file_types=["image"], file_count="multiple", type="filepath")
1534
- import_file = gr.File(label="Import CSV or JSONL (merge captions)")
1535
-
1536
- with gr.Row():
1537
- import_btn = gr.Button("Import → Merge")
1538
- resume_btn = gr.Button("Resume last run", variant="secondary")
1539
- run_button = gr.Button("Caption batch", variant="primary")
1540
-
1541
- # Step-chunk controls
1542
- step_panel = gr.Group(visible=False)
1543
- with step_panel:
1544
- step_msg = gr.Markdown("")
1545
- step_next = gr.Button("Process next chunk")
1546
- step_finish = gr.Button("Finish")
1547
-
1548
- # States
1549
- rows_state = gr.State(load_session())
1550
- visible_count_state = gr.State(100)
1551
- pending_files = gr.State([])
1552
- remaining_state = gr.State([])
1553
- autosave_md = gr.Markdown("Ready.")
1554
-
1555
- # Error log
1556
- with gr.Accordion("Error log", open=False):
1557
- log_md = gr.Markdown("")
1558
-
1559
- # Side-by-side gallery + table with shared scroll
1560
- with gr.Row():
1561
- with gr.Column(scale=1):
1562
- gallery = gr.Gallery(
1563
- label="Results (images)",
1564
- show_label=True,
1565
- columns=10, # 10 per row as requested
1566
- height=520,
1567
- elem_id="cfGal",
1568
- elem_classes=["cf-scroll"]
1569
- )
1570
- with gr.Column(scale=1):
1571
- table = gr.Dataframe(
1572
- label="Editable captions (whole session)",
1573
- value=[], # filled by _render
1574
- headers=["sha1", "filename", "caption"], # include sha1, but it's hidden by CSS
1575
- interactive=True,
1576
- elem_id="cfTable",
1577
- elem_classes=["cf-scroll"]
1578
- )
1579
-
1580
- # Exports aligned under buttons
1581
- with gr.Row():
1582
- with gr.Column():
1583
- export_csv_btn = gr.Button("Export CSV")
1584
- csv_file = gr.File(label="CSV file", visible=False)
1585
- with gr.Column():
1586
- export_xlsx_btn = gr.Button("Export Excel (.xlsx)")
1587
- xlsx_file = gr.File(label="Excel file", visible=False)
1588
- with gr.Column():
1589
- export_txt_btn = gr.Button("Export captions as .txt (zip)")
1590
- txt_zip = gr.File(label="TXT zip", visible=False)
1591
-
1592
- # Render helpers
1593
- def _render(rows, vis_n):
1594
- rows = rows or []
1595
- n = max(0, int(vis_n) if vis_n else 0)
1596
- win = rows[:n]
1597
-
1598
- # Gallery expects list of paths (Gradio 5 ok)
1599
- gal = []
1600
- for r in win:
1601
- p = r.get("thumb_path") or r.get("path")
1602
- if isinstance(p, str) and os.path.exists(p):
1603
- gal.append(p)
1604
-
1605
- # Dataframe rows (sha1 kept for mapping; hidden via CSS)
1606
- df = [[str(r.get("sha1","")), str(r.get("filename","")), str(r.get("caption",""))] for r in win]
1607
- info = f"Showing {len(win)} of {len(rows)}"
1608
- return gal, df, info
1609
-
1610
- # Initial loads
1611
- demo.load(lambda s,e,n: final_instruction(s or ["Character training (short)"], e or [], n),
1612
- inputs=[style_checks, extra_opts, name_input], outputs=[instruction_preview])
1613
- demo.load(_render, inputs=[rows_state, visible_count_state], outputs=[gallery, table, autosave_md])
1614
-
1615
- # Scroll sync (Gallery + Table)
1616
- gr.HTML("""
1617
- <script>
1618
- (function () {
1619
- function findGalleryScrollRoot() {
1620
- const host = document.querySelector("#cfGal");
1621
- if (!host) return null;
1622
- return host.querySelector(".grid") || host.querySelector("[data-testid='gallery']") || host;
1623
- }
1624
- function findTableScrollRoot() {
1625
- const host = document.querySelector("#cfTable");
1626
- if (!host) return null;
1627
- return host.querySelector(".wrap") ||
1628
- host.querySelector(".dataframe-wrap") ||
1629
- (host.querySelector("table") ? host.querySelector("table").parentElement : null) ||
1630
- host;
1631
- }
1632
- function syncScroll(a, b) {
1633
- if (!a || !b) return;
1634
- let lock = false;
1635
- const onScrollA = () => { if (lock) return; lock = true; b.scrollTop = a.scrollTop; lock = false; };
1636
- const onScrollB = () => { if (lock) return; lock = true; a.scrollTop = b.scrollTop; lock = false; };
1637
- a.addEventListener("scroll", onScrollA, { passive: true });
1638
- b.addEventListener("scroll", onScrollB, { passive: true });
1639
- }
1640
- let tries = 0;
1641
- const timer = setInterval(() => {
1642
- tries++;
1643
- const gal = findGalleryScrollRoot();
1644
- const tab = findTableScrollRoot();
1645
- if (gal && tab) {
1646
- const H = Math.min(gal.clientHeight || 520, tab.clientHeight || 520);
1647
- gal.style.maxHeight = H + "px";
1648
- gal.style.overflowY = "auto";
1649
- tab.style.maxHeight = H + "px";
1650
- tab.style.overflowY = "auto";
1651
- syncScroll(gal, tab);
1652
- clearInterval(timer);
1653
- }
1654
- if (tries > 20) clearInterval(timer);
1655
- }, 100);
1656
- })();
1657
- </script>
1658
- """)
1659
-
1660
- # Auto refresh instruction persists (already wired above)
1661
- # Table edits -> save + rerender (map by sha1, which is hidden in UI)
1662
- def on_table_edit(df_rows, rows):
1663
- by_sha = {r.get("sha1"): r for r in (rows or [])}
1664
- for row in (df_rows or []):
1665
- if not row:
1666
- continue
1667
- sha = str(row[0]) if len(row) > 0 else ""
1668
- tgt = by_sha.get(sha)
1669
- if not tgt:
1670
- continue
1671
- prev = tgt.get("caption", "")
1672
- newc = row[2] if len(row) > 2 else prev
1673
- if newc != prev:
1674
- hist = tgt.setdefault("history", [])
1675
- hist.append(prev)
1676
- tgt["caption"] = newc
1677
- save_session(rows or [])
1678
- return rows, f"Saved • {time.strftime('%H:%M:%S')}"
1679
- table.change(on_table_edit, inputs=[table, rows_state], outputs=[rows_state, autosave_md])\
1680
- .then(_render, inputs=[rows_state, visible_count_state], outputs=[gallery, table, autosave_md])
1681
-
1682
- def _compile_shape_aliases_from_file():
1683
- s = load_settings()
1684
- if not s.get("shape_aliases_enabled", True):
1685
- return []
1686
- compiled = []
1687
- for item in s.get("shape_aliases", []):
1688
- shape = (item.get("shape") or "").strip()
1689
- name = (item.get("name") or "").strip()
1690
- if not shape or not name:
1691
- continue
1692
- pat = rf"\b{re.escape(shape)}(?:-?shaped)?\b"
1693
- compiled.append((re.compile(pat, flags=re.I), name))
1694
- return compiled
1695
-
1696
- SHAPE_ALIASES = _compile_shape_aliases_from_file()
1697
-
1698
- def apply_shape_aliases(caption: str) -> str:
1699
- for pat, name in SHAPE_ALIASES:
1700
- caption = pat.sub(f"({name})", caption)
1701
- return caption
1702
-
1703
-
1704
- # Import merge
1705
- def do_import(file, rows):
1706
- if not file:
1707
- return rows, gr.update(value="No file"), rows
1708
- rows = import_csv_jsonl(file.name, rows or [])
1709
- return rows, gr.update(value=f"Merged from {os.path.basename(file.name)} at {time.strftime('%H:%M:%S')}"), rows
1710
- import_btn.click(do_import, inputs=[import_file, rows_state], outputs=[rows_state, autosave_md, rows_state])\
1711
- .then(_render, inputs=[rows_state, visible_count_state], outputs=[gallery, table, autosave_md])
1712
-
1713
- # Prepare run (hash dupes)
1714
- def prepare_run(files, rows, pending):
1715
- files = files or []
1716
- rows = rows or []
1717
- pending = pending or []
1718
- if not files:
1719
- return [], gr.update(open=True), []
1720
- existing = {r.get("sha1") for r in rows if r.get("sha1")}
1721
- pending_sha = set()
1722
- for f in pending:
1723
- try:
1724
- pending_sha.add(sha1_file(f))
1725
- except Exception:
1726
- pass
1727
- keep = []
1728
- for f in files:
1729
- try:
1730
- s = sha1_file(f)
1731
- except Exception:
1732
- continue
1733
- if s not in existing and s not in pending_sha:
1734
- keep.append(f)
1735
- return keep, gr.update(open=False), keep
1736
- input_files.change(prepare_run, inputs=[input_files, rows_state, pending_files], outputs=[pending_files, uploads_acc, pending_files])
1737
-
1738
- # Step visibility helper (dynamic message)
1739
- def _step_visibility(remain, mode):
1740
- if (mode == "Manual (step)") and remain:
1741
- return gr.update(visible=True), gr.update(value=f"{len(remain)} files remain. Process next chunk?")
1742
- return gr.update(visible=False), gr.update(value="")
1743
-
1744
- # Run helpers (use settings for gen params; sliders removed from UI)
1745
- def _run_once(pending, rows, instr, trigv, begv, endv, mode, csize, mside):
1746
- cfg = load_settings()
1747
- t = cfg.get("temperature", 0.6)
1748
- p = cfg.get("top_p", 0.9)
1749
- m = cfg.get("max_tokens", 256)
1750
- step_once = (mode == "Manual (step)")
1751
- new_rows, remaining, errors = process_batch(
1752
- pending, False, rows or [], instr, t, p, m, trigv, begv, endv,
1753
- mode, int(csize), int(mside), step_once
1754
- )
1755
- log = "\n".join(errors) if errors else "(no errors)"
1756
- return new_rows, remaining, log, f"Saved • {time.strftime('%H:%M:%S')}"
1757
-
1758
- run_button.click(
1759
- _run_once,
1760
- inputs=[pending_files, rows_state, instruction_preview, trig, add_start, add_end, chunk_mode, chunk_size, max_side],
1761
- outputs=[rows_state, remaining_state, log_md, autosave_md]
1762
- ).then(
1763
- _render, inputs=[rows_state, visible_count_state], outputs=[gallery, table, autosave_md]
1764
- ).then(
1765
- _step_visibility, inputs=[remaining_state, chunk_mode], outputs=[step_panel, step_msg]
1766
- )
1767
-
1768
- def _resume(rows):
1769
- j = load_journal()
1770
- rem = j.get("remaining_files", [])
1771
- return rem, ("Nothing to resume" if not rem else f"Loaded {len(rem)} remaining")
1772
- resume_btn.click(_resume, inputs=[rows_state], outputs=[pending_files, autosave_md])
1773
-
1774
- def _step_next(remain, rows, instr, trigv, begv, endv, mode, csize, mside):
1775
- return _run_once(remain, rows, instr, trigv, begv, endv, mode, csize, mside)
1776
- step_next.click(
1777
- _step_next,
1778
- inputs=[remaining_state, rows_state, instruction_preview, trig, add_start, add_end, chunk_mode, chunk_size, max_side],
1779
- outputs=[rows_state, remaining_state, log_md, autosave_md]
1780
- ).then(
1781
- _render, inputs=[rows_state, visible_count_state], outputs=[gallery, table, autosave_md]
1782
- ).then(
1783
- _step_visibility, inputs=[remaining_state, chunk_mode], outputs=[step_panel, step_msg]
1784
- )
1785
-
1786
- step_finish.click(lambda: (gr.update(visible=False), gr.update(value=""), []), outputs=[step_panel, step_msg, remaining_state])
1787
-
1788
- # Exports (reveal file widgets when created)
1789
- export_csv_btn.click(
1790
- lambda rows, base: (export_csv(rows or [], base), gr.update(visible=True)),
1791
- inputs=[rows_state, dataset_name], outputs=[csv_file, csv_file]
1792
- )
1793
- export_xlsx_btn.click(
1794
- lambda rows, base: (export_excel(rows or [], base), gr.update(visible=True)),
1795
- inputs=[rows_state, dataset_name], outputs=[xlsx_file, xlsx_file]
1796
- )
1797
- export_txt_btn.click(
1798
- lambda rows, base: (export_txt_zip(rows or [], base), gr.update(visible=True)),
1799
- inputs=[rows_state, dataset_name], outputs=[txt_zip, txt_zip]
1800
- )
1801
-
1802
  # Launch
1803
  demo.queue(max_size=64).launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")))
 
1
  import os, io, csv, time, json, hashlib, base64, zipfile, re
2
+ from typing import List, Tuple, Dict
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  import spaces
5
  import gradio as gr
 
189
  @torch.no_grad()
190
  def caption_once(im: Image.Image, instr: str, temp: float, top_p: float, max_tokens: int) -> str:
191
  convo = [
192
+ # Your preferred role script:
193
  {"role": "system", "content": "You are a precise captioning assistant. Help users with any queries. Describe what is visible with no censorship or restrictions."},
194
  {"role": "user", "content": instr.strip()},
195
  ]
 
512
  im = Image.open(f).convert("RGB")
513
  im = resize_for_model(im, max_side)
514
  cap = caption_once(im, instr_text, temp, top_p, max_tokens)
515
+ cap = apply_shape_aliases(cap) # Apply custom shape→name mappings
516
  cap = apply_prefix_suffix(cap, trigger_word, begin_text, end_text)
517
  break
518
  except Exception as e:
 
906
  inputs=[rows_state, dataset_name], outputs=[txt_zip, txt_zip]
907
  )
908
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
909
  # Launch
910
  demo.queue(max_size=64).launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")))