feat(inspect): accept any non-sampler latent origin
This commit is contained in:
@@ -247,6 +247,14 @@ ancestry are compared with it. Equal texts resolve with the detailer as the samp
|
||||
different texts are `ambiguous` with `multistage-differs` and one candidate per
|
||||
stage; an unreadable stage makes the chain `unresolved`. Unanchored, a sampler
|
||||
whose output only feeds detailers (or previews) is not a candidate of its own.
|
||||
A sampler's latent ancestry is walked through every link input of non-sampler
|
||||
nodes within the same visit and depth budgets: with no earlier sampler it is a
|
||||
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
|
||||
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
|
||||
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
|
||||
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
|
||||
compared; an unlisted node with sampler-shaped inputs is `unsupported-node` naming
|
||||
it; running out of budget is `work-limit` or `depth-limit`, never single stage.
|
||||
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
|
||||
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
|
||||
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
|
||||
|
||||
+10
-10
@@ -636,12 +636,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -678,12 +678,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
|
||||
@@ -1994,7 +1994,9 @@ class TestEncoderAndStringSemantics(unittest.TestCase):
|
||||
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertIsNone(evidence["prompts"]["positive"]["text"])
|
||||
|
||||
@unittest.expectedFailure # needs Task 2.5 (EmptyFlux2LatentImage origin)
|
||||
# TODO(PR3): flip with the f01b vector once output ancestry excludes the
|
||||
# preview-only FaceDetailer (D19); the latent origin is accepted since 2.5.
|
||||
@unittest.expectedFailure
|
||||
def test_f01b_single_flux2_branch_resolves_with_absent_negative(self):
|
||||
result = _inspect(parity.build("f01b")["prompt"])
|
||||
self.assertEqual(result["state"], "absent")
|
||||
@@ -2002,14 +2004,10 @@ class TestEncoderAndStringSemantics(unittest.TestCase):
|
||||
self.assertIsNone(result["negative"])
|
||||
|
||||
def test_f01b_branch_text_is_the_exact_join(self):
|
||||
# Until Task 2.5 the flux2 latent origin is unsupported, so this swaps
|
||||
# it for an EmptyLatentImage and anchors at the branch's saver: the
|
||||
# join, multiline strings and encoder alone must give the exact text.
|
||||
# Anchored at the branch's saver, over its EmptyFlux2LatentImage origin:
|
||||
# the join, multiline strings and encoder alone give the exact text.
|
||||
source = parity.build("f01b")["prompt"]
|
||||
source["1300:7"] = {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 1024, "height": 1024, "batch_size": 1},
|
||||
}
|
||||
self.assertEqual(source["1300:7"]["class_type"], "EmptyFlux2LatentImage")
|
||||
result = _inspect(source, output_node_id="1400")
|
||||
self.assertEqual(result["state"], "absent")
|
||||
self.assertEqual(result["reason"], "Guider has no negative input")
|
||||
@@ -2394,6 +2392,248 @@ class TestDetailerStages(unittest.TestCase):
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
|
||||
|
||||
MULTISTAGE = "Multistage sampler ancestry is ambiguous"
|
||||
OTHER = "a white owl at dusk"
|
||||
|
||||
|
||||
def _latent_from(node: dict, extra: dict = None) -> dict:
|
||||
"""``f02``'s KSampler graph whose ``latent_image`` reads ``node`` (as node 5)."""
|
||||
return parity._ksampler_graph(
|
||||
parity._enc(parity.POS), parity._enc(parity.NEG), latent=node, extra=extra
|
||||
)
|
||||
|
||||
|
||||
def _image_to_image(positive: str = OTHER) -> dict:
|
||||
"""KSampler 3 -> VAEDecode 8 -> VAEEncode 60 -> KSampler 61 -> SaveImage 63."""
|
||||
source = _latent_from(None)
|
||||
source.update(
|
||||
{
|
||||
"60": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["8", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"62": parity._enc(positive),
|
||||
"61": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
**source["3"]["inputs"],
|
||||
"positive": ["62", 0],
|
||||
"latent_image": ["60", 0],
|
||||
"denoise": 0.6,
|
||||
},
|
||||
},
|
||||
"64": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["61", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"63": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "img2img", "images": ["64", 0]},
|
||||
},
|
||||
}
|
||||
)
|
||||
# The first stage's own output is only a preview.
|
||||
source["9"]["class_type"] = "PreviewImage"
|
||||
return source
|
||||
|
||||
|
||||
class TestLatentAncestry(unittest.TestCase):
|
||||
"""Any latent origin without an upstream sampler is a single stage."""
|
||||
|
||||
def test_vaeencode_from_load_image_is_single_stage(self):
|
||||
source = parity.build("f10")["prompt"]
|
||||
for anchor in (None, "9"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(source, output_node_id=anchor)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertIsNone(result["reason_code"])
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
# Ingestion is unchanged (D1).
|
||||
legacy = resolve_prompt_pair({"prompt": source})
|
||||
self.assertEqual(legacy["state"], "unresolved")
|
||||
self.assertEqual(legacy["reason"], "Unsupported latent ancestry")
|
||||
|
||||
def test_model_specific_empty_latents_are_single_stage(self):
|
||||
size = {"width": 1024, "height": 1024, "batch_size": 1}
|
||||
origins = {
|
||||
"f11": parity.build("f11")["prompt"],
|
||||
"EmptyLatentBatch": _latent_from(
|
||||
{"class_type": "EmptyLatentBatch", "inputs": dict(size)}
|
||||
),
|
||||
"EmptyFlux2LatentImage": _latent_from(
|
||||
{"class_type": "EmptyFlux2LatentImage", "inputs": dict(size)}
|
||||
),
|
||||
"SetLatentNoiseMask": _latent_from(
|
||||
{
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {"samples": ["60", 0], "mask": ["50", 1]},
|
||||
},
|
||||
{
|
||||
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
"60": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["50", 0], "vae": ["4", 2]},
|
||||
},
|
||||
},
|
||||
),
|
||||
"Reroute": _latent_from(
|
||||
{"class_type": "Reroute", "inputs": {"value": ["60", 0]}},
|
||||
{"60": {"class_type": "EmptyLatentBatch", "inputs": dict(size)}},
|
||||
),
|
||||
}
|
||||
for name in ("InpaintModelConditioning", "WanImageToVideo"):
|
||||
# Slots 0 and 1 carry the conditioning, slot 2 is the latent origin.
|
||||
source = _latent_from(
|
||||
{
|
||||
"class_type": name,
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"vae": ["4", 2],
|
||||
"pixels": ["50", 0],
|
||||
"start_image": ["50", 0],
|
||||
"mask": ["50", 1],
|
||||
"width": 832,
|
||||
"height": 480,
|
||||
},
|
||||
},
|
||||
{"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}},
|
||||
)
|
||||
source["3"]["inputs"].update(
|
||||
positive=["5", 0], negative=["5", 1], latent_image=["5", 2]
|
||||
)
|
||||
origins[name] = source
|
||||
for name, source in origins.items():
|
||||
with self.subTest(origin=name):
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(_inspect(source, output_node_id="9"), result)
|
||||
|
||||
def test_upstream_sampler_through_vaedecode_vaeencode_is_multistage(self):
|
||||
for positive in (OTHER, parity.POS):
|
||||
with self.subTest(positive=positive):
|
||||
result = _inspect(_image_to_image(positive), output_node_id="63")
|
||||
# Until PR3 compares stage texts, never one stage's prompt.
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["sampler_id"], "61")
|
||||
self.assertEqual(result["reason"], MULTISTAGE)
|
||||
self.assertIsNone(result["reason_code"])
|
||||
self.assertIsNone(result["positive"])
|
||||
self.assertIsNone(result["negative"])
|
||||
# An upstream sampler reached through a non-latent input is a stage too.
|
||||
source = _latent_from(
|
||||
{
|
||||
"class_type": "InpaintModelConditioning",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"vae": ["4", 2],
|
||||
"pixels": ["71", 0],
|
||||
"mask": ["50", 1],
|
||||
},
|
||||
},
|
||||
{
|
||||
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
"70": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["72", 0],
|
||||
},
|
||||
},
|
||||
"71": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["70", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"72": {"class_type": "EmptyLatentImage", "inputs": {}},
|
||||
},
|
||||
)
|
||||
source["3"]["inputs"]["latent_image"] = ["5", 2]
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason"]), ("unresolved", MULTISTAGE)
|
||||
)
|
||||
# A latent that reaches its own sampler is malformed, not a stage.
|
||||
source = _latent_from(
|
||||
{"class_type": "SetLatentNoiseMask", "inputs": {"samples": ["3", 0]}}
|
||||
)
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "malformed")
|
||||
)
|
||||
self.assertEqual(result["reason"], "Cyclic latent ancestry")
|
||||
source["3"]["inputs"]["latent_image"] = None
|
||||
self.assertEqual(_inspect(source)["reason_code"], "malformed")
|
||||
|
||||
def test_unclassified_sampler_shaped_latent_start_is_unsupported_and_named(self):
|
||||
# An unlisted node with sampler inputs may itself be an earlier stage:
|
||||
# never call the chain single-stage across it.
|
||||
source = _latent_from(
|
||||
{
|
||||
"class_type": "MysteryLatentSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"latent_image": ["60", 0],
|
||||
},
|
||||
},
|
||||
{"60": {"class_type": "EmptyLatentImage", "inputs": {}}},
|
||||
)
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["reason"], "Unsupported latent ancestry")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "5", "class_type": "MysteryLatentSampler"}
|
||||
)
|
||||
self.assertIsNone(result["positive"])
|
||||
|
||||
def test_latent_walk_budget_exhaustion_is_work_limit(self):
|
||||
# 2,100 distinct images in the latent ancestry: more than one
|
||||
# candidate's 2,000 visits, and never reported as single stage.
|
||||
extra = {
|
||||
f"i{index}": {"class_type": "LoadImage", "inputs": {"image": f"{index}"}}
|
||||
for index in range(2_100)
|
||||
}
|
||||
extra["50"] = {
|
||||
"class_type": "ImageBatchMulti",
|
||||
"inputs": {f"image_{index}": [f"i{index}", 0] for index in range(2_100)},
|
||||
}
|
||||
source = _latent_from(
|
||||
{
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["50", 0], "vae": ["4", 2]},
|
||||
},
|
||||
extra,
|
||||
)
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "work-limit")
|
||||
self.assertIsNone(result["positive"])
|
||||
self.assertEqual(
|
||||
_inspect(parity.build("f10")["prompt"], max_visits=2)["reason_code"],
|
||||
"work-limit",
|
||||
)
|
||||
# A chain longer than the depth limit is ``depth-limit``.
|
||||
source = _latent_from({"class_type": "EmptyLatentImage", "inputs": {}})
|
||||
previous = "5"
|
||||
for index in range(70):
|
||||
source[f"u{index}"] = {
|
||||
"class_type": "LatentUpscaleBy",
|
||||
"inputs": {"samples": [previous, 0], "scale_by": 1.0},
|
||||
}
|
||||
previous = f"u{index}"
|
||||
source["3"]["inputs"]["latent_image"] = [previous, 0]
|
||||
self.assertEqual(_inspect(source)["reason_code"], "depth-limit")
|
||||
source["3"]["inputs"]["latent_image"] = ["u10", 0]
|
||||
self.assertEqual(_inspect(source)["state"], "resolved")
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
|
||||
|
||||
|
||||
@@ -1135,7 +1135,10 @@ class _GraphResolver:
|
||||
# associating the final image with one prompt pair would be a guess.
|
||||
latent = entry.get("latent")
|
||||
if latent is not None and latent in inputs:
|
||||
self.latent_source(inputs[latent], set(), 0)
|
||||
if self.semantics is None:
|
||||
self.latent_source(inputs[latent], set(), 0)
|
||||
else:
|
||||
self.latent_stages(sampler_id, inputs[latent])
|
||||
guider_id, depth = None, 0
|
||||
conditioning = entry["conditioning"]
|
||||
absent_reason = "Negative input is absent"
|
||||
@@ -1195,6 +1198,44 @@ class _GraphResolver:
|
||||
return
|
||||
raise self.unsupported("Unsupported latent ancestry", node_id)
|
||||
|
||||
def latent_stages(self, origin: str, value: Any) -> None:
|
||||
"""Semantics table: is there an earlier sampler in the latent ancestry?
|
||||
|
||||
Every link input of non-sampler nodes is walked breadth-first (encoders,
|
||||
decoders, masks, conditioning), stopping at listed samplers; each node
|
||||
draws a visit. No sampler found means a single stage whatever the origin
|
||||
(VAEEncode, EmptyLatentBatch, WanImageToVideo slot 2, ...). An unlisted
|
||||
node with sampler-shaped inputs could itself be a stage, so it is
|
||||
``unsupported-node``. Budget or depth exhaustion is never single stage.
|
||||
"""
|
||||
if not _link(value):
|
||||
raise _Unresolved("Unknown latent ancestry", "malformed")
|
||||
seen: set = set()
|
||||
queue = [(str(value[0]), 0)]
|
||||
while queue:
|
||||
node_id, depth = queue.pop(0)
|
||||
if node_id in seen:
|
||||
continue
|
||||
if node_id == origin:
|
||||
raise _Unresolved("Cyclic latent ancestry", "malformed")
|
||||
if depth >= self.max_depth:
|
||||
raise _Unresolved("Resolution depth limit reached", "depth-limit")
|
||||
seen.add(node_id)
|
||||
self.budget.spend()
|
||||
name, inputs = self.node(node_id)
|
||||
if name in self.stage_classes:
|
||||
# ``multistage-differs`` is reserved for when stage texts are
|
||||
# compared (PR3); until then no stage's prompt is chosen.
|
||||
raise _Unresolved("Multistage sampler ancestry is ambiguous")
|
||||
if name not in self.semantics.all_classes and any(
|
||||
_link(inputs.get(key)) for key in _SAMPLER_LIKE_INPUTS
|
||||
):
|
||||
raise self.unsupported("Unsupported latent ancestry", node_id)
|
||||
self.scan(len(inputs))
|
||||
queue.extend(
|
||||
(str(item[0]), depth + 1) for item in inputs.values() if _link(item)
|
||||
)
|
||||
|
||||
def anchor(self, node_id: str) -> str:
|
||||
seen = set()
|
||||
slot = 0
|
||||
|
||||
Reference in New Issue
Block a user