OzzyGT HF Staff commited on
Commit
bdc1856
·
verified ·
1 Parent(s): 32de7f4

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +76 -0
  2. __init__.py +12 -0
  3. ideogram4_caption.py +252 -0
  4. modular_config.json +7 -0
  5. modular_model_index.json +33 -0
README.md CHANGED
@@ -1,3 +1,79 @@
1
  ---
 
 
 
 
 
 
 
 
2
  license: apache-2.0
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ library_name: diffusers
3
+ pipeline_tag: image-to-text
4
+ base_model: google/gemma-4-E4B-it
5
+ tags:
6
+ - image-to-text
7
+ - captioning
8
+ - modular-diffusers
9
+ - ideogram
10
  license: apache-2.0
11
  ---
12
+
13
+ # Ideogram4 caption block
14
+
15
+ A custom [Modular Diffusers](https://huggingface.co/docs/diffusers/main/en/modular_diffusers/overview) block that
16
+ produces **Ideogram 4's native structured-JSON caption** from either an image or a short text idea, using a Gemma-4
17
+ vision-language model. The caption it returns can be fed straight into the Ideogram 4 generation pipeline as `prompt`.
18
+
19
+ ```
20
+ image -> caption it -> { high_level_description, style_description, compositional_deconstruction }
21
+ prompt -> enhance -> { high_level_description, compositional_deconstruction }
22
+ ```
23
+
24
+ The mode is chosen automatically: pass `image` to caption it, or `prompt` (with no image) to enhance a short idea.
25
+ Prompt enhancement reuses Ideogram's canonical magic-prompt system message from
26
+ `diffusers.pipelines.ideogram4.prompt_enhancer`.
27
+
28
+ ## Loading & running
29
+
30
+ ```python
31
+ import torch
32
+ from diffusers import ModularPipeline
33
+ from diffusers.utils import load_image
34
+
35
+ pipe = ModularPipeline.from_pretrained("OzzyGT/ideogram4-caption-blocks", trust_remote_code=True)
36
+ pipe.load_components(torch_dtype=torch.bfloat16)
37
+ pipe.to("cuda")
38
+
39
+ image = load_image("your_image.png")
40
+ caption = pipe(image=image, output="caption") # caption the image -> JSON string
41
+ print(caption)
42
+ ```
43
+
44
+ Enhance a text idea instead (no image) — Ideogram's "magic prompt":
45
+
46
+ ```python
47
+ caption = pipe(
48
+ prompt="a cozy coffee shop on a rainy evening",
49
+ output="caption",
50
+ )
51
+ ```
52
+
53
+ Get the parsed dict instead (or alongside):
54
+
55
+ ```python
56
+ out = pipe(image=image, output=["caption", "caption_json"])
57
+ out["caption_json"] # dict, or None if the model output couldn't be parsed
58
+ ```
59
+
60
+ Inputs: `image` (caption it) **or** `prompt` (enhance it); `instruction` (image-mode schema prompt), `height`/`width`
61
+ (aspect-ratio hint for enhance mode), `max_new_tokens` (2048), `temperature` (0.0 = greedy). Outputs: `caption`
62
+ (pretty JSON string), `caption_json` (parsed dict), `caption_raw` (raw decoded text).
63
+
64
+ ## Caption → generate
65
+
66
+ ```python
67
+ caption = pipe(image=ref, output="caption")
68
+ # feed straight into the Ideogram 4 generation pipeline (see OzzyGT/ideogram4-modular)
69
+ image = gen_pipe(prompt=caption, output="images")[0]
70
+ ```
71
+
72
+ ## Notes
73
+
74
+ - **Default checkpoint:** `google/gemma-4-E4B-it` (official bf16, ~15 GB — gated, needs a license-accepted HF
75
+ token and a >=24 GB GPU). Requires `transformers>=5.12`. To run on a smaller GPU, point
76
+ `pretrained_model_name_or_path` at a quantized checkpoint of the same model — import its quantization backend
77
+ first, as the block no longer bundles one.
78
+ - Ideogram 4 was trained on these JSON captions, so a caption from this block is the ideal `prompt` for
79
+ re-generation / auto-captioned img2img.
__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .ideogram4_caption import (
2
+ DEFAULT_INSTRUCTION,
3
+ Ideogram4CaptionBlocks,
4
+ Ideogram4CaptionStep,
5
+ )
6
+
7
+
8
+ __all__ = [
9
+ "DEFAULT_INSTRUCTION",
10
+ "Ideogram4CaptionBlocks",
11
+ "Ideogram4CaptionStep",
12
+ ]
ideogram4_caption.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 The HuggingFace Team. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Custom modular-diffusers block: image OR text -> Ideogram 4 structured-JSON caption.
15
+
16
+ Wraps a vision-language model (Gemma-4-E4B via `Gemma4ForConditionalGeneration`) with two modes, selected by input:
17
+ - `image` given -> caption the image into Ideogram 4's native JSON schema (the `ideogram4-caption` Space logic).
18
+ - `prompt` given (no image) -> enhance the short text idea into a JSON caption (Ideogram's "magic prompt",
19
+ reusing the canonical `CAPTION_SYSTEM_MESSAGE` from `diffusers.pipelines.ideogram4.prompt_enhancer`).
20
+
21
+ Either way the produced `caption` can be fed straight into the Ideogram 4 generation pipeline as the `prompt`
22
+ (the model is trained on these JSON captions). This is the code-usable backend, minus the Gradio UI.
23
+ """
24
+
25
+ import json
26
+ import math
27
+ import re
28
+
29
+ import torch
30
+
31
+ from diffusers.modular_pipelines.modular_pipeline import ModularPipelineBlocks, PipelineState, SequentialPipelineBlocks
32
+ from diffusers.modular_pipelines.modular_pipeline_utils import ComponentSpec, InputParam, OutputParam
33
+ from diffusers.pipelines.ideogram4.prompt_enhancer import CAPTION_SYSTEM_MESSAGE, CAPTION_USER_TEMPLATE
34
+ from diffusers.utils import logging
35
+
36
+ from transformers import AutoProcessor, Gemma4ForConditionalGeneration
37
+
38
+
39
+ logger = logging.get_logger(__name__) # pylint: disable=invalid-name
40
+
41
+
42
+ # Ideogram 4's native caption schema (from the official ideogram-oss/ideogram4 docs). Key ORDER matters for
43
+ # quality, bbox is [ymin, xmin, ymax, xmax] in 0-1000 normalized coords, colors are UPPERCASE #RRGGBB.
44
+ DEFAULT_INSTRUCTION = (
45
+ "You are a captioner for the Ideogram 4 image model. Look at the image and output ONE JSON object (no prose, "
46
+ "no markdown fences) that reconstructs it in Ideogram 4's native caption schema. Keep keys in the exact order "
47
+ "shown.\n\n"
48
+ "SCHEMA (keys in order):\n"
49
+ "- high_level_description: one or two sentences summarizing the whole image (subject + medium/style).\n"
50
+ "- style_description: an object with EXACTLY ONE of `photo` or `art_style`:\n"
51
+ " photo caption order: aesthetics, lighting, photo, medium, color_palette\n"
52
+ " non-photo caption order: aesthetics, lighting, medium, art_style, color_palette\n"
53
+ ' color_palette: optional, up to 16 UPPERCASE hex colors (e.g. "#1B1B2F").\n'
54
+ "- compositional_deconstruction: {background, elements}.\n"
55
+ " background: the global setting/backdrop.\n"
56
+ " elements: list; keys in order per type:\n"
57
+ " obj: type, bbox, desc, color_palette\n"
58
+ " text: type, bbox, text, desc, color_palette\n\n"
59
+ "RULES:\n"
60
+ "- bbox is [ymin, xmin, ymax, xmax] in 0-1000 normalized coordinates (origin top-left), independent of "
61
+ "resolution. bbox and per-element color_palette are optional (<=5 colors per element).\n"
62
+ "- Main subject first. A coherent subject (one person/animal/object) is exactly ONE element; its parts go in "
63
+ "its `desc`. `desc` is identity-first and concrete (color, material, pose, distinguishing features).\n"
64
+ "- Commit to one concrete value each (no 'various'/'or similar'). `text` elements carry the literal in-image "
65
+ "characters, verbatim. Colors UPPERCASE #RRGGBB. Describe only what is visible; do not invent."
66
+ )
67
+
68
+
69
+ def _balance_json(frag: str) -> str:
70
+ """Close unbalanced strings/brackets in a truncated JSON fragment so it can be parsed."""
71
+ stack, in_str, esc = [], False, False
72
+ for ch in frag:
73
+ if in_str:
74
+ if esc:
75
+ esc = False
76
+ elif ch == "\\":
77
+ esc = True
78
+ elif ch == '"':
79
+ in_str = False
80
+ elif ch == '"':
81
+ in_str = True
82
+ elif ch in "{[":
83
+ stack.append(ch)
84
+ elif ch == "}" and stack and stack[-1] == "{":
85
+ stack.pop()
86
+ elif ch == "]" and stack and stack[-1] == "[":
87
+ stack.pop()
88
+ out = frag
89
+ if in_str:
90
+ out += '"'
91
+ out = out.rstrip()
92
+ if out.endswith(","):
93
+ out = out[:-1]
94
+ for ch in reversed(stack):
95
+ out += "}" if ch == "{" else "]"
96
+ return out
97
+
98
+
99
+ def _parse_json(text: str):
100
+ """Parse the model output into (dict|None, repaired: bool). Robust to markdown fences, trailing prose, and
101
+ truncated output (balances brackets). `repaired=True` means the output was incomplete and was closed to parse."""
102
+ text = text.strip()
103
+ text = re.sub(r"^```(?:json)?\s*", "", text)
104
+ text = re.sub(r"\s*```$", "", text).strip()
105
+ start = text.find("{")
106
+ if start == -1:
107
+ return None, False
108
+ frag = text[start:]
109
+ try:
110
+ obj = json.loads(frag[: frag.rfind("}") + 1], strict=False)
111
+ if isinstance(obj, dict):
112
+ return obj, False
113
+ except json.JSONDecodeError:
114
+ pass
115
+ try:
116
+ obj, _ = json.JSONDecoder(strict=False).raw_decode(frag)
117
+ if isinstance(obj, dict):
118
+ return obj, False
119
+ except json.JSONDecodeError:
120
+ pass
121
+ try:
122
+ obj = json.loads(_balance_json(frag), strict=False)
123
+ return (obj, True) if isinstance(obj, dict) else (None, False)
124
+ except json.JSONDecodeError:
125
+ return None, False
126
+
127
+
128
+ class Ideogram4CaptionStep(ModularPipelineBlocks):
129
+ model_name = "ideogram4_caption"
130
+
131
+ @property
132
+ def description(self) -> str:
133
+ return (
134
+ "Turns an image OR a short text prompt into Ideogram 4's native structured-JSON caption using a "
135
+ "vision-language model (Gemma-4). If `image` is given it is captioned; otherwise `prompt` is enhanced "
136
+ "(magic-prompt) into a caption. Outputs `caption` (JSON string), `caption_json` (parsed dict, or None if "
137
+ "unparseable), and `caption_raw` (raw decoded text)."
138
+ )
139
+
140
+ @property
141
+ def expected_components(self) -> list[ComponentSpec]:
142
+ return [
143
+ ComponentSpec("caption_model", Gemma4ForConditionalGeneration, description="Vision-language captioner (Gemma-4)."),
144
+ ComponentSpec("caption_processor", AutoProcessor, description="Processor paired with the captioner."),
145
+ ]
146
+
147
+ @property
148
+ def inputs(self) -> list[InputParam]:
149
+ return [
150
+ InputParam("image", description="PIL image to caption. If omitted, `prompt` is enhanced into a caption."),
151
+ InputParam("prompt", type_hint=str, description="Short text idea to enhance into a JSON caption (used when no image is given)."),
152
+ InputParam("instruction", default=DEFAULT_INSTRUCTION, description="Instruction for image-caption mode (ignored when enhancing a prompt)."),
153
+ InputParam("height", type_hint=int, description="Target height; sets the aspect ratio hint for prompt enhancement."),
154
+ InputParam("width", type_hint=int, description="Target width; sets the aspect ratio hint for prompt enhancement."),
155
+ InputParam("max_new_tokens", type_hint=int, default=2048, description="Max tokens to generate (captions can be long)."),
156
+ InputParam(
157
+ "temperature",
158
+ type_hint=float,
159
+ default=0.0,
160
+ description="Sampling temperature; 0 = greedy (deterministic).",
161
+ ),
162
+ ]
163
+
164
+ @property
165
+ def intermediate_outputs(self) -> list[OutputParam]:
166
+ return [
167
+ OutputParam("caption", type_hint=str, description="Pretty-printed JSON caption (raw text if unparseable)."),
168
+ OutputParam("caption_json", type_hint=dict, description="Parsed caption dict, or None if unparseable."),
169
+ OutputParam("caption_raw", type_hint=str, description="Raw decoded model output."),
170
+ ]
171
+
172
+ @torch.no_grad()
173
+ def __call__(self, components, state: PipelineState):
174
+ block_state = self.get_block_state(state)
175
+
176
+ model = components.caption_model
177
+ processor = components.caption_processor
178
+ device = model.device # run on wherever the model physically is (offload-friendly)
179
+
180
+ if block_state.image is not None:
181
+ content = [
182
+ {"type": "image", "image": block_state.image},
183
+ {"type": "text", "text": block_state.instruction},
184
+ ]
185
+ elif block_state.prompt:
186
+ h = int(block_state.height or 1024)
187
+ w = int(block_state.width or 1024)
188
+ d = math.gcd(w, h) or 1
189
+ aspect_ratio = f"{w // d}:{h // d}"
190
+ enhance_text = (
191
+ CAPTION_SYSTEM_MESSAGE
192
+ + "\n\n"
193
+ + CAPTION_USER_TEMPLATE.format(aspect_ratio=aspect_ratio, original_prompt=block_state.prompt)
194
+ )
195
+ content = [{"type": "text", "text": enhance_text}]
196
+ else:
197
+ raise ValueError("Provide either `image` (to caption) or `prompt` (to enhance into a caption).")
198
+
199
+ messages = [{"role": "user", "content": content}]
200
+ inputs = processor.apply_chat_template(
201
+ messages,
202
+ add_generation_prompt=True,
203
+ tokenize=True,
204
+ return_dict=True,
205
+ return_tensors="pt",
206
+ enable_thinking=False, # Gemma-4 reasoning mode off for structured-JSON output
207
+ ).to(device)
208
+
209
+ do_sample = float(block_state.temperature) > 0
210
+ generated = model.generate(
211
+ **inputs,
212
+ max_new_tokens=int(block_state.max_new_tokens),
213
+ do_sample=do_sample,
214
+ temperature=float(block_state.temperature) if do_sample else None,
215
+ )
216
+ input_len = inputs["input_ids"].shape[1]
217
+ text = processor.decode(generated[0][input_len:], skip_special_tokens=True)
218
+
219
+ data, _repaired = _parse_json(text)
220
+ block_state.caption_json = data
221
+ block_state.caption = json.dumps(data, indent=2, ensure_ascii=False) if data is not None else text
222
+ block_state.caption_raw = text
223
+
224
+ self.set_block_state(state, block_state)
225
+ return components, state
226
+
227
+
228
+ # auto_docstring
229
+ class Ideogram4CaptionBlocks(SequentialPipelineBlocks):
230
+ """
231
+ Standalone image/text -> Ideogram 4 structured-JSON caption pipeline (Gemma-4). Load with
232
+ `ModularPipeline.from_pretrained(repo, trust_remote_code=True)` +
233
+ `load_components(torch_dtype=torch.bfloat16)`, then either
234
+ `pipe(image=img, output="caption")` (caption an image) or `pipe(prompt="a cat...", output="caption")`
235
+ (enhance a text idea into a caption).
236
+ """
237
+
238
+ model_name = "ideogram4_caption"
239
+ block_classes = [Ideogram4CaptionStep]
240
+ block_names = ["caption"]
241
+
242
+ @property
243
+ def description(self) -> str:
244
+ return "Image or text -> Ideogram 4 structured-JSON caption (Gemma-4; captions an image, or enhances a prompt)."
245
+
246
+ @property
247
+ def outputs(self) -> list[OutputParam]:
248
+ return [
249
+ OutputParam("caption", type_hint=str),
250
+ OutputParam("caption_json", type_hint=dict),
251
+ OutputParam("caption_raw", type_hint=str),
252
+ ]
modular_config.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "Ideogram4CaptionBlocks",
3
+ "_diffusers_version": "0.39.0.dev0",
4
+ "auto_map": {
5
+ "ModularPipelineBlocks": "ideogram4_caption.Ideogram4CaptionBlocks"
6
+ }
7
+ }
modular_model_index.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_blocks_class_name": "Ideogram4CaptionBlocks",
3
+ "_class_name": "ModularPipeline",
4
+ "_diffusers_version": "0.39.0.dev0",
5
+ "caption_model": [
6
+ "transformers",
7
+ "Gemma4ForConditionalGeneration",
8
+ {
9
+ "pretrained_model_name_or_path": "google/gemma-4-E4B-it",
10
+ "revision": null,
11
+ "subfolder": "",
12
+ "type_hint": [
13
+ "transformers",
14
+ "Gemma4ForConditionalGeneration"
15
+ ],
16
+ "variant": null
17
+ }
18
+ ],
19
+ "caption_processor": [
20
+ "transformers",
21
+ "AutoProcessor",
22
+ {
23
+ "pretrained_model_name_or_path": "google/gemma-4-E4B-it",
24
+ "revision": null,
25
+ "subfolder": "",
26
+ "type_hint": [
27
+ "transformers",
28
+ "AutoProcessor"
29
+ ],
30
+ "variant": null
31
+ }
32
+ ]
33
+ }