Tags from a picture, run words to the AI, colour matching after upscales

- tags_from_picture: WD14 tagger (the source's model and thresholds) on a
  picture the user handed; run_status now carries what nodes showed as words
  (texts: a tagger's tags), with a summary and next step for a run that made
  words but no pictures.
- Optional nodes: a block node marked optional goes when its pack is missing,
  and a link "match|decode" takes the first one there. The four upscales end in
  KJNodes' ColorMatch (mkl, the source's) when it is installed; the panel says
  "better with" it otherwise.
- LanPaint's next step keeps the prompt about the whole picture; Follow a pose
  says OpenPose misreads anime (use control_depth), as the source's author did.
- The directory has a size cap: past it, block names alone, so collected ways
  never make begin_task long; shorter headings and few-word lines.

Live-tested on the isolated 8199 ComfyUI: LanPaint (area changed 14.6, outside
0.0, 82 s), tiled 2x (167 s), depth ControlNet (follows the layout), OpenPose
(misreads an anime picture), background removal (8 s), tags (166 s with the
model download), upscale with ColorMatch (73 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-27 03:31:04 +08:00
co-authored by Claude Opus 5.5
parent 22a58d3a83
commit dd9c3ee2f5
21 changed files with 232 additions and 46 deletions
+6
View File
@@ -13,6 +13,9 @@
- **迭代放大**:分 3 小步放大到 1.3 倍,每步轻轻重画,比一步到位更稳。用 Impact Pack。
- **双模型放大**:和"放大"一样,但重画换一个更稳的模型(底模放大时容易走样就用它);新加的模型加载器先是同一个模型,AI 按你的意思换。只用自带节点。
- **背景移除**:把一张图的背景抠掉,存成透明 PNG(文件名以 nobg 开头)。需要节点包 comfyui-inspyrenet-rembg。
- **读图出标签**:你递给 AI 一张图,WD14 打标器读出它的 Danbooru 标签,AI 照着写提示词。需要节点包 ComfyUI-WD14-Tagger,第一次用时会下载打标模型(约 1.2GB)。AI 运行后,节点输出的文字(比如这些标签)现在也会带回给它。
- 装了 KJNodes 时,几种放大都会在最后把颜色对齐到原图(参考工作流的做法),防止放大后颜色跑偏;没装也照常能用,面板上写着"装了 KJNodes 会更好"。
- 照着姿势画用的 OpenPose 认真人照片和 3D 图准,认二次元人物常认错;面板和给 AI 的说明里都写明了,二次元图用照着构图画。
- 画布上有好几个采样器时,AI 要指明用哪一个,AMB 不乱猜。
- AI 工作台右边多了**做法**面板:按"要做的事"分组列出所有做法,每块写着能干什么、从哪学来的。缺节点包或缺模型的变灰,写明缺什么,点缺的节点包会打开 ComfyUI-Manager 去搜它。每块有个开关:关掉的,AI 就看不到,硬要用也会被拒绝。AI 开工时只收到开着又能用的做法,其余的只附一行"装了什么还能用什么"。
- 每块做法现在是一个单独的数据文件(api/blocks/),里面有它的节点、要的节点包和模型、给 AI 的话、面板上的中英文说明。以后收集到新做法,加一个文件就行。
@@ -27,6 +30,9 @@
- **Iterative upscale**: 1.3× in 3 small steps, a light redraw each, steadier than one jump. Uses Impact Pack.
- **Upscale with a second model**: like Upscale, but a second, steadier model redraws it (for a base model that goes wrong enlarged); its new loader starts as the same model and the AI changes it as you say. Built-in nodes only.
- **Remove the background**: saves a picture with its background cut away as a transparent PNG (prefix nobg). Needs comfyui-inspyrenet-rembg.
- **Tags from a picture**: from a picture you hand the AI, the WD14 tagger reads its Danbooru tags for the AI to write the prompt from. Needs ComfyUI-WD14-Tagger; the first time it downloads its model (about 1.2 GB). What a run's nodes show as words (these tags) now reaches the AI with the run.
- With KJNodes, the upscales match their colours to the original at the end (as the source workflow does), so they don't drift; without it they work as before and the panel says they're better with KJNodes.
- OpenPose (Follow a pose) reads photos and 3D well and anime characters often wrongly; the panel and the AI are told so, and to use Follow a layout for anime.
- With several samplers on the canvas the AI has to say which one; nothing is guessed.
- A **Ways** panel on the right of the AI workbench lists every way by job, what it does and where it came from. Ways missing a node pack or a model are greyed with what they lack; a missing pack opens ComfyUI-Manager searched for it. Each has a switch: one turned off is not offered to the AI, and building it is refused. When it begins, the AI gets only the ways that are on and can run here, with one line on what installing which pack would add.
- Each way is now a data file of its own (api/blocks/): its nodes, the packs and models it needs, what the AI is told and the panel's words in both languages, so a way collected later is one more file.
+27 -5
View File
@@ -22,6 +22,8 @@ RUN_POLL_SECONDS = 2.0
MAX_STATUS_WAIT = 55
MAX_PREVIEWS = 8 # the last PreviewImage pictures of a run the AI is told of
MAX_SHOWN = 4 # thumbnails of a finished run's pictures that come with the answer first telling of its end
MAX_TEXTS = 4 # what a run's nodes showed as words (a tagger's tags), the last few
TEXT_CHARS = 2000
def _queue():
@@ -42,6 +44,17 @@ def outputs_of(record_, folder="output"):
return out
def texts_of(record_):
"""What a history record's nodes showed as words (a tagger's tags, a text display): [{node, text}]."""
out = []
for node, output in (record_.get("outputs") or {}).items():
for key in ("tags", "text", "string"):
words = [item for item in output.get(key) or [] if isinstance(item, str) and item.strip()]
if words:
out.append({"node": str(node), "text": " | ".join(words)[:TEXT_CHARS]})
return out
def _registry():
"""ComfyUI's per-node progress of the prompt running now, or None (an older ComfyUI has none)."""
try:
@@ -106,7 +119,7 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
if not prompts:
_stop_run(task_id, step_id, "没有找到已排队的运行 / No queued run was found; completion cannot be confirmed")
return True
images, previews, failed, done = [], [], False, 0
images, previews, texts, failed, done = [], [], [], False, 0
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
stopped = next((item.get("stop") for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
@@ -129,6 +142,7 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
if image not in images: # a prompt ComfyUI answers from its cache repeats the same file
images.append(image)
previews += [image for image in outputs_of(record_, "temp") if image not in previews]
texts += [text for text in texts_of(record_) if text not in texts]
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
@@ -137,13 +151,16 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
failed = failed or bool(step.get("submission_error"))
stopped = step.get("stop")
results = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS]
previews = previews[-MAX_PREVIEWS:]
previews, texts = previews[-MAX_PREVIEWS:], texts[-MAX_TEXTS:]
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
and previews == step.get("previews", []) and (not failed or step.get("ok") is False)):
and previews == step.get("previews", []) and texts == step.get("texts", [])
and (not failed or step.get("ok") is False)):
return False # nothing new to keep: no write
step["results"] = results
if previews:
step["previews"] = previews
if texts:
step["texts"] = texts
step["state"] = "running" if done < len(prompts) else ("cancelled" if stopped else "error" if failed else "done")
step["prompts"] = len(prompts)
if failed:
@@ -307,7 +324,9 @@ def summary(run):
else "Your run was stopped (stop_run).")
if state == "done":
pictures = [item["filename"] for item in run.get("results") or [] if item.get("type") == "image"]
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {len(pictures)} picture(s): {', '.join(pictures[:8])}."
made = f"{len(pictures)} picture(s): {', '.join(pictures[:8])}" if pictures else "no pictures"
words = f" and words from {len(run['texts'])} node(s) (texts)" if run.get("texts") else ""
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {made}{words}."
if state in ("asking", "allowed"):
return "Your run waits for the user's OK in Anomalous; it has not started."
return f"Your run did not finish: {run.get('error') or state}."
@@ -425,6 +444,8 @@ def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
answer["previews_note"] = ("What the run's PreviewImage nodes showed (pose skeletons, masks…), in ComfyUI's temp "
"folder: zoom_image(image, folder='temp') or get_image_info(image, folder='temp', "
"include_image=true) shows them; there is no need to run a preprocessor yourself.")
if run.get("texts"):
answer["texts"] = run["texts"] # what nodes showed as words: a tagger's tags
if run.get("error"):
answer["error"] = run["error"]
# What to do now, in one line a small model can follow (get_situation and the canvas tools say theirs too).
@@ -435,7 +456,8 @@ def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
"allowed": "run_status(wait_seconds=55): it is about to run.",
"done": ("The first answer telling you it ended has its pictures attached (pictures_attached): look at them, "
"then report what came out with images= its pictures; show_panel only to compare or ask. "
"zoom_image looks closer."),
"zoom_image looks closer.") if pictures or not run.get("texts") else
("It made no pictures: texts has what its nodes showed (a tagger's tags); use them (set_prompt), then report."),
"asking": "get_answers(wait_seconds=55): the run waits for the user's OK in Anomalous.",
"cancelled": "It was stopped and nothing more comes of it: ask_user what to change before running again.",
}.get(run.get("state"), "get_situation shows what went wrong (a missing model: check_workflow_models); fix it, then run again.")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"job": "follow",
"rank": 2,
"short": "its layout and shapes",
"short": "its layout",
"title": {
"zh": "照着构图画",
"en": "Follow a layout"
+4 -4
View File
@@ -1,14 +1,14 @@
{
"job": "follow",
"rank": 1,
"short": "its pose",
"short": "its pose, for photos and 3D",
"title": {
"zh": "照着姿势画",
"en": "Follow a pose"
},
"about": {
"zh": "你递给 AI 一张图,AMB 读出人物姿势(OpenPose),用和你底模对得上的控制网让新图摆出这个姿势。",
"en": "From a picture you hand the AI, Anomalous reads the pose (OpenPose) and steers the picture with a ControlNet that fits your model."
"zh": "你递给 AI 一张图,AMB 读出人物姿势(OpenPose),用和你底模对得上的控制网让新图摆出这个姿势。 OpenPose 认真人照片和 3D 图准,认二次元人物常常认错,二次元图用「照着构图画」更稳(参考工作流作者也这么说)。",
"en": "From a picture you hand the AI, Anomalous reads the pose (OpenPose) and steers the picture with a ControlNet that fits your model. OpenPose reads photos and 3D well and anime characters often wrongly; for anime, Follow a layout is steadier (as the source's author says)."
},
"source": {
"zh": "参考工作流的「底图控制网」组",
@@ -75,5 +75,5 @@
"wire_to": "$sampler"
}
},
"after": "run_workflow: the picture now takes that pose (strength 0.8, for the first 80% of the steps; lower either for a looser pose). The prompt still says who and what: change it if it fights the pose."
"after": "run_workflow: the picture now takes that pose (strength 0.8, for the first 80% of the steps; lower either for a looser pose). The prompt still says who and what: change it if it fights the pose. If the picture came out ignoring the pose, OpenPose likely misread an anime picture: try control_depth."
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"job": "detail",
"rank": 1,
"short": "after a picture is made",
"short": "after it is made",
"title": {
"zh": "部位细化(脸和手)",
"en": "Face and hand detailing"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"job": "repaint",
"rank": 3,
"short": "built-in nodes only",
"short": "built-in nodes",
"title": {
"zh": "局部重绘·官方节点",
"en": "Repaint: built-in nodes"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"job": "repaint",
"rank": 1,
"short": "small fixes, the rest untouched",
"short": "small fixes",
"title": {
"zh": "局部重绘·裁切缝合",
"en": "Repaint: crop and stitch"
+1 -1
View File
@@ -75,5 +75,5 @@
}
}
},
"after": "The KSampler is now LanPaint's (same steps, cfg and seed) and repaints only the area, blending it into the rest. Its prompt is still the whole picture's: set_prompt it to what belongs in the area (\"a hand, five fingers\"). Then run_workflow; it takes several times longer than before."
"after": "The KSampler is now LanPaint's (same steps, cfg and seed): it samples the whole picture and changes only the area, blending it in. Keep the prompt about the whole picture and add what the area should show (set_prompt mode append: \"a hand, five fingers\"). Then run_workflow; it takes a few times longer than before."
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"job": "process",
"rank": 1,
"short": "cut the subject out, transparent",
"short": "transparent cut-out",
"title": {
"zh": "背景移除",
"en": "Remove the background"
+46
View File
@@ -0,0 +1,46 @@
{
"job": "follow",
"rank": 3,
"short": "its tags as words",
"title": {
"zh": "读图出标签",
"en": "Tags from a picture"
},
"about": {
"zh": "你递给 AI 一张图,WD14 打标器读出它的 Danbooru 标签,AI 照着写提示词。第一次用时会自动下载打标模型(约 1.2GB)。",
"en": "From a picture you hand the AI, the WD14 tagger reads its Danbooru tags for the AI to write the prompt from. The first time, it downloads its tagger model (about 1.2 GB)."
},
"source": {
"zh": "参考工作流的「图像反推」组",
"en": "The reference workflow's interrogation group"
},
"packs": {
"WD14Tagger|pysssss": "ComfyUI-WD14-Tagger"
},
"takes": "picture",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"tags": {
"class_type": "WD14Tagger|pysssss",
"inputs": {
"image": [
"picture",
0
],
"model": "wd-eva02-large-tagger-v3",
"threshold": 0.35,
"character_threshold": 0.85,
"replace_underscore": false,
"trailing_comma": false,
"exclude_tags": ""
}
}
},
"after": "run_workflow (what the canvas makes is made too): run_status's answer has texts, the picture's tags. Write the prompt from them with set_prompt, keeping the quality words the prompt has, then remove_nodes these two so later runs skip them.",
"notes": "The source's model and thresholds (wd-eva02-large-tagger-v3, 0.35, 0.85)."
}
+24 -4
View File
@@ -7,8 +7,8 @@
"en": "Upscale"
},
"about": {
"zh": "放大模型放大到 1.25 倍,再用同样的设置轻轻重画一遍(强度 0.35),细节是画出来的而不是糊的。另存一张,文件名以 upscaled 开头。",
"en": "An upscale model enlarges it to 1.25×, then a light redraw with the same settings (denoise 0.35) makes the detail real. Saved beside it, prefix upscaled."
"zh": "放大模型放大到 1.25 倍,再用同样的设置轻轻重画一遍(强度 0.35),细节是画出来的而不是糊的。另存一张,文件名以 upscaled 开头。 装了 KJNodes 时,还会把颜色对齐到原图,防止放大后颜色跑偏。",
"en": "An upscale model enlarges it to 1.25×, then a light redraw with the same settings (denoise 0.35) makes the detail real. Saved beside it, prefix upscaled. With KJNodes, its colours are matched to the original, so they don't drift."
},
"source": {
"zh": "参考工作流的「通用放大」组",
@@ -17,7 +17,7 @@
"packs": {},
"upscaler": true,
"after": "run_workflow saves the picture as before and the larger one beside it (prefix upscaled). The redraw sampler has the first one's steps and cfg at denoise 0.35: lower keeps more of the picture, higher adds more (and above 0.5 may change it). For 2× or more use upscale_tiled.",
"notes": "1.25× and denoise 0.35: the source used 1.25 and 0.32; its author says more than 1.5× goes wrong, and 1.5× of 832×1216 is too much for a 6 GB card.",
"notes": "1.25× and denoise 0.35: the source used 1.25 and 0.32; its author says more than 1.5× goes wrong, and 1.5× of 832×1216 is too much for a 6 GB card. ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
"upscale_by": 1.25,
"nodes": {
"upscaler": {
@@ -99,15 +99,35 @@
]
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"fit",
0
],
"image_target": [
"decode",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"decode",
"match|decode",
0
],
"filename_prefix": "upscaled"
}
}
},
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
}
}
+25 -5
View File
@@ -1,14 +1,14 @@
{
"job": "upscale",
"rank": 2,
"short": "1.3× in 3 small steps, steadier",
"short": "1.3× in 3 steps",
"title": {
"zh": "迭代放大",
"en": "Iterative upscale"
},
"about": {
"zh": "分 3 小步放大到 1.3 倍,每步轻轻重画一次,比一步到位更稳。另存一张,文件名以 upscaled 开头。",
"en": "Enlarges to 1.3× in 3 small steps, a light redraw each, steadier than one jump. Saved beside it, prefix upscaled."
"zh": "分 3 小步放大到 1.3 倍,每步轻轻重画一次,比一步到位更稳。另存一张,文件名以 upscaled 开头。 装了 KJNodes 时,还会把颜色对齐到原图,防止放大后颜色跑偏。",
"en": "Enlarges to 1.3× in 3 small steps, a light redraw each, steadier than one jump. Saved beside it, prefix upscaled. With KJNodes, its colours are matched to the original, so they don't drift."
},
"source": {
"zh": "参考工作流的「迭代放大」组(作者另加的噪声增强要 ComfyUI Noise,这里没用)",
@@ -79,11 +79,28 @@
"vae_compression": 8
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"$save.images",
"*"
],
"image_target": [
"steps",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"steps",
"match|steps",
0
],
"filename_prefix": "upscaled"
@@ -91,5 +108,8 @@
}
},
"after": "run_workflow saves the picture as before and the 1.3× one beside it (prefix upscaled): 3 steps, each redrawn at denoise 0.3 with the sampler's steps and cfg.",
"notes": "The source's values: factor 1.32, 3 steps, denoise 0.3, geometric; its noise injection (ComfyUI Noise) is left out."
"notes": "The source's values: factor 1.32, 3 steps, denoise 0.3, geometric; its noise injection (ComfyUI Noise) is left out. ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
}
}
+25 -4
View File
@@ -7,8 +7,8 @@
"en": "Tiled upscale"
},
"about": {
"zh": "放大 2 倍,按 1024 的小块逐块重画,多大的图都放得下,比「放大」慢。",
"en": "2×, redrawn in 1024 tiles, so any size fits; slower than Upscale."
"zh": "放大 2 倍,按 1024 的小块逐块重画,多大的图都放得下,比「放大」慢。 装了 KJNodes 时,还会把颜色对齐到原图,防止放大后颜色跑偏。",
"en": "2×, redrawn in 1024 tiles, so any size fits; slower than Upscale. With KJNodes, its colours are matched to the original, so they don't drift."
},
"source": {
"zh": "节点包 UltimateSDUpscale 自带的示例",
@@ -60,16 +60,37 @@
"tile_height": 1024
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"$save.images",
"*"
],
"image_target": [
"tiles",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"tiles",
"match|tiles",
0
],
"filename_prefix": "upscaled"
}
}
},
"after": "run_workflow saves the picture as before and the 2× one beside it (prefix upscaled); each tile is redrawn with the sampler's steps and cfg at denoise 0.3 (higher adds more detail, and may add seams)."
"after": "run_workflow saves the picture as before and the 2× one beside it (prefix upscaled); each tile is redrawn with the sampler's steps and cfg at denoise 0.3 (higher adds more detail, and may add seams).",
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
},
"notes": "ColorMatch mkl after the redraw is the source's, optional (KJNodes)."
}
+25 -5
View File
@@ -1,14 +1,14 @@
{
"job": "upscale",
"rank": 3,
"short": "1.25×, redrawn by a second, steadier model",
"short": "1.25×, a second model redraws",
"title": {
"zh": "双模型放大",
"en": "Upscale with a second model"
},
"about": {
"zh": "和「放大」一样,但重画换一个模型:底模放大时不稳(眼睛走样、多出肚脐)就换一个更稳的。新加的模型加载器先是同一个模型,由 AI 按你的意思换。",
"en": "Like Upscale, but the redraw uses a second model, for a base model that goes wrong when enlarged. The new loader starts as the same model; the AI changes it as you say."
"zh": "和「放大」一样,但重画换一个模型:底模放大时不稳(眼睛走样、多出肚脐)就换一个更稳的。新加的模型加载器先是同一个模型,由 AI 按你的意思换。 装了 KJNodes 时,还会把颜色对齐到原图,防止放大后颜色跑偏。",
"en": "Like Upscale, but the redraw uses a second model, for a base model that goes wrong when enlarged. The new loader starts as the same model; the AI changes it as you say. With KJNodes, its colours are matched to the original, so they don't drift."
},
"source": {
"zh": "参考工作流的「Refiner/放大」加载器和作者的说明",
@@ -17,7 +17,7 @@
"packs": {},
"upscaler": true,
"after": "The redraw's model comes from a new loader (second_model), for now the same checkpoint as the first: set_model it to the steadier one the user wants (ask_user, search_models), of the same family (SDXL with SDXL: it reuses the first model's prompts). Then run_workflow; the larger one is saved beside it (prefix upscaled).",
"notes": "The source keeps a second loader for upscaling; its author: switch to it when the base model goes wrong enlarged.",
"notes": "The source keeps a second loader for upscaling; its author: switch to it when the base model goes wrong enlarged. ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
"upscale_by": 1.25,
"nodes": {
"second_model": {
@@ -104,15 +104,35 @@
]
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"fit",
0
],
"image_target": [
"decode",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"decode",
"match|decode",
0
],
"filename_prefix": "upscaled"
}
}
},
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ def _blocks(ctx):
info = object_info(ctx) if ctx.get("base_url") else None
except ToolError:
info = None
return {"note": "add_nodes(block=…) builds one on the canvas, wired in; prefer it to node by node.",
return {"note": "add_nodes(block=…) builds one, wired in; prefer it.",
**reference_blocks.directory(info)}
+20 -8
View File
@@ -28,8 +28,9 @@ TAKES = {"inpaint": "the picture with the area as its alpha (inpaint_image from
CONTROLS = {"pose": ("openpose", "pose"), "depth": ("depth",)}
ANY_CONTROL = ("union", "promax")
# The directory's headings, what an AI wants done; under each, its blocks with a few words apiece.
JOBS = {"repaint": "Repaint a marked area", "upscale": "Enlarge a picture", "follow": "Follow a picture the user gave",
"detail": "Fix faces and hands", "process": "Work on a picture the user gave"}
JOBS = {"repaint": "Repaint an area", "upscale": "Upscale", "follow": "Follow a given picture",
"detail": "Fix faces and hands", "process": "Picture work"}
DIRECTORY_CHARS = 800 # what begin_task carries of it: past this, the blocks' names alone, however many are collected
_lock = threading.Lock()
@@ -100,8 +101,10 @@ def status(info):
packs = sorted(set(block["packs"].values()))
lacking = {pack for kind, pack in block["packs"].items() if kind not in info}
models = [] if lacking else _missing_models(block, info)
extras = sorted({pack for kind, pack in (block.get("extras") or {}).items() if kind not in info})
out.append({"name": name, "job": block["job"], "title": block["title"], "about": block["about"],
"source": block.get("source") or {}, "packs": [{"name": pack, "installed": pack not in lacking} for pack in packs],
"better_with": extras,
"models": models, "usable": not lacking and not models, "on": name not in off,
**({"instead": block["instead"]} if block.get("instead") else {})})
return out
@@ -124,11 +127,15 @@ def directory(info=None):
lacking = sorted({pack for kind, pack in block["packs"].items() if kind not in info}) if info is not None else []
models = _missing_models(block, info) if info is not None and not lacking else []
if lacking or models:
later.append(f"{name} ({', '.join(lacking) or ', '.join(item['kind'] + ' model' for item in models)})")
later.append((name, ', '.join(lacking) or ', '.join(item['kind'] + ' model' for item in models)))
else:
out.setdefault(JOBS.get(block["job"], "Other"), []).append(f"{name} ({block['short']})")
listed = {heading: ", ".join(names) for heading, names in out.items() if names}
return {**listed, **({"Not here yet, needs": ", ".join(later)} if later else {})}
out.setdefault(JOBS.get(block["job"], "Other"), []).append((name, block["short"]))
def lines(words): # "name (a few words)" while it fits, else the names alone
listed = {heading: ", ".join(f"{name} ({why})" if words else name for name, why in names) for heading, names in out.items() if names}
return {**listed, **({"Not here yet, needs": ", ".join(f"{name} ({why})" if words else name for name, why in later)} if later else {})}
full = lines(True)
return full if len(json.dumps(full, ensure_ascii=False)) <= DIRECTORY_CHARS else lines(False)
def _choices(info, kind, name):
@@ -204,8 +211,13 @@ def nodes_of(name, info, image=None, main_base=lambda: "", base_of=lambda name:
fills["$fit"] = round(block.get("upscale_by", 1) / times, 4) # the model's 4× brought back to the block's size
if block.get("control"):
fills["$controlnet"] = _controlnet(info, block["control"], main_base(), base_of)
return {key: {**spec, "inputs": {input: fills.get(value, value) if isinstance(value, str) else value
for input, value in spec["inputs"].items()}} for key, spec in block["nodes"].items()}
# A node of a pack that only makes the block better (ColorMatch after an upscale) goes when the pack
# is missing; a link "match|decode" takes the first of those nodes still there.
nodes = {key: spec for key, spec in block["nodes"].items() if not spec.get("optional") or spec["class_type"] in info}
link = lambda value: [next(key for key in value[0].split("|") if key in nodes), value[1]] if "|" in str(value[0]) else value # noqa: E731
return {key: {**{field: value for field, value in spec.items() if field != "optional"},
"inputs": {input: fills.get(value, value) if isinstance(value, str) else link(value) if isinstance(value, list) else value
for input, value in spec["inputs"].items()}} for key, spec in nodes.items()}
def roles_of(name):
+1 -1
View File
@@ -148,7 +148,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
**让 AI 局部重绘**:标注里选 **▧ 涂重绘区**,用笔刷把要重画的地方涂上(笔刷粗细可调;框、圈、画笔圈出的地方也算),右边 **怎么改** 选 **只改标出的地方**(用笔刷时会自动选上)再发给 AI。另外两种是 **交给 AI 判断**(AI 看说明自己定)和 **整张重新出图**(不做局部重绘);你选的会作为你的要求写在 AI 收到的第一句,时间线里那条标注也写着“改法:…”。AMB 会在 ComfyUI 的 input 文件夹里放一张 `anomalous_inpaint_….png`:原图加上你涂的区域做遮罩。AI 收到时会被告诉怎么用它(LoadImage 读进来就带遮罩,用原图的模型和提示词,重绘强度 0.5~0.8 保留原样子,1.0 完全重画),不确定时用选择题问你强度,画完用前后对比给你看。这张文件删任务时不会删,因为你的工作流可能还在用它。
**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**局部重绘·裁切缝合**(圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch)、**局部重绘(官方节点)**(没装那个包时用)、**局部重绘·LanPaint**(融合最好、任何模型都行,但慢好几倍,需要节点包 LanPaint)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**迭代放大**(分 3 小步放大到 1.3 倍,用 Impact Pack)、**双模型放大**(重画换一个更稳的模型)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。
**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**局部重绘·裁切缝合**(圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch)、**局部重绘(官方节点)**(没装那个包时用)、**局部重绘·LanPaint**(融合最好、任何模型都行,但慢好几倍,需要节点包 LanPaint)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**迭代放大**(分 3 小步放大到 1.3 倍,用 Impact Pack)、**双模型放大**(重画换一个更稳的模型)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。
**先对一下重画的范围**:圈得不准也没关系。AI 准备只重画标出的地方时,会先在展台把 **你圈的** 和 **AI 调整后** 的范围并排给你看(红色是要重画的地方;AI 可以挪位置、改成方框或椭圆、往外扩、把边缘做软,图下写着外扩和羽化了多少像素)。点 **用 AI 调整的**、**用我圈的** 或 **我重新圈**,也可以在下面写一句(比如“再往手腕多圈一点”),AI 会照着再调一次;点 **让 AI 定** 就用 AI 调整的。选好以后 AMB 才把带这个范围的 `anomalous_inpaint_….png` 放进 input 文件夹,节点怎么搭由 AI 自己来。
+3 -1
View File
@@ -70,11 +70,13 @@ class ProgressTests(unittest.TestCase):
self.assertIn("run_status(wait_seconds=55)", ai_task_runs.status("Claude")["next"])
history = {"p1": {"status": {"completed": True}, "outputs": {
"9": {"images": [{"filename": "a.png", "subfolder": "x", "type": "output"}]},
"12": {"images": [{"filename": "pose_00001_.png", "type": "temp"}]}}}} # a PreviewImage: the AI's to look at
"12": {"images": [{"filename": "pose_00001_.png", "type": "temp"}]}, # a PreviewImage: the AI's to look at
"14": {"tags": ["1girl, red hair, smile"]}}}} # a tagger's words
threading.Timer(0.3, ai_task_runs._finish_run, (task_id, step_id, ["p1"], history)).start()
status = ai_task_runs.status("Claude", wait_seconds=5)
self.assertEqual((status["state"], status["pictures"], status["previews"]), ("done", ["x/a.png"], ["pose_00001_.png"]))
self.assertIn("folder='temp'", status["previews_note"])
self.assertEqual(status["texts"], [{"node": "14", "text": "1girl, red hair, smile"}], "what nodes showed as words")
self.assertEqual([item["filename"] for item in ai_tasks.get_task(task_id)["steps"][0]["results"]], ["a.png"], "previews are no results")
self.assertNotIn("progress", ai_tasks.get_task(task_id)["steps"][0], "a finished run has no progress left")
+16 -2
View File
@@ -43,7 +43,7 @@ class BlockTests(unittest.TestCase):
def test_the_directory_lists_what_can_run_by_job_and_the_rest_in_a_line(self):
listed = reference_blocks.directory(BUILT_IN)
self.assertEqual(listed[reference_blocks.JOBS["repaint"]], "inpaint_basic (built-in nodes only)", "only what runs here")
self.assertEqual(listed[reference_blocks.JOBS["repaint"]], "inpaint_basic (built-in nodes)", "only what runs here")
self.assertIn("inpaint_lanpaint (LanPaint)", listed["Not here yet, needs"])
self.assertIn("upscale (upscaler model)", listed["Not here yet, needs"], "built-in nodes, but no upscale model")
everything = reference_blocks.directory(EVERYTHING)
@@ -52,6 +52,10 @@ class BlockTests(unittest.TestCase):
self.assertTrue(all(f"{name} (" in text for name in reference_blocks.BLOCKS), "every block is in it")
self.assertEqual(set(reference_blocks.directory()), set(reference_blocks.JOBS.values()), "node list unknown: all, nothing claimed")
self.assertLess(len(" ".join(everything.values())), 900, "small: it goes with begin_task")
with mock.patch.object(reference_blocks, "DIRECTORY_CHARS", 200):
names = reference_blocks.directory(EVERYTHING)
self.assertEqual(names[reference_blocks.JOBS["repaint"]], "inpaint_crop_stitch, inpaint_lanpaint, inpaint_basic",
"too many to say each: the names alone")
def test_a_block_turned_off_is_not_offered_and_cannot_be_built(self):
reference_blocks.set_on("inpaint_lanpaint", False)
@@ -110,6 +114,15 @@ class BlockTests(unittest.TestCase):
reference_blocks.nodes_of("upscale_tiled", {**EVERYTHING, "UpscaleModelLoader": {}})
self.assertIn("upscale_tiled (ComfyUI_UltimateSDUpscale)", reference_blocks.directory(BUILT_IN)["Not here yet, needs"])
def test_a_node_only_making_a_block_better_goes_when_its_pack_is_missing(self):
nodes = reference_blocks.nodes_of("upscale", {**EVERYTHING, "ColorMatch": {}})
self.assertEqual((nodes["match"]["inputs"]["image_target"], nodes["save"]["inputs"]["images"]), (["decode", 0], ["match", 0]))
self.assertNotIn("optional", nodes["match"])
plain = reference_blocks.nodes_of("upscale", {key: value for key, value in EVERYTHING.items() if key != "ColorMatch"})
self.assertEqual(("match" in plain, plain["save"]["inputs"]["images"]), (False, ["decode", 0]))
shown = {item["name"]: item for item in reference_blocks.status(BUILT_IN)}
self.assertEqual(shown["upscale"]["better_with"], ["ComfyUI-KJNodes"])
def test_lanpaint_takes_the_samplers_place(self):
nodes = reference_blocks.nodes_of("inpaint_lanpaint", EVERYTHING, "a.png")
self.assertEqual((nodes["sampler"]["replaces"], nodes["decode"]["replaces"]), ("$sampler", "$decode"))
@@ -137,7 +150,8 @@ class BlockTests(unittest.TestCase):
for value in spec["inputs"].values():
if isinstance(value, list):
with self.subTest(block=name, node=key):
self.assertTrue(value[0] in block["nodes"] or value[0].split(".")[0] in ("$sampler", "$decode", "$save"))
self.assertTrue(all(part in block["nodes"] for part in value[0].split("|"))
or value[0].split(".")[0] in ("$sampler", "$decode", "$save", "$loader"))
for field in ("wire_to", "like", "replaces"):
if field in spec:
self.assertIn(spec[field], ("$sampler", "$decode", "$save", "$loader"))
+2
View File
@@ -1416,6 +1416,7 @@ export const i18n = {
aiBlocksWhat_depth: "深度",
aiBlocksWhat_none: "",
aiBlocksSource: "来源:{source}",
aiBlocksBetterWith: "装了 {names} 会更好",
aiBlocksOnHint: "开着:AI 能用这块",
aiBlocksOffHint: "关着:AI 看不到这块",
aiBlocksSwitch: "让 AI 用「{name}」",
@@ -4846,6 +4847,7 @@ export const i18n = {
aiBlocksWhat_depth: "depth",
aiBlocksWhat_none: "",
aiBlocksSource: "From: {source}",
aiBlocksBetterWith: "Better with {names}",
aiBlocksOnHint: "On: the AI can use it",
aiBlocksOffHint: "Off: the AI doesn't see it",
aiBlocksSwitch: "Let the AI use {name}",
+1
View File
@@ -56,6 +56,7 @@ function blockRow(block, owner, redraw) {
el('span', `anomalous-ai-block-state${block.usable ? ' is-ready' : ''}`, t(block.usable ? 'aiBlocksReady' : 'aiBlocksMissing')));
body.append(head, el('div', 'anomalous-ai-block-about', words(block.about)));
if (!block.usable) body.append(lacks(block, owner));
if (block.better_with?.length) body.append(el('div', 'anomalous-ai-block-source', t('aiBlocksBetterWith', { names: block.better_with.join(', ') })));
if (words(block.source)) body.append(el('div', 'anomalous-ai-block-source', t('aiBlocksSource', { source: words(block.source) })));
const toggle = el('input', 'anomalous-ai-block-switch');
toggle.type = 'checkbox';