File size: 11,428 Bytes
bdc1856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Custom modular-diffusers block: image OR text -> Ideogram 4 structured-JSON caption.

Wraps a vision-language model (Gemma-4-E4B via `Gemma4ForConditionalGeneration`) with two modes, selected by input:
  - `image` given  -> caption the image into Ideogram 4's native JSON schema (the `ideogram4-caption` Space logic).
  - `prompt` given (no image) -> enhance the short text idea into a JSON caption (Ideogram's "magic prompt",
    reusing the canonical `CAPTION_SYSTEM_MESSAGE` from `diffusers.pipelines.ideogram4.prompt_enhancer`).

Either way the produced `caption` can be fed straight into the Ideogram 4 generation pipeline as the `prompt`
(the model is trained on these JSON captions). This is the code-usable backend, minus the Gradio UI.
"""

import json
import math
import re

import torch

from diffusers.modular_pipelines.modular_pipeline import ModularPipelineBlocks, PipelineState, SequentialPipelineBlocks
from diffusers.modular_pipelines.modular_pipeline_utils import ComponentSpec, InputParam, OutputParam
from diffusers.pipelines.ideogram4.prompt_enhancer import CAPTION_SYSTEM_MESSAGE, CAPTION_USER_TEMPLATE
from diffusers.utils import logging

from transformers import AutoProcessor, Gemma4ForConditionalGeneration


logger = logging.get_logger(__name__)  # pylint: disable=invalid-name


# 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.
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."
)


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 += '"'
    out = out.rstrip()
    if out.endswith(","):
        out = out[:-1]
    for ch in reversed(stack):
        out += "}" if ch == "{" else "]"
    return out


def _parse_json(text: str):
    """Parse the model output into (dict|None, repaired: bool). Robust to markdown fences, trailing prose, and
    truncated output (balances brackets). `repaired=True` means the output was incomplete and was closed to parse."""
    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:]
    try:
        obj = json.loads(frag[: frag.rfind("}") + 1], strict=False)
        if isinstance(obj, dict):
            return obj, False
    except json.JSONDecodeError:
        pass
    try:
        obj, _ = json.JSONDecoder(strict=False).raw_decode(frag)
        if isinstance(obj, dict):
            return obj, False
    except json.JSONDecodeError:
        pass
    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


class Ideogram4CaptionStep(ModularPipelineBlocks):
    model_name = "ideogram4_caption"

    @property
    def description(self) -> str:
        return (
            "Turns an image OR a short text prompt into Ideogram 4's native structured-JSON caption using a "
            "vision-language model (Gemma-4). If `image` is given it is captioned; otherwise `prompt` is enhanced "
            "(magic-prompt) into a caption. Outputs `caption` (JSON string), `caption_json` (parsed dict, or None if "
            "unparseable), and `caption_raw` (raw decoded text)."
        )

    @property
    def expected_components(self) -> list[ComponentSpec]:
        return [
            ComponentSpec("caption_model", Gemma4ForConditionalGeneration, description="Vision-language captioner (Gemma-4)."),
            ComponentSpec("caption_processor", AutoProcessor, description="Processor paired with the captioner."),
        ]

    @property
    def inputs(self) -> list[InputParam]:
        return [
            InputParam("image", description="PIL image to caption. If omitted, `prompt` is enhanced into a caption."),
            InputParam("prompt", type_hint=str, description="Short text idea to enhance into a JSON caption (used when no image is given)."),
            InputParam("instruction", default=DEFAULT_INSTRUCTION, description="Instruction for image-caption mode (ignored when enhancing a prompt)."),
            InputParam("height", type_hint=int, description="Target height; sets the aspect ratio hint for prompt enhancement."),
            InputParam("width", type_hint=int, description="Target width; sets the aspect ratio hint for prompt enhancement."),
            InputParam("max_new_tokens", type_hint=int, default=2048, description="Max tokens to generate (captions can be long)."),
            InputParam(
                "temperature",
                type_hint=float,
                default=0.0,
                description="Sampling temperature; 0 = greedy (deterministic).",
            ),
        ]

    @property
    def intermediate_outputs(self) -> list[OutputParam]:
        return [
            OutputParam("caption", type_hint=str, description="Pretty-printed JSON caption (raw text if unparseable)."),
            OutputParam("caption_json", type_hint=dict, description="Parsed caption dict, or None if unparseable."),
            OutputParam("caption_raw", type_hint=str, description="Raw decoded model output."),
        ]

    @torch.no_grad()
    def __call__(self, components, state: PipelineState):
        block_state = self.get_block_state(state)

        model = components.caption_model
        processor = components.caption_processor
        device = model.device  # run on wherever the model physically is (offload-friendly)

        if block_state.image is not None:
            content = [
                {"type": "image", "image": block_state.image},
                {"type": "text", "text": block_state.instruction},
            ]
        elif block_state.prompt:
            h = int(block_state.height or 1024)
            w = int(block_state.width or 1024)
            d = math.gcd(w, h) or 1
            aspect_ratio = f"{w // d}:{h // d}"
            enhance_text = (
                CAPTION_SYSTEM_MESSAGE
                + "\n\n"
                + CAPTION_USER_TEMPLATE.format(aspect_ratio=aspect_ratio, original_prompt=block_state.prompt)
            )
            content = [{"type": "text", "text": enhance_text}]
        else:
            raise ValueError("Provide either `image` (to caption) or `prompt` (to enhance into a caption).")

        messages = [{"role": "user", "content": content}]
        inputs = processor.apply_chat_template(
            messages,
            add_generation_prompt=True,
            tokenize=True,
            return_dict=True,
            return_tensors="pt",
            enable_thinking=False,  # Gemma-4 reasoning mode off for structured-JSON output
        ).to(device)

        do_sample = float(block_state.temperature) > 0
        generated = model.generate(
            **inputs,
            max_new_tokens=int(block_state.max_new_tokens),
            do_sample=do_sample,
            temperature=float(block_state.temperature) if do_sample else None,
        )
        input_len = inputs["input_ids"].shape[1]
        text = processor.decode(generated[0][input_len:], skip_special_tokens=True)

        data, _repaired = _parse_json(text)
        block_state.caption_json = data
        block_state.caption = json.dumps(data, indent=2, ensure_ascii=False) if data is not None else text
        block_state.caption_raw = text

        self.set_block_state(state, block_state)
        return components, state


# auto_docstring
class Ideogram4CaptionBlocks(SequentialPipelineBlocks):
    """
    Standalone image/text -> Ideogram 4 structured-JSON caption pipeline (Gemma-4). Load with
    `ModularPipeline.from_pretrained(repo, trust_remote_code=True)` +
    `load_components(torch_dtype=torch.bfloat16)`, then either
    `pipe(image=img, output="caption")` (caption an image) or `pipe(prompt="a cat...", output="caption")`
    (enhance a text idea into a caption).
    """

    model_name = "ideogram4_caption"
    block_classes = [Ideogram4CaptionStep]
    block_names = ["caption"]

    @property
    def description(self) -> str:
        return "Image or text -> Ideogram 4 structured-JSON caption (Gemma-4; captions an image, or enhances a prompt)."

    @property
    def outputs(self) -> list[OutputParam]:
        return [
            OutputParam("caption", type_hint=str),
            OutputParam("caption_json", type_hint=dict),
            OutputParam("caption_raw", type_hint=str),
        ]