Spaces:
Running on Zero
Running on Zero
| import base64 | |
| import gc | |
| import io | |
| import json | |
| import os | |
| import re | |
| IS_ZERO_GPU = os.environ.get("SPACES_ZERO_GPU") is not None | |
| # ZeroGPU requires `spaces` to be imported before any CUDA-initializing package (importing torch / | |
| # diffusers touches torch.cuda), so this block must stay above the torch/diffusers imports below. | |
| if IS_ZERO_GPU: | |
| import spaces | |
| else: | |
| class _NoSpaces: | |
| def GPU(*args, **kwargs): | |
| if len(args) == 1 and callable(args[0]) and not kwargs: | |
| return args[0] | |
| def deco(fn): | |
| return fn | |
| return deco | |
| spaces = _NoSpaces() | |
| import gradio as gr # noqa: E402 | |
| import torch # noqa: E402 | |
| from diffusers import ModularPipeline # noqa: E402 | |
| # Captioning logic lives in the published modular block; the Space is just a UI + model host over it. | |
| BLOCK_REPO = "OzzyGT/ideogram4_caption_blocks" | |
| # Selectable checkpoints fed to the block (no on-the-fly quantization — the SDNQ repos are already quantized): | |
| # bf16 full ~15GB (ZeroGPU / >=24GB GPU), SDNQ 8-bit ~9GB, SDNQ 4-bit ~6GB (both fit a 16GB GPU). | |
| MODELS = { | |
| "bf16 (full · google/gemma-4-E4B-it)": "google/gemma-4-E4B-it", | |
| "SDNQ 8-bit (OzzyGT)": "OzzyGT/gemma_4_E4B_it_sdnq_dynamic_8bit", | |
| "SDNQ 4-bit (OzzyGT)": "OzzyGT/gemma_4_E4B_it_sdnq_dynamic_4bit", | |
| } | |
| # bf16 on ZeroGPU (48GB, fastest); 8-bit locally (better quality than 4-bit, still fits a 16GB GPU). | |
| DEFAULT_PRECISION = ( | |
| "bf16 (full · google/gemma-4-E4B-it)" if IS_ZERO_GPU else "SDNQ 8-bit (OzzyGT)" | |
| ) | |
| # Ideogram 4's native caption schema (from the official ideogram-oss/ideogram4 docs). Key ORDER matters for | |
| # quality, bbox is [ymin, xmin, ymax, xmax] in 0-1000 normalized coords, colors are UPPERCASE #RRGGBB. The | |
| # verbatim example anchors the exact format. | |
| DEFAULT_INSTRUCTION = ( | |
| "You are a captioner for the Ideogram 4 image model. Look at the image and output ONE JSON object (no prose, " | |
| "no markdown fences) that reconstructs it in Ideogram 4's native caption schema. Keep keys in the exact order " | |
| "shown.\n\n" | |
| "SCHEMA (keys in order):\n" | |
| "- high_level_description: one or two sentences summarizing the whole image (subject + medium/style).\n" | |
| "- style_description: an object with EXACTLY ONE of `photo` or `art_style`:\n" | |
| " photo caption order: aesthetics, lighting, photo, medium, color_palette\n" | |
| " non-photo caption order: aesthetics, lighting, medium, art_style, color_palette\n" | |
| ' color_palette: optional, up to 16 UPPERCASE hex colors (e.g. "#1B1B2F").\n' | |
| "- compositional_deconstruction: {background, elements}.\n" | |
| " background: the global setting/backdrop.\n" | |
| " elements: list; keys in order per type:\n" | |
| " obj: type, bbox, desc, color_palette\n" | |
| " text: type, bbox, text, desc, color_palette\n\n" | |
| "RULES:\n" | |
| "- bbox is [ymin, xmin, ymax, xmax] in 0-1000 normalized coordinates (origin top-left), independent of " | |
| "resolution. bbox and per-element color_palette are optional (<=5 colors per element).\n" | |
| "- Main subject first. A coherent subject (one person/animal/object) is exactly ONE element; its parts go in " | |
| "its `desc`. `desc` is identity-first and concrete (color, material, pose, distinguishing features).\n" | |
| "- Commit to one concrete value each (no 'various'/'or similar'). `text` elements carry the literal in-image " | |
| "characters, verbatim. Colors UPPERCASE #RRGGBB. Describe only what is visible; do not invent.\n\n" | |
| "EXAMPLE (format reference only):\n" | |
| '{"high_level_description":"A medium-shot photograph of Formula 1 driver Max Verstappen wearing his Red Bull ' | |
| "Racing suit and cap, smiling as he holds his helmet and talks to a man in a white shirt and black vest at a " | |
| 'race track.","style_description":{"aesthetics":"saturated primary colors, rule of thirds, joyful and ' | |
| 'triumphant","lighting":"overcast daylight, diffused, soft subtle shadows","photo":"shallow depth of field, ' | |
| 'sharp focus, eye-level, telephoto","medium":"photograph"},"compositional_deconstruction":{"background":"An ' | |
| "out-of-focus racing paddock; blurred figures, a purple and white structure with a red F1 logo on the left, " | |
| 'daylight, sky not visible.","elements":[{"type":"obj","bbox":[55,642,1000,937],"desc":"An older man in ' | |
| "profile facing left, grey hair, fair skin, white long-sleeved button-down shirt under a navy blue quilted " | |
| 'vest, a slight smile."}]}}' | |
| ) | |
| DEVICE = ( | |
| torch.device("cuda") | |
| if (IS_ZERO_GPU or torch.cuda.is_available()) | |
| else torch.device("cpu") | |
| ) | |
| # One pipeline resident at a time: ZeroGPU serves the bf16 default; locally the user picks one and switching reloads. | |
| _current = {"id": None, "pipe": None} | |
| def _load(checkpoint): | |
| if "sdnq" in checkpoint.lower(): | |
| import sdnq # noqa: F401 registers the SDNQ backend so prebuilt quantized checkpoints load | |
| pipe = ModularPipeline.from_pretrained(BLOCK_REPO, trust_remote_code=True) | |
| pipe.load_components( | |
| pretrained_model_name_or_path=checkpoint, torch_dtype=torch.bfloat16 | |
| ) | |
| pipe.to(DEVICE) | |
| return pipe | |
| def get_pipe(precision): | |
| """Return the caption pipeline, (re)loading the selected checkpoint if it changed (one resident at a time).""" | |
| if IS_ZERO_GPU: | |
| precision = DEFAULT_PRECISION # ZeroGPU serves the bf16 default only, whatever the caller passes | |
| checkpoint = MODELS[precision] | |
| if _current["id"] != checkpoint: | |
| _current.update(id=None, pipe=None) | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| _current.update(id=checkpoint, pipe=_load(checkpoint)) | |
| print(f"loaded {checkpoint}") | |
| return _current["pipe"] | |
| # Two paths: | |
| # ZeroGPU -> the platform hands out the GPU only for the duration of a @spaces.GPU call and takes | |
| # it right back, so there's nothing to "hog". We just preload bf16 at module scope (the | |
| # canonical ZeroGPU pattern) and skip the whole load/unload + quantization machinery. | |
| # Local -> we hold a real GPU, so this support app must not squat on VRAM while the user runs the | |
| # Ideogram generation app. Nothing loads until the Load button; Unload frees it again. | |
| if IS_ZERO_GPU: | |
| get_pipe(DEFAULT_PRECISION) | |
| def load_model(precision): | |
| """Load the selected model into VRAM (local only; nothing is resident until this runs).""" | |
| get_pipe(precision) | |
| return f"✅ Loaded: {precision}" | |
| def unload_model(): | |
| """Evict the resident model from VRAM and RAM so it stops hogging the GPU (local only).""" | |
| _current.update(id=None, pipe=None) | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| return "⚪ No model loaded" | |
| def _parse_json(text: str): | |
| """Parse the model output; return (dict|None, repaired: bool). | |
| repaired=True means the JSON was incomplete (unbalanced brackets) and we closed it to parse — | |
| a sign the output was cut short. Robust to markdown fences, stray text/braces, and control | |
| chars in strings (strict=False). | |
| """ | |
| text = text.strip() | |
| text = re.sub(r"^```(?:json)?\s*", "", text) | |
| text = re.sub(r"\s*```$", "", text).strip() | |
| start = text.find("{") | |
| if start == -1: | |
| return None, False | |
| frag = text[start:] | |
| # 1) first '{' .. last '}' (handles the common clean case + trailing prose without braces) | |
| try: | |
| obj = json.loads(frag[: frag.rfind("}") + 1], strict=False) | |
| if isinstance(obj, dict): | |
| return obj, False | |
| except json.JSONDecodeError: | |
| pass | |
| # 2) decode just the first complete object, ignoring any trailing content (stray braces, prose) | |
| try: | |
| obj, _ = json.JSONDecoder(strict=False).raw_decode(frag) | |
| if isinstance(obj, dict): | |
| return obj, False | |
| except json.JSONDecodeError: | |
| pass | |
| # 3) repair truncated/short output (e.g. a missing final '}') by balancing brackets, then parse | |
| try: | |
| obj = json.loads(_balance_json(frag), strict=False) | |
| return (obj, True) if isinstance(obj, dict) else (None, False) | |
| except json.JSONDecodeError: | |
| return None, False | |
| def _balance_json(frag: str) -> str: | |
| """Close unbalanced strings/brackets in a truncated JSON fragment so it can be parsed.""" | |
| stack, in_str, esc = [], False, False | |
| for ch in frag: | |
| if in_str: | |
| if esc: | |
| esc = False | |
| elif ch == "\\": | |
| esc = True | |
| elif ch == '"': | |
| in_str = False | |
| elif ch == '"': | |
| in_str = True | |
| elif ch in "{[": | |
| stack.append(ch) | |
| elif ch == "}" and stack and stack[-1] == "{": | |
| stack.pop() | |
| elif ch == "]" and stack and stack[-1] == "[": | |
| stack.pop() | |
| out = frag | |
| if in_str: | |
| out += '"' # close a string cut off mid-value | |
| out = out.rstrip() | |
| if out.endswith(","): | |
| out = out[:-1] # drop a dangling comma before the closers | |
| for ch in reversed(stack): | |
| out += "}" if ch == "{" else "]" | |
| return out | |
| def _parse_palette(text: str) -> list: | |
| """Parse a free-form list of hex colors into normalized UPPERCASE #RRGGBB (max 16).""" | |
| out = [] | |
| for tok in re.split(r"[,\s]+", (text or "").strip()): | |
| tok = tok.strip().upper().lstrip("#") | |
| if re.fullmatch(r"[0-9A-F]{6}", tok): | |
| out.append("#" + tok) | |
| return out[:16] | |
| _push_counter = {"v": 0} | |
| def _nonce() -> int: | |
| _push_counter["v"] += 1 | |
| return _push_counter["v"] | |
| def _data_url(image, max_side=1400) -> str: | |
| """Downscaled JPEG data URL for the canvas. Full-res PNG of a real photo can be tens of MB, | |
| which bloats the #bbox-in payload and makes the JS JSON.parse fail (so nothing renders). A | |
| display-sized JPEG keeps it small; bboxes are 0-1000 normalized so resolution doesn't matter.""" | |
| img = image.convert("RGB") | |
| w, h = img.size | |
| if max(w, h) > max_side: | |
| scale = max_side / max(w, h) | |
| img = img.resize((max(1, round(w * scale)), max(1, round(h * scale)))) | |
| buf = io.BytesIO() | |
| img.save(buf, format="JPEG", quality=85) | |
| return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() | |
| def _ensure_ids(state): | |
| """Give every element a stable _id (used to reconcile canvas edits back to elements).""" | |
| cd = state.get("compositional_deconstruction") or {} | |
| els = cd.get("elements") or [] | |
| n = state.get("_next_id", 0) | |
| for el in els: | |
| if not el.get("_id"): | |
| el["_id"] = f"e{n}" | |
| n += 1 | |
| state["_next_id"] = n | |
| return state | |
| def _elements(state) -> list: | |
| cd = (state or {}).get("compositional_deconstruction") or {} | |
| return cd.get("elements") or [] | |
| # ---- JSON export (schema-clean, ordered) --------------------------------------------------------- | |
| def _clean_element(el: dict) -> dict: | |
| """Serialize one element in Ideogram4 key order, dropping empty optionals.""" | |
| out = {"type": el.get("type", "obj")} | |
| if el.get("bbox"): | |
| out["bbox"] = el["bbox"] | |
| if el.get("type") == "text": | |
| out["text"] = el.get("text", "") | |
| if el.get("desc"): | |
| out["desc"] = el["desc"] | |
| else: | |
| if el.get("desc"): | |
| out["desc"] = el["desc"] | |
| if el.get("color_palette"): | |
| out["color_palette"] = el["color_palette"] | |
| return out | |
| def _export(state) -> dict: | |
| """Normalize the working state into the clean Ideogram4 JSON object.""" | |
| state = state or {} | |
| out = {} | |
| if state.get("high_level_description"): | |
| out["high_level_description"] = state["high_level_description"] | |
| if state.get("style_description"): | |
| out["style_description"] = state["style_description"] | |
| cd = state.get("compositional_deconstruction") or {} | |
| cdd = {} | |
| if cd.get("background"): | |
| cdd["background"] = cd["background"] | |
| cdd["elements"] = [_clean_element(e) for e in (cd.get("elements") or [])] | |
| out["compositional_deconstruction"] = cdd | |
| return out | |
| def _dumps(state) -> str: | |
| return json.dumps(_export(state), indent=2, ensure_ascii=False) | |
| def canvas_payload(image, state, include_image=True) -> str: | |
| """Build the JSON payload for the JS editor (#bbox-in): the source image + the full element list | |
| (type / bbox / desc / text / palette), each keyed by a stable id. The JS renders both the boxes | |
| and the right-side cards from this. include_image=False = elements only (no image reload).""" | |
| _ensure_ids(state) | |
| elements = [] | |
| for el in _elements(state): | |
| bbox = el.get("bbox") | |
| if not (isinstance(bbox, (list, tuple)) and len(bbox) == 4): | |
| bbox = [350, 350, 650, 650] # give box-less elements a default so they're editable | |
| ymin, xmin, ymax, xmax = bbox | |
| elements.append( | |
| { | |
| "id": el["_id"], | |
| "type": el.get("type", "obj"), | |
| "ymin": ymin, "xmin": xmin, "ymax": ymax, "xmax": xmax, | |
| "desc": el.get("desc", ""), | |
| "text": el.get("text", ""), | |
| "colors": el.get("color_palette") or [], | |
| } | |
| ) | |
| payload = {"nonce": _nonce(), "elements": elements} | |
| payload["style_colors"] = (state.get("style_description") or {}).get("color_palette") or [] | |
| payload["image"] = _data_url(image) if (include_image and image is not None) else None | |
| return json.dumps(payload) | |
| def reconcile_canvas(state, out_json): | |
| """Fold JS editor edits (#bbox-out) back into element state by stable id, preserving top-level | |
| fields. The JS owns the element list, so this just rebuilds compositional_deconstruction.elements | |
| and returns the new state (no re-render signalling needed — the JS already drew everything).""" | |
| try: | |
| data = json.loads(out_json or "{}") | |
| except json.JSONDecodeError: | |
| return state | |
| out_els = data.get("elements") or [] | |
| by_id = {el.get("_id"): el for el in _elements(state) if el.get("_id")} | |
| new_elems = [] | |
| for oe in out_els: | |
| el = dict(by_id.get(oe.get("id")) or {}) | |
| el["_id"] = oe.get("id") | |
| el["type"] = oe.get("type", "obj") | |
| el["bbox"] = oe.get("bbox") | |
| el["desc"] = oe.get("desc", "") | |
| el["text"] = oe.get("text", "") | |
| el["color_palette"] = _parse_palette(" ".join(oe.get("colors") or [])) | |
| new_elems.append(el) | |
| new_state = dict(state or {}) | |
| # Style palette is edited on the canvas side too — fold it back, preserving other style fields. | |
| if "style_colors" in data: | |
| style = dict(new_state.get("style_description") or {}) | |
| colors = _parse_palette(" ".join(data.get("style_colors") or [])) | |
| if colors: | |
| style["color_palette"] = colors | |
| else: | |
| style.pop("color_palette", None) | |
| if style: | |
| new_state["style_description"] = style | |
| else: | |
| new_state.pop("style_description", None) | |
| cd = dict(new_state.get("compositional_deconstruction") or {}) | |
| cd["elements"] = new_elems | |
| cd.setdefault("background", "") | |
| new_state["compositional_deconstruction"] = cd | |
| return new_state | |
| def sync_from_state(image, state): | |
| """From the caption state: return (state-with-ids, top fields..., canvas payload).""" | |
| state = dict(state or {}) | |
| cd = dict(state.get("compositional_deconstruction") or {}) | |
| cd["elements"] = [dict(e) for e in (cd.get("elements") or [])] | |
| state["compositional_deconstruction"] = cd | |
| _ensure_ids(state) | |
| style = state.get("style_description") or {} | |
| if "art_style" in style and "photo" not in style: | |
| kind, kind_val = "art_style", style.get("art_style", "") | |
| else: | |
| kind, kind_val = "photo", style.get("photo", "") | |
| background = cd.get("background", "") | |
| return ( | |
| state, | |
| state.get("high_level_description", ""), | |
| background, | |
| kind, | |
| style.get("aesthetics", ""), | |
| style.get("lighting", ""), | |
| style.get("medium", ""), | |
| kind_val, | |
| canvas_payload(image, state, include_image=True), | |
| ) | |
| def rebuild_from_fields( | |
| state, hld, background, kind, aesthetics, lighting, medium, kind_val | |
| ): | |
| """Write the non-label fields back into the caption state (preserving elements + the JS-managed | |
| color palette) and refresh JSON.""" | |
| state = dict(state or {}) | |
| state["high_level_description"] = hld | |
| # Key order matters for quality: photo -> aesthetics, lighting, photo, medium, color_palette; | |
| # non-photo -> aesthetics, lighting, medium, art_style, color_palette. | |
| colors = (state.get("style_description") or {}).get("color_palette") or [] # owned by the canvas | |
| style = {} | |
| if aesthetics: | |
| style["aesthetics"] = aesthetics | |
| if lighting: | |
| style["lighting"] = lighting | |
| if kind == "photo": | |
| if kind_val: | |
| style["photo"] = kind_val | |
| if medium: | |
| style["medium"] = medium | |
| else: | |
| if medium: | |
| style["medium"] = medium | |
| if kind_val: | |
| style["art_style"] = kind_val | |
| if colors: | |
| style["color_palette"] = colors | |
| if style: | |
| state["style_description"] = style | |
| else: | |
| state.pop("style_description", None) | |
| cd = dict(state.get("compositional_deconstruction") or {}) | |
| cd["background"] = background | |
| cd.setdefault("elements", []) | |
| state["compositional_deconstruction"] = cd | |
| return state, _dumps(state) | |
| def apply_json(image, text): | |
| """Parse the raw-JSON tab back into state, refreshing every field + the canvas.""" | |
| data, _ = _parse_json(text) | |
| if data is None: | |
| raise gr.Error("Invalid JSON — could not parse.") | |
| return sync_from_state(image, data) # (state, fields..., payload) | |
| def caption( | |
| image, | |
| instruction, | |
| precision, | |
| max_new_tokens, | |
| temperature, | |
| ): | |
| if image is None: | |
| raise gr.Error("An image is required.") | |
| pipe = get_pipe(precision) | |
| text = pipe( | |
| image=image, | |
| instruction=instruction, | |
| max_new_tokens=int(max_new_tokens), | |
| temperature=float(temperature), | |
| output="caption_raw", | |
| ) | |
| data, repaired = _parse_json(text) | |
| if data is None: | |
| return {}, text, ( | |
| "⚠️ Couldn't parse the model output as JSON — it may have been truncated at the token " | |
| "limit. Raise **Max new tokens** and re-run, or fix it in the JSON tab and click Apply JSON." | |
| ) | |
| # Ended naturally but dropped trailing brackets = complete content we just closed. | |
| status = ( | |
| "✅ Caption ready (minor auto-repair of the closing brackets)." | |
| if repaired | |
| else "✅ Caption ready." | |
| ) | |
| return data, _dumps(data), status | |
| CSS = """ | |
| #caption-btn { | |
| background: linear-gradient(180deg, #2563eb 0%, #1e3a8a 100%) !important; | |
| color: #fff !important; border: none !important; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 1px 2px rgba(0,0,0,0.25) !important; | |
| } | |
| #load-btn { | |
| background: linear-gradient(180deg, #15803d 0%, #14532d 100%) !important; | |
| color: #fff !important; border: none !important; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 1px 2px rgba(0,0,0,0.25) !important; | |
| } | |
| #unload-btn { | |
| background: linear-gradient(180deg, #b91c1c 0%, #7f1d1d 100%) !important; | |
| color: #fff !important; border: none !important; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 1px 2px rgba(0,0,0,0.25) !important; | |
| } | |
| #caption-btn, #load-btn, #unload-btn { min-height: 56px !important; height: 56px !important; } | |
| /* ---- Scrollless, full-viewport layout: the page never scrolls; only the fields column does. ---- */ | |
| html, body { height: 100%; margin: 0; overflow: hidden; } | |
| gradio-app { height: var(--app-h, 100vh); overflow: hidden; } | |
| .gradio-container { | |
| height: var(--app-h, 100vh) !important; | |
| max-width: 100% !important; | |
| padding: 6px 14px 4px !important; | |
| overflow: hidden !important; | |
| } | |
| #app-title { margin: 0 0 2px !important; } | |
| #app-title h1 { font-size: 1.2rem !important; margin: 0 !important; } | |
| #app-title small { | |
| font-size: 0.68rem; line-height: 1.35; | |
| color: var(--body-text-color-subdued, #9aa0ac); | |
| } | |
| /* The right-hand editor column is the only thing that scrolls (like the desktop app). */ | |
| #fields-col { | |
| max-height: calc(var(--app-h, 100vh) - 118px); | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| padding-right: 6px; | |
| } | |
| #canvas-col { min-height: 0; } | |
| /* Theme tokens for the custom editor — light by default, dark when Gradio adds .dark to <body> | |
| (inherited by the body-appended popover too, so everything follows the HF light/dark toggle). */ | |
| :root { | |
| --ib-input-bg: #ffffff; --ib-input-fg: #1a1b25; --ib-border: #d7dae1; | |
| --ib-panel-bg: #ffffff; --ib-card-bg: #f6f7f9; --ib-eye-bg: #eef0f4; --ib-subdued: #6b7280; | |
| } | |
| .dark { | |
| --ib-input-bg: #15181f; --ib-input-fg: #e6e6e6; --ib-border: #3a3f4b; | |
| --ib-panel-bg: #1b1e25; --ib-card-bg: #1f2229; --ib-eye-bg: #2a2f3a; --ib-subdued: #9aa0ac; | |
| } | |
| /* ---- Hand-rolled bbox canvas ---- */ | |
| #bbox-root { | |
| width: 100%; | |
| height: calc(var(--app-h, 100vh) - 120px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| touch-action: none; | |
| } | |
| .ib-hint { color: var(--ib-subdued); font-size: 0.9rem; padding: 12px; } | |
| .ib-stage { position: relative; display: inline-block; max-width: 100%; max-height: 100%; line-height: 0; } | |
| .ib-img { display: block; max-width: 100%; max-height: calc(var(--app-h, 100vh) - 120px); width: auto; height: auto; user-select: none; } | |
| .ib-box { | |
| position: absolute; | |
| border: 2px solid #4da460; | |
| background: rgba(77, 164, 96, 0.15); | |
| box-sizing: border-box; | |
| cursor: move; | |
| } | |
| .ib-box.sel { border-color: #ff8c00; background: rgba(255, 140, 0, 0.18); z-index: 5; } | |
| /* eyedrop mode: crosshair over the image, and boxes must not intercept the sample click */ | |
| .ib-stage.ib-eyedrop { cursor: crosshair !important; } | |
| .ib-stage.ib-eyedrop .ib-box { pointer-events: none !important; } | |
| .ib-label { | |
| position: absolute; top: 0; left: 0; | |
| font: bold 12px/1.5 sans-serif; color: #fff; | |
| background: rgba(0, 0, 0, 0.6); padding: 0 4px; | |
| white-space: nowrap; pointer-events: none; | |
| } | |
| .ib-h { position: absolute; width: 11px; height: 11px; background: #fff; border: 1px solid #ff8c00; box-sizing: border-box; display: none; } | |
| .ib-box.sel .ib-h { display: block; } | |
| .ib-nw { left: -6px; top: -6px; cursor: nwse-resize; } | |
| .ib-ne { right: -6px; top: -6px; cursor: nesw-resize; } | |
| .ib-sw { left: -6px; bottom: -6px; cursor: nesw-resize; } | |
| .ib-se { right: -6px; bottom: -6px; cursor: nwse-resize; } | |
| /* group-transform box (shown when 2+ boxes selected): outline is click-through, handles aren't */ | |
| .ib-group { | |
| position: absolute; border: 1px dashed #ff8c00; box-sizing: border-box; | |
| pointer-events: none; z-index: 6; | |
| } | |
| .ib-gh { | |
| position: absolute; width: 11px; height: 11px; background: #fff; border: 1px solid #ff8c00; | |
| box-sizing: border-box; pointer-events: auto; | |
| } | |
| .ib-gh.ib-n { left: 50%; top: -6px; transform: translateX(-50%); cursor: ns-resize; } | |
| .ib-gh.ib-s { left: 50%; bottom: -6px; transform: translateX(-50%); cursor: ns-resize; } | |
| .ib-gh.ib-e { right: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; } | |
| .ib-gh.ib-w { left: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; } | |
| /* ---- Right-side element cards (rendered by JS into #panel-root) ---- */ | |
| .ib-panel-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; } | |
| .ib-add-wrap { margin-left: auto; display: flex; gap: 6px; align-items: center; } | |
| /* !important throughout: Gradio's global button/select styles otherwise win on specificity. */ | |
| .ib-add { | |
| display: inline-flex !important; align-items: center !important; justify-content: center !important; | |
| background: linear-gradient(180deg, #15803d 0%, #14532d 100%) !important; | |
| color: #fff !important; border: none !important; border-radius: 6px !important; | |
| padding: 0 10px !important; font-size: 0.75rem !important; line-height: 1 !important; | |
| height: 26px !important; min-width: 0 !important; box-shadow: none !important; | |
| margin: 0 !important; box-sizing: border-box !important; align-self: center !important; | |
| vertical-align: middle !important; cursor: pointer; | |
| } | |
| .ib-add:hover { filter: brightness(1.12); } | |
| #panel-root { display: flex; flex-direction: column; gap: 6px; } | |
| .ib-empty { color: var(--ib-subdued); font-size: 0.85rem; padding: 8px 2px; } | |
| .ib-card { | |
| border: 1px solid var(--ib-border); | |
| border-radius: 8px; padding: 6px 8px; background: var(--ib-card-bg); | |
| display: flex; flex-direction: column; gap: 5px; | |
| } | |
| .ib-card.sel { border-color: #ff8c00; box-shadow: 0 0 0 1px #ff8c00 inset; } | |
| .ib-card-head { display: flex; align-items: center; gap: 6px; } | |
| .ib-badge { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| width: 19px; height: 19px; border-radius: 50%; background: #4da460; color: #fff; | |
| font-size: 0.7rem; font-weight: 700; flex: 0 0 auto; | |
| } | |
| /* Native <select> ignores theme bg unless appearance:none — that was the white box. */ | |
| .ib-type { | |
| appearance: none !important; -webkit-appearance: none !important; -moz-appearance: none !important; | |
| background-color: var(--ib-input-bg) !important; | |
| background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path d="M1 3l4 4 4-4" fill="none" stroke="%23888" stroke-width="1.5"/></svg>') !important; | |
| background-repeat: no-repeat !important; background-position: right 6px center !important; | |
| color: var(--ib-input-fg) !important; border: 1px solid var(--ib-border) !important; border-radius: 6px !important; | |
| padding: 2px 20px 2px 8px !important; font-size: 0.76rem !important; height: 24px !important; | |
| min-width: 0 !important; width: 74px !important; box-shadow: none !important; cursor: pointer; | |
| } | |
| .ib-type option { background: var(--ib-input-bg); color: var(--ib-input-fg); } | |
| .ib-del { | |
| margin-left: auto; background: #b91c1c !important; color: #fff !important; border: none !important; | |
| border-radius: 5px !important; width: 22px !important; height: 22px !important; cursor: pointer; | |
| font-size: 0.72rem !important; line-height: 1 !important; min-width: 0 !important; | |
| padding: 0 !important; box-shadow: none !important; | |
| display: inline-flex !important; align-items: center; justify-content: center; flex: 0 0 auto; | |
| } | |
| .ib-del:hover { filter: brightness(1.15); } | |
| .ib-f { | |
| width: 100%; box-sizing: border-box; background: var(--ib-input-bg); | |
| color: var(--ib-input-fg); border: 1px solid var(--ib-border); | |
| border-radius: 6px; padding: 5px 7px; font-size: 0.8rem; font-family: inherit; | |
| } | |
| .ib-desc { resize: vertical; min-height: 38px; } | |
| .ib-f:focus { outline: none; border-color: #4da460; } | |
| .ib-f::placeholder { color: #6b7280; } | |
| /* ---- color palette chips (swatch tags + picker). !important: bare <button>s still pick up | |
| Gradio's base button sizing, which is what was bloating the chips. ---- */ | |
| .ib-pal { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } | |
| .ib-pal-label { font-size: 0.8rem; color: var(--ib-subdued); margin: 2px 0; } | |
| #style-pal-root { min-height: 20px; } | |
| .ib-chip { | |
| display: inline-flex !important; align-items: center; gap: 2px; | |
| border-radius: 4px; padding: 0 3px 0 6px; font-size: 0.65rem !important; font-weight: 600; | |
| border: 1px solid rgba(0, 0, 0, 0.35); cursor: pointer; height: 18px; box-sizing: border-box; | |
| } | |
| .ib-chip-hex { letter-spacing: 0.2px; } | |
| .ib-chip-x { | |
| background: none !important; border: none !important; box-shadow: none !important; | |
| color: inherit !important; cursor: pointer; opacity: 0.7; | |
| font-size: 0.8rem !important; line-height: 1 !important; | |
| padding: 0 1px !important; margin: 0 !important; | |
| min-width: 0 !important; width: auto !important; height: auto !important; | |
| } | |
| .ib-chip-x:hover { opacity: 1; } | |
| .ib-chip-add { | |
| display: inline-flex !important; align-items: center !important; | |
| background: none !important; color: var(--ib-subdued) !important; cursor: pointer; | |
| border: 1px dashed var(--ib-border) !important; border-radius: 4px !important; | |
| padding: 0 7px !important; font-size: 0.65rem !important; line-height: 1 !important; | |
| height: 18px !important; min-width: 0 !important; box-shadow: none !important; margin: 0 !important; | |
| } | |
| .ib-chip-add:hover { color: var(--ib-input-fg) !important; border-color: var(--ib-subdued) !important; } | |
| /* ---- inline color-picker popover ---- */ | |
| .ib-cp { | |
| position: fixed; z-index: 9999; width: 188px; box-sizing: border-box; | |
| background: var(--ib-panel-bg); border: 1px solid var(--ib-border); border-radius: 8px; padding: 8px; | |
| box-shadow: 0 8px 26px rgba(0, 0, 0, 0.55); display: flex; flex-direction: column; gap: 8px; | |
| user-select: none; | |
| } | |
| .ib-cp-sv { position: relative; width: 100%; height: 120px; border-radius: 5px; cursor: crosshair; } | |
| .ib-cp-svknob { | |
| position: absolute; width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff; | |
| box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); transform: translate(-50%, -50%); pointer-events: none; | |
| } | |
| .ib-cp-hue { | |
| position: relative; width: 100%; height: 14px; border-radius: 7px; cursor: pointer; | |
| background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); | |
| } | |
| .ib-cp-hueknob { | |
| position: absolute; top: 50%; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff; | |
| box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); transform: translate(-50%, -50%); pointer-events: none; | |
| } | |
| .ib-cp-row { display: flex; align-items: center; gap: 6px; } | |
| .ib-cp-prev { width: 24px; height: 24px; border-radius: 5px; border: 1px solid var(--ib-border); flex: 0 0 auto; } | |
| .ib-cp-hex { | |
| flex: 1; min-width: 0 !important; background: var(--ib-input-bg) !important; color: var(--ib-input-fg) !important; | |
| border: 1px solid var(--ib-border) !important; border-radius: 5px !important; padding: 3px 6px !important; | |
| font-size: 0.72rem !important; font-family: monospace !important; height: 24px !important; | |
| box-sizing: border-box !important; | |
| } | |
| .ib-cp-ok { | |
| background: #15803d !important; color: #fff !important; border: none !important; | |
| border-radius: 5px !important; padding: 0 10px !important; font-size: 0.72rem !important; | |
| height: 24px !important; min-width: 0 !important; box-shadow: none !important; cursor: pointer; | |
| display: inline-flex !important; align-items: center !important; | |
| } | |
| .ib-cp-ok:hover { filter: brightness(1.12); } | |
| .ib-cp-eye { | |
| background: var(--ib-eye-bg) !important; color: var(--ib-input-fg) !important; border: 1px solid var(--ib-border) !important; | |
| border-radius: 5px !important; height: 24px !important; width: 26px !important; | |
| min-width: 0 !important; padding: 0 !important; box-shadow: none !important; cursor: pointer; | |
| display: inline-flex !important; align-items: center !important; justify-content: center !important; | |
| flex: 0 0 auto; | |
| } | |
| .ib-cp-eye:hover { border-color: var(--ib-subdued) !important; color: var(--ib-input-fg) !important; } | |
| .ib-cp-eye.active { | |
| background: #15803d !important; border-color: #15803d !important; color: #fff !important; | |
| box-shadow: 0 0 0 2px rgba(21, 128, 61, 0.4) !important; | |
| } | |
| /* ---- Compact the Gradio top-level fields (denser than default, but consistent padding/width) ---- */ | |
| #fields-col .block { padding: 6px 8px !important; box-sizing: border-box !important; } | |
| #fields-col .form { gap: 6px !important; } | |
| #fields-col .gap { gap: 6px !important; } | |
| #fields-col span[data-testid="block-info"], #fields-col label > span:first-child { | |
| font-size: 0.8rem !important; margin-bottom: 2px !important; | |
| } | |
| #fields-col textarea, #fields-col input[type="text"] { font-size: 0.85rem !important; } | |
| /* The elements live in a gr.HTML .block (already padded 6px 8px like the fields), so its content | |
| needs no extra inset — that keeps every panel aligned on the same left/right edges. */ | |
| #fields-col .ib-panel-head { padding: 0; } | |
| #panel-root { padding: 0; } | |
| """ | |
| # The whole interactive canvas (image + draggable/resizable boxes), injected once via demo.load. | |
| # JS owns box identity (each box has a stable id); it emits edits to the hidden #bbox-out textbox | |
| # and receives loads/relabels via #bbox-in (a `.change(js=...)` calls window.IB.load). | |
| CANVAS_JS = r""" | |
| () => { | |
| function setup() { | |
| if (window.IB) return true; | |
| const canvasRoot = document.getElementById("bbox-root"); | |
| if (!canvasRoot) return false; // canvas mounts independently of the (optional) right panel | |
| const stage = document.createElement("div"); | |
| stage.className = "ib-stage"; | |
| const img = document.createElement("img"); | |
| img.className = "ib-img"; | |
| img.draggable = false; | |
| const hint = document.createElement("div"); | |
| hint.className = "ib-hint"; | |
| hint.textContent = "Caption an image to start editing boxes here."; | |
| stage.appendChild(img); | |
| canvasRoot.appendChild(hint); | |
| canvasRoot.appendChild(stage); | |
| let els = []; // {id,type,ymin,xmin,ymax,xmax,desc,text,palette} | |
| let styleColors = []; // the top-level style_description.color_palette | |
| let sel = new Set(); // selected ids (multi-select via ctrl/cmd-click) | |
| let drag = null; | |
| let jsCounter = 0; | |
| let emitTimer = null; | |
| let eyedropSample = null; // active eyedrop callback, else null | |
| let sampleCtx = null, sampleW = 0, sampleH = 0; // offscreen copy of the image for pixel sampling | |
| // Keep an offscreen canvas of the (same-origin data-URL) image so we can sample pixels in any | |
| // browser — cross-browser eyedropper without the Chromium-only EyeDropper API. | |
| img.addEventListener("load", () => { | |
| try { | |
| const c = document.createElement("canvas"); | |
| c.width = img.naturalWidth; | |
| c.height = img.naturalHeight; | |
| const cx = c.getContext("2d"); | |
| cx.drawImage(img, 0, 0); | |
| sampleCtx = cx; | |
| sampleW = img.naturalWidth; | |
| sampleH = img.naturalHeight; | |
| } catch (e) { | |
| sampleCtx = null; | |
| } | |
| }); | |
| const clamp = (v) => Math.max(0, Math.min(1000, v)); | |
| const outEl = () => document.querySelector("#bbox-out textarea, #bbox-out input"); | |
| const byId = (id) => els.find((e) => e.id === id); | |
| function setNativeValue(el, value) { | |
| const proto = el.tagName === "TEXTAREA" | |
| ? window.HTMLTextAreaElement.prototype | |
| : window.HTMLInputElement.prototype; | |
| Object.getOwnPropertyDescriptor(proto, "value").set.call(el, value); | |
| } | |
| function emitNow() { | |
| const el = outEl(); | |
| if (!el) return; | |
| const payload = { | |
| elements: els.map((e) => ({ | |
| id: e.id, | |
| type: e.type || "obj", | |
| bbox: [Math.round(e.ymin), Math.round(e.xmin), Math.round(e.ymax), Math.round(e.xmax)], | |
| desc: e.desc || "", | |
| text: e.text || "", | |
| colors: e.colors || [], | |
| })), | |
| style_colors: styleColors, | |
| selected: Array.from(sel), | |
| }; | |
| setNativeValue(el, JSON.stringify(payload)); | |
| el.dispatchEvent(new Event("input", { bubbles: true })); | |
| } | |
| function emit() { | |
| clearTimeout(emitTimer); | |
| emitTimer = setTimeout(emitNow, 150); | |
| } | |
| function labelText(e, i) { | |
| if (e.type === "text") return (i + 1) + '. "' + (e.text || "").slice(0, 18) + '"'; | |
| return (i + 1) + ". " + (e.desc || "").slice(0, 18); | |
| } | |
| function renderBoxes() { | |
| stage.querySelectorAll(".ib-box, .ib-group").forEach((n) => n.remove()); | |
| const single = sel.size === 1; | |
| els.forEach((e, i) => { | |
| const d = document.createElement("div"); | |
| d.className = "ib-box" + (sel.has(e.id) ? " sel" : ""); | |
| d.style.left = e.xmin / 10 + "%"; | |
| d.style.top = e.ymin / 10 + "%"; | |
| d.style.width = (e.xmax - e.xmin) / 10 + "%"; | |
| d.style.height = (e.ymax - e.ymin) / 10 + "%"; | |
| d.dataset.id = e.id; | |
| const lab = document.createElement("span"); | |
| lab.className = "ib-label"; | |
| lab.textContent = labelText(e, i); | |
| d.appendChild(lab); | |
| if (single && sel.has(e.id)) { | |
| ["nw", "ne", "sw", "se"].forEach((h) => { | |
| const hd = document.createElement("div"); | |
| hd.className = "ib-h ib-" + h; | |
| hd.dataset.h = h; | |
| d.appendChild(hd); | |
| }); | |
| } | |
| stage.appendChild(d); | |
| }); | |
| // A single group box with 8 handles when 2+ are selected — drag a handle to scale them together. | |
| if (sel.size >= 2) { | |
| const g = groupBBox(); | |
| const go = document.createElement("div"); | |
| go.className = "ib-group"; | |
| go.style.left = g.x0 / 10 + "%"; | |
| go.style.top = g.y0 / 10 + "%"; | |
| go.style.width = (g.x1 - g.x0) / 10 + "%"; | |
| go.style.height = (g.y1 - g.y0) / 10 + "%"; | |
| ["nw", "n", "ne", "e", "se", "s", "sw", "w"].forEach((h) => { | |
| const hd = document.createElement("div"); | |
| hd.className = "ib-gh ib-" + h; | |
| hd.dataset.gh = h; | |
| go.appendChild(hd); | |
| }); | |
| stage.appendChild(go); | |
| } | |
| } | |
| function cardHTML(e, i) { | |
| const isText = e.type === "text"; | |
| return ( | |
| '<div class="ib-card' + (sel.has(e.id) ? " sel" : "") + '" data-id="' + e.id + '">' + | |
| '<div class="ib-card-head">' + | |
| '<span class="ib-badge">' + (i + 1) + "</span>" + | |
| '<select class="ib-type" data-f="type">' + | |
| '<option value="obj"' + (isText ? "" : " selected") + ">obj</option>" + | |
| '<option value="text"' + (isText ? " selected" : "") + ">text</option>" + | |
| "</select>" + | |
| '<button class="ib-del" title="Delete">✕</button>' + | |
| "</div>" + | |
| (isText | |
| ? '<input class="ib-f" data-f="text" placeholder="text (verbatim)" value="' + | |
| escapeAttr(e.text || "") + '">' | |
| : "") + | |
| '<textarea class="ib-f ib-desc" data-f="desc" rows="2" placeholder="description">' + | |
| escapeHtml(e.desc || "") + "</textarea>" + | |
| '<div class="ib-pal">' + paletteHTML(e) + "</div>" + | |
| "</div>" | |
| ); | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| } | |
| function escapeAttr(s) { | |
| return escapeHtml(s).replace(/"/g, """); | |
| } | |
| // ---- per-element color palette (swatch chips + native color picker) ---- | |
| function textColor(hex) { | |
| const m = /^#?([0-9a-f]{6})$/i.exec(hex || ""); | |
| if (!m) return "#fff"; | |
| const n = parseInt(m[1], 16); | |
| const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; | |
| return 0.299 * r + 0.587 * g + 0.114 * b > 140 ? "#000" : "#fff"; | |
| } | |
| function paletteChipsHTML(colors) { | |
| const chips = (colors || []).map((c, ci) => | |
| '<span class="ib-chip" data-ci="' + ci + '" style="background:' + escapeAttr(c) + | |
| ";color:" + textColor(c) + '">' + | |
| '<span class="ib-chip-hex">' + escapeHtml(c) + "</span>" + | |
| '<button class="ib-chip-x" data-ci="' + ci + '" title="remove" type="button">×</button>' + | |
| "</span>" | |
| ).join(""); | |
| return chips + '<button class="ib-chip-add" type="button">+ color</button>'; | |
| } | |
| const paletteHTML = (e) => paletteChipsHTML(e.colors || []); | |
| function refreshPalette(id) { | |
| const card = document.querySelector('#panel-root .ib-card[data-id="' + id + '"]'); | |
| if (!card) return; | |
| const pal = card.querySelector(".ib-pal"); | |
| if (pal) pal.innerHTML = paletteHTML(byId(id)); | |
| } | |
| function renderStylePalette() { | |
| const root = document.getElementById("style-pal-root"); | |
| if (root) root.innerHTML = paletteChipsHTML(styleColors); | |
| } | |
| // ---- self-contained color picker popover (native <input type=color> can't be positioned and | |
| // opens an OS dialog with no OK on Linux) ---- | |
| const clamp2 = (x, a, b) => Math.max(a, Math.min(b, x)); | |
| function hexToRgb(hex) { | |
| const m = /^#?([0-9a-f]{6})$/i.exec(hex || ""); | |
| if (!m) return { r: 77, g: 164, b: 96 }; | |
| const n = parseInt(m[1], 16); | |
| return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; | |
| } | |
| function rgbToHex(r, g, b) { | |
| const h = (x) => ("0" + Math.round(x).toString(16)).slice(-2); | |
| return ("#" + h(r) + h(g) + h(b)).toUpperCase(); | |
| } | |
| function rgbToHsv(r, g, b) { | |
| r /= 255; g /= 255; b /= 255; | |
| const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; | |
| let h = 0; | |
| if (d) { | |
| if (mx === r) h = ((g - b) / d) % 6; | |
| else if (mx === g) h = (b - r) / d + 2; | |
| else h = (r - g) / d + 4; | |
| h *= 60; if (h < 0) h += 360; | |
| } | |
| return { h: h, s: (mx ? d / mx : 0) * 100, v: mx * 100 }; | |
| } | |
| function hsvToRgb(h, s, v) { | |
| s /= 100; v /= 100; | |
| const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; | |
| let r = 0, g = 0, b = 0; | |
| if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } | |
| else if (h < 180) { g = c; b = x; } else if (h < 240) { g = x; b = c; } | |
| else if (h < 300) { r = x; b = c; } else { r = c; b = x; } | |
| return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 }; | |
| } | |
| function sampleAt(clientX, clientY) { | |
| if (!sampleCtx) return null; | |
| const r = stage.getBoundingClientRect(); | |
| const fx = (clientX - r.left) / r.width, fy = (clientY - r.top) / r.height; | |
| if (fx < 0 || fx > 1 || fy < 0 || fy > 1) return null; | |
| const px = Math.min(sampleW - 1, Math.max(0, Math.floor(fx * sampleW))); | |
| const py = Math.min(sampleH - 1, Math.max(0, Math.floor(fy * sampleH))); | |
| const d = sampleCtx.getImageData(px, py, 1, 1).data; | |
| return rgbToHex(d[0], d[1], d[2]); | |
| } | |
| // Enter "pick from image" mode: hover previews, click commits, Esc cancels. onSample(hex|null, committed). | |
| function startEyedrop(onSample) { | |
| stage.classList.add("ib-eyedrop"); | |
| const move = (ev) => { const hx = sampleAt(ev.clientX, ev.clientY); if (hx) onSample(hx, false); }; | |
| const down = (ev) => { | |
| if (ev.button !== 0) return; | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| const hx = sampleAt(ev.clientX, ev.clientY); | |
| end(); | |
| onSample(hx, true); | |
| }; | |
| const key = (ev) => { if (ev.key === "Escape") { end(); onSample(null, true); } }; | |
| function end() { | |
| eyedropSample = null; | |
| stage.classList.remove("ib-eyedrop"); | |
| stage.removeEventListener("pointermove", move, true); | |
| stage.removeEventListener("pointerdown", down, true); | |
| document.removeEventListener("keydown", key, true); | |
| } | |
| eyedropSample = onSample; | |
| stage.addEventListener("pointermove", move, true); | |
| stage.addEventListener("pointerdown", down, true); | |
| document.addEventListener("keydown", key, true); | |
| } | |
| function openColorPopover(initial, anchor, cb) { | |
| document.querySelectorAll(".ib-cp").forEach((n) => n.remove()); | |
| const rgb = hexToRgb(initial); | |
| const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); | |
| let h = hsv.h, s = hsv.s, v = hsv.v; | |
| const eyeBtn = | |
| '<button class="ib-cp-eye" type="button" title="Pick color from the image">' + | |
| '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + | |
| 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + | |
| '<path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/>' + | |
| '<path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 ' + | |
| '2.1 0 1 1 3-3l.4.4Z"/></svg></button>'; | |
| const pop = document.createElement("div"); | |
| pop.className = "ib-cp"; | |
| pop.innerHTML = | |
| '<div class="ib-cp-sv"><div class="ib-cp-svknob"></div></div>' + | |
| '<div class="ib-cp-hue"><div class="ib-cp-hueknob"></div></div>' + | |
| '<div class="ib-cp-row"><span class="ib-cp-prev"></span>' + eyeBtn + | |
| '<input class="ib-cp-hex" maxlength="7" spellcheck="false">' + | |
| '<button class="ib-cp-ok" type="button">OK</button></div>'; | |
| document.body.appendChild(pop); | |
| const r0 = anchor ? anchor.getBoundingClientRect() : { left: 100, top: 100, bottom: 100 }; | |
| const pw = 188, ph = 210; | |
| pop.style.left = Math.max(8, Math.min(r0.left, window.innerWidth - pw - 8)) + "px"; | |
| let top = r0.bottom + 6; | |
| if (top + ph > window.innerHeight) top = Math.max(8, r0.top - ph - 6); | |
| pop.style.top = top + "px"; | |
| const sv = pop.querySelector(".ib-cp-sv"); | |
| const svk = pop.querySelector(".ib-cp-svknob"); | |
| const hue = pop.querySelector(".ib-cp-hue"); | |
| const huek = pop.querySelector(".ib-cp-hueknob"); | |
| const prev = pop.querySelector(".ib-cp-prev"); | |
| const hexIn = pop.querySelector(".ib-cp-hex"); | |
| const curHex = () => { const c = hsvToRgb(h, s, v); return rgbToHex(c.r, c.g, c.b); }; | |
| function paint() { | |
| sv.style.background = | |
| "linear-gradient(to top,#000,rgba(0,0,0,0)),linear-gradient(to right,#fff,hsl(" + | |
| h + ",100%,50%))"; | |
| svk.style.left = s + "%"; | |
| svk.style.top = 100 - v + "%"; | |
| huek.style.left = (h / 360) * 100 + "%"; | |
| const hx = curHex(); | |
| prev.style.background = hx; | |
| if (document.activeElement !== hexIn) hexIn.value = hx; | |
| } | |
| paint(); | |
| const svFrom = (ev) => { | |
| const r = sv.getBoundingClientRect(); | |
| s = clamp2(((ev.clientX - r.left) / r.width) * 100, 0, 100); | |
| v = clamp2((1 - (ev.clientY - r.top) / r.height) * 100, 0, 100); | |
| paint(); | |
| }; | |
| const hueFrom = (ev) => { | |
| const r = hue.getBoundingClientRect(); | |
| h = clamp2(((ev.clientX - r.left) / r.width) * 360, 0, 360); | |
| paint(); | |
| }; | |
| function drag(el, fn) { | |
| el.addEventListener("pointerdown", (ev) => { | |
| el.setPointerCapture(ev.pointerId); | |
| fn(ev); | |
| const mv = (e) => fn(e); | |
| const up = () => { | |
| el.removeEventListener("pointermove", mv); | |
| el.removeEventListener("pointerup", up); | |
| }; | |
| el.addEventListener("pointermove", mv); | |
| el.addEventListener("pointerup", up); | |
| ev.preventDefault(); | |
| }); | |
| } | |
| drag(sv, svFrom); | |
| drag(hue, hueFrom); | |
| hexIn.addEventListener("input", () => { | |
| if (/^#?[0-9a-f]{6}$/i.test(hexIn.value)) { | |
| const c = hexToRgb(hexIn.value); | |
| const hs = rgbToHsv(c.r, c.g, c.b); | |
| h = hs.h; s = hs.s; v = hs.v; | |
| paint(); | |
| } | |
| }); | |
| function close() { | |
| pop.remove(); | |
| document.removeEventListener("pointerdown", outside, true); | |
| } | |
| function outside(ev) { if (!pop.contains(ev.target)) close(); } | |
| setTimeout(() => document.addEventListener("pointerdown", outside, true), 0); | |
| pop.querySelector(".ib-cp-ok").addEventListener("click", () => { | |
| const hx = curHex(); | |
| close(); | |
| cb(hx); | |
| }); | |
| const eyeEl = pop.querySelector(".ib-cp-eye"); | |
| eyeEl.addEventListener("click", (ev) => { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| // Suspend click-outside close while sampling over the image, then re-arm it. | |
| document.removeEventListener("pointerdown", outside, true); | |
| eyeEl.classList.add("active"); // show the pipette is armed | |
| startEyedrop((hx, committed) => { | |
| if (hx) { | |
| const c = hexToRgb(hx); | |
| const hs = rgbToHsv(c.r, c.g, c.b); | |
| h = hs.h; s = hs.s; v = hs.v; | |
| paint(); | |
| } | |
| if (committed) { | |
| eyeEl.classList.remove("active"); | |
| if (pop.isConnected) { | |
| setTimeout(() => document.addEventListener("pointerdown", outside, true), 50); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| function renderPanel() { | |
| const panelRoot = document.getElementById("panel-root"); | |
| if (!panelRoot) return; // panel may not be mounted yet; canvas still works | |
| if (!els.length) { | |
| panelRoot.innerHTML = '<div class="ib-empty">No elements yet — draw a box on the ' + | |
| "image, or use + Object / + Text.</div>"; | |
| return; | |
| } | |
| panelRoot.innerHTML = els.map((e, i) => cardHTML(e, i)).join(""); | |
| } | |
| function renderAll() { | |
| renderBoxes(); | |
| renderPanel(); | |
| renderStylePalette(); | |
| } | |
| const selArr = () => els.filter((e) => sel.has(e.id)); | |
| function groupBBox() { | |
| const s = selArr(); | |
| if (!s.length) return null; | |
| let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9; | |
| s.forEach((e) => { | |
| x0 = Math.min(x0, e.xmin); y0 = Math.min(y0, e.ymin); | |
| x1 = Math.max(x1, e.xmax); y1 = Math.max(y1, e.ymax); | |
| }); | |
| return { x0: x0, y0: y0, x1: x1, y1: y1 }; | |
| } | |
| function applySelClasses() { | |
| stage.querySelectorAll(".ib-box").forEach((n) => n.classList.toggle("sel", sel.has(n.dataset.id))); | |
| const pr = document.getElementById("panel-root"); | |
| if (pr) pr.querySelectorAll(".ib-card").forEach((n) => n.classList.toggle("sel", sel.has(n.dataset.id))); | |
| } | |
| function scrollToCard(id) { | |
| const pr = document.getElementById("panel-root"); | |
| const c = pr && pr.querySelector('.ib-card[data-id="' + id + '"]'); | |
| if (c) c.scrollIntoView({ block: "nearest" }); | |
| } | |
| function selectOnly(id) { | |
| sel = new Set(id ? [id] : []); | |
| renderBoxes(); | |
| applySelClasses(); | |
| if (id) scrollToCard(id); | |
| } | |
| function toggleSelect(id) { | |
| if (sel.has(id)) sel.delete(id); else sel.add(id); | |
| renderBoxes(); | |
| applySelClasses(); | |
| } | |
| function toNorm(ev) { | |
| const r = stage.getBoundingClientRect(); | |
| return { | |
| x: clamp(((ev.clientX - r.left) / r.width) * 1000), | |
| y: clamp(((ev.clientY - r.top) / r.height) * 1000), | |
| }; | |
| } | |
| // snapshot the current geometry of the selected boxes (for move/group-resize deltas) | |
| const snapshot = () => { | |
| const o = {}; | |
| selArr().forEach((e) => { o[e.id] = { xmin: e.xmin, ymin: e.ymin, xmax: e.xmax, ymax: e.ymax }; }); | |
| return o; | |
| }; | |
| function resizeSingle(dr, p, shift) { | |
| const e = dr.e, o = dr.orig, h = dr.h; | |
| let xmin = o.xmin, ymin = o.ymin, xmax = o.xmax, ymax = o.ymax; | |
| if (h.includes("w")) xmin = Math.min(p.x, o.xmax - 1); | |
| if (h.includes("e")) xmax = Math.max(p.x, o.xmin + 1); | |
| if (h.includes("n")) ymin = Math.min(p.y, o.ymax - 1); | |
| if (h.includes("s")) ymax = Math.max(p.y, o.ymin + 1); | |
| if (shift) { // preserve aspect ratio, anchored at the opposite corner | |
| const ow = o.xmax - o.xmin, oh = o.ymax - o.ymin; | |
| const scale = Math.max((xmax - xmin) / ow, (ymax - ymin) / oh); | |
| const nw = ow * scale, nh = oh * scale; | |
| if (h.includes("w")) xmin = o.xmax - nw; else xmax = o.xmin + nw; | |
| if (h.includes("n")) ymin = o.ymax - nh; else ymax = o.ymin + nh; | |
| } | |
| e.xmin = clamp(xmin); e.ymin = clamp(ymin); e.xmax = clamp(xmax); e.ymax = clamp(ymax); | |
| } | |
| function groupResize(dr, p, shift) { | |
| const h = dr.h, g = dr.g; | |
| let nx0 = g.x0, ny0 = g.y0, nx1 = g.x1, ny1 = g.y1; | |
| if (h.includes("w")) nx0 = Math.min(p.x, g.x1 - 1); | |
| if (h.includes("e")) nx1 = Math.max(p.x, g.x0 + 1); | |
| if (h.includes("n")) ny0 = Math.min(p.y, g.y1 - 1); | |
| if (h.includes("s")) ny1 = Math.max(p.y, g.y0 + 1); | |
| let sx = (nx1 - nx0) / (g.x1 - g.x0); | |
| let sy = (ny1 - ny0) / (g.y1 - g.y0); | |
| if (h === "n" || h === "s") sx = 1; | |
| if (h === "e" || h === "w") sy = 1; | |
| if (shift && h.length === 2) { const f = Math.max(sx, sy); sx = f; sy = f; } | |
| const ax = h.includes("w") ? g.x1 : g.x0; // anchor = opposite side | |
| const ay = h.includes("n") ? g.y1 : g.y0; | |
| selArr().forEach((e) => { | |
| const o = dr.orig[e.id]; | |
| e.xmin = clamp(ax + (o.xmin - ax) * sx); | |
| e.xmax = clamp(ax + (o.xmax - ax) * sx); | |
| e.ymin = clamp(ay + (o.ymin - ay) * sy); | |
| e.ymax = clamp(ay + (o.ymax - ay) * sy); | |
| }); | |
| } | |
| // ---- canvas interaction ---- | |
| // Pick the SMALLEST box under a point, so overlapping big boxes don't block small ones. | |
| function hitBox(p) { | |
| let best = null, bestArea = Infinity; | |
| for (const e of els) { | |
| if (p.x >= e.xmin && p.x <= e.xmax && p.y >= e.ymin && p.y <= e.ymax) { | |
| const area = (e.xmax - e.xmin) * (e.ymax - e.ymin); | |
| if (area < bestArea) { bestArea = area; best = e; } | |
| } | |
| } | |
| return best; | |
| } | |
| stage.addEventListener("pointerdown", (ev) => { | |
| if (ev.button !== 0 || !img.src || eyedropSample) return; | |
| const p = toNorm(ev); | |
| const gh = ev.target.classList.contains("ib-gh") ? ev.target.dataset.gh : null; | |
| const boxEl = ev.target.closest(".ib-box"); | |
| const handle = ev.target.classList.contains("ib-h") ? ev.target.dataset.h : null; | |
| const additive = ev.ctrlKey || ev.metaKey; | |
| if (gh) { | |
| drag = { mode: "gresize", h: gh, g: groupBBox(), orig: snapshot() }; | |
| } else if (handle && boxEl) { | |
| const e = byId(boxEl.dataset.id); | |
| drag = { mode: "resize", h: handle, e: e, | |
| orig: { xmin: e.xmin, ymin: e.ymin, xmax: e.xmax, ymax: e.ymax } }; | |
| } else if (hitBox(p)) { | |
| const id = hitBox(p).id; // smallest box under the cursor | |
| if (additive) { | |
| toggleSelect(id); | |
| stage.setPointerCapture(ev.pointerId); | |
| ev.preventDefault(); | |
| return; | |
| } | |
| if (!sel.has(id)) selectOnly(id); | |
| drag = { mode: "move", start: p, orig: snapshot(), ids: Array.from(sel) }; | |
| } else { | |
| sel = new Set(); | |
| const id = "j" + jsCounter++; | |
| const e = { id: id, type: "obj", ymin: p.y, xmin: p.x, ymax: p.y, xmax: p.x, | |
| desc: "", text: "", colors: [] }; | |
| els.push(e); | |
| sel = new Set([id]); | |
| drag = { mode: "draw", e: e, start: p }; | |
| renderAll(); | |
| } | |
| stage.setPointerCapture(ev.pointerId); | |
| ev.preventDefault(); | |
| }); | |
| stage.addEventListener("pointermove", (ev) => { | |
| if (!drag) return; | |
| const p = toNorm(ev); | |
| const shift = ev.shiftKey; | |
| if (drag.mode === "move") { | |
| let dx = p.x - drag.start.x, dy = p.y - drag.start.y; | |
| let gx0 = 1e9, gy0 = 1e9, gx1 = -1e9, gy1 = -1e9; | |
| drag.ids.forEach((id) => { | |
| const o = drag.orig[id]; | |
| gx0 = Math.min(gx0, o.xmin); gy0 = Math.min(gy0, o.ymin); | |
| gx1 = Math.max(gx1, o.xmax); gy1 = Math.max(gy1, o.ymax); | |
| }); | |
| dx = Math.max(-gx0, Math.min(1000 - gx1, dx)); | |
| dy = Math.max(-gy0, Math.min(1000 - gy1, dy)); | |
| drag.ids.forEach((id) => { | |
| const o = drag.orig[id], e = byId(id); | |
| if (!e) return; | |
| e.xmin = o.xmin + dx; e.ymin = o.ymin + dy; | |
| e.xmax = o.xmax + dx; e.ymax = o.ymax + dy; | |
| }); | |
| } else if (drag.mode === "draw") { | |
| const e = drag.e; | |
| e.xmin = Math.min(drag.start.x, p.x); | |
| e.xmax = Math.max(drag.start.x, p.x); | |
| e.ymin = Math.min(drag.start.y, p.y); | |
| e.ymax = Math.max(drag.start.y, p.y); | |
| } else if (drag.mode === "resize") { | |
| resizeSingle(drag, p, shift); | |
| } else if (drag.mode === "gresize") { | |
| groupResize(drag, p, shift); | |
| } | |
| renderBoxes(); | |
| }); | |
| function endDrag() { | |
| if (!drag) return; | |
| if (drag.mode === "draw") { | |
| const e = drag.e; | |
| if (e.xmax - e.xmin < 8 || e.ymax - e.ymin < 8) { | |
| els = els.filter((x) => x !== e); | |
| sel = new Set(); | |
| } | |
| } | |
| const wasDraw = drag.mode === "draw"; | |
| drag = null; | |
| renderAll(); | |
| applySelClasses(); | |
| emitNow(); | |
| if (wasDraw) { | |
| const pr = document.getElementById("panel-root"); | |
| const card = pr && pr.querySelector(".ib-card.sel .ib-desc"); | |
| if (card) card.focus(); | |
| } | |
| } | |
| stage.addEventListener("pointerup", endDrag); | |
| stage.addEventListener("pointercancel", endDrag); | |
| window.addEventListener("keydown", (ev) => { | |
| if (ev.key === "Delete" && sel.size) { | |
| const t = document.activeElement; | |
| if (t && (t.tagName === "TEXTAREA" || t.tagName === "INPUT")) return; | |
| els = els.filter((x) => !sel.has(x.id)); | |
| sel = new Set(); | |
| renderAll(); | |
| emitNow(); | |
| } | |
| }); | |
| // ---- panel interaction: document-level delegation so it works no matter when #panel-root | |
| // renders and survives re-renders (the panel div is owned by Gradio's HTML component) ---- | |
| document.addEventListener("input", (ev) => { | |
| const card = ev.target.closest && ev.target.closest("#panel-root .ib-card"); | |
| if (!card) return; | |
| const e = byId(card.dataset.id); | |
| const f = ev.target.dataset.f; | |
| if (!e || !f) return; | |
| e[f] = ev.target.value; | |
| if (f === "desc" || f === "text") { | |
| const i = els.indexOf(e); | |
| const lab = stage.querySelector('.ib-box[data-id="' + e.id + '"] .ib-label'); | |
| if (lab) lab.textContent = labelText(e, i); | |
| } | |
| emit(); | |
| }); | |
| document.addEventListener("change", (ev) => { | |
| if (!ev.target.classList || !ev.target.classList.contains("ib-type")) return; | |
| if (!ev.target.closest("#panel-root")) return; | |
| const card = ev.target.closest(".ib-card"); | |
| const e = byId(card.dataset.id); | |
| if (!e) return; | |
| e.type = ev.target.value; | |
| renderAll(); | |
| selectOnly(e.id); | |
| emitNow(); | |
| }); | |
| document.addEventListener("click", (ev) => { | |
| if (!ev.target.closest) return; | |
| if (ev.target.id === "ib-add-obj") { addElement("obj"); return; } | |
| if (ev.target.id === "ib-add-text") { addElement("text"); return; } | |
| // top-level style palette (same chip UI as elements, but backed by styleColors) | |
| if (ev.target.closest("#style-pal-root")) { | |
| const addB = ev.target.closest(".ib-chip-add"); | |
| if (addB) { | |
| const last = styleColors.length ? styleColors[styleColors.length - 1] : "#4DA460"; | |
| openColorPopover(last, addB, (hex) => { | |
| styleColors.push(hex); renderStylePalette(); emitNow(); | |
| }); | |
| return; | |
| } | |
| const xB = ev.target.closest(".ib-chip-x"); | |
| if (xB) { styleColors.splice(parseInt(xB.dataset.ci, 10), 1); renderStylePalette(); emitNow(); return; } | |
| const ch = ev.target.closest(".ib-chip"); | |
| if (ch) { | |
| const ci = parseInt(ch.dataset.ci, 10); | |
| openColorPopover(styleColors[ci], ch, (hex) => { | |
| styleColors[ci] = hex; renderStylePalette(); emitNow(); | |
| }); | |
| } | |
| return; | |
| } | |
| const card = ev.target.closest("#panel-root .ib-card"); | |
| if (!card) return; | |
| const e = byId(card.dataset.id); | |
| if (ev.target.classList.contains("ib-del")) { | |
| els = els.filter((x) => x.id !== card.dataset.id); | |
| sel.delete(card.dataset.id); | |
| renderAll(); | |
| emitNow(); | |
| return; | |
| } | |
| // ---- color palette chips ---- | |
| const addBtn = ev.target.closest(".ib-chip-add"); | |
| if (addBtn) { | |
| const last = e.colors && e.colors.length ? e.colors[e.colors.length - 1] : "#4DA460"; | |
| openColorPopover(last, addBtn, (hex) => { | |
| e.colors = (e.colors || []).concat(hex); | |
| refreshPalette(e.id); | |
| emitNow(); | |
| }); | |
| return; | |
| } | |
| const xBtn = ev.target.closest(".ib-chip-x"); | |
| if (xBtn) { | |
| e.colors.splice(parseInt(xBtn.dataset.ci, 10), 1); | |
| refreshPalette(e.id); | |
| emitNow(); | |
| return; | |
| } | |
| const chip = ev.target.closest(".ib-chip"); | |
| if (chip) { | |
| const ci = parseInt(chip.dataset.ci, 10); | |
| openColorPopover(e.colors[ci], chip, (hex) => { | |
| e.colors[ci] = hex; | |
| refreshPalette(e.id); | |
| emitNow(); | |
| }); | |
| return; | |
| } | |
| if (!ev.target.closest(".ib-f, .ib-type, .ib-pal")) { | |
| if (ev.ctrlKey || ev.metaKey) toggleSelect(card.dataset.id); | |
| else selectOnly(card.dataset.id); | |
| } | |
| }); | |
| function addElement(type) { | |
| const id = "j" + jsCounter++; | |
| els.push({ id: id, type: type, ymin: 350, xmin: 350, ymax: 650, xmax: 650, | |
| desc: "", text: "", colors: [] }); | |
| renderAll(); | |
| selectOnly(id); | |
| emitNow(); | |
| } | |
| window.IB = { | |
| load(v) { | |
| try { | |
| console.log("[IB] load called, payload length", (v || "").length); | |
| const p = typeof v === "string" ? JSON.parse(v || "{}") : v; | |
| if (!p) { console.warn("[IB] load: empty payload"); return; } | |
| console.log("[IB] load: hasImage", !!p.image, "elements", | |
| Array.isArray(p.elements) ? p.elements.length : "none"); | |
| if (p.image) { img.src = p.image; hint.style.display = "none"; } | |
| if (Array.isArray(p.elements)) { | |
| els = p.elements.map((e) => Object.assign({}, e)); | |
| } | |
| styleColors = Array.isArray(p.style_colors) ? p.style_colors.slice() : []; | |
| sel = new Set(Array.isArray(p.selected) ? p.selected : []); | |
| renderAll(); | |
| } catch (e) { | |
| console.error("[IB] load error", e, "payload head:", (v || "").slice(0, 120)); | |
| } | |
| }, | |
| }; | |
| console.log("[IB] setup complete"); | |
| if (window.__bboxPending) window.IB.load(window.__bboxPending); | |
| return true; | |
| } | |
| // Gradio 6 lazily renders inactive tab content, so #bbox-root may not exist until the Editor tab | |
| // is shown (which can be minutes later, after inference + auto-switch). Mount the instant it | |
| // appears via a MutationObserver, with a slow poll as backup. setup() applies any pending payload. | |
| if (!setup()) { | |
| const obs = new MutationObserver(() => { if (setup()) obs.disconnect(); }); | |
| obs.observe(document.body, { childList: true, subtree: true }); | |
| let tries = 0; | |
| const t = setInterval(() => { | |
| if (setup() || ++tries > 3000) clearInterval(t); | |
| }, 200); | |
| } | |
| } | |
| """ | |
| # Inject CSS + the canvas script straight into <head> as literal tags. This bypasses Gradio 6's | |
| # config-js path (which needs `new Function` and may be blocked/uninvoked), so the browser just runs | |
| # the <script> on load. CANVAS_JS is an arrow fn; `(...)()` self-invokes it. | |
| # --app-h is a *fixed pixel* stand-in for 100vh. On HF Spaces the app runs in a <gradio-app> | |
| # iframe that resizes to its content, so `vh` units feed back and grow forever. Pinning a pixel | |
| # height from window.innerHeight (updated only on real resize) breaks that loop. | |
| # --app-h is a fixed-px stand-in for the app height. | |
| # Direct URL (top-level page): use the real available viewport -> full-height, scrollless. | |
| # Embedded in an iframe (e.g. the HF Space page): the iframe is content-sized, so innerHeight is | |
| # circular and unrelated to the visible area — we can't know it, so we use a sensible fixed height. | |
| EMBED_HEIGHT_PX = 720 | |
| HEIGHT_SYNC_JS = ( | |
| "(function(){var embedded=window.top!==window.self;var h=0;function s(){" | |
| "if(embedded){h=" + str(EMBED_HEIGHT_PX) + ";}" | |
| "else{var el=document.querySelector('.gradio-container');" | |
| "var top=el?el.getBoundingClientRect().top:0;" | |
| "var a=Math.max(320, window.innerHeight - Math.max(0, top));" | |
| "if(!h||a<h)h=a;}" # only ever shrink on the direct URL (never chase an auto-sizing parent) | |
| "document.documentElement.style.setProperty('--app-h', h + 'px');}" | |
| "s();window.addEventListener('resize', s);window.addEventListener('load', s);" | |
| "setTimeout(s,300);setTimeout(s,900);})();" | |
| ) | |
| HEAD = ( | |
| "<style>\n" + CSS + "\n</style>\n" | |
| "<script>\nconsole.log('[IB] head script running');\n" | |
| + HEIGHT_SYNC_JS + "\n(" + CANVAS_JS + ")();\n</script>\n" | |
| ) | |
| intro = ( | |
| "# 🏷️ Ideogram 4 Caption Studio\n" | |
| "<small>Gemma-4 describes your image in Ideogram 4's native JSON schema, then edit it visually. " | |
| " · <b>Caption</b> tab: upload → Caption. " | |
| " · <b>Editor</b> tab: drag on the image to draw a box; drag it to move, handles to " | |
| "resize; <b>Ctrl-click</b> to multi-select (group-scale, hold <b>Shift</b> to keep aspect); " | |
| "<b>Del</b> removes. Edit each box's text & colors on the right (the pipette samples the " | |
| "image). The <b>JSON</b> tab holds the raw caption to feed the generator.</small>" | |
| ) | |
| with gr.Blocks(title="Ideogram4 Caption (Gemma4)") as demo: | |
| gr.Markdown(intro, elem_id="app-title") | |
| # Single source of truth for the whole caption; the fields, JSON tab and canvas are views of it. | |
| caption_state = gr.State({}) | |
| # The Ideogram4 schema prompt is fixed and hidden; kept as a state so it's still passed to caption(). | |
| instruction = gr.State(value=DEFAULT_INSTRUCTION) | |
| with gr.Tabs(elem_id="main-tabs") as main_tabs: | |
| # ---- Caption tab: upload + model + generation controls ------------------------------------- | |
| with gr.Tab("Caption", id="caption"): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| image = gr.Image( | |
| label="Source image", | |
| type="pil", | |
| height="calc(var(--app-h, 100vh) - 190px)", | |
| ) | |
| with gr.Column(scale=1): | |
| run = gr.Button( | |
| "Caption", variant="primary", elem_id="caption-btn" | |
| ) | |
| run_status = gr.Markdown("") | |
| if IS_ZERO_GPU: | |
| # ZeroGPU: bf16 preloaded, GPU is per-request — no selector / load / unload. | |
| precision = gr.State(value=DEFAULT_PRECISION) | |
| else: | |
| # Local: pick a model, load on demand, free it so we don't squat on VRAM. | |
| with gr.Row(equal_height=True): | |
| precision = gr.Dropdown( | |
| choices=list(MODELS), | |
| value=DEFAULT_PRECISION, | |
| show_label=False, | |
| scale=2, | |
| ) | |
| load_btn = gr.Button("Load model", elem_id="load-btn") | |
| unload_btn = gr.Button("Unload model", elem_id="unload-btn") | |
| model_status = gr.Textbox( | |
| label="Model status", | |
| value="⚪ No model loaded", | |
| interactive=False, | |
| ) | |
| max_new_tokens = gr.Slider( | |
| 256, 4096, value=2048, step=64, label="Max new tokens" | |
| ) | |
| temperature = gr.Slider( | |
| 0.0, 2.0, value=0.0, step=0.1, label="Temperature (0 = greedy)" | |
| ) | |
| # ---- Editor tab: interactive box canvas (left) + fields / JSON (right) --------------------- | |
| with gr.Tab("Editor", id="editor"): | |
| # Hidden bridge to the JS canvas: Python writes #bbox-in (load/relabel), JS writes | |
| # #bbox-out (user edits). Neither is user-visible. | |
| bbox_in = gr.Textbox(visible=False, elem_id="bbox-in") | |
| bbox_out = gr.Textbox(visible=False, elem_id="bbox-out") | |
| with gr.Row(elem_id="editor-row"): | |
| with gr.Column(scale=3, elem_id="canvas-col"): | |
| gr.HTML( | |
| '<div id="bbox-root"></div>', | |
| label="Boxes — drag on empty space to draw · drag box to move · " | |
| "handles to resize · Del to remove", | |
| ) | |
| with gr.Column(scale=2, elem_id="fields-col"): | |
| with gr.Tabs(): | |
| with gr.Tab("Fields"): | |
| hld = gr.Textbox(label="High-level description", lines=2) | |
| background = gr.Textbox(label="Background", lines=2) | |
| with gr.Group(): | |
| gr.Markdown("**Style**") | |
| medium_kind = gr.Radio( | |
| ["photo", "art_style"], | |
| value="photo", | |
| label="Medium kind", | |
| ) | |
| aesthetics = gr.Textbox(label="Aesthetics") | |
| lighting = gr.Textbox(label="Lighting") | |
| medium = gr.Textbox(label="Medium") | |
| kind_value = gr.Textbox( | |
| label="Photo / art_style value", | |
| info="camera & lens for photo, or the named art style", | |
| ) | |
| # Style color palette — same JS chip editor (picker + eyedropper) | |
| # as the elements; backed by styleColors in the canvas script. | |
| gr.HTML( | |
| '<div class="ib-pal-label">Color palette</div>' | |
| '<div id="style-pal-root" class="ib-pal"></div>' | |
| ) | |
| # Element cards are rendered by the JS editor (kept in lockstep with the | |
| # boxes: draw/select/edit sync both ways). #ib-add-* buttons are wired in JS. | |
| gr.HTML( | |
| '<div class="ib-panel-head">' | |
| "<strong>Elements</strong>" | |
| '<span class="ib-add-wrap">' | |
| '<button id="ib-add-obj" class="ib-add" type="button">+ Object</button>' | |
| '<button id="ib-add-text" class="ib-add" type="button">+ Text</button>' | |
| "</span></div>" | |
| '<div id="panel-root"></div>' | |
| ) | |
| with gr.Tab("JSON"): | |
| json_code = gr.Code( | |
| label="Ideogram4 JSON caption", language="json" | |
| ) | |
| apply_btn = gr.Button("Apply JSON", variant="secondary") | |
| # sync_from_state returns (state-with-ids, top fields..., canvas payload) in this exact order. | |
| # (color_palette is now a JS chip editor, not a Gradio field.) | |
| sync_outputs = [ | |
| caption_state, | |
| hld, | |
| background, | |
| medium_kind, | |
| aesthetics, | |
| lighting, | |
| medium, | |
| kind_value, | |
| bbox_in, | |
| ] | |
| # Non-label fields (they never change the boxes) -> just rewrite state + JSON. | |
| field_inputs = [ | |
| caption_state, | |
| hld, | |
| background, | |
| medium_kind, | |
| aesthetics, | |
| lighting, | |
| medium, | |
| kind_value, | |
| ] | |
| for field in [ | |
| hld, | |
| background, | |
| medium_kind, | |
| aesthetics, | |
| lighting, | |
| medium, | |
| kind_value, | |
| ]: | |
| field.input( | |
| rebuild_from_fields, | |
| inputs=field_inputs, | |
| outputs=[caption_state, json_code], | |
| ) | |
| # All editor edits (draw / move / resize / delete / add / type / desc / text / palette / select) | |
| # happen in JS and arrive together via #bbox-out. We just fold them into state + refresh JSON; | |
| # the JS already rendered everything, so there's nothing to push back. | |
| def on_canvas_out(out, st): | |
| new_st = reconcile_canvas(st, out) | |
| return new_st, _dumps(new_st) | |
| bbox_out.input( | |
| on_canvas_out, | |
| [bbox_out, caption_state], | |
| [caption_state, json_code], | |
| ) | |
| if not IS_ZERO_GPU: | |
| load_btn.click(load_model, inputs=precision, outputs=model_status, api_name="load") | |
| unload_btn.click( | |
| unload_model, inputs=None, outputs=model_status, api_name="unload" | |
| ) | |
| # Show the image on the canvas as soon as it's uploaded (boxes appear once captioned/edited). | |
| image.change( | |
| lambda img, st: canvas_payload(img, st, include_image=True), | |
| inputs=[image, caption_state], | |
| outputs=bbox_in, | |
| ) | |
| # Push #bbox-in payloads into the JS canvas. | |
| bbox_in.change( | |
| None, | |
| inputs=bbox_in, | |
| outputs=None, | |
| js="(v) => { console.log('[IB] bbox_in change, len', (v||'').length, 'IB?', !!window.IB); window.__bboxPending = v; if (window.IB) window.IB.load(v); }", | |
| ) | |
| run.click( | |
| lambda: "⏳ Running the model…", | |
| outputs=run_status, | |
| ).then( | |
| caption, | |
| inputs=[image, instruction, precision, max_new_tokens, temperature], | |
| outputs=[caption_state, json_code, run_status], | |
| api_name="caption", | |
| ).then( | |
| sync_from_state, | |
| inputs=[image, caption_state], | |
| outputs=sync_outputs, | |
| ).then( | |
| lambda: gr.Tabs(selected="editor"), | |
| outputs=main_tabs, | |
| ) | |
| apply_btn.click( | |
| apply_json, | |
| inputs=[image, json_code], | |
| outputs=sync_outputs, | |
| ) | |
| # In Gradio 6, css/js/head live on launch(). We inject via head (literal <style>/<script>) so it | |
| # doesn't depend on Gradio's config-js execution. | |
| if __name__ == "__main__": | |
| demo.queue().launch(head=HEAD) | |