266 lines
10 KiB
Python
266 lines
10 KiB
Python
"""EasyEdit delta-caption builders — CPU-only prompt helpers, no dependencies.
|
|
|
|
The ``anima_subject_edit`` EasyControl adapter is trained on *instructions*, not
|
|
descriptions: the prompt is the tag delta between the conditioning image's
|
|
caption and the desired result's caption —
|
|
|
|
additions (bare tags, in the target caption's order)
|
|
then removals (the source's tags that should go, each prefixed with ``-``)
|
|
|
|
Shared tags cancel out, which is the whole point: the character name drops out
|
|
of the prompt, so identity can only come from the reference image. Hand-writing
|
|
that string is easy to get subtly wrong (removal prefix, ordering, a tag left in
|
|
both halves), so these two nodes build it:
|
|
|
|
``AnimaEasyEditInstruction``
|
|
Two text fields — what to add, what to remove — into one instruction.
|
|
|
|
``AnimaEasyEditDelta``
|
|
Two full captions — the reference's and your edited copy of it — into the
|
|
delta between them. Mirrors ``easycontrol_adapters/tools/
|
|
subject_edit_pairs.py::delta_caption`` (the miner that produced the training
|
|
prompts) exactly, so what the node emits is shaped like what the adapter
|
|
saw. Wire the Anima Tagger's caption output into ``source_caption``, edit a
|
|
copy into ``target_caption``, and you get "edit by editing the caption"
|
|
without an inversion pass.
|
|
"""
|
|
|
|
# Marks a tag as "remove this" in a delta caption. Matches the miner's
|
|
# --removal_prefix default; the adapter learned negation against this exact
|
|
# token, so it is a constant here rather than a user knob.
|
|
REMOVAL_PREFIX = "-"
|
|
|
|
# Underscore→space normalization is skipped for short tags so Danbooru
|
|
# emoticons (``^_^``, ``>_<``, ``x_x``, ``._.`` — all 3 chars) survive intact,
|
|
# while ordinary underscored tags (``blue_eyes``) are converted to the
|
|
# space-separated form Anima's vocabulary actually uses.
|
|
_EMOTICON_MAX_LEN = 3
|
|
|
|
|
|
def _normalize(tag: str, underscores_to_spaces: bool) -> str:
|
|
tag = tag.strip()
|
|
if (
|
|
underscores_to_spaces
|
|
and "_" in tag
|
|
and len(tag) > _EMOTICON_MAX_LEN
|
|
and any(c.isalpha() for c in tag)
|
|
):
|
|
tag = tag.replace("_", " ").strip()
|
|
return tag
|
|
|
|
|
|
def split_tags(text: str, underscores_to_spaces: bool = True) -> list[str]:
|
|
"""A caption / tag-list string → an ordered, de-duplicated tag list.
|
|
|
|
Commas and newlines both separate (users paste one-per-line as often as
|
|
comma-joined). Empty fragments are dropped; the first occurrence of a
|
|
repeated tag wins, so order is stable.
|
|
"""
|
|
if not text:
|
|
return []
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for chunk in text.replace("\n", ",").split(","):
|
|
tag = _normalize(chunk, underscores_to_spaces)
|
|
if tag and tag not in seen:
|
|
seen.add(tag)
|
|
out.append(tag)
|
|
return out
|
|
|
|
|
|
def build_instruction(additions: list[str], removals: list[str]) -> str:
|
|
"""(additions, removals) → the delta-caption instruction string.
|
|
|
|
Additions come first as bare tags, then removals prefixed with
|
|
``REMOVAL_PREFIX``. A tag already carrying the prefix is not double-
|
|
prefixed, and a tag present in *both* lists is treated as an addition only
|
|
(a "add X and remove X" instruction is contradictory; the adapter follows
|
|
additions far more reliably, so that is the safe reading).
|
|
"""
|
|
add = [t.lstrip(REMOVAL_PREFIX).strip() for t in additions]
|
|
add = [t for t in add if t]
|
|
add_set = set(add)
|
|
rem = []
|
|
for t in removals:
|
|
bare = t.lstrip(REMOVAL_PREFIX).strip()
|
|
if bare and bare not in add_set:
|
|
rem.append(f"{REMOVAL_PREFIX}{bare}")
|
|
return ", ".join(add + rem)
|
|
|
|
|
|
def caption_delta(
|
|
src_tags: list[str], dst_tags: list[str], include_removals: bool = True
|
|
) -> tuple[list[str], list[str]]:
|
|
"""(additions, removals) between two tag lists.
|
|
|
|
Additions in the target caption's order, removals in the source's — the
|
|
same convention the pair miner used to write the training prompts.
|
|
"""
|
|
src, dst = set(src_tags), set(dst_tags)
|
|
additions = [t for t in dst_tags if t not in src]
|
|
removals = [t for t in src_tags if t not in dst] if include_removals else []
|
|
return additions, removals
|
|
|
|
|
|
_UNDERSCORE_TOOLTIP = (
|
|
"Convert underscored tags to the space-separated form Anima's vocabulary "
|
|
"uses (blue_eyes -> blue eyes). Short emoticon tags (^_^, x_x) are left "
|
|
"alone. Turn off if you are pasting tags that are already normalized."
|
|
)
|
|
|
|
|
|
class AnimaEasyEditInstruction:
|
|
"""Build an EasyEdit instruction from separate add / remove tag lists."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"additions": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": (
|
|
"Tags to ADD, comma- or newline-separated. What "
|
|
"should be true of the result but is not true of "
|
|
"the reference image: 'wading in water, twintails, "
|
|
"night'. Anima tag vocabulary."
|
|
),
|
|
},
|
|
),
|
|
"removals": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": (
|
|
"Tags to REMOVE, comma- or newline-separated, "
|
|
"written WITHOUT the '-' prefix (the node adds it). "
|
|
"These must be tags that describe the REFERENCE "
|
|
"image. Note: removing an object that is physically "
|
|
"present in the reference is the adapter's weakest "
|
|
"case — see the README."
|
|
),
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"underscores_to_spaces": (
|
|
"BOOLEAN",
|
|
{"default": True, "tooltip": _UNDERSCORE_TOOLTIP},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("instruction",)
|
|
FUNCTION = "build"
|
|
CATEGORY = "utils"
|
|
DESCRIPTION = (
|
|
"Build an Anima EasyEdit delta-caption instruction from two tag lists "
|
|
"(add / remove). Emits 'additions..., -removals...' — the prompt "
|
|
"convention the subject_edit EasyControl adapter was trained on. Feed "
|
|
"the result into your positive text encode; feed the reference image "
|
|
"into Anima EasyControl (KSampler)."
|
|
)
|
|
|
|
def build(self, additions, removals, underscores_to_spaces=True):
|
|
add = split_tags(additions, underscores_to_spaces)
|
|
rem = split_tags(removals, underscores_to_spaces)
|
|
return (build_instruction(add, rem),)
|
|
|
|
|
|
class AnimaEasyEditDelta:
|
|
"""Compute the EasyEdit instruction as the delta between two captions."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"source_caption": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": (
|
|
"The REFERENCE image's caption. Wire the Anima "
|
|
"Tagger's STRING output here, or paste the tags "
|
|
"yourself."
|
|
),
|
|
},
|
|
),
|
|
"target_caption": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": (
|
|
"The caption you WANT — normally a copy of "
|
|
"source_caption with tags edited, added, or "
|
|
"deleted. The node emits only the difference, so "
|
|
"everything you left alone (including the "
|
|
"character name) drops out of the prompt."
|
|
),
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"include_removals": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"tooltip": (
|
|
"Emit source-only tags as '-tag' removals. Off = "
|
|
"additions only, which is a weaker but safer "
|
|
"instruction when the two captions differ in ways "
|
|
"you did not intend to change."
|
|
),
|
|
},
|
|
),
|
|
"underscores_to_spaces": (
|
|
"BOOLEAN",
|
|
{"default": True, "tooltip": _UNDERSCORE_TOOLTIP},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("instruction", "additions", "removals")
|
|
FUNCTION = "diff"
|
|
CATEGORY = "utils"
|
|
DESCRIPTION = (
|
|
"Diff two Anima captions into an EasyEdit delta-caption instruction: "
|
|
"tags only in the target become additions, tags only in the source "
|
|
"become '-' removals, shared tags cancel. Mirrors the pair miner that "
|
|
"generated the subject_edit adapter's training prompts. Pair with the "
|
|
"Anima Tagger to edit an image by editing its caption."
|
|
)
|
|
|
|
def diff(
|
|
self,
|
|
source_caption,
|
|
target_caption,
|
|
include_removals=True,
|
|
underscores_to_spaces=True,
|
|
):
|
|
src = split_tags(source_caption, underscores_to_spaces)
|
|
dst = split_tags(target_caption, underscores_to_spaces)
|
|
additions, removals = caption_delta(src, dst, include_removals)
|
|
return (
|
|
build_instruction(additions, removals),
|
|
", ".join(additions),
|
|
", ".join(removals),
|
|
)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"AnimaEasyEditInstruction": AnimaEasyEditInstruction,
|
|
"AnimaEasyEditDelta": AnimaEasyEditDelta,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"AnimaEasyEditInstruction": "Anima EasyEdit Instruction (add / remove)",
|
|
"AnimaEasyEditDelta": "Anima EasyEdit Delta (caption diff)",
|
|
}
|