Feat: pid followup - #9474
Conversation
Follow-up to invoke-ai#9281, addressing the review items: - Require the complete LQ projection. Identification accepted a checkpoint with a single `lq_proj.*` key and `load_pid_decoder` tolerated every missing `lq_proj.*`. Models are built under `skip_torch_weight_init()`, so those weights stayed uninitialised and would decode to garbage/NaNs. `required_lq_proj_keys()` derives the expected key set from the vendored network, and both identification and load now reject any missing key. - Match the install source when identifying backbone and variant. A direct single-file install is stored as `<uuid>/model_ema_bf16.pth`, so the name carried no `res2k…` marker and no backbone hint: SDXL/Qwen-Image decoders were labelled `res2k_sr4x` although only the 2K-to-4K preset exists, and SD3/Qwen-Image decoders were registered as `flux`, which their decode node then rejects. The source (HF path/URL) survives the download and is now matched alongside the on-disk name, with a per-backbone variant fallback. - Replace the hardcoded-path Gemma-2 GGUF loader test (always skipped in CI) with a synthetic tiny Gemma-2 built from mocked GGUF tensors: asserts quantized retention, norm materialisation, meta-buffer repair, absence of meta parameters and a finite forward. The real-file comparison is now opt-in via INVOKEAI_TEST_GEMMA2_GGUF. - Drop stale doc claims: PiD as a decode is now documented separately from the prototype `pid_upscale` node, the starter checkpoints are spread over `checkpoints/` and `checkpoints_deprecated/`, and the GGUF encoder is loaded natively instead of being dequantized by transformers.
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/backend/model_manager/configs/pid_decoder.py:218: Completeness check runs after backbone inference. Anonymous truncated files missinglq_proj.latent_proj.0.weightfail with “cannot determine backbone,” then defaultallow_unknown_models=TrueregistersUnknown_Config, contradicting PR QA requirement to reject them. Test: classify a temp file containing onlynet.lq_proj.latent_proj.1.weightwith nobase/source andallow_unknown=True; current result isUnknown_Config.
Other findings/issues:
invokeai/backend/pid/decode.py:195:required_lq_proj_keys()initializes Conv/Linear weights only to inspect names, consuming global CPU RNG during model identification. Later unseeded randomness changes with install order. Test: compare the nexttorch.rand()after callingrequired_lq_proj_keys.cache_clear(); required_lq_proj_keys(...)against a seeded control; values differ.
Alternative implementation ideas
-
Instead of constructing initialized
LQProjection2D, build it under meta initialization ortorch.random.fork_rng; this keeps key probing deterministic and side-effect free. -
Instead of validating LQ keys after backbone inference, validate normalized LQ keys first using a base-independent expected set; this catches missing diagnostic keys and prevents
Unknown_Configfallback. -
Instead of duplicating
net.normalization ininvokeai/backend/model_manager/configs/pid_decoder.pyand the loader, share one normalizer; this prevents future identification/runtime key-contract drift.
… RNG Review follow-ups for invoke-ai#9474. 1. `_raise_if_lq_projection_incomplete` ran after `_validate_base`, but the backbone is read from `lq_proj.latent_proj.0.weight` — one of the weights a truncated file may be missing. Such a file therefore failed with "cannot determine PiD decoder backbone" instead of the "missing … LQ projection weights" message the install flow promises. Completeness is now checked first, against `common_required_lq_proj_keys()`, the key set every backbone requires. The per-backbone check stays after the backbone is known: the two sets are the same 71 keys today, so it is a no-op that keeps the check from silently weakening to the intersection if a backbone ever adds LQ parameters of its own. Note this does not change the `Unknown_Config` fallback: `ModelConfigFactory` applies that to any file no config matches, and `allow_unknown_models` defaults to true. With it disabled the truncated file is rejected outright. The PR's QA step is worded as if rejection were unconditional; it is not, and that is a model-manager-wide behaviour rather than anything PiD-specific. 2. `required_lq_proj_keys()` built a real `LQProjection2D` just to read parameter names, running every `reset_parameters()` and so drawing from the global CPU RNG during model identification — leaving later unseeded randomness dependent on how many candidate files were probed. It is now built on the meta device inside `torch.random.fork_rng`. 3. The `net.` normalisation existed twice, and the copies had already diverged: only the loader dropped the distill-only submodules (`net_ema.`, `fake_score.`, `discriminator.`). Since `net_ema.*` shadows PidNet's own parameter names, the drift ran in the direction where identification accepts what the loader then refuses. Both now share `backend/pid/state_dict_utils.py`.
JPPhoto
left a comment
There was a problem hiding this comment.
Prior RNG and key-normalization findings are fixed!
This has surfaced:
invokeai/backend/model_manager/configs/pid_decoder.py:197:NotAMatchErroris caught byfactory.py:665-688; defaultallow_unknown_models=truereturnsUnknown_Config, and_probepersists it. Test: partial.pthcontaining onlynet.lq_proj.latent_proj.1.weightwithallow_unknown=TruereturnsUnknown_Config. Violates PR QA contract.
Other ideas:
-
Instead of routing invalid PiD files through generic
Unknown_Config, make recognized-but-invalid PiD classification fatal; this prevents bad database records. -
Instead of intersecting per-backbone probes, expose one normalized expected-key contract; this removes duplicate checks and future key drift.
…gistering it Identification rejected a truncated PiD checkpoint with NotAMatchError, which only means "not my kind of model": ModelConfigFactory collects those, finds no match, and with allow_unknown_models (default: true) falls back to Unknown_Config. A file that had already identified itself as a PiD decoder and was then found to be missing LQ projection weights was therefore still installed, as an unknown model with a database record, and only failed once something tried to load it. Add InvalidMatchError for "recognised, and unusable". It is deliberately not a NotAMatchError subclass, since the factory catches that one per candidate class and would swallow it. When no config class matched and at least one raised it, classification returns no config regardless of allow_unknown, and ModelInstallService._probe reports the specific reason rather than the misleading "could not identify model". Order the architecture check ahead of the completeness check. A v1.5 checkpoint is intact, just built to a shape InvokeAI cannot construct; judged against the legacy key set it would be misreported as truncated and now hard-rejected on top of that. It stays a plain no-match, so it remains registrable as an unknown model - only a broken file is fatal. This costs the completeness check nothing: the hidden dim is read from lq_proj.latent_proj.0.weight, so a file truncated past that weight falls straight through to it. Collapse the LQ key contract to one entry point. common_required_lq_proj_keys() and the per-backbone re-check are gone; required_lq_proj_keys() takes no backbone, the probe lives in the private _probe_lq_proj_keys(), and test_pid_decode.py pins that every backbone agrees, so key drift fails in CI instead of silently weakening the install-time check.
JPPhoto
left a comment
There was a problem hiding this comment.
To fix:
-
invokeai/backend/model_manager/configs/pid_decoder.py:196-211/invokeai/backend/model_manager/configs/factory.py:693-706: A truncated 1024-dim/v1.5 PiD file bypasses completeness, falls back toUnknown_Config, and is registered despite PR metadata promising partial checkpoints are rejected.Test:remove one LQ key from a 1024-dim checkpoint; factory currently returnsUnknown_Config. -
invokeai/app/services/model_install/model_install_default.py:1122-1153: Startup orphan scanning catches only duplicate errors; the new invalid-checkpoint exception propagates and abortsstart()when one corrupt PiD file exists.Test:enablescan_models_on_startup, add a partial PiD orphan, and verify startup continues.
Ideas:
-
Instead of short-circuiting on non-512 dimensions, validate the relevant architecture contract first (or reject unsupported PiD files explicitly); this prevents truncated files entering the unknown-model fallback.
-
Instead of propagating
InvalidModelConfigExceptionfrom orphan scanning, catch/log it and returnFalse; this skips one bad file without aborting startup.
The previous commit made a truncated checkpoint fatal but left the architecture check a plain no-match, on the reasoning that an intact v1.5 file is merely unsupported and should stay registrable. That opened a hole: a file that is both 1024-dim and truncated is rejected by the architecture check first, never reaches the completeness check, and lands back in Unknown_Config - the exact outcome the previous commit set out to prevent. The distinction does not survive contact with the failure mode. Once a file has identified itself as a PiD decoder, any rejection that does not depend on which backbone it is will be raised identically by all five config classes, so the file ends up with no match and is registered through the Unknown_Config fallback. Those rejections are now all InvalidMatchError: unsupported lq_hidden_dim, an incomplete LQ projection, a latent channel count no backbone uses, and a checkpoint whose backbone cannot be determined at all. Splitting them out of _validate_base is what makes that legible. _validate_base now only ever answers "not *this* backbone", which four of the five classes are supposed to say about every valid checkpoint, and every rejection in it stays a NotAMatchError. The backbone-independent checks run ahead of it in from_model_on_disk, architecture first so an intact v1.5 file is diagnosed as unsupported rather than judged against the legacy key set and misreported as truncated. Also handle InvalidModelConfigException in the startup orphan scan. ModelSearch._walk_directory already contains anything the on_model_found callback raises, so startup was never actually at risk; catching it in the callback makes skipping a bad file a property of the scan rather than of its caller, and names the file and the reason in the log.
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
-
invokeai/backend/model_manager/configs/pid_decoder.py:241: Arbitrary local source paths affect backbone detection;/flux/model_sd3.pthis classified as FLUX becausefluxmatches first. Test: classify that path with a complete 16-channel PiD fixture; current result is FLUX, not SD3. -
invokeai/backend/model_manager/configs/pid_decoder.py:45: Completeness checks only LQ keys. A file with all LQ keys but no 385 backbone keys is registered, thenload_pid_decoder()rejects it. Test: factory-classify an LQ-only checkpoint; expect no config, but current result is a FLUX config.
Other findings/issues:
invokeai/backend/model_manager/configs/pid_decoder.py:87: Wrong-shaped required tensors are accepted when the filename supplies a backbone. Loading later fails with a shape mismatch. Test: use complete key names with rank-1lq_proj.latent_proj.0.weight; classification currently succeeds.
Alternative implementation ideas:
-
Instead of concatenating full source paths with model names, parse only trusted HF metadata and prefer explicit overrides; this prevents incidental directory names from changing base or variant.
-
Instead of checking only LQ keys, derive and compare the full normalized PidNet key contract on meta tensors; this keeps installation and loading acceptance identical.
-
Instead of accepting malformed diagnostic tensors by filename, validate required tensor ranks and shapes during identification; this turns runtime load failures into clear install errors.
…sing from paths Identification checked less than the loader demands and inferred the rest from file paths. Three consequences, all reported in review: A checkpoint with every lq_proj weight and none of the 385 backbone weights was registered, then refused by load_pid_decoder. Only the 71-key LQ projection was ever checked. A subset check is not a milder version of the same guarantee: loaders run under skip_torch_weight_init(), so a weight the checkpoint does not supply is uninitialised memory rather than a default. required_pid_net_shapes() now derives the whole contract - 456 keys and their shapes - from a meta-device PidNet, the same trick the LQ probe already used but applied to the real network instead of one submodule. Missing keys, unexpected keys and wrong shapes are all fatal, because all three are fatal in load_pid_decoder; a stricter installer cannot reject a file that would have loaded. Probing the real net also removes the reason _LQ_PROBE_DIM, _LQ_NUM_RES_BLOCKS_DEFAULT and the hand-copied num_outputs derivation existed, along with the test that kept them in sync. Wrong-shaped tensors were accepted when a filename supplied a backbone. The architecture, the backbone and the kernel are all read off lq_proj.latent_proj.0 .weight, and each read answered None when it was not a 4D conv - so one malformed tensor made all three abstain at once and the file fell through to name-only matching. That weight is now validated first, and the three reads only run when it is there; its absence is a truncation, which the contract check diagnoses better than a guess about the architecture. Backbone detection concatenated the install source, the parent directory and the filename into one string and substring-matched it with a fixed precedence, so /flux/model_sd3.pth matched flux first and was registered as FLUX although the file says sd3. Name components are now matched most-specific-first, a component naming two different backbones decides nothing rather than being resolved by precedence, and a local-path source is not evidence at all - the model manager sets source to the file's own path when there is no remote one, so trusting it means matching arbitrary ancestor directories of the user's model library. Nothing is lost: install_path identifies a local file before it moves it. Requiring the full contract also makes the latent channel count always readable, which retires the name-only backbone path entirely. The name can now only break the FLUX.1 / SD3 / Qwen-Image tie, never pick a backbone outright, and an explicit base override - already validated against one class's Literal - beats it. The checks that would rule out all five configs move out of _validate_base, leaving it to answer only "not this backbone". Also fixes, orthogonally: from_model_on_disk popped `variant` out of the override dict the factory builds once and shares across every candidate class, so the first PiD class to run consumed it and a later one that actually matched fell back to name inference. Verified against all 11 NVIDIA checkpoints: every one matches the contract exactly (missing=0, unexpected=0, no shape mismatch), the 9 supported decoders identify with the right base and variant both in place and as a direct single-file install, and the dinov2 / siglip decoders are rejected by latent channel count rather than registered as unknown models.
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/backend/model_manager/configs/pid_decoder.py:155andinvokeai/backend/pid/state_dict_utils.py:76: A complete bare-PidNet state dict with both a non-string key and an unexpected string key reachessorted(shapes.keys() - contract.keys()); bare checkpoints are intentionally preserved, so mixedint/strkeys raiseTypeError.ModelConfigFactorycatches that as a generic candidate failure, thenallow_unknown=Truereturns and registersUnknown_Config, bypassing this PR's unusable-checkpoint rejection. Test: build a bare fixture fromrequired_pid_net_shapes()plus{1: torch.zeros(1), "not_a_pid_key": torch.zeros(1)}, callModelConfigFactory.from_model_on_disk(..., allow_unknown=True), and assertresult.config is None; current code returnsUnknown_Configafter theTypeError. Exact-head execution was unavailable because the immutable head is absent from the read-only local Git objects.
A bare (un-prefixed) PidNet checkpoint is passed through strip_net_prefix
untouched, on purpose: without the net. prefix there is no evidence the file is
a distill serialisation, so a stray key must reach the unexpected-key checks
rather than be dropped. A .pth unpickles to whatever it contains, so those keys
need not all be strings - and reporting the unexpected ones sorts them. Sorting
{1, "not_a_pid_key"} raises TypeError.
That failure does not surface as a failure. ModelConfigFactory catches an
unexpected exception from a candidate class as a generic no-match, so all five
PiD configs drop out and allow_unknown_models registers the file as
Unknown_Config - the exact fallback these checks exist to close. A complete bare
contract plus one non-string key and one unexpected string key was therefore
installed as an unknown model.
Sort both key sets with key=str, and stop the type annotations claiming
otherwise: strip_net_prefix and pid_net_shapes return dict[Any, ...], not
dict[str, ...], and _Shapes follows. The old signature was not merely imprecise
- it carried a type: ignore for the pass-through return, which is what let a
str-only assumption look checked.
Verified against the eleven real NVIDIA checkpoints: unchanged, all five
supported backbones identify in place and as a direct single-file install, and
the dinov2 / siglip decoders are still rejected by latent channel count.
JPPhoto
left a comment
There was a problem hiding this comment.
-
invokeai/backend/pid/decode.py:219(fed byinvokeai/backend/pid/state_dict_utils.py:48): Bare checkpoints preserve non-string keys, but the loader passes them directly to PyTorch, which raisesAttributeErroron.startswith()before reporting an unexpected key. Test: pass a complete state dict plus{1: torch.zeros(1)}toload_pid_decoder(); current head raisesAttributeErrorinstead of InvokeAI'sRuntimeError.- Instead of passing non-string keys into
load_state_dict(), reject them inload_pid_decoder()first; this keeps malformed-checkpoint handling explicit and avoids PyTorch's low-level error.
- Instead of passing non-string keys into
The previous commit taught identification to tolerate a bare checkpoint whose
keys are not all strings, and justified keeping those keys with a claim about
the loader that is simply wrong: nn.Module.load_state_dict calls .startswith()
on every key, so a non-string one raises AttributeError from inside torch before
any unexpected key is reported. Passing a complete state dict plus {1: tensor}
to load_pid_decoder raised that AttributeError rather than the RuntimeError the
function reports every other unusable checkpoint with.
load_pid_decoder now checks for non-string keys before it hands anything to
torch, and says what is actually wrong with the file. Identification already
rejects such a checkpoint, so this is the second line rather than the first -
but load_pid_decoder is public, the model cache reaches it for records written
before this PR, and a file can be swapped on disk after install.
The reasoning in strip_net_prefix and its test is corrected to match what torch
does. Keeping non-string keys is still right - dropping them would hide a
malformed file from the checks meant to catch it - but the burden it puts on
consumers is the opposite of what was written there: neither may assume the key
type, so identification sorts its key reports with key=str and the loader
rejects non-strings up front.
Verified: the reviewer's repro now raises "PiD checkpoint has 1 keys that are
not strings and so cannot name a PidNet parameter: [1]". The eleven real NVIDIA
checkpoints are unaffected, 22/22 as before.
JPPhoto
left a comment
There was a problem hiding this comment.
You might want to look into this or make another followup:
invokeai/backend/pid/state_dict_utils.py:34andinvokeai/backend/pid/state_dict_utils.py:67: Prefixed checkpoints silently drop non-string keys before contract validation and loader rejection. Test: add{1: torch.zeros(1)}to a completenet.-prefixed fixture and classify withallow_unknown=True; current head returns a PiD config instead of rejecting the malformed checkpoint.
Alternative implementation ideas:
- Instead of dropping non-string keys during prefixed normalization, preserve and reject them before loading; this gives bare and prefixed checkpoints one consistent contract and prevents silent acceptance.
Summary
Follow-up to #9281 (PiD decode). Backend, tests and docs only — no frontend behaviour changes (the
schema.tsdiff is a regenerated docstring).PiD decoder identification checked less than the loader demands and inferred the rest from file paths, so a checkpoint InvokeAI cannot load could be installed and only fail later. This PR closes that gap and the follow-on problem it exposed: a file that identification does reject was still being registered, because a rejection had no way to say "this is my kind of model, and it is broken".
1. Hold a checkpoint to the whole
PidNetcontractOnly the 71-key LQ projection was ever checked, so a checkpoint carrying every
lq_projweight and none of the 385 backbone weights was registered and then refused byload_pid_decoder. A subset check is not a milder version of the same guarantee: loaders run underskip_torch_weight_init(), so a weight the checkpoint does not supply is uninitialised memory rather than a default — such a file decodes to garbage or NaNs instead of failing.required_pid_net_shapes()now derives the full contract — 456 keys and their shapes — from a meta-devicePidNet, the same probe trick the LQ check already used, applied to the real network instead of one submodule. Missing keys, unexpected keys and wrong shapes are all fatal, because all three are fatal inload_pid_decoder. A stricter installer cannot reject a file that would have loaded.Probing the real network also retires
_LQ_PROBE_DIM,_LQ_NUM_RES_BLOCKS_DEFAULTand the hand-copiednum_outputsderivation, along with the test that kept them in sync with the vendored module.2. Stop guessing the backbone from paths
lq_proj.latent_proj.0.weight, and each read answeredNonewhen it was not a 4D conv — so one malformed tensor made all three abstain at once and the file fell through to name-only matching. That weight is now validated first and the three reads only run when it is there. Its absence is a truncation, which the contract check diagnoses far better than a guess about the architecture./flux/model_sd3.pthmatchedfluxfirst and was registered as FLUX although the file says sd3. Components are now matched most-specific-first, and a component naming two different backbones decides nothing rather than being resolved by precedence.sourceto the file's own path when there is no remote one, so trusting it means matching arbitrary ancestor directories of wherever the user keeps their models. Nothing is lost:install_pathidentifies a local file before it moves it.baseoverride beats it.from_model_on_diskpoppedvariantout of the override dict the factory builds once and shares across every candidate class, so the first PiD class to run consumed it and a later one that actually matched fell back to name inference.3. Make a rejection stick (
InvalidMatchError)Every config class signals "not mine" with
NotAMatchError. The factory collects those and, withallow_unknown_models(default: true), answers no-match-at-all by registeringUnknown_Config. That is right for a file nobody recognises and wrong for one that was recognised and found broken: a truncated PiD checkpoint was stored as a normal record and failed only when something tried to load it.InvalidMatchErroris the new signal for "recognised, and unusable". It is deliberately not aNotAMatchErrorsubclass, since the factory catches that one per candidate class and would swallow it. When no class matched and at least one raised it,ModelConfigFactory.from_model_on_diskreturns no config regardless ofallow_unknown, andModelInstallService._probereports the specific reason instead of a misleading "could not identify model".Every rejection that would rule out all five backbone configs raises it — unsupported
lq_hidden_dim, an unmet contract, a latent channel count no backbone uses, a malformed discriminator. Those checks moved out of_validate_base, which is left answering only "not this backbone" and stays entirelyNotAMatchError.Two hazards this class of change surfaced, both fixed:
build_pid_netcannot construct; judged against the legacy contract it would be reported as a pile of missing and unexpected keys rather than as the newer architecture it is. Ordering it first costs the contract check nothing — the hidden dim comes from the same weight, so a file truncated past it falls straight through.strip_net_prefixuntouched by design, so a.pthcan hand identification keys that are not strings — and sorting{1, "not_a_pid_key"}for the unexpected-key report raisesTypeError. The factory catches that as a generic candidate failure, so all five configs drop out and the file is registered asUnknown_Config. Both key sets now sort withkey=str, andstrip_net_prefix/pid_net_shapes/_Shapesno longer annotate a key type they do not guarantee (the old signature carried atype: ignorefor exactly that pass-through, which is what made a str-only assumption look checked).4. Startup orphan scan
_register_orphaned_modelsruns insideModelInstallService.start(), which re-raises anything that escapes it.ModelSearch._walk_directoryalready contains everyon_model_foundexception, so startup was never actually at risk — verified. HandlingInvalidModelConfigExceptionin the callback makes skipping a bad file a property of the scan rather than of its caller, and names the file and the reason in the log.5. Gemma-2 GGUF loader tests
test_gemma2_encoder_gguf_loader.py's only load test pointed at a hardcoded local Windows path and therefore always skipped in CI. It is replaced by a synthetic 2-layer Gemma-2 built from mocked GGUF tensors (Q8_0 projections, F32 norms), exercising the real loader path: projections stayGGMLTensorwith the correct dequantized shape, embedding and RMSNorms are materialized (including llama.cpp's folded+1norm convention), the rotaryinv_freqmeta-buffer is rebuilt, no meta parameters or buffers remain, a forward pass with quantized weights in place returns finite values, and an unexpected tensor fails loudly. The real-file comparison against transformers' own GGUF loader is kept as an opt-in test driven byINVOKEAI_TEST_GEMMA2_GGUF.6. Docs
pid-decode.mdxclaimed PiD "is not a separate upscale pass" although Feat: Add PiD (Pixel Diffusion Decoder) 4× super-resolution decode for FLUX / FLUX.2 / SD3 / SDXL / Z-Image / Qwen-Image #9281 added thepid_upscalenode. Generation PiD and the prototype node are now documented separately, with the node's FLUX-AutoEncoder-only constraint spelled out.checkpoints/andcheckpoints_deprecated/on Hugging Face, not all under the latter.Gemma2Encoder_GGUF_Configstill said transformers dequantizes the GGUF; it is loaded natively. Added a note that a Gemma-2-2b GGUF encoder is a supported manual install.openapi.json/schema.tsregenerated for the docstring.Related Issues / Discussions
Follow-up to #9281 — #9281 (review)
Also addresses the reviews on this PR: 4889406183, 4889527773, 4891646254, 4893418632, 4896390812.
QA Instructions
Automated
pytest tests/backend tests/model_identification tests/app/services/model_install— green. 83 tests across the four touched test files (tests/backend/pid/,test_pid_decoder_config.py,test_orphan_scan.py,test_gemma2_encoder_gguf_loader.py).tests/app/services/model_install/test_model_install.pyon my machine are network-bound (they resolve a localHF_ENDPOINT) and are identical onmain.ruff check/ruff format --checkclean;schema.tsregenerated via the standard typegen.Every rejection path has a factory-level test asserting
result.config is None, that noUnknown_Configwas produced, and that the specific reason survives for_probeto report — partial file, missing backbone weight, unexpected key, wrong shape, intact v1.5, truncated v1.5, unsupported latent channels, malformed discriminator, and a bare checkpoint with a non-string key.Verified against the real NVIDIA checkpoints
11 files locally — all five supported backbones, both presets, plus the two unsupported (
dinov2,siglip) decoders. 22/22 assertions pass (each checkpoint both in place and as a direct single-file install):missing=0, unexpected=0, shape-mismatch=0.baseandvariant.<uuid>/model_ema_bf16.pthwith nobase/variantoverride, all 9 still come out correct (before this work: SD3 2K, SD3 2K-to-4K and Qwen-Image were identified asflux).dinov2(768ch) andsiglip(1152ch) decoders are rejected by latent channel count rather than registered as unknown models.Manual
nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth) into Model Manager → registers assd-3/ 2K and is accepted by the SD3 PiD decode node.Merge Plan
Self-contained.
Checklist
What's Newcopy (if doing a release after this PR)