Skip to content

Commit a59f29a

Browse files
committed
Tile Captioned Sampling: Add grid full-size image for reference for VLM on current tile. Improves detection of copped ambiguous objects.
1 parent b4d7ad7 commit a59f29a

8 files changed

Lines changed: 247 additions & 59 deletions

File tree

‎modules/interface/tile_activity.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""The tiles a tiled sampler is working on, announced as they change.
22
3-
:data:`EVENT` carries ``node_id``, ``prompt_id``, ``phase``, ``tiles``, ``step``, ``steps`` and
4-
``boxes``, each box ``[top, bottom, left, right]`` in fractions of the frame.
3+
:data:`EVENT` carries ``node_id``, ``prompt_id``, ``phase``, ``tiles``, ``step``, ``steps``,
4+
``visited`` and ``boxes``, each box ``[top, bottom, left, right]`` in fractions of the frame.
55
"""
66

77
from __future__ import annotations
@@ -39,27 +39,51 @@ def __init__(self, boxes: list, node_id=None, prompt_id=None):
3939
self.node_id = node_id if node_id is not None else executing_node_id()
4040
self.prompt_id = prompt_id if prompt_id is not None else executing_prompt_id()
4141
self.last = None
42+
self.step = 0
43+
self.steps = 0
44+
self.visited = set()
4245

43-
def __call__(self, phase: str, tiles=(), step: int = 0, steps: int = 0) -> None:
46+
def begin(self, steps: int) -> None:
47+
"""Start counting a sampling run.
48+
49+
Args:
50+
steps: Steps in the run.
51+
"""
52+
self.steps, self.step, self.visited = int(steps), 0, set()
53+
54+
def finished(self, step: int) -> None:
55+
"""Note a step the sampler finished, which opens the next.
56+
57+
Args:
58+
step: The finished step, from 0.
59+
"""
60+
self.step, self.visited = min(int(step) + 1, max(self.steps - 1, 0)), set()
61+
62+
def __call__(self, phase: str, tiles=(), step=None, steps=None) -> None:
4463
"""Announce the tiles being worked on.
4564
4665
Args:
4766
phase: :data:`PREPARE`, :data:`OVERVIEW`, :data:`CAPTION`, :data:`SAMPLE` or
4867
:data:`DONE`.
4968
tiles: Indices of the tiles, row by row.
50-
step: The step being taken, from 0.
51-
steps: Steps in the run.
69+
step: The step being taken from 0, the counted one when left out.
70+
steps: Steps in the run, the counted number when left out.
5271
"""
5372
if self.node_id is None:
5473
return
5574
active = sorted({int(tile) for tile in tiles})
56-
key = (phase, tuple(active), int(step))
75+
step = self.step if step is None else int(step)
76+
steps = self.steps if steps is None else int(steps)
77+
if phase == SAMPLE:
78+
self.visited.update(active)
79+
key = (phase, tuple(active), step)
5780
if key == self.last:
5881
return
5982
self.last = key
6083
fields = {
6184
"node_id": str(self.node_id), "prompt_id": self.prompt_id or "", "phase": phase,
62-
"tiles": active, "step": int(step), "steps": int(steps), "boxes": self.boxes,
85+
"tiles": active, "step": step, "steps": steps, "boxes": self.boxes,
86+
"visited": len(self.visited) if phase == SAMPLE else 0,
6387
}
6488
try:
6589
from server import PromptServer

‎modules/latent/h3_writer.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,9 @@ def make_writer(clip, seed: int, sampling: Sampling, system: str = ""):
314314
system: The rules every question is asked under, empty for none.
315315
316316
Returns:
317-
``write(prompt, image=None, max_length=1024, temperature=None, ruled=True)`` answering
318-
text; ``temperature`` replaces the sampling's own, ``ruled`` asks under ``system``.
317+
``write(prompt, image=None, max_length=1024, temperature=None, ruled=True, images=None)``
318+
answering text; ``temperature`` replaces the sampling's own, ``ruled`` asks under
319+
``system``, ``images`` hands several pictures in place of ``image``.
319320
"""
320321
import comfy.model_management
321322
import comfy.utils
@@ -328,15 +329,16 @@ def make_writer(clip, seed: int, sampling: Sampling, system: str = ""):
328329
def quiet(*args, **kwargs):
329330
comfy.model_management.throw_exception_if_processing_interrupted()
330331

331-
def write(prompt, image=None, max_length=1024, temperature=None, ruled=True):
332+
def write(prompt, image=None, max_length=1024, temperature=None, ruled=True, images=None):
332333
asked = str(prompt)
333334
extra = {}
334335
if ruled and rules:
335336
if honoured:
336337
extra["system_prompt"] = rules
337338
else:
338339
asked = f"Follow these rules.\n\n{rules}\n\n----------\n\n{asked}"
339-
tokens = clip.tokenize(asked, image=image, min_length=1, thinking=bool(sampling.thinking), **extra)
340+
pictures = {"images": list(images)} if images else {"image": image}
341+
tokens = clip.tokenize(asked, min_length=1, thinking=bool(sampling.thinking), **pictures, **extra)
340342
budget = int(max_length) + (THINKING_TOKENS if sampling.thinking else 0)
341343
heat = sampling.temperature if temperature is None else min(float(temperature), sampling.temperature)
342344
# The node's own bar counts its steps; the model's per-token bar only checks for Stop.

‎modules/sampling/tile_captions.py‎

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,38 @@
2121
"OVERVIEW_CONTEXT",
2222
"OVERVIEW_QUESTION",
2323
"SYSTEM_CONTEXT",
24+
"TILE_CONTEXT",
2425
"TILE_QUESTION",
26+
"TILE_QUESTION_PANELS",
2527
"caption_tiles",
2628
"encode",
2729
"require_captioner",
2830
"takes_pictures",
31+
"takes_several_pictures",
2932
]
3033

3134
logger = log.get_logger("sampling.tile_captions")
3235

3336
#: The rules every tile caption is written under.
3437
DEFAULT_SYSTEM_PROMPT = (
3538
"You write the prompt an image model uses to repaint a crop of a larger picture.\n"
36-
"- Look only at the attached crop. Name each subject and object visible in it, largest "
37-
"first, including any only partly in frame, and say which part of it shows. Then describe "
38-
"their surfaces, textures, colours and lighting, and any legible text in double quotes.\n"
39-
"- The overview describes the whole picture, and most of what it names lies outside this "
40-
"crop. Use it only to name and style what the crop shows. Never name anything that is not "
41-
"visible in the crop.\n"
39+
"- You are shown the whole picture with its tiles ruled and numbered and the crop's tile "
40+
"outlined in yellow, and the crop itself at full detail. Describe only what lies inside "
41+
"the crop.\n"
42+
"- Name each subject and object inside the crop, largest first, including any only partly "
43+
"in frame. For each, say which part of it shows, where that part sits within the frame and "
44+
"how much of the frame it fills. For a person or animal only partly in the crop, name "
45+
"exactly which part of the body shows and whose it is, as the whole picture shows it. Then "
46+
"describe their surfaces, textures, colours and lighting, and any legible text in double "
47+
"quotes.\n"
48+
"- Use the whole picture and the overview only to recognise and name what the crop shows. "
49+
"Most of what they show lies outside the crop. Never name anything that is not inside it.\n"
4250
"- Keep the medium and style of the whole picture.\n"
4351
"- Where the crop holds only empty background, sky, water or a plain surface, name that "
4452
"surface and its colour in a few words.\n"
4553
"- Describe what is there directly. Write one plain paragraph of at most 60 words, with no "
46-
"preamble and no list. Never write the words tile, crop, section, corner, overview, "
47-
"attached or image, and never say where in the picture the crop sits."
54+
"preamble and no list. Never write the words tile, crop, overview, outlined, numbered, "
55+
"attached or image, and never say where the crop sits in the whole picture."
4856
)
4957

5058
#: What the model is asked about the whole picture.
@@ -60,14 +68,35 @@
6068
"Call things what the prompt calls them.\n\n"
6169
)
6270

71+
#: What precedes each tile question where the picture's own prompt is known; ``{context}`` is
72+
#: filled in.
73+
TILE_CONTEXT = "The whole picture was made from this prompt, given for names only: {context}\n\n"
74+
6375
#: What is added to the system prompt where the picture's own prompt is known.
6476
SYSTEM_CONTEXT = "\n\nThe original prompt and context of the image was:\n\n{context}"
6577

66-
#: What the model is asked about each tile; ``{overview}`` and ``{place}`` are filled in.
78+
#: What the model is asked about each tile, shown as two pictures; ``{overview}``,
79+
#: ``{number}`` and ``{place}`` are filled in.
6780
TILE_QUESTION = (
68-
"Overview of the whole picture: {overview}\n\n"
69-
"The attached picture is a crop of that picture ({place}). Write the prompt for everything "
70-
"visible in the attached picture."
81+
"Overview of the whole picture, given for names only: {overview}\n\n"
82+
"The first attached picture is the whole picture with its tiles ruled and numbered; tile "
83+
"{number} ({place}) is outlined in yellow. The second is tile {number} at full detail.\n\n"
84+
"Write the prompt for the second picture only. Start with its largest subject. For "
85+
"everything cut off by an edge of the second picture, say which edge it touches and which "
86+
"part of it shows. Leave out anything outside tile {number}, even where the overview or "
87+
"the prompt names it, and copy no sentence from either."
88+
)
89+
90+
#: What the model is asked about each tile, shown as one picture of two panels.
91+
TILE_QUESTION_PANELS = (
92+
"Overview of the whole picture, given for names only: {overview}\n\n"
93+
"The attached picture has two panels. On the left is the whole picture with its tiles "
94+
"ruled and numbered, tile {number} ({place}) outlined in yellow; on the right is tile "
95+
"{number} at full detail.\n\n"
96+
"Write the prompt for the right panel only. Start with its largest subject. For "
97+
"everything cut off by an edge of the right panel, say which edge it touches and which "
98+
"part of it shows. Leave out anything outside tile {number}, even where the overview or "
99+
"the prompt names it, and copy no sentence from either."
71100
)
72101

73102
#: Longest side in pixels of the picture the overview is written from.
@@ -76,6 +105,9 @@
76105
#: Longest side in pixels of the picture a tile caption is written from.
77106
TILE_SIDE = 768
78107

108+
#: Longest side in pixels of the whole picture shown beside each tile.
109+
CONTEXT_SIDE = 768
110+
79111
#: Most tokens an overview may run to.
80112
OVERVIEW_TOKENS = 200
81113

@@ -117,6 +149,22 @@ def require_captioner(captioner, clip, node: str) -> None:
117149
)
118150

119151

152+
def _flat(tokens) -> list:
153+
"""Every entry of a tokenizer's answer, in order."""
154+
batches = tokens.values() if isinstance(tokens, dict) else [tokens]
155+
return [item for batch in batches for row in batch for item in row]
156+
157+
158+
def _pictures_in(tokens) -> int:
159+
"""How many entries of a tokenizer's answer carry a picture rather than a token id."""
160+
count = 0
161+
for item in _flat(tokens):
162+
head = item[0] if isinstance(item, tuple) and item else item
163+
if isinstance(head, dict):
164+
count += 1
165+
return count
166+
167+
120168
def takes_pictures(clip) -> bool:
121169
"""Whether a CLIP's tokenizer turns a picture into tokens.
122170
@@ -126,16 +174,38 @@ def takes_pictures(clip) -> bool:
126174
Returns:
127175
True where tokenizing with a picture answers different tokens than without one.
128176
"""
129-
def flat(tokens):
130-
batches = tokens.values() if isinstance(tokens, dict) else [tokens]
131-
return [item for batch in batches for row in batch for item in row]
177+
try:
178+
plain = clip.tokenize("probe", min_length=1)
179+
seen = clip.tokenize("probe", image=torch.zeros(1, 64, 64, 3), min_length=1)
180+
except Exception:
181+
return False
182+
return len(_flat(seen)) != len(_flat(plain)) or _pictures_in(seen) > 0
183+
184+
185+
def takes_several_pictures(clip) -> bool:
186+
"""Whether a CLIP's tokenizer takes two pictures into one question.
187+
188+
Args:
189+
clip: A ComfyUI ``CLIP``.
132190
191+
Returns:
192+
True where two pictures handed together both arrive in the tokens.
193+
"""
133194
try:
134-
plain = flat(clip.tokenize("probe", min_length=1))
135-
seen = flat(clip.tokenize("probe", image=torch.zeros(1, 64, 64, 3), min_length=1))
195+
probe = torch.zeros(1, 64, 64, 3)
196+
return _pictures_in(clip.tokenize("probe", images=[probe, probe], min_length=1)) == 2
136197
except Exception:
137198
return False
138-
return len(seen) != len(plain) or any(not isinstance(item, (int, tuple)) for item in seen)
199+
200+
201+
def _panels(context: torch.Tensor, crop: torch.Tensor) -> torch.Tensor:
202+
"""The context picture and the crop side by side at one height, a dark gap between them."""
203+
height = int(context.shape[1])
204+
width = max(1, round(crop.shape[2] * height / max(1, crop.shape[1])))
205+
resized = torch.nn.functional.interpolate(crop.movedim(-1, 1), size=(height, width), mode="bicubic",
206+
align_corners=False, antialias=True).movedim(1, -1)
207+
gap = torch.zeros(1, height, max(4, height // 64), 3)
208+
return torch.cat([context, gap, resized.clamp(0.0, 1.0)], dim=2)
139209

140210

141211
def _fit(picture: torch.Tensor, side: int) -> torch.Tensor:
@@ -181,14 +251,19 @@ def _tidy(text: str) -> str:
181251
return reply
182252

183253

184-
def _ask(write, store: OrderedDict, picture: torch.Tensor, question: str, system: str,
254+
def _ask(write, store: OrderedDict, pictures: list, question: str, system: str,
185255
budget: int, ruled: bool) -> str:
186-
"""One answer about a picture, read from the cache where the same question was asked before."""
187-
key = (_digest(picture), question, system if ruled else "", int(budget))
256+
"""One answer about pictures, read from the cache where the same question was asked before."""
257+
key = (tuple(_digest(picture) for picture in pictures), question, system if ruled else "",
258+
int(budget))
188259
if key in store:
189260
store.move_to_end(key)
190261
return store[key]
191-
answer = _tidy(write(question, image=picture, max_length=budget, ruled=ruled))
262+
if len(pictures) > 1:
263+
reply = write(question, images=pictures, max_length=budget, ruled=ruled)
264+
else:
265+
reply = write(question, image=pictures[0], max_length=budget, ruled=ruled)
266+
answer = _tidy(reply)
192267
store[key] = answer
193268
while len(store) > MOST_CACHED:
194269
store.popitem(last=False)
@@ -223,25 +298,30 @@ def caption_tiles(captioner, image: torch.Tensor, grid: tiles.Grid, system_promp
223298
write = h3_writer.make_writer(captioner, 0, SAMPLING, system)
224299
store = _store(captioner)
225300
lead = OVERVIEW_CONTEXT.format(context=context) if context else ""
301+
tile_lead = TILE_CONTEXT.format(context=context) if context else ""
226302
if announce is not None:
227303
announce("overview")
228-
overview = _ask(write, store, _fit(image, OVERVIEW_SIDE), lead + OVERVIEW_QUESTION, system,
304+
overview = _ask(write, store, [_fit(image, OVERVIEW_SIDE)], lead + OVERVIEW_QUESTION, system,
229305
OVERVIEW_TOKENS, ruled=False)
230306
if progress is not None:
231307
progress.update(1)
232308
height, width = int(image.shape[1]), int(image.shape[2])
309+
several = takes_several_pictures(captioner)
310+
whole = _fit(image, CONTEXT_SIDE)
233311
captions = []
234312
for index, (h0, h1, w0, w1) in enumerate(grid.boxes()):
235313
comfy.model_management.throw_exception_if_processing_interrupted()
236314
if announce is not None:
237315
announce("caption", [index])
238316
top, bottom = int(round(h0 * grid.scale)), min(height, int(round(h1 * grid.scale)))
239317
left, right = int(round(w0 * grid.scale)), min(width, int(round(w1 * grid.scale)))
240-
crop = image[:, top:max(bottom, top + 1), left:max(right, left + 1)]
241-
question = lead + TILE_QUESTION.format(overview=overview or "not available",
242-
place=tiles.position(grid, index))
243-
captions.append(_ask(write, store, _fit(crop, TILE_SIDE), question, system, TILE_TOKENS,
244-
ruled=True))
318+
crop = _fit(image[:, top:max(bottom, top + 1), left:max(right, left + 1)], TILE_SIDE)
319+
context_picture = tiles.numbered(whole, grid, index, CONTEXT_SIDE)
320+
template = TILE_QUESTION if several else TILE_QUESTION_PANELS
321+
pictures = [context_picture, crop] if several else [_panels(context_picture, crop)]
322+
question = tile_lead + template.format(overview=overview or "not available",
323+
number=index + 1, place=tiles.position(grid, index))
324+
captions.append(_ask(write, store, pictures, question, system, TILE_TOKENS, ruled=True))
245325
if progress is not None:
246326
progress.update(1)
247327
return overview, captions

‎modules/sampling/tile_refine.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"prepare",
2626
"publish",
2727
"release",
28+
"tracked",
2829
"tile_settings",
2930
"upscaled",
3031
]
@@ -227,6 +228,26 @@ def release(*keep) -> None:
227228
logger.debug("The other models could not be released: %s", error)
228229

229230

231+
def tracked(callback, announce, steps: int):
232+
"""A sampler callback that also counts finished steps for the node's panel.
233+
234+
Args:
235+
callback: The callback the sampler is handed, called as ``callback(step, x0, x, total)``.
236+
announce: The frame's announcer.
237+
steps: Steps in the run.
238+
239+
Returns:
240+
The wrapped callback.
241+
"""
242+
announce.begin(steps)
243+
244+
def counted(step, x0, x, total_steps):
245+
announce.finished(step)
246+
return callback(step, x0, x, total_steps)
247+
248+
return counted
249+
250+
230251
def _publish_plan(picture: torch.Tensor, grid: tiles.Grid) -> None:
231252
"""Publish the tile plan over a picture, for the node's own panel."""
232253
from ..interface import preview

0 commit comments

Comments
 (0)