DZRobo commited on
Commit
695fbf0
·
unverified ·
1 Parent(s): 00b8d8e

Initial project structure and core files added (#1)

Browse files

Add MagicNodes repository with documentation, licensing, assets, workflows, presets, and main Python modules for Easy/Hard nodes, CADE 2.5, ControlFusion, QSilk, and supporting utilities. Includes sample images, negative LoRA, Depth Anything v2 vendor code, and setup scripts for ComfyUI integration.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +8 -0
  2. .github/CODEOWNERS +9 -0
  3. .github/FUNDING.yml +5 -0
  4. .github/ISSUE_TEMPLATE/bug_report.yml +67 -0
  5. .github/ISSUE_TEMPLATE/config.yml +8 -0
  6. .github/ISSUE_TEMPLATE/feature_request.yml +39 -0
  7. .github/ISSUE_TEMPLATE/question.yml +20 -0
  8. .github/PULL_REQUEST_TEMPLATE.md +28 -0
  9. .github/workflows/hf-mirror.yml +41 -0
  10. .gitignore +35 -0
  11. CITATION.cff +35 -0
  12. CREDITS.md +14 -0
  13. LICENSE +19 -0
  14. NOTICE +6 -0
  15. README.md +322 -0
  16. __init__.py +97 -0
  17. assets/Anime1.jpg +3 -0
  18. assets/Anime1_crop.jpg +3 -0
  19. assets/Dog1_crop_ours_CADE25_QSilk.jpg +3 -0
  20. assets/Dog1_ours_CADE25_QSilk.jpg +3 -0
  21. assets/MagicNodes.png +3 -0
  22. assets/PhotoCup1.jpg +3 -0
  23. assets/PhotoCup1_crop.jpg +3 -0
  24. assets/PhotoPortrait1.jpg +3 -0
  25. assets/PhotoPortrait1_crop1.jpg +3 -0
  26. assets/PhotoPortrait1_crop2.jpg +3 -0
  27. assets/PhotoPortrait1_crop3.jpg +3 -0
  28. depth-anything/place depth model here +0 -0
  29. docs/EasyNodes.md +54 -0
  30. docs/HardNodes.md +11 -0
  31. docs/hard/CADE25.md +72 -0
  32. docs/hard/ControlFusion.md +70 -0
  33. docs/hard/IDS.md +20 -0
  34. docs/hard/UpscaleModule.md +23 -0
  35. docs/hard/ZeSmartSampler.md +22 -0
  36. init +0 -1
  37. mod/__init__.py +8 -0
  38. mod/easy/__init__.py +8 -0
  39. mod/easy/mg_cade25_easy.py +0 -0
  40. mod/easy/mg_controlfusion_easy.py +611 -0
  41. mod/easy/mg_supersimple_easy.py +148 -0
  42. mod/easy/preset_loader.py +115 -0
  43. mod/hard/__init__.py +9 -0
  44. mod/hard/mg_adaptive.py +39 -0
  45. mod/hard/mg_cade25.py +1864 -0
  46. mod/hard/mg_controlfusion.py +519 -0
  47. mod/hard/mg_ids.py +67 -0
  48. mod/hard/mg_upscale_module.py +72 -0
  49. mod/hard/mg_zesmart_sampler_v1_1.py +210 -0
  50. mod/mg_combinode.py +448 -0
.gitattributes CHANGED
@@ -1,3 +1,11 @@
 
 
 
 
 
 
 
 
1
  *.jpg filter=lfs diff=lfs merge=lfs -text
2
  *.jpeg filter=lfs diff=lfs merge=lfs -text
3
  *.png filter=lfs diff=lfs merge=lfs -text
 
1
+ * text=auto
2
+
3
+ # Track large model files with Git LFS
4
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.pt filter=lfs diff=lfs merge=lfs -text
7
+ *.pth filter=lfs diff=lfs merge=lfs -text
8
+ *.bin filter=lfs diff=lfs merge=lfs -text
9
  *.jpg filter=lfs diff=lfs merge=lfs -text
10
  *.jpeg filter=lfs diff=lfs merge=lfs -text
11
  *.png filter=lfs diff=lfs merge=lfs -text
.github/CODEOWNERS ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Code owners for MagicNodes
2
+ # Docs: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
3
+
4
+ # Default owner for the whole repository
5
+ * @1dZb1
6
+
7
+ # (Optional)
8
+ # /docs/ @1dZb1
9
+ # /mod/ @1dZb1
.github/FUNDING.yml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ github: 1dZb1
2
+ buy_me_a_coffee: dzrobo
3
+ custom:
4
+ - https://buymeacoffee.com/dzrobo
5
+ - https://github.com/sponsors/1dZb1
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Bug report"
2
+ description: "Something is broken or not working as expected"
3
+ title: "[Bug] <short summary>"
4
+ labels: [bug]
5
+ body:
6
+ - type: checkboxes
7
+ id: preflight
8
+ attributes:
9
+ label: Pre‑flight
10
+ description: Please confirm you checked these first
11
+ options:
12
+ - label: I searched existing issues and discussions
13
+ required: true
14
+ - label: I’m on the latest MagicNodes commit and preset files
15
+ required: false
16
+ - type: textarea
17
+ id: summary
18
+ attributes:
19
+ label: Summary
20
+ description: What happened? What did you expect instead?
21
+ placeholder: A clear and concise description of the issue
22
+ validations:
23
+ required: true
24
+ - type: textarea
25
+ id: repro
26
+ attributes:
27
+ label: Steps to reproduce
28
+ description: Minimal steps / workflow to reproduce the problem
29
+ placeholder: |
30
+ 1. Preset/step/config used
31
+ 2. Node settings (seed/steps/cfg/denoise/sampler)
32
+ 3. What you observed
33
+ validations:
34
+ required: true
35
+ - type: textarea
36
+ id: env
37
+ attributes:
38
+ label: Environment
39
+ description: OS/GPU/driver and versions
40
+ placeholder: |
41
+ OS: Windows 11 / Linux
42
+ GPU: RTX 4090 (driver 560.xx)
43
+ Python: 3.10.x | PyTorch: 2.8.0+cu129
44
+ ComfyUI: <commit/date>
45
+ MagicNodes: <commit>
46
+ validations:
47
+ required: false
48
+ - type: textarea
49
+ id: logs
50
+ attributes:
51
+ label: Logs / Screenshots
52
+ description: Paste relevant logs, stack traces, or attach screenshots
53
+ render: shell
54
+ validations:
55
+ required: false
56
+ - type: dropdown
57
+ id: severity
58
+ attributes:
59
+ label: Impact
60
+ options:
61
+ - Crash/blocks generation
62
+ - Wrong output/quality regression
63
+ - UI/Docs glitch
64
+ - Minor inconvenience
65
+ validations:
66
+ required: false
67
+
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Q&A and ideas (Discussions)
4
+ url: https://github.com/1dZb1/MagicNodes/discussions
5
+ about: Ask questions and share ideas in Discussions
6
+ - name: Hugging Face page
7
+ url: https://huggingface.co/DD32/MagicNodes
8
+ about: Releases and mirrors on HF
.github/ISSUE_TEMPLATE/feature_request.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Feature request"
2
+ description: "Request an enhancement or new capability"
3
+ title: "[Feat] <short summary>"
4
+ labels: [enhancement]
5
+ body:
6
+ - type: textarea
7
+ id: problem
8
+ attributes:
9
+ label: Problem / motivation
10
+ description: What use‑case does this solve? Why is it valuable?
11
+ validations:
12
+ required: true
13
+ - type: textarea
14
+ id: proposal
15
+ attributes:
16
+ label: Proposed solution
17
+ description: API/UI/UX draft, presets, examples
18
+ placeholder: Describe the change and how it would work
19
+ validations:
20
+ required: true
21
+ - type: textarea
22
+ id: alternatives
23
+ attributes:
24
+ label: Alternatives considered
25
+ description: Any workarounds or different approaches
26
+ validations:
27
+ required: false
28
+ - type: checkboxes
29
+ id: scope
30
+ attributes:
31
+ label: Scope
32
+ options:
33
+ - label: Easy nodes / presets
34
+ - label: Hard nodes / advanced params
35
+ - label: Docs / examples / workflows
36
+ - label: Performance / memory
37
+ validations:
38
+ required: false
39
+
.github/ISSUE_TEMPLATE/question.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Question / Help"
2
+ description: "Ask a question about usage, presets, or behavior"
3
+ title: "[Q] <short summary>"
4
+ labels: [question]
5
+ body:
6
+ - type: textarea
7
+ id: question
8
+ attributes:
9
+ label: Your question
10
+ description: What do you want to understand or achieve?
11
+ validations:
12
+ required: true
13
+ - type: textarea
14
+ id: context
15
+ attributes:
16
+ label: Context
17
+ description: Share your preset, node settings, or screenshot if helpful
18
+ validations:
19
+ required: false
20
+
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Thank you for your contribution to MagicNodes!
3
+ Please provide a clear summary and the minimal context to review safely.
4
+ -->
5
+
6
+ Title: <short, action‑oriented summary>
7
+
8
+ What
9
+ - Briefly describe the change (user‑facing behavior, docs, presets, or internals).
10
+
11
+ Why
12
+ - What problem does it solve? Link issues/discussions if applicable.
13
+
14
+ How (high level)
15
+ - Outline the approach. Call out any trade‑offs.
16
+
17
+ Test plan
18
+ - Steps or screenshots proving it works (minimal workflow, seed/steps/cfg if relevant).
19
+
20
+ Checklist
21
+ - [ ] Builds/runs locally with default presets
22
+ - [ ] Docs/README updated if behavior changed
23
+ - [ ] No large binaries added (weights go to HF or Releases)
24
+ - [ ] Passes lint/format (if configured)
25
+
26
+ Notes for reviewers
27
+ - Anything sensitive or risky to double‑check (paths, presets, defaults).
28
+
.github/workflows/hf-mirror.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Mirror to Hugging Face
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ concurrency:
9
+ group: hf-mirror-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ mirror:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout (full history)
17
+ uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ lfs: false
21
+
22
+ - name: Configure git identity
23
+ run: |
24
+ git config user.name "github-actions[bot]"
25
+ git config user.email "github-actions[bot]@users.noreply.github.com"
26
+
27
+ - name: Push to HF mirror
28
+ env:
29
+ HF_USERNAME: ${{ secrets.HF_USERNAME }}
30
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
31
+ HF_REPO: MagicNodes
32
+ run: |
33
+ if [ -z "${HF_USERNAME}" ] || [ -z "${HF_TOKEN}" ]; then
34
+ echo "HF secrets are missing. Set HF_USERNAME and HF_TOKEN in repo secrets." >&2
35
+ exit 1
36
+ fi
37
+ git lfs install || true
38
+ git remote add hf "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/${HF_USERNAME}/${HF_REPO}.git" 2>/dev/null || true
39
+ # Mirror current branch to HF main
40
+ git push --force --prune hf HEAD:main
41
+
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / cache
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+
6
+ # Virtual envs / tooling
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .env.*
11
+ .idea/
12
+ .vscode/
13
+
14
+ # OS junk
15
+ .DS_Store
16
+ Thumbs.db
17
+
18
+ # Build/output/temp
19
+ dist/
20
+ build/
21
+ out/
22
+ *.log
23
+ *.tmp
24
+ temp/
25
+ temp_patch.diff
26
+
27
+ # NOTE: We intentionally keep model weights in repo via Git LFS.
28
+ # If you prefer not to ship them, re-add ignores for models/** and weight extensions.
29
+
30
+ # ComfyUI caches
31
+ **/web/tmp/
32
+ **/web/dist/
33
+
34
+ # Node / front-end (if any submodules appear later)
35
+ node_modules/
CITATION.cff ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: If you use CADE 2.5 / MG_SuperSimple, please cite our preprints (ZeResFDG and QSilk).
3
+ title: "MagicNodes: CADE 2.5 (ZeResFDG and QSilk)"
4
+ authors:
5
+ - family-names: Rychkovskiy
6
+ given-names: Denis
7
+ alias: DZRobo
8
+ version: preprint
9
+ date-released: 2025-10-11
10
+ repository-code: https://github.com/1dZb1/MagicNodes
11
+ url: https://huggingface.co/DD32/MagicNodes
12
+
13
+ preferred-citation:
14
+ type: article
15
+ title: CADE 2.5: ZeResFDG - Frequency-Decoupled, Rescaled and Zero-Projected Guidance for SD/SDXL Latent Diffusion Models
16
+ authors:
17
+ - family-names: Rychkovskiy
18
+ given-names: Denis
19
+ year: 2025
20
+ journal: arXiv
21
+ identifiers:
22
+ - type: url
23
+ value: https://arxiv.org/abs/2510.12954
24
+
25
+ references:
26
+ - type: article
27
+ title: QSilk: Micrograin Stabilization and Adaptive Quantile Clipping for Detail-Friendly Latent Diffusion
28
+ authors:
29
+ - family-names: Rychkovskiy
30
+ given-names: Denis
31
+ year: 2025
32
+ journal: arXiv
33
+ identifiers:
34
+ - type: url
35
+ value: https://arxiv.org/abs/2510.15761
CREDITS.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Credits and Attributions
2
+
3
+ This project includes adapted code and ideas from:
4
+
5
+ - KJ-Nodes — ComfyUI-KJNodes (GPL-3.0) by KJ (kijai)
6
+ Repository: https://github.com/kijai/ComfyUI-KJNodes
7
+ Usage: SageAttention integration and attention override approach in mod/mg_sagpu_attention.py.
8
+
9
+ - ComfyUI (GPL-3.0+)
10
+ Source idea: early beta node "Mahiro is so cute that she deserves a better guidance function!! (。・ω・。)"
11
+ Repository: https://github.com/comfyanonymous/ComfyUI
12
+ Usage: inspiration for directional post‑mix ("Muse Blend"); implementation rewritten and expanded in MagicNodes.
13
+
14
+ Licensing note: Under GPLv3 §13, the combined work is distributed under AGPL-3.0-or-later. See LICENSE.
LICENSE ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MagicNodes
2
+ SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ This project is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ The full text of the GNU Affero General Public License v3.0 is available at:
10
+ https://www.gnu.org/licenses/agpl-3.0.txt
11
+
12
+ You should have received a copy of the GNU Affero General Public License
13
+ along with this program. If not, see https://www.gnu.org/licenses/.
14
+
15
+ Copyright (C) 2025 MagicNodes contributors
16
+
17
+ Third-party notices: Portions of this project are derived from third-party code.
18
+ See CREDITS.md for attribution and links to original repositories.
19
+
NOTICE ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Attribution (kind request)
2
+
3
+ Includes CADE 2.5 (ZeResFDG) by Denis Rychkovskiy (“DZRobo”).
4
+
5
+ If you use this work or parts of it, please consider preserving this notice
6
+ in your README/About or documentation.
README.md ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MagicNodes — ComfyUI Render Pipeline (SD/SDXL)
2
+ Simple start. Expert-grade results. Reliable detail.
3
+ [![arXiv](https://img.shields.io/badge/arXiv-2510.12954-B31B1B.svg)](https://arxiv.org/abs/2510.12954) / [![arXiv](https://img.shields.io/badge/arXiv-2510.15761-B31B1B.svg)](https://arxiv.org/pdf/2510.15761)
4
+
5
+ <table>
6
+ <tr>
7
+ <td width="140" valign="top">
8
+ <img src="assets/MagicNodes.png" alt="MagicNodes" width="120" />
9
+ </td>
10
+ <td>
11
+ TL;DR: MagicNodes, it's a plug-and-play multi-pass "render-machine" for SD/SDXL models. Simple one-node start, expert-grade results. Core is ZeResFDG (Frequency-Decoupled + Rescale + Zero-Projection) and the always-on QSilk Micrograin Stabilizer, complemented by practical stabilizers (NAG, local masks, EPS, Muse Blend, Polish). Ships with a four-pass preset for robust, clean, and highly detailed outputs.
12
+
13
+ Our pipeline runs through several purposeful passes: early steps assemble global shapes, mid steps refine important regions, and late steps polish without overcooking the texture. We gently stabilize the amplitudes of the "image’s internal draft" (latent) and adapt the allowed value range per region: where the model is confident we give more freedom, and where it’s uncertain we act more conservatively. The result is clean gradients, crisp edges, and photographic detail even at very high resolutions and, as a side effect on SDXL models, text becomes noticeably more stable and legible.
14
+ </tr>
15
+ </table>
16
+
17
+ Please note that the SDXL architecture itself has limitations and the result depends on the success of the seed, the purity of your prompt and the quality of your model+LoRA.
18
+
19
+ Draw
20
+ <div align="center">
21
+ <img src="assets/Anime1.jpg" alt="Anime full" width="39%" />
22
+ <img src="assets/Anime1_crop.jpg" alt="Anime crop" width="39%" />
23
+
24
+ </div>
25
+
26
+ Photo Portrait
27
+ <div align="center">
28
+ <img src="assets/PhotoPortrait1.jpg" alt="Photo A" width="39%" />
29
+ <img src="assets/PhotoPortrait1_crop1.jpg" alt="Photo B" width="39%" />
30
+ </div>
31
+ <div align="center">
32
+ <img src="assets/PhotoPortrait1_crop2.jpg" alt="Photo C" width="39%" />
33
+ <img src="assets/PhotoPortrait1_crop3.jpg" alt="Photo D" width="39%" />
34
+ </div>
35
+
36
+ Photo Cup
37
+ <div align="center">
38
+ <img src="assets/PhotoCup1.jpg" alt="Photo A" width="39%" />
39
+ <img src="assets/PhotoCup1_crop.jpg" alt="Photo B" width="39%" />
40
+ </div>
41
+
42
+ Photo Dog
43
+ <div align="center">
44
+ <img src="assets/Dog1_crop_ours_CADE25_QSilk.jpg" alt="Photo A" width="39%" />
45
+ <img src="assets/Dog1_ours_CADE25_QSilk.jpg" alt="Photo B" width="39%" />
46
+ </div>
47
+
48
+ ---
49
+
50
+ ## Features
51
+ - ZeResFDG: LF/HF split, energy rescale, and zero-projection (stable early, sharp late)
52
+ - NAG (Normalized Attention Guidance): small attention variance normalization (positive branch)
53
+ - Local spatial gating: optional CLIPSeg masks for faces/hands/pose
54
+ - EPS scale: small early-step exposure bias
55
+ - QSilk Micrograin Stabilizer: gently smooths rare spikes and lets natural micro-texture (skin, fabric, tiny hairs) show through — without halos or grid patterns. Always on, zero knobs, near‑zero cost.
56
+ - Adaptive Quantile Clip (AQClip): softly adapts the allowed range per region. Confident areas keep more texture; uncertain ones get cleaner denoising. Tile‑based with seamless blending (no seams). Optional Attn mode uses attention confidence for an even smarter balance.
57
+ - MGHybrid scheduler: hybrid Karras/Beta sigma stack with smooth tail blending and tiny schedule jitter (ZeSmart-inspired) for more stable, detail-friendly denoising; used by CADE and SuperSimple by default
58
+ - Seed Latent (MG_SeedLatent): fast, deterministic latent initializer aligned to VAE stride; supports pure-noise starts or image-mixed starts (encode + noise) to gently bias content; batch-ready and resolution-agnostic, pairs well with SuperSimple recommended latent sizes for reproducible pipelines
59
+ - Muse Blend and Polish: directional post-mix and final low-frequency-preserving clean-up
60
+ - SmartSeed (CADE Easy and SuperSimple): set `seed = 0` to auto-pick a good seed from a tiny low-step probe. Uses a low-discrepancy sweep, avoids speckles/overexposure, and, if available, leverages CLIP-Vision (with `reference_image`) and CLIPSeg focus text to favor semantically aligned candidates. Logs `Smart_seed_random: Start/End`.
61
+ <b>I highly recommend working with SmartSeed.</b>
62
+ - CADE2.5 pipeline does not just upscale the image, it iterates and adds small details, doing it carefully, at every stage.
63
+
64
+ ## Hardware Requirements
65
+ - GPU VRAM: ~10-28 GB (free memory) for the default presets (start latent ~ 672x944 -> final ~ 3688x5192 across 4 steps). 15-25 GB is recommended; 32 GB is comfortable for large prompts/batches.
66
+ - System RAM: ~12-20 GB during generation (depends on start latent and whether Depth/ControlFusion are enabled). 16+ GB recommended.
67
+ - Notes
68
+ - Lowering the starting latent (e.g., 512x768) reduces both VRAM and RAM.
69
+ - Disabling hi-res depth/edges (ControlFusion) reduces peaks. (not recommended!)
70
+ - Depth weights add a bit of RAM on load; models live under `depth-anything/`.
71
+
72
+
73
+ ## Install (ComfyUI 0.3.60, tested on this version)
74
+ Preparing:
75
+ I recomend update pytorch version: 2.8.0+cu129.
76
+ 1. PyTorch install: `pip install torch==2.8.0 torchvision==0.23.0 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu129`
77
+ 2. CUDA manual download and install: https://developer.nvidia.com/cuda-12-9-0-download-archive?target_os=Windows&target_arch=x86_64&target_version=11&target_type=exe_local
78
+ 3. Install `SageAttention 2.2.0`, manualy `https://github.com/thu-ml/SageAttention` or use script `scripts/check_sageattention.bat`. The installation takes a few minutes, wait for the installation to finish.
79
+
80
+ Next:
81
+ 1. Clone or download this repo into `ComfyUI/custom_nodes/`
82
+ 2. Install helpers: `pip install -r requirements.txt`
83
+ 3. Take my negative LoRA `models/LoRA/mg_7lambda_negative.safetensors` and place the file in ComfyUI, to `ComfyUI/models/loras`
84
+ 4. download model `depth_anything_v2_vitl.pth` https://huggingface.co/depth-anything/Depth-Anything-V2-Large/tree/main and place inside in to `depth-anything/` folder.
85
+ 5. Workflows
86
+ Folder `workflows/` contains ready-to-use graphs:
87
+ - `mg_SuperSimple-Workflow.json` — one-node pipeline (2/3/4 steps) with presets
88
+ - `mg_Easy-Workflow.json` — the same logic built from individual Easy nodes
89
+ You can save this workflow to ComfyUI `ComfyUI\user\default\workflows`
90
+ 6. Restart ComfyUI. Nodes appear under the "MagicNodes" categories.
91
+
92
+ 💥 I strongly recommend use `mg_Easy-Workflow` workflow + default settings + your model and my negative LoRA `mg_7lambda_negative.safetensors`, for best result.
93
+
94
+
95
+ ## 🚀 "One-Node" Quickstart (MG_SuperSimple)
96
+ Start with `MG_SuperSimple` for the easiest path:
97
+ 1. Drop `MG_SuperSimple` into the graph
98
+ 2. Connect `model / positive / negative / vae / latent` and a `Load ControlNet Model` module
99
+ 3. Choose `step_count` (2/3/4) and Run
100
+
101
+ or load `mg_SuperSimple-Workflow` in panel ComfyUI
102
+
103
+ Notes:
104
+ - When "Custom" is off, presets fully drive parameters
105
+ - When "Custom" is on, the visible CADE controls override the Step presets across all steps; Step 1 still enforces `denoise=1.0`
106
+ - CLIP Vision (if connected) is applied from Step 2 onward; if no reference image is provided, SuperSimple uses the previous step image as reference
107
+
108
+ ## ❗Tips
109
+ (!) There are almost always artifacts in the first step, don't pay attention to them, they will be removed in the next steps. Keep your prompt clean and logical, don't duplicate details and be careful with symbols.
110
+
111
+ 0) `MG_SuperSimple-Workflow` is a bit less flexible than `MG_Easy-Workflow`, but extremely simple to use. If you just want a stable, interesting result, start with SuperSimple.
112
+
113
+ 1) Recommended negative LoRA: `mg_7lambda_negative.safetensors` with `strength_model = -1.0`, `strength_clip = 0.2`. Place LoRA files under `ComfyUI/models/loras` so they appear in the LoRA selector.
114
+
115
+ 2) Download a CLIP Vision model and place it under `ComfyUI/models/clip_vision` (e.g., https://huggingface.co/openai/clip-vit-large-patch14; heavy alternative: https://huggingface.co/laion/CLIP-ViT-H-14-laion2B-s32B-b79K). SuperSimple/CADE will use it for reference-based polish.
116
+
117
+ 3) Samplers: i recomend use `ddim` for many cases (Draw and Realism style). Scheduler: use `MGHybrid` in this pipeline.
118
+
119
+ 4) Denoise: higher -> more expressive and vivid; you can go up to 1.0. The same applies to CFG: higher -> more expressive but may introduce artifacts. Suggested CFG range: ~4.5–8.5.
120
+
121
+ 5) If you see unwanted artifacts on the final (4th) step, slightly lower denoise to ~0.5–0.6 or simply change the seed.
122
+
123
+ 6) You can get interesting results by repeating steps (in Easy/Hard workflows), e.g., `1 -> 2 -> 3 -> 3`.  Just experiment with it!
124
+
125
+ 7) Recommended starting latent close to ~672x944 (other aspect ratios are fine). With that, step 4 produces ~3688x5192. Larger starting sizes are OK if the model and your hardware allow.
126
+
127
+ 8) Unlucky seeds happen — just try another. (We may later add stabilization to this process.)
128
+
129
+ 9) Rarely, step 3 can show a strange grid artifact (in both Easy and Hard workflows). If this happens, try changing CFG or seed. Root cause still under investigation.
130
+
131
+ 10) Results depend on checkpoint/LoRA quality. The pipeline “squeezes” everything SDXL and your model can deliver, so prefer high‑quality checkpoints and non‑overtrained LoRAs.
132
+
133
+ 11) Avoid using more than 3 LoRAs at once, and keep only one “lead” LoRA (one you trust is not overtrained). Too many/strong LoRAs can spoil results.
134
+
135
+ 12) Try connecting reference images in either workflow — you can get unusual and interesting outcomes.
136
+
137
+ 13) Very often, the image in `step 3 is of very good quality`, but it usually lacks sharpness. But if you have a `weak system`, you can `limit yourself to 3 steps`.
138
+
139
+ 14) SmartSeed (auto seed pick): set `seed = 0` in Easy or SuperSimple. The node will sample several candidate seeds and do a quick low‑step probe to choose a balanced one. You’ll see logs `Smart_seed_random: Start` and `Smart_seed_random: End. Seed is: <number>`. Use any non‑zero seed for fully deterministic runs.
140
+
141
+ 15) The 4th step sometimes saves the image for a long time, just wait for the end of the process, it depends on the initial resolution you set.
142
+
143
+
144
+
145
+ ## Repository Layout
146
+ ```
147
+ MagicNodes/
148
+ ├─ README.md
149
+ ├─ LICENSE # AGPL-3.0-or-later
150
+ ├─ assets/
151
+ ├─ docs/
152
+ │ ├─ EasyNodes.md
153
+ │ ├─ HardNodes.md
154
+ │ └─ hard/
155
+ │ ├─ CADE25.md
156
+ │ ├─ ControlFusion.md
157
+ │ ├─ UpscaleModule.md
158
+ │ ├─ IDS.md
159
+ │ └─ ZeSmartSampler.md
160
+
161
+ ├─ mod/
162
+ │ ├─ easy/
163
+ │ │ ├─ mg_cade25_easy.py
164
+ │ │ ├─ mg_controlfusion_easy.py
165
+ │ │ └─ mg_supersimple_easy.py
166
+ │ │ └─ preset_loader.py
167
+ │ └─ hard/
168
+ │ ├─ mg_cade25.py
169
+ │ ├─ mg_controlfusion.py
170
+ │ ├─ mg_tde2.py
171
+ │ ├─ mg_upscale_module.py
172
+ │ ├─ mg_ids.py
173
+ │ └─ mg_zesmart_sampler_v1_1.py
174
+
175
+ ├─ pressets/
176
+ │ ├─ mg_cade25.cfg
177
+ │ └─ mg_controlfusion.cfg
178
+
179
+ ├─ scripts/
180
+ │ ├─ check_sageattention.bat
181
+ │ └─ check_sageattention.ps1
182
+
183
+ ├─ depth-anything/ # place Depth Anything v2 weights (.pth), e.g., depth_anything_v2_vitl.pth
184
+ │ └─depth_anything_v2_vitl.pth
185
+
186
+ ├─ vendor/
187
+ │ └─ depth_anything_v2/ # vendored Depth Anything v2 code (Apache-2.0)
188
+
189
+ ├─ models/
190
+ │ └─ LoRA/
191
+ │ └─ mg_7lambda_negative.safetensors
192
+
193
+ ├─ workflows/
194
+ │ ├─ mg_SuperSimple-Workflow.json
195
+ │ └─ mg_Easy-Workflow.json
196
+ |
197
+ └─ requirements.txt
198
+ ```
199
+
200
+ Models folder
201
+ - The repo includes a sample negative LoRA at `models/LoRA/mg_7lambda_negative.safetensors`.
202
+ - To use it in ComfyUI, copy or move the file to `ComfyUI/models/loras` — it will then appear in LoRA selectors.
203
+ - Keeping a copy under `models/` here is fine as a backup.
204
+
205
+ Depth models (Depth Anything v2)
206
+ - Place DA v2 weights (`.pth`) in `depth-anything/`. Recommended: `depth_anything_v2_vitl.pth` (ViT-L). Supported names include:
207
+ `depth_anything_v2_vits.pth`, `depth_anything_v2_vitb.pth`, `depth_anything_v2_vitl.pth`, `depth_anything_v2_vitg.pth`,
208
+ and the metric variants `depth_anything_v2_metric_vkitti_vitl.pth`, `depth_anything_v2_metric_hypersim_vitl.pth`.
209
+ - ControlFusion auto-detects the correct config from the filename and uses this path by default. You can override via the
210
+ `depth_model_path` parameter (preset) if needed.
211
+ - If no weights are found, ControlFusion falls back gracefully (luminance pseudo-depth), but results are better with DA v2.
212
+ - Where to get weights: see the official Depth Anything v2 repository (https://github.com/DepthAnything/Depth-Anything-V2)
213
+ and its Hugging Face models page (https://huggingface.co/Depth-Anything) for pre-trained `.pth` files.
214
+
215
+
216
+ ## Documentation
217
+ - Easy nodes overview and `MG_SuperSimple`: `docs/EasyNodes.md`
218
+ - Hard nodes documentation index: `docs/HardNodes.md`
219
+
220
+ ## Control Fusion (mg_controlfusion.py, mg_controlfusion_easy.py,)
221
+ - Builds depth + edge masks with preserved aspect ratio; hires-friendly mask mode
222
+ - Key surface knobs: `edge_alpha`, `edge_smooth`, `edge_width`, `edge_single_line`/`edge_single_strength`, `edge_depth_gate`/`edge_depth_gamma`
223
+ - Preview can optionally reflect ControlNet strength via `preview_show_strength` and `preview_strength_branch`
224
+
225
+ ## CADE 2.5 (mg_cade25.py, mg_cade25_easy.py)
226
+ - Deterministic preflight: CLIPSeg pinned to CPU; preview mask reset; noise tied to `iter_seed`
227
+ - Encode/Decode: stride-aligned, with larger overlap for >2K to avoid artifacts
228
+ - Polish mode (final hi-res refinement):
229
+ - `polish_enable`, `polish_keep_low` (global form from reference), `polish_edge_lock`, `polish_sigma`
230
+ - Smooth start via `polish_start_after` and `polish_keep_low_ramp`
231
+ - `eps_scale` supported for gentle exposure shaping
232
+
233
+ ## Depth Anything v2 (vendor)
234
+ - Lives under `vendor/depth_anything_v2`; Apache-2.0 license
235
+
236
+ ## MG_ZeSmartSampler (Experimental)
237
+ - Custom sampler that builds hybrid sigma schedules (Karras/Beta blend) with tail smoothing
238
+ - Inputs/Outputs match KSampler: `MODEL/SEED/STEPS/CFG/base_sampler/schedule/CONDITIONING/LATENT` -> `LATENT`
239
+ - Key params: `hybrid_mix`, `jitter_sigma`, `tail_smooth`, optional PC2-like shaping (`smart_strength`, `target_error`, `curv_sensitivity`)
240
+
241
+ ## Seed Latent (mg_seed_latent.py)
242
+ - Purpose: quick LATENT initializer aligned to VAE stride (4xC, H/8, W/8). Can start from pure noise or mix an input image encoding with noise to gently bias content.
243
+ - Inputs
244
+ - `width`, `height`, `batch_size`
245
+ - `sigma` (noise amplitude) and `bias` (additive offset)
246
+ - Optional `vae` and `image` when `mix_image` is enabled
247
+ - Output: `LATENT` dict `{ "samples": tensor }` ready to feed into CADE/SuperSimple.
248
+ - Usage notes
249
+ - Keep dimensions multiples of 8; recommended starting sizes around ~672x944 (other aspect ratios work). With SuperSimple’s default scale, step 4 lands near ~3688x5192.
250
+ - `mix_image=True` encodes the provided image via VAE and adds noise: a soft way to keep global structure while allowing refinement downstream.
251
+ - For run-to-run comparability, hold your sampler seed fixed (in SuperSimple/CADE). SeedLatent itself does not expose a seed; variation is primarily controlled by the sampler seed.
252
+ - Batch friendly: `batch_size>1` produces independent latents of the chosen size.
253
+
254
+ ## Dependencies (Why These Packages)
255
+ - transformers — used by CADE for CLIPSeg (CIDAS/clipseg-rd64-refined) to build text‑driven masks (e.g., face/hands). If missing, CLIPSeg is disabled gracefully.
256
+
257
+ - opencv-contrib-python — ControlFusion edge stack (Pyramid Canny, thinning via ximgproc), morphological ops, light smoothing.
258
+ - Pillow — image I/O and small conversions in preview/CLIPSeg pipelines.
259
+ - scipy — preferred Gaussian filtering path for IDS (quality). If not installed, IDS falls back to a PyTorch implementation.
260
+ - sageattention — accelerated attention kernels (auto-picks a kernel per GPU arch); CADE/attention patch falls back to stock attention if not present.
261
+
262
+ Optional extras
263
+ - controlnet-aux — alternative loader for Depth Anything v2 if you don’t use the vendored implementation (not required by default).
264
+
265
+
266
+ ## Preprint
267
+ - CADE 2.5 - ZeResFDG
268
+ - PDF: https://arxiv.org/pdf/2510.12954.pdf
269
+ - arXiv: https://arxiv.org/abs/2510.12954
270
+
271
+ - CADE 2.5 - QSilk
272
+ - PDF: https://arxiv.org/pdf/2510.15761
273
+ - arXiv: https://arxiv.org/abs/2510.15761
274
+
275
+
276
+ ### How to Cite
277
+ ```
278
+ @misc{rychkovskiy2025cade25zeresfdg,
279
+ title={CADE 2.5 - ZeResFDG: Frequency-Decoupled, Rescaled and Zero-Projected Guidance for SD/SDXL Latent Diffusion Models},
280
+ author={Denis Rychkovskiy},
281
+ year={2025},
282
+ eprint={2510.12954},
283
+ archivePrefix={arXiv},
284
+ primaryClass={cs.CV},
285
+ url={https://arxiv.org/abs/2510.12954},
286
+ }
287
+ ```
288
+ ```
289
+ @misc{rychkovskiy2025qsilkmicrograinstabilizationadaptive,
290
+ title={QSilk: Micrograin Stabilization and Adaptive Quantile Clipping for Detail-Friendly Latent Diffusion},
291
+ author={Denis Rychkovskiy},
292
+ year={2025},
293
+ eprint={2510.15761},
294
+ archivePrefix={arXiv},
295
+ primaryClass={cs.CV},
296
+ url={https://arxiv.org/abs/2510.15761},
297
+ }
298
+ ```
299
+
300
+ ## Attribution (kind request)
301
+ If you use this work or parts of it, please consider adding the following credit in your README/About/credits: "Includes CADE 2.5 (ZeResFDG, QSilk) by Denis Rychkovskiy (“DZRobo”)"
302
+
303
+
304
+ ## License and Credits
305
+ - License: AGPL-3.0-or-later (see `LICENSE`)
306
+
307
+
308
+ ## Support
309
+ If this project saved you time, you can leave a tip:
310
+ - GitHub Sponsors: https://github.com/sponsors/1dZb1
311
+ - Bymeacoffee: https://buymeacoffee.com/dzrobo
312
+
313
+
314
+
315
+
316
+
317
+
318
+
319
+
320
+
321
+
322
+
__init__.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, importlib.util
2
+
3
+ # Normalize package name so relative imports work even if loaded by absolute path
4
+ if __name__ != 'MagicNodes':
5
+ sys.modules['MagicNodes'] = sys.modules[__name__]
6
+ __package__ = 'MagicNodes'
7
+ # Precreate subpackage alias MagicNodes.mod
8
+ _mod_pkg_name = 'MagicNodes.mod'
9
+ _mod_pkg_dir = os.path.join(os.path.dirname(__file__), 'mod')
10
+ _mod_pkg_file = os.path.join(_mod_pkg_dir, '__init__.py')
11
+ if _mod_pkg_name not in sys.modules and os.path.isfile(_mod_pkg_file):
12
+ _spec = importlib.util.spec_from_file_location(
13
+ _mod_pkg_name, _mod_pkg_file, submodule_search_locations=[_mod_pkg_dir]
14
+ )
15
+ _mod = importlib.util.module_from_spec(_spec)
16
+ sys.modules[_mod_pkg_name] = _mod
17
+ assert _spec.loader is not None
18
+ _spec.loader.exec_module(_mod)
19
+
20
+ # Imports of active nodes
21
+ from .mod.mg_combinode import MagicNodesCombiNode
22
+ from .mod.hard.mg_upscale_module import MagicUpscaleModule
23
+ from .mod.hard.mg_adaptive import AdaptiveSamplerHelper
24
+ from .mod.hard.mg_cade25 import ComfyAdaptiveDetailEnhancer25
25
+ from .mod.hard.mg_ids import IntelligentDetailStabilizer
26
+ from .mod.mg_seed_latent import MagicSeedLatent
27
+ from .mod.mg_sagpu_attention import PatchSageAttention
28
+ from .mod.hard.mg_controlfusion import MG_ControlFusion
29
+ from .mod.hard.mg_zesmart_sampler_v1_1 import MG_ZeSmartSampler
30
+ from .mod.easy.mg_cade25_easy import CADEEasyUI as ComfyAdaptiveDetailEnhancer25_Easy
31
+ from .mod.easy.mg_controlfusion_easy import MG_ControlFusionEasyUI as MG_ControlFusion_Easy
32
+ from .mod.easy.mg_supersimple_easy import MG_SuperSimple
33
+
34
+ # Place Easy/Hard variants under dedicated UI categories
35
+ try:
36
+ ComfyAdaptiveDetailEnhancer25_Easy.CATEGORY = "MagicNodes/Easy"
37
+ except Exception:
38
+ pass
39
+ try:
40
+ MG_ControlFusion_Easy.CATEGORY = "MagicNodes/Easy"
41
+ except Exception:
42
+ pass
43
+ try:
44
+ MG_SuperSimple.CATEGORY = "MagicNodes/Easy"
45
+ except Exception:
46
+ pass
47
+ try:
48
+ ComfyAdaptiveDetailEnhancer25.CATEGORY = "MagicNodes/Hard"
49
+ IntelligentDetailStabilizer.CATEGORY = "MagicNodes/Hard"
50
+ MagicUpscaleModule.CATEGORY = "MagicNodes/Hard"
51
+ AdaptiveSamplerHelper.CATEGORY = "MagicNodes/Hard"
52
+ PatchSageAttention.CATEGORY = "MagicNodes"
53
+ MG_ControlFusion.CATEGORY = "MagicNodes/Hard"
54
+ MG_ZeSmartSampler.CATEGORY = "MagicNodes/Hard"
55
+ except Exception:
56
+ pass
57
+
58
+ NODE_CLASS_MAPPINGS = {
59
+ "MagicNodesCombiNode": MagicNodesCombiNode,
60
+ "MagicSeedLatent": MagicSeedLatent,
61
+ "PatchSageAttention": PatchSageAttention,
62
+ "MagicUpscaleModule": MagicUpscaleModule,
63
+ "ComfyAdaptiveDetailEnhancer25": ComfyAdaptiveDetailEnhancer25,
64
+ "IntelligentDetailStabilizer": IntelligentDetailStabilizer,
65
+ "MG_ControlFusion": MG_ControlFusion,
66
+ "MG_ZeSmartSampler": MG_ZeSmartSampler,
67
+ # Easy variants (limited-surface controls)
68
+ "ComfyAdaptiveDetailEnhancer25_Easy": ComfyAdaptiveDetailEnhancer25_Easy,
69
+ "MG_ControlFusion_Easy": MG_ControlFusion_Easy,
70
+ "MG_SuperSimple": MG_SuperSimple,
71
+ }
72
+
73
+ NODE_DISPLAY_NAME_MAPPINGS = {
74
+ "MagicNodesCombiNode": "MG_CombiNode",
75
+ "MagicSeedLatent": "MG_SeedLatent",
76
+ # TDE removed from this build
77
+ "PatchSageAttention": "MG_AccelAttention",
78
+ "ComfyAdaptiveDetailEnhancer25": "MG_CADE 2.5",
79
+ "MG_ControlFusion": "MG_ControlFusion",
80
+ "MG_ZeSmartSampler": "MG_ZeSmartSampler",
81
+ "IntelligentDetailStabilizer": "MG_IDS",
82
+ "MagicUpscaleModule": "MG_UpscaleModule",
83
+ # Easy variants (grouped under MagicNodes/Easy)
84
+ "ComfyAdaptiveDetailEnhancer25_Easy": "MG_CADE 2.5 (Easy)",
85
+ "MG_ControlFusion_Easy": "MG_ControlFusion (Easy)",
86
+ "MG_SuperSimple": "MG_SuperSimple",
87
+ }
88
+
89
+ __all__ = [
90
+ 'NODE_CLASS_MAPPINGS',
91
+ 'NODE_DISPLAY_NAME_MAPPINGS',
92
+ ]
93
+
94
+
95
+
96
+
97
+
assets/Anime1.jpg ADDED

Git LFS Details

  • SHA256: d837bededf87a14359c770da2b5f34f037372725d0be48195049645c05437345
  • Pointer size: 131 Bytes
  • Size of remote file: 680 kB
assets/Anime1_crop.jpg ADDED

Git LFS Details

  • SHA256: 94a52c1a6209bfef368c8d1b042bee69be7a7e7456dfbd843a5dc501e1cc1d6c
  • Pointer size: 131 Bytes
  • Size of remote file: 403 kB
assets/Dog1_crop_ours_CADE25_QSilk.jpg ADDED

Git LFS Details

  • SHA256: bd12b232fcf55a7a3e85c25307e53510d4b2672a94919d81f82149a664a9bdb1
  • Pointer size: 131 Bytes
  • Size of remote file: 294 kB
assets/Dog1_ours_CADE25_QSilk.jpg ADDED

Git LFS Details

  • SHA256: 7352d5eaec36ddbd3f658711ffc8b31585b561ede97687f4e7bae61ae067863b
  • Pointer size: 131 Bytes
  • Size of remote file: 360 kB
assets/MagicNodes.png ADDED

Git LFS Details

  • SHA256: fd0efb24491b12d5dad7ca7961e5bcea1997e1d14258ecec34f1bcae660a88e2
  • Pointer size: 129 Bytes
  • Size of remote file: 8.11 kB
assets/PhotoCup1.jpg ADDED

Git LFS Details

  • SHA256: 1272b61d2c39cd4cc825077649768a4e65047c3a6dd9bb3cc4a539eb8284e455
  • Pointer size: 131 Bytes
  • Size of remote file: 274 kB
assets/PhotoCup1_crop.jpg ADDED

Git LFS Details

  • SHA256: b1dc467287b69ab4e1b6e60dc674a81d9c1b8499bc684aaaa54eab6f89a8b603
  • Pointer size: 131 Bytes
  • Size of remote file: 258 kB
assets/PhotoPortrait1.jpg ADDED

Git LFS Details

  • SHA256: 04f60ab63d7ab6c392f02c30343ff78214dc950540490f6ec0b08d012e3b59d0
  • Pointer size: 131 Bytes
  • Size of remote file: 317 kB
assets/PhotoPortrait1_crop1.jpg ADDED

Git LFS Details

  • SHA256: 18afb84e894c236ed416a74c3fdd26c2203783c3f24abcc0453b1808300b35d8
  • Pointer size: 131 Bytes
  • Size of remote file: 271 kB
assets/PhotoPortrait1_crop2.jpg ADDED

Git LFS Details

  • SHA256: 620685c05fc9114ae42c03a5e89343e175fba0123976c8937d2b47d91b7d3851
  • Pointer size: 131 Bytes
  • Size of remote file: 274 kB
assets/PhotoPortrait1_crop3.jpg ADDED

Git LFS Details

  • SHA256: 5aa9059493a089d05165502dad20a9304b422b333541f8a2f37105f6e9042721
  • Pointer size: 131 Bytes
  • Size of remote file: 234 kB
depth-anything/place depth model here ADDED
File without changes
docs/EasyNodes.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Easy Nodes and MG_SuperSimple
2
+
3
+ MagicNodes provides simplified “Easy” variants that expose only high‑value controls while relying on preset files for the rest. These are grouped under the UI category `MagicNodes/Easy`.
4
+
5
+ - Presets live in `pressets/mg_cade25.cfg` and `pressets/mg_controlfusion.cfg` with INI‑like sections `Step 1..4` and simple `key: value` pairs. The token `$(ROOT)` is supported in paths and is substituted at load time.
6
+ - Loader: `mod/easy/preset_loader.py` caches by mtime and does light type parsing.
7
+ - The Step+Custom scheme keeps UI and presets in sync: choose a Step to load defaults, then optionally toggle Custom to override only the visible controls, leaving hidden parameters from the Step preset intact.
8
+
9
+ ## MG_SuperSimple (Easy)
10
+
11
+ Single node that reproduces the 2/3/4‑step CADE+ControlFusion pipeline with minimal surface.
12
+
13
+ Category: `MagicNodes/Easy`
14
+
15
+ Inputs
16
+ - `model` (MODEL)
17
+ - `positive` (CONDITIONING), `negative` (CONDITIONING)
18
+ - `vae` (VAE)
19
+ - `latent` (LATENT)
20
+ - `control_net` (CONTROL_NET) — required by ControlFusion
21
+ - `reference_image` (IMAGE, optional) — forwarded to CADE
22
+ - `clip_vision` (CLIP_VISION, optional) — forwarded to CADE
23
+
24
+ Controls
25
+ - `step_count` int (1..4): how many steps to run
26
+ - `custom` toggle: when On, the visible CADE controls below override the Step presets across all steps; when Off, all CADE values come from presets
27
+ - `seed` int with `control_after_generate`
28
+ - `steps` int (default 25) — applies to steps 2..4
29
+ - `cfg` float (default 4.5)
30
+ - `denoise` float (default 0.65, clamped 0.45..0.9) — applies to steps 2..4
31
+ - `sampler_name` (default `ddim`)
32
+ - `scheduler` (default `MGHybrid`)
33
+ - `clipseg_text` string (default `hand, feet, face`)
34
+
35
+ Behavior
36
+ - Step 1 runs CADE with `Step 1` preset and forces `denoise=1.0` (single exception to the override rule). All other visible fields follow the Step+Custom logic described above.
37
+ - For steps 2..N: ControlFusion (with `Step N` preset) updates `positive/negative` based on the current image, then CADE (with `Step N` preset) refines the latent/image.
38
+ - Initial `positive/negative` come from the node inputs; subsequent steps use the latest CF outputs. `latent` is always taken from the previous CADE.
39
+ - When `custom` is Off, UI values are ignored entirely; presets define all CADE parameters.
40
+ - ControlFusion inside this node always relies on presets (no additional CF UI here) to keep the surface minimal.
41
+
42
+ Outputs
43
+ - `(LATENT, IMAGE)` from the final executed step (e.g., step 2 if `step_count=2`). No preview outputs.
44
+
45
+ Quickstart
46
+ 1) Drop `MG_SuperSimple` into your graph under `MagicNodes/Easy`.
47
+ 2) Connect `model/positive/negative/vae/latent`, and a `control_net` module; optionally connect `reference_image` and `clip_vision`.
48
+ 3) Choose `step_count` (2/3/4). Leave `custom` Off to use pure presets, or enable it to apply your `seed/steps/cfg/denoise/sampler/scheduler/clipseg_text` across all steps (with Step 1 `denoise=1.0`).
49
+ 4) Run. The node returns the final `(LATENT, IMAGE)` for the chosen depth.
50
+
51
+ Notes
52
+ - Presets are read from `pressets/mg_cade25.cfg` and `pressets/mg_controlfusion.cfg`. Keep them in UTF‑8 and prefer `$(ROOT)` over absolute paths.
53
+ - `seed` is shared across all steps for determinism; if per‑step offsets are desired later, this can be added as an option without breaking current behavior.
54
+
docs/HardNodes.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hard Nodes (Overview)
2
+
3
+ This folder documents the advanced (Hard) variants in MagicNodes. These nodes expose the full surface of controls and are intended for expert tuning and experimentation. Easy variants cover most use‑cases with presets; Hard variants reveal the rest.
4
+
5
+ Available docs
6
+ - CADE 2.5: see `docs/hard/CADE25.md`
7
+ - ControlFusion: see `docs/hard/ControlFusion.md`
8
+ - Upscale Module: see `docs/hard/UpscaleModule.md`
9
+ - Intelligent Detail Stabilizer (IDS): see `docs/hard/IDS.md`
10
+ - ZeSmart Sampler: see `docs/hard/ZeSmartSampler.md`
11
+
docs/hard/CADE25.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CADE 2.5 (ComfyAdaptiveDetailEnhancer25)
2
+
3
+ CADE 2.5 is a refined adaptive enhancer with a single clean iteration loop, optional reference‑driven polishing, and flexible sampler scheduling. It can run standalone or as part of multi‑step pipelines (e.g., with ControlFusion masks in between passes).
4
+
5
+ This document describes the Hard variant — the full‑surface node that exposes advanced controls. For a minimal, preset‑driven experience, use the Easy variant or the `MG_SuperSimple` orchestrator.
6
+
7
+ ## Overview
8
+ - Iterative latent refinement with configurable steps/CFG/denoise
9
+ - Optional guidance override (Rescale/CFGZero‑style, FDG/NAG ideas, epsilon scaling)
10
+ - Hybrid schedule path (`MGHybrid`) that builds ZeSmart‑style sigma stacks
11
+ - Local spatial guidance via CLIPSeg prompts
12
+ - Reference polishing with CLIP‑Vision (preserves low‑frequency structure)
13
+ - Optional upscaling mid‑run, detail stabilization, and gentle sharpening
14
+ - Determinism helpers: CLIPSeg pinned to CPU, mask state cleared per run
15
+
16
+ ## Inputs
17
+ - `model` (MODEL)
18
+ - `positive` (CONDITIONING), `negative` (CONDITIONING)
19
+ - `vae` (VAE)
20
+ - `latent` (LATENT)
21
+ - `reference_image` (IMAGE, optional)
22
+ - `clip_vision` (CLIP_VISION, optional)
23
+
24
+ ## Outputs
25
+ - `LATENT`: refined latent
26
+ - `IMAGE`: decoded image after the last internal iteration
27
+ - `mask_preview` (IMAGE): last fused mask preview (RGB 0..1)
28
+ - Internal values like effective `steps/cfg/denoise` are tracked across the loop (the Easy wrapper surfaces them if needed).
29
+
30
+ ## Core Controls (essentials)
31
+ - `seed` (with control_after_generate)
32
+ - `steps`, `cfg`, `denoise`
33
+ - `sampler_name` (e.g., `ddim`)
34
+ - `scheduler` (`MGHybrid` recommended for smooth tails)
35
+
36
+ Typical starting points
37
+ - General: steps≈25, cfg≈7.0, denoise≈0.7, sampler=`euler_ancestral`, scheduler=`MGHybrid`
38
+ - As the first pass of a multi‑step pipeline: denoise=1.0 (full rewrite pass)
39
+
40
+ ## MGHybrid schedule
41
+ When `scheduler = MGHybrid`, CADE builds a hybrid sigma schedule compatible with the internal KSampler path. It follows ZeSmart principles (hybrid mix and smooth tail), then calls a custom sampler entry — falling back to `nodes.common_ksampler` if anything goes wrong. The behavior remains deterministic under fixed `seed/steps/cfg/denoise`.
42
+
43
+ ## Local guidance (CLIPSeg)
44
+ - CLIPSeg prompts (comma‑separated) produce a soft mask that can attenuate denoise/CFG.
45
+ - CLIPSeg inference is pinned to CPU by default for reproducibility.
46
+
47
+ ## Reference polish (CLIP‑Vision)
48
+ Provide `reference_image` and `clip_vision` to preserve global form while refining details. CADE encodes the current and reference images and reduces denoise/CFG when they diverge; in polish mode it also mixes low frequencies from the reference using a blur‑based split.
49
+
50
+ ## Advanced features (high‑level)
51
+ - Guidance override wrapper (rescale curves, momentum, perpendicular dampers)
52
+ - FDG/ZeRes‑inspired options with adaptive thresholds
53
+ - Mid‑run upscale support via `MagicUpscaleModule` with post‑adjusted CFG/denoise
54
+ - Post passes: `IntelligentDetailStabilizer`, optional mild sharpen
55
+
56
+ ## Related
57
+ - QSilk (micrograin stabilizer + AQClip): a lightweight latent‑space regularizer that suppresses rare activation tails while preserving micro‑texture. Works plug‑and‑play inside CADE 2.5 and synergizes with ZeResFDG by allowing slightly higher effective CFG without speckle. See preprint draft in `Arxiv_QSilk/` (source: [Arxiv_QSilk/main_qsilk.tex](../../Arxiv_QSilk/main_qsilk.tex)). Replace with arXiv link when available.
58
+
59
+ ## Tips
60
+ - Keep `vae` consistent across passes; CADE re‑encodes when scale changes.
61
+ - For multi‑step flows (e.g., with ControlFusion), feed the current decoded `IMAGE` into CF, update `positive/negative`, then run CADE again with the latest `LATENT`.
62
+ - If you rely on presets, consider the Easy wrapper or `MG_SuperSimple` to avoid UI/preset drift.
63
+
64
+ ## Quickstart (Hard)
65
+ 1) Connect `MODEL / VAE / CONDITIONING / LATENT`.
66
+ 2) Set `seed`, `steps≈25`, `cfg≈7.0`, `denoise≈0.7`, `sampler=euler_ancestral`, `scheduler=MGHybrid`.
67
+ 3) (Optional) Add `reference_image` and `clip_vision`, and a CLIPSeg prompt.
68
+ 4) Run and fine‑tune denoise/CFG first; only then adjust sampler/schedule.
69
+
70
+ Notes
71
+ - The node clears internal masks and patches at the end of a run even on errors.
72
+ - Some experimental toggles are intentionally conservative in default configs to avoid destabilizing results.
docs/hard/ControlFusion.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ControlFusion (Hard)
2
+
3
+ Builds a fused control mask from Depth and Pyramid Canny Edges, then injects it into ControlNet for both positive and negative conditionings. Designed to be resolution‑aware (keeps aspect), with optional split application (Depth then Edges) and a rich set of edge post‑processing knobs.
4
+
5
+ For minimal usage, see the Easy wrapper documented in `docs/EasyNodes.md`.
6
+
7
+ ## Overview
8
+ - Depth: Depth Anything v2 if available (vendored/local/aux fallbacks), otherwise pseudo‑depth from luminance + blur.
9
+ - Edges: multi‑scale Pyramid Canny with optional thinning, width adjust, smoothing, single‑line collapse, and depth‑based gating.
10
+ - Blending: `normal` (weighted mix), `max`, or `edge_over_depth` prior to ControlNet.
11
+ - Application: single fused hint or `split_apply` (Depth first, then Edges) with independent strengths and schedules.
12
+ - Preview: aspect‑kept visualization with optional strength reflection (display‑only).
13
+
14
+ ## Inputs
15
+ - `image` (IMAGE, BHWC 0..1)
16
+ - `positive` (CONDITIONING), `negative` (CONDITIONING)
17
+ - `control_net` (CONTROL_NET)
18
+ - `vae` (VAE)
19
+
20
+ ## Outputs
21
+ - `positive` (CONDITIONING), `negative` (CONDITIONING) — updated with ControlNet hint
22
+ - `Mask_Preview` (IMAGE) — fused mask preview (RGB 0..1)
23
+
24
+ ## Core Controls
25
+ Depth
26
+ - `enable_depth` (bool)
27
+ - `depth_model_path` (pth for Depth Anything v2)
28
+ - `depth_resolution` (min‑side target; hires mode keeps aspect)
29
+
30
+ Edges (PyraCanny)
31
+ - `enable_pyra` (bool), `pyra_low`, `pyra_high`, `pyra_resolution`
32
+ - `edge_thin_iter` (thinning passes, auto‑tuned in smart mode)
33
+ - `edge_alpha` (pre‑blend opacity), `edge_boost` (micro‑contrast), `smart_tune`, `smart_boost`
34
+
35
+ Blend and Strength
36
+ - `blend_mode`: `normal` | `max` | `edge_over_depth`
37
+ - `blend_factor` (for `normal`)
38
+ - `strength_pos`, `strength_neg` (global)
39
+ - `start_percent`, `end_percent` (schedule window 0..1)
40
+
41
+ Preview and Quality
42
+ - `preview_res` (min‑side), `mask_brightness`
43
+ - `preview_show_strength` with `preview_strength_branch` = `positive` | `negative` | `max` | `avg`
44
+ - `hires_mask_auto` (keep aspect and higher caps)
45
+
46
+ Application Options
47
+ - `apply_to_uncond` (mirror ControlNet hint to uncond)
48
+ - `stack_prev_control` (stack with previous ControlNet in the cond dict)
49
+ - `split_apply` (Depth first, Edges second)
50
+ - Separate schedules and multipliers when split:
51
+ - Depth: `depth_start_percent`, `depth_end_percent`, `depth_strength_mul`
52
+ - Edges: `edge_start_percent`, `edge_end_percent`, `edge_strength_mul`
53
+
54
+ Extra Edge Controls
55
+ - `edge_width` (thin/thicken), `edge_smooth` (reduce pixelation)
56
+ - `edge_single_line`, `edge_single_strength` (collapse double outlines)
57
+ - `edge_depth_gate`, `edge_depth_gamma` (weigh edges by depth)
58
+
59
+ ## Behavior Notes
60
+ - Depth min‑side is capped (default 1024) and aspect is preserved to avoid distortions.
61
+ - In `split_apply`, the order is deterministic: Depth → Edges.
62
+ - Preview image reflects strength only if `preview_show_strength` is enabled; it does not affect the hint itself.
63
+ - When both Depth and Edges are disabled, the node passes inputs through and returns a zero preview.
64
+
65
+ ## Quickstart
66
+ 1) Connect `image/positive/negative/control_net/vae`.
67
+ 2) Enable Depth and/or PyraCanny. Start with `edge_alpha≈1.0`, `blend_mode=normal`, `blend_factor≈0.02`.
68
+ 3) Schedule the apply window (`start_percent/end_percent`) and tune `strength_pos/neg`.
69
+ 4) Use `split_apply` if you want Depth to anchor structure and Edges to refine contours separately.
70
+
docs/hard/IDS.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # IntelligentDetailStabilizer (IDS)
2
+
3
+ Gentle, fast post‑pass for stabilizing micro‑detail and suppressing noise while preserving sharpness.
4
+
5
+ ## Overview
6
+ - Two‑stage blur/sharpen split with strength‑controlled recombination.
7
+ - Uses SciPy Gaussian if available; otherwise a portable PyTorch separable blur.
8
+ - Operates on images (BHWC, 0..1) and returns a single stabilized `IMAGE`.
9
+
10
+ ## Inputs
11
+ - `image` (IMAGE)
12
+ - `ids_strength` (float, default 0.5, range −1.0..1.0)
13
+
14
+ ## Outputs
15
+ - `IMAGE` — stabilized image
16
+
17
+ ## Tips
18
+ - Start around `ids_strength≈0.5` for gentle cleanup.
19
+ - Negative values bias toward more smoothing; positive increases sharpening of denoised base.
20
+
docs/hard/UpscaleModule.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MagicUpscaleModule
2
+
3
+ Lightweight latent‑space upscaler that keeps shapes aligned to the VAE stride to avoid border artifacts.
4
+
5
+ ## Overview
6
+ - Decodes latent to image, resamples with selected filter, and re‑encodes.
7
+ - Aligns target size up to the VAE spatial compression stride to keep shapes consistent.
8
+ - Clears GPU/RAM caches to minimize fragmentation before heavy resizes.
9
+
10
+ ## Inputs
11
+ - `samples` (LATENT)
12
+ - `vae` (VAE)
13
+ - `upscale_method` in `nearest-exact | bilinear | area | bicubic | lanczos`
14
+ - `scale_by` (float)
15
+
16
+ ## Outputs
17
+ - `LATENT` — upscaled latent
18
+ - `Upscaled Image` — convenience decoded image
19
+
20
+ ## Tips
21
+ - Use modest `scale_by` first (e.g., 1.2–1.5) and chain passes if needed.
22
+ - Keep the same `vae` before and after upscale in a larger pipeline.
23
+
docs/hard/ZeSmartSampler.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MG_ZeSmartSampler (v1.1)
2
+
3
+ Custom sampler that builds hybrid sigma schedules (Karras/Beta blend), adds tiny schedule jitter, and optionally applies a PC2‑like predictor‑corrector shaping.
4
+
5
+ ## Overview
6
+ - Inputs/Outputs match a standard KSampler: `MODEL / SEED / STEPS / CFG / base_sampler / schedule / CONDITIONING / LATENT` → `LATENT`.
7
+ - `hybrid_mix` blends the tail toward Beta; `tail_smooth` softens tail jumps adaptively.
8
+ - `jitter_sigma` introduces a tiny monotonic noise to schedules for de‑ringing; remains deterministic with fixed seed.
9
+ - PC2‑style shaping is available via `smart_strength/target_error/curv_sensitivity` (kept conservative by default).
10
+
11
+ ## Controls (high‑level)
12
+ - `base_sampler` and `schedule` (karras/beta/hybrid)
13
+ - `hybrid_mix` ∈ [0..1]
14
+ - `jitter_sigma` ∈ [0..0.1]
15
+ - `tail_smooth` ∈ [0..1]
16
+ - `smart_strength`, `target_error`, `curv_sensitivity`
17
+
18
+ ## Tips
19
+ - Start hybrid at `hybrid_mix≈0.3` for 2D work; 0.5–0.7 for photo‑like.
20
+ - Keep `jitter_sigma` very small (≈0.005–0.01) to avoid destabilizing steps.
21
+ - If using inside CADE (`scheduler=MGHybrid`), CADE will construct the schedule and run the custom path automatically.
22
+
init DELETED
@@ -1 +0,0 @@
1
- Init: MagicNodes (CADE 2.5, QSilk)
 
 
mod/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """MagicNodes.mod package
2
+
3
+ Holds the primary node implementations after repo cleanup. Keeping this as a
4
+ package ensures stable relative imports from the project root.
5
+ """
6
+
7
+ # No runtime side effects; modules are imported from MagicNodes.__init__.
8
+
mod/easy/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """MagicNodes Easy variants
2
+
3
+ Holds simplified, user‑friendly node variants that expose only
4
+ high‑level parameters. Registered under category "MagicNodes/Easy".
5
+ """
6
+
7
+ # Modules are imported from MagicNodes.__init__ to control registration.
8
+
mod/easy/mg_cade25_easy.py ADDED
The diff for this file is too large to render. See raw diff
 
mod/easy/mg_controlfusion_easy.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import math
4
+ import torch
5
+ import torch.nn.functional as F
6
+ import numpy as np
7
+
8
+ import comfy.model_management as model_management
9
+ from .preset_loader import get as load_preset
10
+
11
+
12
+ _DEPTH_INIT = False
13
+ _DEPTH_MODEL = None
14
+ _DEPTH_PROC = None
15
+
16
+
17
+ def _insert_aux_path():
18
+ try:
19
+ base = os.path.dirname(os.path.dirname(__file__)) # .../custom_nodes
20
+ aux_root = os.path.join(base, 'comfyui_controlnet_aux')
21
+ aux_src = os.path.join(aux_root, 'src')
22
+ for p in (aux_src, aux_root):
23
+ if os.path.isdir(p) and p not in sys.path:
24
+ sys.path.insert(0, p)
25
+ except Exception:
26
+ pass
27
+
28
+
29
+ def _try_init_depth_anything(model_path: str):
30
+ global _DEPTH_INIT, _DEPTH_MODEL, _DEPTH_PROC
31
+ if _DEPTH_INIT:
32
+ return _DEPTH_MODEL is not None
33
+ _DEPTH_INIT = True
34
+ # Prefer our vendored implementation first
35
+ try:
36
+ from ...vendor.depth_anything_v2.dpt import DepthAnythingV2 # type: ignore
37
+ # Guess config from filename
38
+ fname = os.path.basename(model_path or '')
39
+ cfgs = {
40
+ 'depth_anything_v2_vits.pth': dict(encoder='vits', features=64, out_channels=[48,96,192,384]),
41
+ 'depth_anything_v2_vitb.pth': dict(encoder='vitb', features=128, out_channels=[96,192,384,768]),
42
+ 'depth_anything_v2_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
43
+ 'depth_anything_v2_vitg.pth': dict(encoder='vitg', features=384, out_channels=[1536,1536,1536,1536]),
44
+ 'depth_anything_v2_metric_vkitti_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
45
+ 'depth_anything_v2_metric_hypersim_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
46
+ }
47
+ # fallback to vitl if unknown
48
+ cfg = cfgs.get(fname, cfgs['depth_anything_v2_vitl.pth'])
49
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
50
+ m = DepthAnythingV2(**cfg)
51
+ sd = torch.load(model_path, map_location='cpu')
52
+ m.load_state_dict(sd)
53
+ _DEPTH_MODEL = m.to(device).eval()
54
+ _DEPTH_PROC = True
55
+ return True
56
+ except Exception:
57
+ # Try local checkout of comfyui_controlnet_aux (if present)
58
+ _insert_aux_path()
59
+ try:
60
+ from custom_controlnet_aux.depth_anything_v2.dpt import DepthAnythingV2 # type: ignore
61
+ fname = os.path.basename(model_path or '')
62
+ cfgs = {
63
+ 'depth_anything_v2_vits.pth': dict(encoder='vits', features=64, out_channels=[48,96,192,384]),
64
+ 'depth_anything_v2_vitb.pth': dict(encoder='vitb', features=128, out_channels=[96,192,384,768]),
65
+ 'depth_anything_v2_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
66
+ 'depth_anything_v2_vitg.pth': dict(encoder='vitg', features=384, out_channels=[1536,1536,1536,1536]),
67
+ 'depth_anything_v2_metric_vkitti_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
68
+ 'depth_anything_v2_metric_hypersim_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
69
+ }
70
+ cfg = cfgs.get(fname, cfgs['depth_anything_v2_vitl.pth'])
71
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
72
+ m = DepthAnythingV2(**cfg)
73
+ sd = torch.load(model_path, map_location='cpu')
74
+ m.load_state_dict(sd)
75
+ _DEPTH_MODEL = m.to(device).eval()
76
+ _DEPTH_PROC = True
77
+ return True
78
+ except Exception:
79
+ # Fallback: packaged auxiliary API
80
+ try:
81
+ from controlnet_aux.depth_anything import DepthAnythingDetector, DepthAnythingV2 # type: ignore
82
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
83
+ _DEPTH_MODEL = DepthAnythingV2(model_path=model_path, device=device)
84
+ _DEPTH_PROC = True
85
+ return True
86
+ except Exception:
87
+ _DEPTH_MODEL = None
88
+ _DEPTH_PROC = False
89
+ return False
90
+
91
+
92
+ def _build_depth_map(image_bhwc: torch.Tensor, res: int, model_path: str, hires_mode: bool = True) -> torch.Tensor:
93
+ B, H, W, C = image_bhwc.shape
94
+ dev = image_bhwc.device
95
+ dtype = image_bhwc.dtype
96
+ # Choose target min-side for processing. In hires mode we allow higher caps and keep aspect.
97
+ # DepthAnything v2 can be memory-hungry on large inputs; cap min-side at 1024
98
+ cap = 1024
99
+ target = int(max(16, min(cap, res)))
100
+ if _try_init_depth_anything(model_path):
101
+ try:
102
+ # to CPU uint8
103
+ img = image_bhwc.detach().to('cpu')
104
+ x = img[0].movedim(-1, 0).unsqueeze(0)
105
+ # keep aspect ratio: scale so that min(H,W) == target
106
+ _, Cc, Ht, Wt = x.shape
107
+ min_side = max(1, min(Ht, Wt))
108
+ scale = float(target) / float(min_side)
109
+ out_h = max(1, int(round(Ht * scale)))
110
+ out_w = max(1, int(round(Wt * scale)))
111
+ x = F.interpolate(x, size=(out_h, out_w), mode='bilinear', align_corners=False)
112
+ # make channels-last and ensure contiguous layout for OpenCV
113
+ arr = (x[0].movedim(0, -1).contiguous().numpy() * 255.0).astype('uint8')
114
+ # Prefer direct DepthAnythingV2 inference if model has infer_image
115
+ if hasattr(_DEPTH_MODEL, 'infer_image'):
116
+ import cv2
117
+ # Drive input_size from desired depth resolution (min side), let DA keep aspect
118
+ input_sz = int(max(224, min(cap, res)))
119
+ depth = _DEPTH_MODEL.infer_image(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR), input_size=input_sz, max_depth=20.0)
120
+ d = np.asarray(depth, dtype=np.float32)
121
+ # Normalize DepthAnythingV2 output (0..max_depth) to 0..1
122
+ d = d / 20.0
123
+ else:
124
+ depth = _DEPTH_MODEL(arr)
125
+ d = np.asarray(depth, dtype=np.float32)
126
+ if d.max() > 1.0:
127
+ d = d / 255.0
128
+ d = torch.from_numpy(d)[None, None] # 1,1,h,w
129
+ d = F.interpolate(d, size=(H, W), mode='bilinear', align_corners=False)
130
+ d = d[0, 0].to(device=dev, dtype=dtype)
131
+ d = d.clamp(0, 1)
132
+ return d
133
+ except Exception:
134
+ pass
135
+ # Fallback pseudo-depth: luminance + gentle blur
136
+ lum = (0.2126 * image_bhwc[..., 0] + 0.7152 * image_bhwc[..., 1] + 0.0722 * image_bhwc[..., 2]).to(dtype=dtype)
137
+ x = lum.movedim(-1, 0).unsqueeze(0) if lum.ndim == 3 else lum.unsqueeze(0).unsqueeze(0)
138
+ x = F.interpolate(x, size=(H, W), mode='bilinear', align_corners=False)
139
+ x = F.avg_pool2d(x, kernel_size=3, stride=1, padding=1)
140
+ return x[0, 0].clamp(0, 1)
141
+
142
+
143
+ def _pyracanny(image_bhwc: torch.Tensor,
144
+ low: int,
145
+ high: int,
146
+ res: int,
147
+ thin_iter: int = 0,
148
+ edge_boost: float = 0.0,
149
+ smart_tune: bool = False,
150
+ smart_boost: float = 0.2,
151
+ preserve_aspect: bool = True) -> torch.Tensor:
152
+ try:
153
+ import cv2
154
+ except Exception:
155
+ # Fallback: simple Sobel magnitude
156
+ x = image_bhwc.movedim(-1, 1)
157
+ xg = x.mean(dim=1, keepdim=True)
158
+ gx = F.conv2d(xg, torch.tensor([[[-1, 0, 1],[-2,0,2],[-1,0,1]]], dtype=x.dtype, device=x.device).unsqueeze(1), padding=1)
159
+ gy = F.conv2d(xg, torch.tensor([[[-1,-2,-1],[0,0,0],[1,2,1]]], dtype=x.dtype, device=x.device).unsqueeze(1), padding=1)
160
+ mag = torch.sqrt(gx*gx + gy*gy)
161
+ mag = (mag - mag.amin())/(mag.amax()-mag.amin()+1e-6)
162
+ return mag[0,0].clamp(0,1)
163
+ B,H,W,C = image_bhwc.shape
164
+ img = (image_bhwc.detach().to('cpu')[0].contiguous().numpy()*255.0).astype('uint8')
165
+ cap = 4096
166
+ target = int(max(64, min(cap, res)))
167
+ if preserve_aspect:
168
+ scale = float(target) / float(max(1, min(H, W)))
169
+ out_h = max(8, int(round(H * scale)))
170
+ out_w = max(8, int(round(W * scale)))
171
+ img_res = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
172
+ else:
173
+ img_res = cv2.resize(img, (target, target), interpolation=cv2.INTER_LINEAR)
174
+ gray = cv2.cvtColor(img_res, cv2.COLOR_RGB2GRAY)
175
+ pyr_scales = [1.0, 0.5, 0.25]
176
+ acc = None
177
+ for s in pyr_scales:
178
+ if preserve_aspect:
179
+ sz = (max(8, int(round(img_res.shape[1]*s))), max(8, int(round(img_res.shape[0]*s))))
180
+ else:
181
+ sz = (max(8, int(target*s)), max(8, int(target*s)))
182
+ g = cv2.resize(gray, sz, interpolation=cv2.INTER_AREA)
183
+ g = cv2.GaussianBlur(g, (5,5), 0)
184
+ e = cv2.Canny(g, threshold1=int(low*s), threshold2=int(high*s))
185
+ e = cv2.resize(e, (W, H), interpolation=cv2.INTER_LINEAR)
186
+ e = (e.astype(np.float32)/255.0)
187
+ acc = e if acc is None else np.maximum(acc, e)
188
+ # Estimate density and sharpness for smart tuning
189
+ edensity_pre = None
190
+ try:
191
+ edensity_pre = float(np.mean(acc)) if acc is not None else None
192
+ except Exception:
193
+ edensity_pre = None
194
+ lap_var = None
195
+ try:
196
+ g32 = gray.astype(np.float32) / 255.0
197
+ lap = cv2.Laplacian(g32, cv2.CV_32F)
198
+ lap_var = float(lap.var())
199
+ except Exception:
200
+ lap_var = None
201
+
202
+ # optional thinning
203
+ try:
204
+ thin_iter_eff = int(thin_iter)
205
+ if smart_tune:
206
+ # simple heuristic: more thinning on high res and dense edges
207
+ auto = 0
208
+ if target >= 1024:
209
+ auto += 1
210
+ if target >= 1400:
211
+ auto += 1
212
+ if edensity_pre is not None and edensity_pre > 0.12:
213
+ auto += 1
214
+ if edensity_pre is not None and edensity_pre < 0.05:
215
+ auto = max(0, auto - 1)
216
+ thin_iter_eff = max(thin_iter_eff, min(3, auto))
217
+ if thin_iter_eff > 0:
218
+ import cv2
219
+ if hasattr(cv2, 'ximgproc') and hasattr(cv2.ximgproc, 'thinning'):
220
+ th = acc.copy()
221
+ th = (th*255).astype('uint8')
222
+ th = cv2.ximgproc.thinning(th)
223
+ acc = th.astype(np.float32)/255.0
224
+ else:
225
+ # simple erosion-based thinning approximation
226
+ kernel = np.ones((3,3), np.uint8)
227
+ t = (acc*255).astype('uint8')
228
+ for _ in range(int(thin_iter_eff)):
229
+ t = cv2.erode(t, kernel, iterations=1)
230
+ acc = t.astype(np.float32)/255.0
231
+ except Exception:
232
+ pass
233
+ # optional edge boost (unsharp on edge map)
234
+ # We fix a gentle boost for micro‑contrast; smart_tune may nudge it slightly
235
+ boost_eff = 0.10
236
+ if smart_tune:
237
+ try:
238
+ lv = 0.0 if lap_var is None else max(0.0, min(1.0, lap_var / 2.0))
239
+ dens = 0.0 if edensity_pre is None else float(max(0.0, min(1.0, edensity_pre)))
240
+ boost_eff = max(0.05, min(0.20, boost_eff + (1.0 - dens) * 0.05 + (1.0 - lv) * 0.02))
241
+ except Exception:
242
+ pass
243
+ if boost_eff and boost_eff != 0.0:
244
+ try:
245
+ import cv2
246
+ blur = cv2.GaussianBlur(acc, (0,0), sigmaX=1.0)
247
+ acc = np.clip(acc + float(boost_eff)*(acc - blur), 0.0, 1.0)
248
+ except Exception:
249
+ pass
250
+ ed = torch.from_numpy(acc).to(device=image_bhwc.device, dtype=image_bhwc.dtype)
251
+ return ed.clamp(0,1)
252
+
253
+
254
+ def _blend(depth: torch.Tensor, edges: torch.Tensor, mode: str, factor: float) -> torch.Tensor:
255
+ depth = depth.clamp(0,1)
256
+ edges = edges.clamp(0,1)
257
+ if mode == 'max':
258
+ return torch.maximum(depth, edges)
259
+ if mode == 'edge_over_depth':
260
+ # edges override depth (edge=1) while preserving depth elsewhere
261
+ return (depth * (1.0 - edges) + edges).clamp(0,1)
262
+ # normal
263
+ f = float(max(0.0, min(1.0, factor)))
264
+ return (depth*(1.0-f) + edges*f).clamp(0,1)
265
+
266
+
267
+ def _apply_controlnet_separate(positive, negative, control_net, image_bhwc: torch.Tensor,
268
+ strength_pos: float, strength_neg: float,
269
+ start_percent: float, end_percent: float, vae=None,
270
+ apply_to_uncond: bool = False,
271
+ stack_prev_control: bool = False):
272
+ control_hint = image_bhwc.movedim(-1,1)
273
+ out_pos = []
274
+ out_neg = []
275
+ # POS
276
+ for t in positive:
277
+ d = t[1].copy()
278
+ prev = d.get('control', None) if stack_prev_control else None
279
+ c_net = control_net.copy().set_cond_hint(control_hint, float(strength_pos), (start_percent, end_percent), vae=vae, extra_concat=[])
280
+ c_net.set_previous_controlnet(prev)
281
+ d['control'] = c_net
282
+ d['control_apply_to_uncond'] = bool(apply_to_uncond)
283
+ out_pos.append([t[0], d])
284
+ # NEG
285
+ for t in negative:
286
+ d = t[1].copy()
287
+ prev = d.get('control', None) if stack_prev_control else None
288
+ c_net = control_net.copy().set_cond_hint(control_hint, float(strength_neg), (start_percent, end_percent), vae=vae, extra_concat=[])
289
+ c_net.set_previous_controlnet(prev)
290
+ d['control'] = c_net
291
+ d['control_apply_to_uncond'] = bool(apply_to_uncond)
292
+ out_neg.append([t[0], d])
293
+ return out_pos, out_neg
294
+
295
+
296
+ class MG_ControlFusion:
297
+ @classmethod
298
+ def INPUT_TYPES(cls):
299
+ return {
300
+ "required": {
301
+ "preset_step": (["Custom", "Step 2", "Step 3", "Step 4"], {"default": "Custom", "tooltip": "Apply preset values from pressets/mg_controlfusion.cfg. UI values override."}),
302
+ "image": ("IMAGE", {"tooltip": "Input RGB image (B,H,W,3) in 0..1."}),
303
+ "positive": ("CONDITIONING", {"tooltip": "Positive conditioning to apply ControlNet to."}),
304
+ "negative": ("CONDITIONING", {"tooltip": "Negative conditioning to apply ControlNet to."}),
305
+ "control_net": ("CONTROL_NET", {"tooltip": "ControlNet module receiving the fused mask as hint."}),
306
+ "vae": ("VAE", {"tooltip": "VAE used by ControlNet when encoding the hint."}),
307
+ },
308
+ "optional": {
309
+ "enable_depth": ("BOOLEAN", {"default": True, "tooltip": "Enable depth map fusion (Depth Anything v2 if available)."}),
310
+ "depth_model_path": ("STRING", {"default": os.path.join(os.path.dirname(os.path.dirname(__file__)), 'MagicNodes','depth-anything','depth_anything_v2_vitl.pth') if False else os.path.join(os.path.dirname(__file__), '..','depth-anything','depth_anything_v2_vitl.pth'), "tooltip": "Path to Depth Anything v2 .pth weights (vits/vitb/vitl/vitg)."}),
311
+ "depth_resolution": ("INT", {"default": 768, "min": 64, "max": 1024, "step": 64, "tooltip": "Depth min-side resolution (cap 1024). In Hi‑Res mode drives DepthAnything input_size."}),
312
+ "enable_pyra": ("BOOLEAN", {"default": True, "tooltip": "Enable PyraCanny edge detector."}),
313
+ "pyra_low": ("INT", {"default": 109, "min": 0, "max": 255, "tooltip": "Canny low threshold (0..255)."}),
314
+ "pyra_high": ("INT", {"default": 147, "min": 0, "max": 255, "tooltip": "Canny high threshold (0..255)."}),
315
+ "pyra_resolution": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 64, "tooltip": "Working resolution for edges (min side, keeps aspect)."}),
316
+ "edge_thin_iter": ("INT", {"default": 0, "min": 0, "max": 10, "step": 1, "tooltip": "Thinning iterations for edges (skeletonize). 0 = off."}),
317
+ "edge_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Opacity for edges before blending (0..1)."}),
318
+ "edge_boost": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Deprecated: internal boost fixed (~0.10); use edge_alpha instead."}),
319
+ "smart_tune": ("BOOLEAN", {"default": False, "tooltip": "Auto-adjust thinning/boost from image edge density and sharpness."}),
320
+ "smart_boost": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Scale for auto edge boost when Smart Tune is on."}),
321
+ "blend_mode": (["normal","max","edge_over_depth"], {"default": "normal", "tooltip": "Depth+edges merge: normal (mix), max (strongest), edge_over_depth (edges overlay)."}),
322
+ "blend_factor": ("FLOAT", {"default": 0.02, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Blend strength for edges into depth (depends on mode)."}),
323
+ "strength_pos": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01, "tooltip": "ControlNet strength for positive branch."}),
324
+ "strength_neg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01, "tooltip": "ControlNet strength for negative branch."}),
325
+ "start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Start percentage along the sampling schedule."}),
326
+ "end_percent": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "End percentage along the sampling schedule."}),
327
+ "preview_res": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 64, "tooltip": "Preview minimum side (keeps aspect ratio)."}),
328
+ "mask_brightness": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Preview brightness multiplier (visualization only)."}),
329
+ "preview_show_strength": ("BOOLEAN", {"default": True, "tooltip": "Multiply preview by ControlNet strength for visualization."}),
330
+ "preview_strength_branch": (["positive","negative","max","avg"], {"default": "max", "tooltip": "Which strength to reflect in preview (display only)."}),
331
+ "hires_mask_auto": ("BOOLEAN", {"default": True, "tooltip": "High‑res mask: keep aspect ratio, scale by minimal side for depth/edges, and drive DepthAnything with your depth_resolution (no 2K cap)."}),
332
+ "apply_to_uncond": ("BOOLEAN", {"default": False, "tooltip": "Apply ControlNet hint to the unconditional branch as well (stronger global hold on very large images)."}),
333
+ "stack_prev_control": ("BOOLEAN", {"default": False, "tooltip": "Chain with any previously attached ControlNet in the conditioning (advanced). Off = replace to avoid memory bloat."}),
334
+ # Split apply: chain Depth and Edges with separate schedules/strengths (fixed order: depth -> edges)
335
+ "split_apply": ("BOOLEAN", {"default": False, "tooltip": "Apply Depth and Edges as two chained ControlNets (fixed order: depth then edges)."}),
336
+ "edge_start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Edges start percent (when split is enabled)."}),
337
+ "edge_end_percent": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Edges end percent (when split is enabled)."}),
338
+ "depth_start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Depth start percent (when split is enabled)."}),
339
+ "depth_end_percent": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Depth end percent (when split is enabled)."}),
340
+ "edge_strength_mul": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01, "tooltip": "Multiply global strength for Edges when split is enabled."}),
341
+ "depth_strength_mul": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01, "tooltip": "Multiply global strength for Depth when split is enabled."}),
342
+ # Extra edge controls (bottom)
343
+ "edge_width": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 1.5, "step": 0.05, "tooltip": "Edge thickness adjust: negative thins, positive thickens."}),
344
+ "edge_smooth": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "Small smooth on edges to reduce pixelation (0..1)."}),
345
+ "edge_single_line": ("BOOLEAN", {"default": False, "tooltip": "Try to collapse double outlines into a single centerline."}),
346
+ "edge_single_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Strength of single-line collapse (0..1). 0 = off, 1 = strong."}),
347
+ "edge_depth_gate": ("BOOLEAN", {"default": False, "tooltip": "Weigh edges by depth so distant lines are fainter."}),
348
+ "edge_depth_gamma": ("FLOAT", {"default": 1.5, "min": 0.2, "max": 4.0, "step": 0.1, "tooltip": "Gamma for depth gating: edges *= (1−depth)^gamma."}),
349
+ }
350
+ }
351
+
352
+ RETURN_TYPES = ("CONDITIONING","CONDITIONING","IMAGE")
353
+ RETURN_NAMES = ("positive","negative","Mask_Preview")
354
+ FUNCTION = "apply"
355
+ CATEGORY = "MagicNodes"
356
+
357
+ def apply(self, image, positive, negative, control_net, vae,
358
+ enable_depth=True, depth_model_path="", depth_resolution=1024,
359
+ enable_pyra=True, pyra_low=109, pyra_high=147, pyra_resolution=1024,
360
+ edge_thin_iter=0, edge_alpha=1.0, edge_boost=0.0,
361
+ smart_tune=False, smart_boost=0.2,
362
+ blend_mode="normal", blend_factor=0.02,
363
+ strength_pos=1.0, strength_neg=1.0, start_percent=0.0, end_percent=1.0,
364
+ preview_res=1024, mask_brightness=1.0,
365
+ preview_show_strength=True, preview_strength_branch="max",
366
+ hires_mask_auto=True, apply_to_uncond=False, stack_prev_control=False,
367
+ edge_width=0.0, edge_smooth=0.0, edge_single_line=False, edge_single_strength=0.0,
368
+ edge_depth_gate=False, edge_depth_gamma=1.5,
369
+ split_apply=False, edge_start_percent=0.0, edge_end_percent=0.6,
370
+ depth_start_percent=0.0, depth_end_percent=1.0,
371
+ edge_strength_mul=1.0, depth_strength_mul=1.0,
372
+ preset_step="Step 2", custom_override=False):
373
+
374
+ # Merge preset values (if selected) with UI values; UI overrides preset
375
+ try:
376
+ if isinstance(preset_step, str) and preset_step.lower() != "custom":
377
+ p = load_preset("mg_controlfusion", preset_step)
378
+ else:
379
+ p = {}
380
+ except Exception:
381
+ p = {}
382
+ def pv(name, cur):
383
+ return p.get(name, cur)
384
+ enable_depth = bool(pv('enable_depth', enable_depth))
385
+ depth_model_path = str(pv('depth_model_path', depth_model_path))
386
+ depth_resolution = int(pv('depth_resolution', depth_resolution))
387
+ enable_pyra = bool(pv('enable_pyra', enable_pyra))
388
+ pyra_low = int(pv('pyra_low', pyra_low))
389
+ pyra_high = int(pv('pyra_high', pyra_high))
390
+ pyra_resolution = int(pv('pyra_resolution', pyra_resolution))
391
+ edge_thin_iter = int(pv('edge_thin_iter', edge_thin_iter))
392
+ edge_alpha = float(pv('edge_alpha', edge_alpha))
393
+ edge_boost = float(pv('edge_boost', edge_boost))
394
+ smart_tune = bool(pv('smart_tune', smart_tune))
395
+ smart_boost = float(pv('smart_boost', smart_boost))
396
+ blend_mode = str(pv('blend_mode', blend_mode))
397
+ blend_factor = float(pv('blend_factor', blend_factor))
398
+ strength_pos = float(pv('strength_pos', strength_pos))
399
+ strength_neg = float(pv('strength_neg', strength_neg))
400
+ start_percent = float(pv('start_percent', start_percent))
401
+ end_percent = float(pv('end_percent', end_percent))
402
+ preview_res = int(pv('preview_res', preview_res))
403
+ mask_brightness = float(pv('mask_brightness', mask_brightness))
404
+ preview_show_strength = bool(pv('preview_show_strength', preview_show_strength))
405
+ preview_strength_branch = str(pv('preview_strength_branch', preview_strength_branch))
406
+ hires_mask_auto = bool(pv('hires_mask_auto', hires_mask_auto))
407
+ apply_to_uncond = bool(pv('apply_to_uncond', apply_to_uncond))
408
+ stack_prev_control = bool(pv('stack_prev_control', stack_prev_control))
409
+ split_apply = bool(pv('split_apply', split_apply))
410
+ edge_start_percent = float(pv('edge_start_percent', edge_start_percent))
411
+ edge_end_percent = float(pv('edge_end_percent', edge_end_percent))
412
+ depth_start_percent = float(pv('depth_start_percent', depth_start_percent))
413
+ depth_end_percent = float(pv('depth_end_percent', depth_end_percent))
414
+ edge_strength_mul = float(pv('edge_strength_mul', edge_strength_mul))
415
+ depth_strength_mul = float(pv('depth_strength_mul', depth_strength_mul))
416
+ edge_width = float(pv('edge_width', edge_width))
417
+ edge_smooth = float(pv('edge_smooth', edge_smooth))
418
+ edge_single_line = bool(pv('edge_single_line', edge_single_line))
419
+ edge_single_strength = float(pv('edge_single_strength', edge_single_strength))
420
+ edge_depth_gate = bool(pv('edge_depth_gate', edge_depth_gate))
421
+ edge_depth_gamma = float(pv('edge_depth_gamma', edge_depth_gamma))
422
+
423
+ dev = image.device
424
+ dtype = image.dtype
425
+ B,H,W,C = image.shape
426
+ # Build depth/edges
427
+ depth = None
428
+ edges = None
429
+ if enable_depth:
430
+ model_path = depth_model_path or os.path.join(os.path.dirname(__file__), '..','depth-anything','depth_anything_v2_vitl.pth')
431
+ depth = _build_depth_map(image, int(depth_resolution), model_path, bool(hires_mask_auto))
432
+ if enable_pyra:
433
+ edges = _pyracanny(image,
434
+ int(pyra_low), int(pyra_high), int(pyra_resolution),
435
+ int(edge_thin_iter), float(edge_boost),
436
+ bool(smart_tune), float(smart_boost), bool(hires_mask_auto))
437
+ if depth is None and edges is None:
438
+ # Nothing to do: return inputs and zero preview
439
+ prev = torch.zeros((B, max(H,1), max(W,1), 3), device=dev, dtype=dtype)
440
+ return positive, negative, prev
441
+
442
+ if depth is None:
443
+ depth = torch.zeros_like(edges)
444
+ if edges is None:
445
+ edges = torch.zeros_like(depth)
446
+
447
+ # Edge post-process: width/single-line/smooth
448
+ def _edges_post(acc_t: torch.Tensor) -> torch.Tensor:
449
+ try:
450
+ import cv2, numpy as _np
451
+ acc = acc_t.detach().to('cpu').numpy()
452
+ img = (acc*255.0).astype(_np.uint8)
453
+ k = _np.ones((3,3), _np.uint8)
454
+ # Adjust thickness
455
+ w = float(edge_width)
456
+ if abs(w) > 1e-6:
457
+ it = int(abs(w))
458
+ frac = abs(w) - it
459
+ op = cv2.dilate if w > 0 else cv2.erode
460
+ y = img.copy()
461
+ for _ in range(max(0, it)):
462
+ y = op(y, k, iterations=1)
463
+ if frac > 1e-6:
464
+ y2 = op(y, k, iterations=1)
465
+ y = ((1.0-frac)*y.astype(_np.float32) + frac*y2.astype(_np.float32)).astype(_np.uint8)
466
+ img = y
467
+ # Collapse double lines to single centerline
468
+ if bool(edge_single_line) and float(edge_single_strength) > 1e-6:
469
+ try:
470
+ s = float(edge_single_strength)
471
+ close = cv2.morphologyEx(img, cv2.MORPH_CLOSE, k, iterations=1)
472
+ if hasattr(cv2, 'ximgproc') and hasattr(cv2.ximgproc, 'thinning'):
473
+ sk = cv2.ximgproc.thinning(close)
474
+ else:
475
+ # limited-iteration morphological skeletonization
476
+ iters = max(1, int(round(2 + 6*s)))
477
+ sk = _np.zeros_like(close)
478
+ src = close.copy()
479
+ elem = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
480
+ for _ in range(iters):
481
+ er = cv2.erode(src, elem, iterations=1)
482
+ op = cv2.morphologyEx(er, cv2.MORPH_OPEN, elem)
483
+ tmp = cv2.subtract(er, op)
484
+ sk = cv2.bitwise_or(sk, tmp)
485
+ src = er
486
+ if not _np.any(src):
487
+ break
488
+ # Blend skeleton back with original according to strength
489
+ img = ((_np.float32(1.0 - s) * img.astype(_np.float32)) + (_np.float32(s) * sk.astype(_np.float32))).astype(_np.uint8)
490
+ except Exception:
491
+ pass
492
+ # Smooth
493
+ if float(edge_smooth) > 1e-6:
494
+ sigma = max(0.1, min(2.0, float(edge_smooth) * 1.2))
495
+ img = cv2.GaussianBlur(img, (0,0), sigmaX=sigma)
496
+ out = torch.from_numpy((img.astype(_np.float32)/255.0)).to(device=acc_t.device, dtype=acc_t.dtype)
497
+ return out.clamp(0,1)
498
+ except Exception:
499
+ # Torch fallback: light blur-only
500
+ if float(edge_smooth) > 1e-6:
501
+ s = max(1, int(round(float(edge_smooth)*2)))
502
+ return F.avg_pool2d(acc_t.unsqueeze(0).unsqueeze(0), kernel_size=2*s+1, stride=1, padding=s)[0,0].clamp(0,1)
503
+ return acc_t
504
+
505
+ edges = _edges_post(edges)
506
+
507
+ # Depth gating of edges
508
+ if bool(edge_depth_gate):
509
+ # Inverted gating per feedback: use depth^gamma (nearer = stronger if depth is larger)
510
+ g = (depth.clamp(0,1)) ** float(edge_depth_gamma)
511
+ edges = (edges * g).clamp(0,1)
512
+
513
+ # Apply edge alpha before blending
514
+ edges = (edges * float(edge_alpha)).clamp(0,1)
515
+
516
+ fused = _blend(depth, edges, str(blend_mode), float(blend_factor))
517
+ # Apply as split (Edges then Depth) or single fused hint
518
+ if bool(split_apply):
519
+ # Fixed order for determinism: Depth first, then Edges
520
+ hint_edges = edges.unsqueeze(-1).repeat(1,1,1,3)
521
+ hint_depth = depth.unsqueeze(-1).repeat(1,1,1,3)
522
+ # Depth first
523
+ pos_mid, neg_mid = _apply_controlnet_separate(
524
+ positive, negative, control_net, hint_depth,
525
+ float(strength_pos) * float(depth_strength_mul),
526
+ float(strength_neg) * float(depth_strength_mul),
527
+ float(depth_start_percent), float(depth_end_percent), vae,
528
+ bool(apply_to_uncond), True
529
+ )
530
+ # Then edges
531
+ pos_out, neg_out = _apply_controlnet_separate(
532
+ pos_mid, neg_mid, control_net, hint_edges,
533
+ float(strength_pos) * float(edge_strength_mul),
534
+ float(strength_neg) * float(edge_strength_mul),
535
+ float(edge_start_percent), float(edge_end_percent), vae,
536
+ bool(apply_to_uncond), True
537
+ )
538
+ else:
539
+ hint = fused.unsqueeze(-1).repeat(1,1,1,3)
540
+ pos_out, neg_out = _apply_controlnet_separate(
541
+ positive, negative, control_net, hint,
542
+ float(strength_pos), float(strength_neg),
543
+ float(start_percent), float(end_percent), vae,
544
+ bool(apply_to_uncond), bool(stack_prev_control)
545
+ )
546
+ # Build preview: keep aspect ratio, set minimal side
547
+ prev_res = int(max(256, min(2048, preview_res)))
548
+ scale = prev_res / float(min(H, W))
549
+ out_h = max(1, int(round(H * scale)))
550
+ out_w = max(1, int(round(W * scale)))
551
+ prev = F.interpolate(fused.unsqueeze(0).unsqueeze(0), size=(out_h, out_w), mode='bilinear', align_corners=False)[0,0]
552
+ # Optionally reflect ControlNet strength in preview (display only)
553
+ if bool(preview_show_strength):
554
+ br = str(preview_strength_branch)
555
+ sp = float(strength_pos)
556
+ sn = float(strength_neg)
557
+ if br == 'negative':
558
+ s_vis = sn
559
+ elif br == 'max':
560
+ s_vis = max(sp, sn)
561
+ elif br == 'avg':
562
+ s_vis = 0.5 * (sp + sn)
563
+ else:
564
+ s_vis = sp
565
+ # clamp for display range
566
+ s_vis = max(0.0, min(1.0, s_vis))
567
+ prev = prev * s_vis
568
+ # Apply visualization brightness only for preview
569
+ prev = (prev * float(mask_brightness)).clamp(0.0, 1.0)
570
+ prev = prev.unsqueeze(-1).repeat(1,1,3).to(device=dev, dtype=dtype).unsqueeze(0)
571
+ return (pos_out, neg_out, prev)
572
+
573
+
574
+ # === Easy UI wrapper: simplified controls + Step/Custom preset logic ===
575
+ class MG_ControlFusionEasyUI(MG_ControlFusion):
576
+ @classmethod
577
+ def INPUT_TYPES(cls):
578
+ return {
579
+ "required": {
580
+ # Step preset first for emphasis
581
+ "preset_step": (["Step 2", "Step 3", "Step 4"], {"default": "Step 2", "tooltip": "Choose the Step preset. Toggle Custom below to apply UI values; otherwise Step preset values are used."}),
582
+ # Custom toggle: when enabled, UI values override the Step for visible controls
583
+ "custom": ("BOOLEAN", {"default": False, "tooltip": "Custom override: when enabled, your UI values override the selected Step for visible controls; hidden parameters still come from the Step preset."}),
584
+ # Connectors
585
+ "image": ("IMAGE", {"tooltip": "Input RGB image (B,H,W,3) in 0..1."}),
586
+ "positive": ("CONDITIONING", {"tooltip": "Positive conditioning to apply ControlNet to."}),
587
+ "negative": ("CONDITIONING", {"tooltip": "Negative conditioning to apply ControlNet to."}),
588
+ "control_net": ("CONTROL_NET", {"tooltip": "ControlNet module receiving the fused mask as hint."}),
589
+ "vae": ("VAE", {"tooltip": "VAE used by ControlNet when encoding the hint."}),
590
+ # Minimal surface controls
591
+ "enable_depth": ("BOOLEAN", {"default": True, "tooltip": "Enable depth map fusion (Depth Anything v2 if available)."}),
592
+ "enable_pyra": ("BOOLEAN", {"default": True, "tooltip": "Enable PyraCanny edge detector."}),
593
+ "edge_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Opacity for edges before blending (0..1)."}),
594
+ "blend_factor": ("FLOAT", {"default": 0.02, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Blend strength for edges into depth (depends on mode)."}),
595
+ },
596
+ "optional": {}
597
+ }
598
+
599
+ RETURN_TYPES = ("CONDITIONING","CONDITIONING","IMAGE")
600
+ RETURN_NAMES = ("positive","negative","Mask_Preview")
601
+ FUNCTION = "apply_easy"
602
+
603
+ def apply_easy(self, preset_step, custom, image, positive, negative, control_net, vae,
604
+ enable_depth=True, enable_pyra=True, edge_alpha=1.0, blend_factor=0.02):
605
+ # Use Step preset; if custom is True, allow visible UI values to override inside base impl via custom_override
606
+ return super().apply(
607
+ image=image, positive=positive, negative=negative, control_net=control_net, vae=vae,
608
+ enable_depth=bool(enable_depth), enable_pyra=bool(enable_pyra), edge_alpha=float(edge_alpha), blend_factor=float(blend_factor),
609
+ preset_step=str(preset_step) if not bool(custom) else "Custom",
610
+ custom_override=bool(custom),
611
+ )
mod/easy/mg_supersimple_easy.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """MG_SuperSimple: Orchestrates a 1–4 step pipeline over CF→CADE pairs.
4
+
5
+ - Step 1: CADE with Step 1 preset. Exception: forces denoise=1.0.
6
+ - Steps 2..N: ControlFusion (CF) with Step N preset → CADE with Step N preset.
7
+ - When custom is True: visible CADE controls (seed/steps/cfg/denoise/sampler/scheduler/clipseg_text)
8
+ override corresponding Step presets across all steps (except step 1 denoise is always 1.0).
9
+ - When custom is False: all CADE values come from Step presets; node UI values are ignored.
10
+ - CF always uses its Step presets (no extra UI here) to keep the node minimal.
11
+
12
+ Inputs
13
+ - model/vae/latent/positive/negative: standard Comfy connectors
14
+ - control_net: ControlNet module for CF (required)
15
+ - reference_image/clip_vision: forwarded into CADE (optional)
16
+
17
+ Outputs
18
+ - (LATENT, IMAGE) from the final executed step
19
+ """
20
+
21
+ import torch
22
+
23
+ from .mg_cade25_easy import ComfyAdaptiveDetailEnhancer25 as _CADE
24
+ from .mg_controlfusion_easy import MG_ControlFusion as _CF
25
+ from .mg_cade25_easy import _sampler_names as _sampler_names
26
+ from .mg_cade25_easy import _scheduler_names as _scheduler_names
27
+
28
+
29
+ class MG_SuperSimple:
30
+ CATEGORY = "MagicNodes/Easy"
31
+
32
+ @classmethod
33
+ def INPUT_TYPES(cls):
34
+ return {
35
+ "required": {
36
+ # High-level pipeline control
37
+ "step_count": ("INT", {"default": 4, "min": 1, "max": 4, "tooltip": "Number of steps to run (1..4)."}),
38
+ "custom": ("BOOLEAN", {"default": False, "tooltip": "When enabled, CADE UI values below override Step presets across all steps (denoise on Step 1 is still forced to 1.0)."}),
39
+
40
+ # Connectors
41
+ "model": ("MODEL", {}),
42
+ "positive": ("CONDITIONING", {}),
43
+ "negative": ("CONDITIONING", {}),
44
+ "vae": ("VAE", {}),
45
+ "latent": ("LATENT", {}),
46
+ "control_net": ("CONTROL_NET", {"tooltip": "ControlNet module used by ControlFusion."}),
47
+
48
+ # Shared CADE surface controls
49
+ "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True, "tooltip": "Seed 0 = SmartSeed (Sobol + light probe). Non-zero = fixed seed (deterministic)."}),
50
+ "steps": ("INT", {"default": 25, "min": 1, "max": 10000, "tooltip": "KSampler steps for CADE (applies to all steps)."}),
51
+ "cfg": ("FLOAT", {"default": 4.5, "min": 0.0, "max": 100.0, "step": 0.1}),
52
+ # Denoise is clamped; Step 1 uses 1.0 regardless
53
+ "denoise": ("FLOAT", {"default": 0.65, "min": 0.35, "max": 0.9, "step": 0.0001}),
54
+ "sampler_name": (_sampler_names(), {"default": _sampler_names()[0]}),
55
+ "scheduler": (_scheduler_names(), {"default": "MGHybrid"}),
56
+ "clipseg_text": ("STRING", {"default": "hand, feet, face", "multiline": False, "tooltip": "Focus terms for CLIPSeg (comma-separated)."}),
57
+ },
58
+ "optional": {
59
+ "reference_image": ("IMAGE", {}),
60
+ "clip_vision": ("CLIP_VISION", {}),
61
+ },
62
+ }
63
+
64
+ RETURN_TYPES = ("LATENT", "IMAGE")
65
+ RETURN_NAMES = ("LATENT", "IMAGE")
66
+ FUNCTION = "run"
67
+
68
+ def _cade(self,
69
+ preset_step: str,
70
+ custom_override: bool,
71
+ model, vae, positive, negative, latent,
72
+ seed: int, steps: int, cfg: float, denoise: float,
73
+ sampler_name: str, scheduler: str,
74
+ clipseg_text: str,
75
+ reference_image=None, clip_vision=None):
76
+ # CADE core call mirrors CADEEasyUI -> apply_cade2
77
+ lat, img, _s, _c, _d, _mask = _CADE().apply_cade2(
78
+ model, vae, positive, negative, latent,
79
+ int(seed), int(steps), float(cfg), float(denoise),
80
+ str(sampler_name), str(scheduler), 0.0,
81
+ preset_step=str(preset_step), custom_override=bool(custom_override),
82
+ clipseg_text=str(clipseg_text),
83
+ reference_image=reference_image, clip_vision=clip_vision,
84
+ )
85
+ return lat, img
86
+
87
+ def _cf(self,
88
+ preset_step: str,
89
+ image, positive, negative, control_net, vae):
90
+ # Keep CF strictly on presets for SuperSimple (no extra UI),
91
+ # so pass custom_override=False intentionally.
92
+ pos, neg, _prev = _CF().apply(
93
+ image=image, positive=positive, negative=negative,
94
+ control_net=control_net, vae=vae,
95
+ preset_step=str(preset_step), custom_override=False,
96
+ )
97
+ return pos, neg
98
+
99
+ def run(self,
100
+ step_count, custom,
101
+ model, positive, negative, vae, latent, control_net,
102
+ seed, steps, cfg, denoise, sampler_name, scheduler, clipseg_text,
103
+ reference_image=None, clip_vision=None):
104
+ # Clamp step_count to 1..4
105
+ n = int(max(1, min(4, step_count)))
106
+
107
+ cur_latent = latent
108
+ cur_image = None
109
+ cur_pos = positive
110
+ cur_neg = negative
111
+
112
+ # Step 1: CADE with Step 1 preset, denoise forced to 1.0
113
+ denoise_step1 = 1.0
114
+ lat1, img1 = self._cade(
115
+ preset_step="Step 1",
116
+ custom_override=bool(custom),
117
+ model=model, vae=vae, positive=cur_pos, negative=cur_neg, latent=cur_latent,
118
+ seed=seed, steps=steps, cfg=cfg, denoise=denoise_step1,
119
+ sampler_name=sampler_name, scheduler=scheduler,
120
+ clipseg_text=clipseg_text,
121
+ reference_image=reference_image, clip_vision=clip_vision,
122
+ )
123
+ cur_latent, cur_image = lat1, img1
124
+
125
+ # Steps 2..n: CF -> CADE per step
126
+ for i in range(2, n + 1):
127
+ # ControlFusion on current image/conds
128
+ cur_pos, cur_neg = self._cf(
129
+ preset_step=f"Step {i}",
130
+ image=cur_image, positive=cur_pos, negative=cur_neg,
131
+ control_net=control_net, vae=vae,
132
+ )
133
+ # CADE with shared controls
134
+ # If no external reference_image is provided, use the previous step image
135
+ # so that reference_clean / CLIP-Vision gating can take effect.
136
+ ref_img = reference_image if (reference_image is not None) else cur_image
137
+ lat_i, img_i = self._cade(
138
+ preset_step=f"Step {i}",
139
+ custom_override=bool(custom),
140
+ model=model, vae=vae, positive=cur_pos, negative=cur_neg, latent=cur_latent,
141
+ seed=seed, steps=steps, cfg=cfg, denoise=denoise,
142
+ sampler_name=sampler_name, scheduler=scheduler,
143
+ clipseg_text=clipseg_text,
144
+ reference_image=ref_img, clip_vision=clip_vision,
145
+ )
146
+ cur_latent, cur_image = lat_i, img_i
147
+
148
+ return (cur_latent, cur_image)
mod/easy/preset_loader.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Dict, Tuple
3
+
4
+ _CACHE: Dict[str, Tuple[float, Dict[str, Dict[str, object]]]] = {}
5
+
6
+ _MSG_PREFIX = "[MagicNodes][Presets]"
7
+
8
+ def _root_dir() -> str:
9
+ # .../MagicNodes/mod/easy -> .../MagicNodes
10
+ return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
11
+
12
+ def _pressets_dir() -> str:
13
+ return os.path.join(_root_dir(), "pressets")
14
+
15
+ def _cfg_path(kind: str) -> str:
16
+ # kind examples: "mg_cade25", "mg_controlfusion"
17
+ return os.path.join(_pressets_dir(), f"{kind}.cfg")
18
+
19
+ def _parse_value(raw: str):
20
+ s = raw.strip()
21
+ if not s:
22
+ return ""
23
+ low = s.lower()
24
+ if low in ("true", "false"):
25
+ return low == "true"
26
+ try:
27
+ if "." in s or "e" in low:
28
+ return float(s)
29
+ return int(s)
30
+ except Exception:
31
+ pass
32
+ # variable substitution
33
+ s = s.replace("$(ROOT)", _root_dir())
34
+ if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
35
+ s = s[1:-1]
36
+ return s
37
+
38
+ def _load_kind(kind: str) -> Dict[str, Dict[str, object]]:
39
+ path = _cfg_path(kind)
40
+ if not os.path.isfile(path):
41
+ print(f"{_MSG_PREFIX} No configuration file for '{kind}' found; loaded defaults — results may be unpredictable!")
42
+ return {}
43
+ try:
44
+ mtime = os.path.getmtime(path)
45
+ cached = _CACHE.get(path)
46
+ if cached and cached[0] == mtime:
47
+ return cached[1]
48
+
49
+ data: Dict[str, Dict[str, object]] = {}
50
+ cur_section = None
51
+ with open(path, "r", encoding="utf-8") as f:
52
+ for ln, line in enumerate(f, start=1):
53
+ line = line.strip()
54
+ if not line or line.startswith("#") or line.startswith(";"):
55
+ continue
56
+ if line.startswith("[") and line.endswith("]"):
57
+ cur_section = line[1:-1].strip().lower()
58
+ data.setdefault(cur_section, {})
59
+ continue
60
+ if ":" in line:
61
+ if cur_section is None:
62
+ print(f"{_MSG_PREFIX} Parse warning at line {ln}: key outside of any [section]; ignored")
63
+ continue
64
+ k, v = line.split(":", 1)
65
+ key = k.strip()
66
+ try:
67
+ val = _parse_value(v)
68
+ except Exception:
69
+ print(f"{_MSG_PREFIX} Missing or invalid parameter '{key}'; this may affect results!")
70
+ continue
71
+ data[cur_section][key] = val
72
+ else:
73
+ print(f"{_MSG_PREFIX} Unknown line at {ln}: '{line}'; ignored")
74
+
75
+ _CACHE[path] = (mtime, data)
76
+ return data
77
+ except Exception as e:
78
+ print(f"{_MSG_PREFIX} Failed to read '{path}': {e}. Loaded defaults — results may be unpredictable!")
79
+ return {}
80
+
81
+ def get(kind: str, step: str) -> Dict[str, object]:
82
+ """Return dict of parameters for a given kind and step.
83
+ step accepts 'Step 1', '1', 'step1', case-insensitive.
84
+ """
85
+ data = _load_kind(kind)
86
+ if not data:
87
+ return {}
88
+ label = step.strip().lower().replace(" ", "")
89
+ if label.startswith("step"):
90
+ key = label
91
+ elif label.isdigit():
92
+ key = f"step{label}"
93
+ else:
94
+ key = f"step{label}"
95
+
96
+ if key not in data:
97
+ # Special case: CF is intentionally not applied on Step 1 in this pipeline.
98
+ # Suppress noisy log for missing 'Step 1' in mg_controlfusion.
99
+ if kind == "mg_controlfusion" and key in ("step1", "1"):
100
+ return {}
101
+ print(f"{_MSG_PREFIX} Preset step '{step}' not found for '{kind}'; using defaults")
102
+ return {}
103
+ res = dict(data[key])
104
+ # Side-effect: when CADE presets are loaded, optionally enable KV pruning in attention
105
+ try:
106
+ if kind == "mg_cade25":
107
+ from .. import mg_sagpu_attention as sa_patch # local import to avoid cycles
108
+ kv_enable = bool(res.get("kv_prune_enable", False))
109
+ kv_keep = float(res.get("kv_keep", 0.85))
110
+ kv_min = int(res.get("kv_min_tokens", 128)) if "kv_min_tokens" in res else 128
111
+ if hasattr(sa_patch, "set_kv_prune"):
112
+ sa_patch.set_kv_prune(kv_enable, kv_keep, kv_min)
113
+ except Exception:
114
+ pass
115
+ return res
mod/hard/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """MagicNodes Hard variants
2
+
3
+ Complex, full‑control node implementations. Imported and registered
4
+ from the package root to expose them under the UI category
5
+ "MagicNodes/Hard".
6
+ """
7
+
8
+ # No side effects on import.
9
+
mod/hard/mg_adaptive.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adaptive sampler helper node (moved to mod/).
2
+
3
+ Keeps class/key name AdaptiveSamplerHelper for backward compatibility.
4
+ """
5
+
6
+ import numpy as np
7
+ from scipy.ndimage import laplace
8
+
9
+
10
+ class AdaptiveSamplerHelper:
11
+ @classmethod
12
+ def INPUT_TYPES(cls):
13
+ return {
14
+ "required": {
15
+ "image": ("IMAGE", {}),
16
+ "steps": ("INT", {"default": 20, "min": 1, "max": 200}),
17
+ "cfg": ("FLOAT", {"default": 7.0, "min": 0.1, "max": 20.0, "step": 0.1}),
18
+ "denoise": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
19
+ }
20
+ }
21
+
22
+ RETURN_TYPES = ("INT", "FLOAT", "FLOAT")
23
+ RETURN_NAMES = ("steps", "cfg", "denoise")
24
+ FUNCTION = "tune"
25
+ CATEGORY = "MagicNodes"
26
+
27
+ def tune(self, image, steps, cfg, denoise):
28
+ img = image[0].cpu().numpy()
29
+ gray = img.mean(axis=2)
30
+ brightness = float(gray.mean())
31
+ contrast = float(gray.std())
32
+ sharpness = float(np.var(laplace(gray)))
33
+
34
+ tuned_steps = int(max(1, round(steps + sharpness * 10)))
35
+ tuned_cfg = float(cfg + contrast * 2.0)
36
+ tuned_denoise = float(np.clip(denoise + (0.5 - brightness), 0.0, 1.0))
37
+
38
+ return (tuned_steps, tuned_cfg, tuned_denoise)
39
+
mod/hard/mg_cade25.py ADDED
@@ -0,0 +1,1864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CADE 2.5: refined adaptive enhancer with reference clean and accumulation override.
2
+
3
+ Builds on the CADE2 Beta: single clean iteration loop, optional latent-based
4
+ parameter damping, CLIP-based reference clean, and per-run SageAttention
5
+ accumulation override.
6
+ """
7
+
8
+ from __future__ import annotations # moved/renamed module: mg_cade25
9
+
10
+ import torch
11
+ import os
12
+ import numpy as np
13
+ import torch.nn.functional as F
14
+
15
+ import nodes
16
+ import comfy.model_management as model_management
17
+
18
+ from .mg_adaptive import AdaptiveSamplerHelper
19
+ from .mg_zesmart_sampler_v1_1 import _build_hybrid_sigmas
20
+ import comfy.sample as _sample
21
+ import comfy.samplers as _samplers
22
+ import comfy.utils as _utils
23
+ from .mg_upscale_module import MagicUpscaleModule, clear_gpu_and_ram_cache
24
+ from .mg_controlfusion import _build_depth_map as _cf_build_depth_map
25
+ from .mg_ids import IntelligentDetailStabilizer
26
+ from .. import mg_sagpu_attention as sa_patch
27
+ # FDG/NAG experimental paths removed for now; keeping code lean
28
+
29
+
30
+ # Lazy CLIPSeg cache
31
+ _CLIPSEG_MODEL = None
32
+ _CLIPSEG_PROC = None
33
+ _CLIPSEG_DEV = "cpu"
34
+ _CLIPSEG_FORCE_CPU = True # pin CLIPSeg to CPU to avoid device drift
35
+
36
+ # Per-iteration spatial guidance mask (B,1,H,W) in [0,1]; used by cfg_func when enabled
37
+ # Kept for potential future use with non-ONNX masks (e.g., CLIPSeg/ControlFusion),
38
+ # but not set by this node since ONNX paths are removed.
39
+ CURRENT_ONNX_MASK_BCHW = None
40
+
41
+
42
+ # ONNX runtime initialization removed
43
+
44
+
45
+ def _try_init_clipseg():
46
+ """Lazy-load CLIPSeg processor + model and choose device.
47
+ Returns True on success.
48
+ """
49
+ global _CLIPSEG_MODEL, _CLIPSEG_PROC, _CLIPSEG_DEV
50
+ if (_CLIPSEG_MODEL is not None) and (_CLIPSEG_PROC is not None):
51
+ return True
52
+ try:
53
+ from transformers import CLIPSegProcessor, CLIPSegForImageSegmentation # type: ignore
54
+ except Exception:
55
+ if not globals().get("_CLIPSEG_WARNED", False):
56
+ print("[CADE2.5][CLIPSeg] transformers not available; CLIPSeg disabled.")
57
+ globals()["_CLIPSEG_WARNED"] = True
58
+ return False
59
+ try:
60
+ _CLIPSEG_PROC = CLIPSegProcessor.from_pretrained("CIDAS/clipseg-rd64-refined")
61
+ _CLIPSEG_MODEL = CLIPSegForImageSegmentation.from_pretrained("CIDAS/clipseg-rd64-refined")
62
+ if _CLIPSEG_FORCE_CPU:
63
+ _CLIPSEG_DEV = "cpu"
64
+ else:
65
+ _CLIPSEG_DEV = "cuda" if torch.cuda.is_available() else "cpu"
66
+ _CLIPSEG_MODEL = _CLIPSEG_MODEL.to(_CLIPSEG_DEV)
67
+ _CLIPSEG_MODEL.eval()
68
+ return True
69
+ except Exception as e:
70
+ print(f"[CADE2.5][CLIPSeg] failed to load model: {e}")
71
+ return False
72
+
73
+
74
+ def _clipseg_build_mask(image_bhwc: torch.Tensor,
75
+ text: str,
76
+ preview: int = 224,
77
+ threshold: float = 0.4,
78
+ blur: float = 7.0,
79
+ dilate: int = 4,
80
+ gain: float = 1.0,
81
+ ref_embed: torch.Tensor | None = None,
82
+ clip_vision=None,
83
+ ref_threshold: float = 0.03) -> torch.Tensor | None:
84
+ """Return BHWC single-channel mask [0,1] from CLIPSeg.
85
+ - Uses cached CLIPSeg model; gracefully returns None on failure.
86
+ - Applies optional threshold/blur/dilate and scaling gain.
87
+ - If clip_vision + ref_embed provided, gates mask by CLIP-Vision distance.
88
+ """
89
+ if not text or not isinstance(text, str):
90
+ return None
91
+ if not _try_init_clipseg():
92
+ return None
93
+ try:
94
+ # Prepare preview image (CPU PIL)
95
+ target = int(max(16, min(1024, preview)))
96
+ img = image_bhwc.detach().to('cpu')
97
+ B, H, W, C = img.shape
98
+ x = img[0].movedim(-1, 0).unsqueeze(0) # 1,C,H,W
99
+ x = F.interpolate(x, size=(target, target), mode='bilinear', align_corners=False)
100
+ x = x.clamp(0, 1)
101
+ arr = (x[0].movedim(0, -1).numpy() * 255.0).astype('uint8')
102
+ from PIL import Image # lazy import
103
+ pil_img = Image.fromarray(arr)
104
+
105
+ # Run CLIPSeg
106
+ import re
107
+ prompts = [t.strip() for t in re.split(r"[\|,;\n]+", text) if t.strip()]
108
+ if not prompts:
109
+ prompts = [text.strip()]
110
+ prompts = prompts[:8]
111
+ inputs = _CLIPSEG_PROC(text=prompts, images=[pil_img] * len(prompts), return_tensors="pt")
112
+ inputs = {k: v.to(_CLIPSEG_DEV) for k, v in inputs.items()}
113
+ with torch.inference_mode():
114
+ outputs = _CLIPSEG_MODEL(**inputs) # type: ignore
115
+ # logits: [N, H', W'] for N prompts
116
+ logits = outputs.logits # [N,h,w]
117
+ if logits.ndim == 2:
118
+ logits = logits.unsqueeze(0)
119
+ prob = torch.sigmoid(logits) # [N,h,w]
120
+ # Soft-OR fuse across prompts
121
+ prob = 1.0 - torch.prod(1.0 - prob.clamp(0, 1), dim=0, keepdim=True) # [1,h,w]
122
+ prob = prob.unsqueeze(1) # [1,1,h,w]
123
+ # Resize to original image size
124
+ prob = F.interpolate(prob, size=(H, W), mode='bilinear', align_corners=False)
125
+ m = prob[0, 0].to(dtype=image_bhwc.dtype, device=image_bhwc.device)
126
+ # Threshold + blur (approx)
127
+ if threshold > 0.0:
128
+ m = torch.where(m > float(threshold), m, torch.zeros_like(m))
129
+ # Gaussian blur via our depthwise helper
130
+ if blur > 0.0:
131
+ rad = int(max(1, min(7, round(blur))))
132
+ m = _gaussian_blur_nchw(m.unsqueeze(0).unsqueeze(0), sigma=float(max(0.5, blur)), radius=rad)[0, 0]
133
+ # Dilation via max-pool
134
+ if int(dilate) > 0:
135
+ k = int(dilate) * 2 + 1
136
+ p = int(dilate)
137
+ m = F.max_pool2d(m.unsqueeze(0).unsqueeze(0), kernel_size=k, stride=1, padding=p)[0, 0]
138
+ # Optional CLIP-Vision gating by reference distance
139
+ if (clip_vision is not None) and (ref_embed is not None):
140
+ try:
141
+ cur = _encode_clip_image(image_bhwc, clip_vision, target_res=224)
142
+ dist = _clip_cosine_distance(cur, ref_embed)
143
+ if dist > float(ref_threshold):
144
+ # up to +50% gain if сильно уехали
145
+ gate = 1.0 + min(0.5, (dist - float(ref_threshold)) * 4.0)
146
+ m = m * gate
147
+ except Exception:
148
+ pass
149
+ m = (m * float(max(0.0, gain))).clamp(0, 1)
150
+ return m.unsqueeze(0).unsqueeze(-1) # BHWC with B=1,C=1
151
+ except Exception as e:
152
+ if not globals().get("_CLIPSEG_WARNED", False):
153
+ print(f"[CADE2.5][CLIPSeg] mask failed: {e}")
154
+ globals()["_CLIPSEG_WARNED"] = True
155
+ return None
156
+
157
+
158
+ def _np_to_mask_tensor(np_map: np.ndarray, out_h: int, out_w: int, device, dtype):
159
+ """Convert numpy heatmap [H,W] or [1,H,W] or [H,W,1] to BHWC torch mask with B=1 and resize to out_h,out_w."""
160
+ if np_map.ndim == 3:
161
+ np_map = np_map.reshape(np_map.shape[-2], np_map.shape[-1]) if (np_map.shape[0] == 1) else np_map.squeeze()
162
+ if np_map.ndim != 2:
163
+ return None
164
+ t = torch.from_numpy(np_map.astype(np.float32))
165
+ t = t.clamp_min(0.0)
166
+ t = t.unsqueeze(0).unsqueeze(0) # B=1,C=1,H,W
167
+ t = F.interpolate(t, size=(out_h, out_w), mode="bilinear", align_corners=False)
168
+ t = t.permute(0, 2, 3, 1).to(device=device, dtype=dtype) # B,H,W,C
169
+ return t.clamp(0, 1)
170
+
171
+
172
+ # --- Firefly/Hot-pixel remover (image space, BHWC in 0..1) ---
173
+ def _median_pool3x3_bhwc(img_bhwc: torch.Tensor) -> torch.Tensor:
174
+ B, H, W, C = img_bhwc.shape
175
+ x = img_bhwc.permute(0, 3, 1, 2) # B,C,H,W
176
+ unfold = F.unfold(x, kernel_size=3, padding=1) # B, 9*C, H*W
177
+ unfold = unfold.view(B, x.shape[1], 9, H, W) # B,C,9,H,W
178
+ med, _ = torch.median(unfold, dim=2) # B,C,H,W
179
+ return med.permute(0, 2, 3, 1) # B,H,W,C
180
+
181
+
182
+ def _despeckle_fireflies(img_bhwc: torch.Tensor,
183
+ thr: float = 0.985,
184
+ max_iso: float | None = None,
185
+ grad_gate: float = 0.25) -> torch.Tensor:
186
+ try:
187
+ dev, dt = img_bhwc.device, img_bhwc.dtype
188
+ B, H, W, C = img_bhwc.shape
189
+ s = max(H, W) / 1024.0
190
+ k = 3 if s <= 1.1 else (5 if s <= 2.0 else 7)
191
+ pad = k // 2
192
+ lum = (0.2126 * img_bhwc[..., 0] + 0.7152 * img_bhwc[..., 1] + 0.0722 * img_bhwc[..., 2]).to(device=dev, dtype=dt)
193
+ try:
194
+ q = float(torch.quantile(lum.reshape(-1), 0.9995).item())
195
+ thr_eff = max(float(thr), min(0.997, q))
196
+ except Exception:
197
+ thr_eff = float(thr)
198
+ # S/V based candidate: white, low saturation
199
+ R, G, Bc = img_bhwc[..., 0], img_bhwc[..., 1], img_bhwc[..., 2]
200
+ V = torch.maximum(R, torch.maximum(G, Bc))
201
+ mi = torch.minimum(R, torch.minimum(G, Bc))
202
+ S = 1.0 - (mi / (V + 1e-6))
203
+ v_thr = max(0.985, thr_eff)
204
+ s_thr = 0.06
205
+ cand = (V > v_thr) & (S < s_thr)
206
+ # gradient gate
207
+ kx = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], device=dev, dtype=dt).view(1, 1, 3, 3)
208
+ ky = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], device=dev, dtype=dt).view(1, 1, 3, 3)
209
+ gx = F.conv2d(lum.unsqueeze(1), kx, padding=1)
210
+ gy = F.conv2d(lum.unsqueeze(1), ky, padding=1)
211
+ grad = torch.sqrt(gx * gx + gy * gy).squeeze(1)
212
+ safe_gate = float(grad_gate) * (k / 3.0) ** 0.5
213
+ cand = cand & (grad < safe_gate)
214
+ if cand.any():
215
+ try:
216
+ import cv2, numpy as _np
217
+ masks = []
218
+ for b in range(cand.shape[0]):
219
+ msk = cand[b].detach().to('cpu').numpy().astype('uint8') * 255
220
+ num, labels, stats, _ = cv2.connectedComponentsWithStats(msk, connectivity=8)
221
+ rem = _np.zeros_like(msk, dtype='uint8')
222
+ area_max = int(max(3, round((k * k) * 0.6)))
223
+ for lbl in range(1, num):
224
+ area = stats[lbl, cv2.CC_STAT_AREA]
225
+ if area <= area_max:
226
+ rem[labels == lbl] = 255
227
+ masks.append(torch.from_numpy(rem > 0))
228
+ rm = torch.stack(masks, dim=0).to(device=dev)
229
+ rm = rm.unsqueeze(-1)
230
+ if rm.any():
231
+ med = _median_pool3x3_bhwc(img_bhwc)
232
+ return torch.where(rm, med, img_bhwc)
233
+ except Exception:
234
+ pass
235
+ # Fallback: density isolation
236
+ bright = (img_bhwc.min(dim=-1).values > v_thr)
237
+ dens = F.avg_pool2d(bright.float().unsqueeze(1), k, 1, pad).squeeze(1)
238
+ max_iso_eff = (2.0 / (k * k)) if (max_iso is None) else float(max_iso)
239
+ iso = bright & (dens < max_iso_eff) & (grad < safe_gate)
240
+ if not iso.any():
241
+ return img_bhwc
242
+ med = _median_pool3x3_bhwc(img_bhwc)
243
+ return torch.where(iso.unsqueeze(-1), med, img_bhwc)
244
+ except Exception:
245
+ return img_bhwc
246
+
247
+
248
+ def _try_heatmap_from_outputs(outputs: list, preview_hw: tuple[int, int]):
249
+ """Return [H,W] heatmap from model outputs if possible.
250
+ Supports:
251
+ - Segmentation logits/probabilities (NCHW / NHWC)
252
+ - Keypoints arrays -> gaussian disks on points
253
+ - Bounding boxes -> soft rectangles
254
+ """
255
+ if not outputs:
256
+ return None
257
+
258
+ Ht, Wt = int(preview_hw[0]), int(preview_hw[1])
259
+
260
+ def to_float(arr):
261
+ if arr.dtype not in (np.float32, np.float64):
262
+ try:
263
+ arr = arr.astype(np.float32)
264
+ except Exception:
265
+ return None
266
+ return arr
267
+
268
+ def sigmoid(x):
269
+ return 1.0 / (1.0 + np.exp(-x))
270
+
271
+ # 1) Prefer any spatial heatmap first
272
+ for out in outputs:
273
+ try:
274
+ arr = np.asarray(out)
275
+ except Exception:
276
+ continue
277
+ arr = to_float(arr)
278
+ if arr is None:
279
+ continue
280
+ if arr.ndim == 4:
281
+ n, a, b, c = arr.shape
282
+ if c <= 4 and a >= 8 and b >= 8:
283
+ if c == 1:
284
+ hm = sigmoid(arr[0, :, :, 0]) if np.max(np.abs(arr)) > 1.5 else arr[0, :, :, 0]
285
+ else:
286
+ ex = np.exp(arr[0] - np.max(arr[0], axis=-1, keepdims=True))
287
+ prob = ex / np.clip(ex.sum(axis=-1, keepdims=True), 1e-6, None)
288
+ hm = 1.0 - prob[..., 0] if prob.shape[-1] > 1 else prob[..., 0]
289
+ return hm.astype(np.float32)
290
+ else:
291
+ if a == 1:
292
+ ch = arr[0, 0]
293
+ hm = sigmoid(ch) if np.max(np.abs(ch)) > 1.5 else ch
294
+ return hm.astype(np.float32)
295
+ else:
296
+ x = arr[0]
297
+ x = x - np.max(x, axis=0, keepdims=True)
298
+ ex = np.exp(x)
299
+ prob = ex / np.clip(np.sum(ex, axis=0, keepdims=True), 1e-6, None)
300
+ bg = prob[0] if prob.shape[0] > 1 else prob[0]
301
+ hm = 1.0 - bg
302
+ return hm.astype(np.float32)
303
+ if arr.ndim == 3:
304
+ if arr.shape[0] == 1 and arr.shape[1] >= 8 and arr.shape[2] >= 8:
305
+ return arr[0].astype(np.float32)
306
+ if arr.ndim == 2 and arr.shape[0] >= 8 and arr.shape[1] >= 8:
307
+ return arr.astype(np.float32)
308
+
309
+ # 2) Try keypoints and boxes
310
+ heat = np.zeros((Ht, Wt), dtype=np.float32)
311
+
312
+ def draw_gaussian(hm, cx, cy, sigma=2.5, amp=1.0):
313
+ r = max(1, int(3 * sigma))
314
+ xs = np.arange(-r, r + 1, dtype=np.float32)
315
+ ys = np.arange(-r, r + 1, dtype=np.float32)
316
+ gx = np.exp(-(xs**2) / (2 * sigma * sigma))
317
+ gy = np.exp(-(ys**2) / (2 * sigma * sigma))
318
+ g = np.outer(gy, gx) * float(amp)
319
+ x0 = int(round(cx)) - r
320
+ y0 = int(round(cy)) - r
321
+ x1 = x0 + g.shape[1]
322
+ y1 = y0 + g.shape[0]
323
+ if x1 < 0 or y1 < 0 or x0 >= Wt or y0 >= Ht:
324
+ return
325
+ xs0 = max(0, x0)
326
+ ys0 = max(0, y0)
327
+ xs1 = min(Wt, x1)
328
+ ys1 = min(Ht, y1)
329
+ gx0 = xs0 - x0
330
+ gy0 = ys0 - y0
331
+ gx1 = gx0 + (xs1 - xs0)
332
+ gy1 = gy0 + (ys1 - ys0)
333
+ hm[ys0:ys1, xs0:xs1] = np.maximum(hm[ys0:ys1, xs0:xs1], g[gy0:gy1, gx0:gx1])
334
+
335
+ def draw_soft_rect(hm, x0, y0, x1, y1, edge=3.0):
336
+ x0, y0, x1, y1 = int(x0), int(y0), int(x1), int(y1)
337
+ if x1 <= 0 or y1 <= 0 or x0 >= Wt or y0 >= Ht:
338
+ return
339
+ xs0 = max(0, min(x0, x1))
340
+ ys0 = max(0, min(y0, y1))
341
+ xs1 = min(Wt, max(x0, x1))
342
+ ys1 = min(Ht, max(y0, y1))
343
+ if xs1 - xs0 <= 0 or ys1 - ys0 <= 0:
344
+ return
345
+ hm[ys0:ys1, xs0:xs1] = np.maximum(hm[ys0:ys1, xs0:xs1], 1.0)
346
+ # feather edges with simple blur-like falloff
347
+ if edge > 0:
348
+ rad = int(edge)
349
+ if rad > 0:
350
+ # quick separable triangle filter
351
+ line = np.linspace(0, 1, rad + 1, dtype=np.float32)[1:]
352
+ for d in range(1, rad + 1):
353
+ w = line[d - 1]
354
+ if ys0 - d >= 0:
355
+ hm[ys0 - d:ys0, xs0:xs1] = np.maximum(hm[ys0 - d:ys0, xs0:xs1], w)
356
+ if ys1 + d <= Ht:
357
+ hm[ys1:ys1 + d, xs0:xs1] = np.maximum(hm[ys1:ys1 + d, xs0:xs1], w)
358
+ if xs0 - d >= 0:
359
+ hm[max(0, ys0 - d):min(Ht, ys1 + d), xs0 - d:xs0] = np.maximum(
360
+ hm[max(0, ys0 - d):min(Ht, ys1 + d), xs0 - d:xs0], w)
361
+ if xs1 + d <= Wt:
362
+ hm[max(0, ys0 - d):min(Ht, ys1 + d), xs1:xs1 + d] = np.maximum(
363
+ hm[max(0, ys0 - d):min(Ht, ys1 + d), xs1:xs1 + d], w)
364
+
365
+ # Inspect outputs to find plausible keypoints/boxes
366
+ for out in outputs:
367
+ try:
368
+ arr = np.asarray(out)
369
+ except Exception:
370
+ continue
371
+ arr = to_float(arr)
372
+ if arr is None:
373
+ continue
374
+ a = arr
375
+ # Squeeze batch dims like [1,N,4] -> [N,4]
376
+ while a.ndim > 2 and a.shape[0] == 1:
377
+ a = np.squeeze(a, axis=0)
378
+ # Keypoints: [N,2] or [N,3] or [K, N, 2/3] (relax N limit; subsample if huge)
379
+ if a.ndim == 2 and a.shape[-1] in (2, 3):
380
+ pts = a
381
+ elif a.ndim == 3 and a.shape[-1] in (2, 3):
382
+ pts = a.reshape(-1, a.shape[-1])
383
+ else:
384
+ pts = None
385
+ if pts is not None:
386
+ # Coordinates range guess: if max>1.2 -> absolute; else normalized
387
+ maxv = float(np.nanmax(np.abs(pts[:, :2]))) if pts.size else 0.0
388
+ for px, py, *rest in pts:
389
+ if np.isnan(px) or np.isnan(py):
390
+ continue
391
+ if maxv <= 1.2:
392
+ cx = float(px) * (Wt - 1)
393
+ cy = float(py) * (Ht - 1)
394
+ else:
395
+ cx = float(px)
396
+ cy = float(py)
397
+ base_sig = max(1.5, min(Ht, Wt) / 128.0)
398
+ if _ONNX_KPTS_ENABLE:
399
+ draw_gaussian(heat, cx, cy, sigma=base_sig * float(_ONNX_KPTS_SIGMA), amp=float(_ONNX_KPTS_GAIN))
400
+ else:
401
+ draw_gaussian(heat, cx, cy, sigma=base_sig)
402
+ continue
403
+
404
+ # Wholebody-style packed keypoints: [N, K*3] with triples (x,y,conf)
405
+ if _ONNX_KPTS_ENABLE and a.ndim == 2 and a.shape[-1] >= 6 and (a.shape[-1] % 3) == 0:
406
+ K = a.shape[-1] // 3
407
+ if K >= 5 and K <= 256:
408
+ # Guess coordinate range once
409
+ with np.errstate(invalid='ignore'):
410
+ maxv = float(np.nanmax(np.abs(a[:, :2]))) if a.size else 0.0
411
+ for i in range(a.shape[0]):
412
+ row = a[i]
413
+ kp = row.reshape(K, 3)
414
+ for (px, py, pc) in kp:
415
+ if np.isnan(px) or np.isnan(py):
416
+ continue
417
+ if np.isfinite(pc) and pc < float(_ONNX_KPTS_CONF):
418
+ continue
419
+ if maxv <= 1.2:
420
+ cx = float(px) * (Wt - 1)
421
+ cy = float(py) * (Ht - 1)
422
+ else:
423
+ cx = float(px)
424
+ cy = float(py)
425
+ base_sig = max(1.0, min(Ht, Wt) / 128.0)
426
+ draw_gaussian(heat, cx, cy, sigma=base_sig * float(_ONNX_KPTS_SIGMA), amp=float(_ONNX_KPTS_GAIN))
427
+ continue
428
+ # Boxes: [N,4+] (x0,y0,x1,y1) or [N, (x,y,w,h, [conf, ...])]; relax N limit (handle YOLO-style outputs)
429
+ if a.ndim == 2 and a.shape[-1] >= 4:
430
+ boxes = a
431
+ elif a.ndim == 3 and a.shape[-1] >= 4:
432
+ # choose the smallest first two dims as N
433
+ if a.shape[0] == 1:
434
+ boxes = a.reshape(-1, a.shape[-1])
435
+ else:
436
+ boxes = a.reshape(-1, a.shape[-1])
437
+ else:
438
+ boxes = None
439
+ if boxes is not None:
440
+ # Optional score gating (try to find a confidence column)
441
+ score = None
442
+ if boxes.shape[-1] >= 6:
443
+ score = boxes[:, 4]
444
+ # if classes follow, mix in best class prob
445
+ try:
446
+ score = score * np.max(boxes[:, 5:], axis=-1)
447
+ except Exception:
448
+ pass
449
+ elif boxes.shape[-1] == 5:
450
+ score = boxes[:, 4]
451
+ # Keep top-K by score if available
452
+ if score is not None:
453
+ try:
454
+ order = np.argsort(-score)
455
+ keep = order[: min(64, order.shape[0])]
456
+ boxes = boxes[keep]
457
+ score = score[keep]
458
+ except Exception:
459
+ score = None
460
+
461
+ xy = boxes[:, :4]
462
+ maxv = float(np.nanmax(np.abs(xy))) if xy.size else 0.0
463
+ if maxv <= 1.2:
464
+ x0 = xy[:, 0] * (Wt - 1)
465
+ y0 = xy[:, 1] * (Ht - 1)
466
+ x1 = xy[:, 2] * (Wt - 1)
467
+ y1 = xy[:, 3] * (Ht - 1)
468
+ else:
469
+ x0, y0, x1, y1 = xy[:, 0], xy[:, 1], xy[:, 2], xy[:, 3]
470
+ # Heuristic: if many boxes are inverted, treat as [x,y,w,h]
471
+ invalid = np.sum((x1 <= x0) | (y1 <= y0))
472
+ if invalid > 0.5 * x0.shape[0]:
473
+ x, y, w, h = x0, y0, x1, y1
474
+ x0 = x - w * 0.5
475
+ y0 = y - h * 0.5
476
+ x1 = x + w * 0.5
477
+ y1 = y + h * 0.5
478
+ for i in range(x0.shape[0]):
479
+ if score is not None and np.isfinite(score[i]) and score[i] < 0.2:
480
+ continue
481
+ draw_soft_rect(heat, x0[i], y0[i], x1[i], y1[i], edge=3.0)
482
+
483
+ # Embedded keypoints in YOLO-style rows: try to parse trailing triples (x,y,conf)
484
+ if _ONNX_KPTS_ENABLE and boxes.shape[-1] > 6:
485
+ D = boxes.shape[-1]
486
+ for i in range(boxes.shape[0]):
487
+ row = boxes[i]
488
+ parsed = False
489
+ # try [xyxy, conf, cls, kpts] or [xyxy, conf, kpts] or [xyxy, kpts]
490
+ for offset in (6, 5, 4):
491
+ t = D - offset
492
+ if t >= 6 and t % 3 == 0:
493
+ k = t // 3
494
+ kp = row[offset:offset + 3 * k].reshape(k, 3)
495
+ parsed = True
496
+ break
497
+ if not parsed:
498
+ continue
499
+ for (px, py, pc) in kp:
500
+ if np.isnan(px) or np.isnan(py):
501
+ continue
502
+ if pc < float(_ONNX_KPTS_CONF):
503
+ continue
504
+ if maxv <= 1.2:
505
+ cx = float(px) * (Wt - 1)
506
+ cy = float(py) * (Ht - 1)
507
+ else:
508
+ cx = float(px)
509
+ cy = float(py)
510
+ base_sig = max(1.0, min(Ht, Wt) / 128.0)
511
+ draw_gaussian(heat, cx, cy, sigma=base_sig * float(_ONNX_KPTS_SIGMA), amp=float(_ONNX_KPTS_GAIN))
512
+
513
+ if heat.max() > 0:
514
+ heat = np.clip(heat, 0.0, 1.0)
515
+ return heat
516
+ return None
517
+
518
+
519
+ def _onnx_build_mask(image_bhwc: torch.Tensor, preview: int, sensitivity: float, models_dir: str, anomaly_gain: float = 1.0) -> torch.Tensor:
520
+ """Deprecated: ONNX path removed. Returns zero mask of input size."""
521
+ B, H, W, C = image_bhwc.shape
522
+ return torch.zeros((B, H, W, 1), device=image_bhwc.device, dtype=image_bhwc.dtype)
523
+ if not _try_init_onnx(models_dir):
524
+ return torch.zeros((image_bhwc.shape[0], image_bhwc.shape[1], image_bhwc.shape[2], 1), device=image_bhwc.device, dtype=image_bhwc.dtype)
525
+
526
+ if not _ONNX_SESS:
527
+ return torch.zeros((image_bhwc.shape[0], image_bhwc.shape[1], image_bhwc.shape[2], 1), device=image_bhwc.device, dtype=image_bhwc.dtype)
528
+
529
+ B, H, W, C = image_bhwc.shape
530
+ device = image_bhwc.device
531
+ dtype = image_bhwc.dtype
532
+
533
+ # Process per-batch image
534
+ masks = []
535
+ img_cpu = image_bhwc.detach().to('cpu')
536
+ for b in range(B):
537
+ masks_b = []
538
+ # Prepare input resized square preview
539
+ target = int(max(16, min(1024, preview)))
540
+ xb = img_cpu[b].movedim(-1, 0).unsqueeze(0) # 1,C,H,W
541
+ x_stretch = F.interpolate(xb, size=(target, target), mode='bilinear', align_corners=False).clamp(0, 1)
542
+ x_letter = _letterbox_nchw(xb, target).clamp(0, 1)
543
+ # Try four variants: stretch RGB, letterbox RGB, stretch BGR, letterbox BGR
544
+ variants = [
545
+ ("stretch-RGB", x_stretch),
546
+ ("letterbox-RGB", x_letter),
547
+ ("stretch-BGR", x_stretch[:, [2, 1, 0], :, :]),
548
+ ("letterbox-BGR", x_letter[:, [2, 1, 0], :, :]),
549
+ ]
550
+ if _ONNX_DEBUG:
551
+ try:
552
+ print(f"[CADE2.5][ONNX] Build mask for image[{b}] -> preview {target}x{target}")
553
+ except Exception:
554
+ pass
555
+
556
+ for name, sess in list(_ONNX_SESS.items()):
557
+ try:
558
+ inputs = sess.get_inputs()
559
+ if not inputs:
560
+ continue
561
+ in_name = inputs[0].name
562
+ in_shape = inputs[0].shape if hasattr(inputs[0], 'shape') else None
563
+ # Choose layout automatically based on the presence of channel dim=3
564
+ if isinstance(in_shape, (list, tuple)) and len(in_shape) == 4:
565
+ dim_vals = []
566
+ for d in in_shape:
567
+ try:
568
+ dim_vals.append(int(d))
569
+ except Exception:
570
+ dim_vals.append(-1)
571
+ if dim_vals[-1] == 3:
572
+ layout = "NHWC"
573
+ else:
574
+ layout = "NCHW"
575
+ else:
576
+ layout = "NCHW?"
577
+ if _ONNX_DEBUG:
578
+ try:
579
+ print(f"[CADE2.5][ONNX] Model '{name}' in_shape={in_shape} layout={layout}")
580
+ except Exception:
581
+ pass
582
+ # Try multiple input variants and scales
583
+ hm = None
584
+ chosen = None
585
+ for vname, vx in variants:
586
+ if layout.startswith("NHWC"):
587
+ xin = vx.permute(0, 2, 3, 1)
588
+ else:
589
+ xin = vx
590
+ for scale in (1.0, 255.0):
591
+ inp = (xin * float(scale)).numpy().astype(np.float32)
592
+ feed = {in_name: inp}
593
+ outs = sess.run(None, feed)
594
+ if _ONNX_DEBUG:
595
+ try:
596
+ shapes = []
597
+ for o in outs:
598
+ try:
599
+ shapes.append(tuple(np.asarray(o).shape))
600
+ except Exception:
601
+ shapes.append("?")
602
+ print(f"[CADE2.5][ONNX] '{name}' {vname} scale={scale} -> outs shapes {shapes}")
603
+ except Exception:
604
+ pass
605
+ hm = _try_heatmap_from_outputs(outs, (target, target))
606
+ if _ONNX_DEBUG:
607
+ try:
608
+ if hm is None:
609
+ print(f"[CADE2.5][ONNX] '{name}' {vname} scale={scale}: no spatial heatmap detected")
610
+ else:
611
+ print(f"[CADE2.5][ONNX] '{name}' {vname} scale={scale}: heat stats min={np.min(hm):.4f} max={np.max(hm):.4f} mean={np.mean(hm):.4f}")
612
+ except Exception:
613
+ pass
614
+ if hm is not None and np.max(hm) > 0:
615
+ chosen = (vname, scale)
616
+ break
617
+ if hm is not None and np.max(hm) > 0:
618
+ break
619
+ if hm is None:
620
+ continue
621
+ # Scale by sensitivity and optional anomaly gain
622
+ gain = float(max(0.0, sensitivity))
623
+ if 'anomaly' in name.lower():
624
+ gain *= float(max(0.0, anomaly_gain))
625
+ hm = np.clip(hm * gain, 0.0, 1.0)
626
+ tmask = _np_to_mask_tensor(hm, H, W, device, dtype)
627
+ if tmask is not None:
628
+ masks_b.append(tmask)
629
+ if _ONNX_DEBUG:
630
+ try:
631
+ area = float(tmask.movedim(-1,1).mean().item())
632
+ if chosen is not None:
633
+ vname, scale = chosen
634
+ print(f"[CADE2.5][ONNX] '{name}' via {vname} x{scale} area={area:.4f}")
635
+ else:
636
+ print(f"[CADE2.5][ONNX] '{name}' contribution area={area:.4f}")
637
+ except Exception:
638
+ pass
639
+ except Exception:
640
+ # Ignore failing models
641
+ continue
642
+ if not masks_b:
643
+ masks.append(torch.zeros((1, H, W, 1), device=device, dtype=dtype))
644
+ else:
645
+ # Soft-OR fusion: 1 - prod(1 - m)
646
+ stack = torch.stack([masks_b[i] for i in range(len(masks_b))], dim=0) # M,1,H,W,1? actually B dims kept as 1
647
+ fused = 1.0 - torch.prod(1.0 - stack.clamp(0, 1), dim=0)
648
+ # Light smoothing via bilinear down/up (anti alias)
649
+ ch = fused.permute(0, 3, 1, 2) # B=1,C=1,H,W
650
+ dd = F.interpolate(ch, scale_factor=0.5, mode='bilinear', align_corners=False, recompute_scale_factor=False)
651
+ uu = F.interpolate(dd, size=(H, W), mode='bilinear', align_corners=False)
652
+ fused = uu.permute(0, 2, 3, 1).clamp(0, 1)
653
+ if _ONNX_DEBUG:
654
+ try:
655
+ area = float(fused.movedim(-1,1).mean().item())
656
+ print(f"[CADE2.5][ONNX] Fused area (image[{b}])={area:.4f}")
657
+ except Exception:
658
+ pass
659
+ masks.append(fused)
660
+
661
+ return torch.cat(masks, dim=0)
662
+
663
+ def _sampler_names():
664
+ try:
665
+ import comfy.samplers
666
+ return comfy.samplers.KSampler.SAMPLERS
667
+ except Exception:
668
+ return ["euler"]
669
+
670
+
671
+ def _scheduler_names():
672
+ try:
673
+ import comfy.samplers
674
+ scheds = list(comfy.samplers.KSampler.SCHEDULERS)
675
+ if "MGHybrid" not in scheds:
676
+ scheds.append("MGHybrid")
677
+ return scheds
678
+ except Exception:
679
+ return ["normal", "MGHybrid"]
680
+
681
+
682
+ def safe_decode(vae, lat, tile=512, ovlp=64):
683
+ h, w = lat["samples"].shape[-2:]
684
+ if min(h, w) > 1024:
685
+ # Increase overlap for ultra-hires to reduce seam artifacts
686
+ ov = 128 if max(h, w) > 2048 else ovlp
687
+ return vae.decode_tiled(lat["samples"], tile_x=tile, tile_y=tile, overlap=ov)
688
+ return vae.decode(lat["samples"])
689
+
690
+
691
+ def safe_encode(vae, img, tile=512, ovlp=64):
692
+ import math, torch.nn.functional as F
693
+ h, w = img.shape[1:3]
694
+ try:
695
+ stride = int(vae.spacial_compression_decode())
696
+ except Exception:
697
+ stride = 8
698
+ if stride <= 0:
699
+ stride = 8
700
+ def _align_up(x, s):
701
+ return int(((x + s - 1) // s) * s)
702
+ Ht = _align_up(h, stride)
703
+ Wt = _align_up(w, stride)
704
+ x = img
705
+ if (Ht != h) or (Wt != w):
706
+ # pad on bottom/right using replicate to avoid black borders
707
+ pad_h = Ht - h
708
+ pad_w = Wt - w
709
+ x_nchw = img.movedim(-1, 1)
710
+ x_nchw = F.pad(x_nchw, (0, pad_w, 0, pad_h), mode='replicate')
711
+ x = x_nchw.movedim(1, -1)
712
+ if min(Ht, Wt) > 1024:
713
+ ov = 128 if max(Ht, Wt) > 2048 else ovlp
714
+ return vae.encode_tiled(x[:, :, :, :3], tile_x=tile, tile_y=tile, overlap=ov)
715
+ return vae.encode(x[:, :, :, :3])
716
+
717
+
718
+
719
+ def _gaussian_kernel(kernel_size: int, sigma: float, device=None):
720
+ x, y = torch.meshgrid(
721
+ torch.linspace(-1, 1, kernel_size, device=device),
722
+ torch.linspace(-1, 1, kernel_size, device=device),
723
+ indexing="ij",
724
+ )
725
+ d = torch.sqrt(x * x + y * y)
726
+ g = torch.exp(-(d * d) / (2.0 * sigma * sigma))
727
+ return g / g.sum()
728
+
729
+
730
+ def _sharpen_image(image: torch.Tensor, sharpen_radius: int, sigma: float, alpha: float):
731
+ if sharpen_radius == 0:
732
+ return (image,)
733
+
734
+ image = image.to(model_management.get_torch_device())
735
+ batch_size, height, width, channels = image.shape
736
+
737
+ kernel_size = sharpen_radius * 2 + 1
738
+ kernel = _gaussian_kernel(kernel_size, sigma, device=image.device) * -(alpha * 10)
739
+ kernel = kernel.to(dtype=image.dtype)
740
+ center = kernel_size // 2
741
+ kernel[center, center] = kernel[center, center] - kernel.sum() + 1.0
742
+ kernel = kernel.repeat(channels, 1, 1).unsqueeze(1)
743
+
744
+ tensor_image = image.permute(0, 3, 1, 2)
745
+ tensor_image = F.pad(tensor_image, (sharpen_radius, sharpen_radius, sharpen_radius, sharpen_radius), 'reflect')
746
+ sharpened = F.conv2d(tensor_image, kernel, padding=center, groups=channels)[:, :, sharpen_radius:-sharpen_radius, sharpen_radius:-sharpen_radius]
747
+ sharpened = sharpened.permute(0, 2, 3, 1)
748
+
749
+ result = torch.clamp(sharpened, 0, 1)
750
+ return (result.to(model_management.intermediate_device()),)
751
+
752
+
753
+ def _encode_clip_image(image: torch.Tensor, clip_vision, target_res: int) -> torch.Tensor:
754
+ # image: BHWC in [0,1]
755
+ img = image.movedim(-1, 1) # BCHW
756
+ img = F.interpolate(img, size=(target_res, target_res), mode="bilinear", align_corners=False)
757
+ img = (img * 2.0) - 1.0
758
+ embeds = clip_vision.encode_image(img)["image_embeds"]
759
+ embeds = F.normalize(embeds, dim=-1)
760
+ return embeds
761
+
762
+
763
+ def _clip_cosine_distance(a: torch.Tensor, b: torch.Tensor) -> float:
764
+ if a.shape != b.shape:
765
+ m = min(a.shape[0], b.shape[0])
766
+ a = a[:m]
767
+ b = b[:m]
768
+ sim = (a * b).sum(dim=-1).mean().clamp(-1.0, 1.0).item()
769
+ return 1.0 - sim
770
+
771
+
772
+ def _gaussian_blur_nchw(x: torch.Tensor, sigma: float = 1.0, radius: int = 1) -> torch.Tensor:
773
+ """Lightweight depthwise Gaussian blur for NCHW tensors.
774
+ Uses reflect padding and a normalized kernel built by _gaussian_kernel.
775
+ """
776
+ if radius <= 0:
777
+ return x
778
+ ksz = radius * 2 + 1
779
+ kernel = _gaussian_kernel(ksz, sigma, device=x.device).to(dtype=x.dtype)
780
+ kernel = kernel.repeat(x.shape[1], 1, 1).unsqueeze(1) # [C,1,K,K]
781
+ x_pad = F.pad(x, (radius, radius, radius, radius), mode='reflect')
782
+ y = F.conv2d(x_pad, kernel, padding=0, groups=x.shape[1])
783
+ return y
784
+
785
+
786
+ def _letterbox_nchw(x: torch.Tensor, target: int, pad_val: float = 114.0 / 255.0) -> torch.Tensor:
787
+ """Letterbox a BCHW tensor to target x target with constant padding (YOLO-style).
788
+ Preserves aspect ratio, centers content, pads with pad_val.
789
+ """
790
+ if x.ndim != 4:
791
+ return F.interpolate(x, size=(target, target), mode='bilinear', align_corners=False)
792
+ b, c, h, w = x.shape
793
+ if h == 0 or w == 0:
794
+ return F.interpolate(x, size=(target, target), mode='bilinear', align_corners=False)
795
+ r = float(min(target / max(1, h), target / max(1, w)))
796
+ nh = max(1, int(round(h * r)))
797
+ nw = max(1, int(round(w * r)))
798
+ y = F.interpolate(x, size=(nh, nw), mode='bilinear', align_corners=False)
799
+ pt = (target - nh) // 2
800
+ pb = target - nh - pt
801
+ pl = (target - nw) // 2
802
+ pr = target - nw - pl
803
+ if pt < 0 or pb < 0 or pl < 0 or pr < 0:
804
+ # Fallback stretch if rounding went weird
805
+ return F.interpolate(x, size=(target, target), mode='bilinear', align_corners=False)
806
+ return F.pad(y, (pl, pr, pt, pb), mode='constant', value=float(pad_val))
807
+
808
+
809
+ def _fdg_filter(delta: torch.Tensor, low_gain: float, high_gain: float, sigma: float = 1.0, radius: int = 1) -> torch.Tensor:
810
+ """Frequency-Decoupled Guidance: split delta into low/high bands and reweight.
811
+ delta: [B,C,H,W]
812
+ """
813
+ low = _gaussian_blur_nchw(delta, sigma=sigma, radius=radius)
814
+ high = delta - low
815
+ return low * float(low_gain) + high * float(high_gain)
816
+
817
+
818
+ def _fdg_split_three(delta: torch.Tensor,
819
+ sigma_lo: float = 0.8,
820
+ sigma_hi: float = 2.0,
821
+ radius: int = 1) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
822
+ """Tri-band split: returns (low, mid, high) for NCHW delta.
823
+ low = G(sigma_hi)
824
+ mid = G(sigma_lo) - G(sigma_hi)
825
+ high = delta - G(sigma_lo)
826
+ """
827
+ sig_lo = float(max(0.05, sigma_lo))
828
+ sig_hi = float(max(sig_lo + 1e-3, sigma_hi))
829
+ blur_lo = _gaussian_blur_nchw(delta, sigma=sig_lo, radius=radius)
830
+ blur_hi = _gaussian_blur_nchw(delta, sigma=sig_hi, radius=radius)
831
+ low = blur_hi
832
+ mid = blur_lo - blur_hi
833
+ high = delta - blur_lo
834
+ return low, mid, high
835
+
836
+
837
+ def _fdg_energy_fraction(delta: torch.Tensor, sigma: float = 1.0, radius: int = 1) -> torch.Tensor:
838
+ """Return fraction of high-frequency energy: E_high / (E_low + E_high)."""
839
+ low = _gaussian_blur_nchw(delta, sigma=sigma, radius=radius)
840
+ high = delta - low
841
+ e_low = (low * low).mean(dim=(1, 2, 3), keepdim=True)
842
+ e_high = (high * high).mean(dim=(1, 2, 3), keepdim=True)
843
+ frac = e_high / (e_low + e_high + 1e-8)
844
+ return frac
845
+
846
+
847
+ def _wrap_model_with_guidance(model, guidance_mode: str, rescale_multiplier: float, momentum_beta: float, cfg_curve: float, perp_damp: float, use_zero_init: bool=False, zero_init_steps: int=0, fdg_low: float = 0.6, fdg_high: float = 1.3, fdg_sigma: float = 1.0, ze_zero_steps: int = 0, ze_adaptive: bool = False, ze_r_switch_hi: float = 0.6, ze_r_switch_lo: float = 0.45, fdg_low_adaptive: bool = False, fdg_low_min: float = 0.45, fdg_low_max: float = 0.7, fdg_ema_beta: float = 0.8, use_local_mask: bool = False, mask_inside: float = 1.0, mask_outside: float = 1.0,
848
+ midfreq_enable: bool = False, midfreq_gain: float = 0.0, midfreq_sigma_lo: float = 0.8, midfreq_sigma_hi: float = 2.0,
849
+ mahiro_plus_enable: bool = False, mahiro_plus_strength: float = 0.5,
850
+ eps_scale_enable: bool = False, eps_scale: float = 0.0):
851
+
852
+ """Clone model and attach a cfg mixing function implementing RescaleCFG/FDG, CFGZero*/FD, or hybrid ZeResFDG.
853
+ guidance_mode: 'default' | 'RescaleCFG' | 'RescaleFDG' | 'CFGZero*' | 'CFGZeroFD' | 'ZeResFDG'
854
+ """
855
+ if guidance_mode == "default":
856
+ return model
857
+ m = model.clone()
858
+
859
+ # State for momentum and sigma normalization across steps
860
+ prev_delta = {"t": None}
861
+ sigma_seen = {"max": None, "min": None}
862
+ # Spectral switching/adaptive low state
863
+ spec_state = {"ema": None, "mode": "CFGZeroFD"}
864
+
865
+ def cfg_func(args):
866
+ cond = args["cond"]
867
+ uncond = args["uncond"]
868
+ cond_scale = args["cond_scale"]
869
+ sigma = args.get("sigma", None)
870
+ x_orig = args.get("input", None)
871
+
872
+ # Local spatial gain from CURRENT_ONNX_MASK_BCHW, resized to cond spatial size
873
+ def _local_gain_for(hw):
874
+ if not bool(use_local_mask):
875
+ return None
876
+ m = globals().get("CURRENT_ONNX_MASK_BCHW", None)
877
+ if m is None:
878
+ return None
879
+ try:
880
+ Ht, Wt = int(hw[0]), int(hw[1])
881
+ g = m.to(device=cond.device, dtype=cond.dtype)
882
+ if g.shape[-2] != Ht or g.shape[-1] != Wt:
883
+ g = F.interpolate(g, size=(Ht, Wt), mode='bilinear', align_corners=False)
884
+ gi = float(mask_inside)
885
+ go = float(mask_outside)
886
+ gain = g * gi + (1.0 - g) * go # [B,1,H,W]
887
+ return gain
888
+ except Exception:
889
+ return None
890
+
891
+ # Allow hybrid switch per-step
892
+ mode = guidance_mode
893
+ if guidance_mode == "ZeResFDG":
894
+ if bool(ze_adaptive):
895
+ try:
896
+ delta_raw = args["cond"] - args["uncond"]
897
+ frac_b = _fdg_energy_fraction(delta_raw, sigma=float(fdg_sigma), radius=1) # [B,1,1,1]
898
+ frac = float(frac_b.mean().clamp(0.0, 1.0).item())
899
+ except Exception:
900
+ frac = 0.0
901
+ if spec_state["ema"] is None:
902
+ spec_state["ema"] = frac
903
+ else:
904
+ beta = float(max(0.0, min(0.99, fdg_ema_beta)))
905
+ spec_state["ema"] = beta * float(spec_state["ema"]) + (1.0 - beta) * frac
906
+ r = float(spec_state["ema"])
907
+ # Hysteresis: switch up/down with two thresholds
908
+ if spec_state["mode"] == "CFGZeroFD" and r >= float(ze_r_switch_hi):
909
+ spec_state["mode"] = "RescaleFDG"
910
+ elif spec_state["mode"] == "RescaleFDG" and r <= float(ze_r_switch_lo):
911
+ spec_state["mode"] = "CFGZeroFD"
912
+ mode = spec_state["mode"]
913
+ else:
914
+ try:
915
+ sigmas = args["model_options"]["transformer_options"]["sample_sigmas"]
916
+ matched_idx = (sigmas == args["timestep"][0]).nonzero()
917
+ if len(matched_idx) > 0:
918
+ current_idx = matched_idx.item()
919
+ else:
920
+ current_idx = 0
921
+ except Exception:
922
+ current_idx = 0
923
+ mode = "CFGZeroFD" if current_idx <= int(ze_zero_steps) else "RescaleFDG"
924
+
925
+ if mode in ("CFGZero*", "CFGZeroFD"):
926
+ # Optional zero-init for the first N steps
927
+ if use_zero_init and "model_options" in args and args.get("timestep") is not None:
928
+ try:
929
+ sigmas = args["model_options"]["transformer_options"]["sample_sigmas"]
930
+ matched_idx = (sigmas == args["timestep"][0]).nonzero()
931
+ if len(matched_idx) > 0:
932
+ current_idx = matched_idx.item()
933
+ else:
934
+ # fallback lookup
935
+ current_idx = 0
936
+ if current_idx <= int(zero_init_steps):
937
+ return cond * 0.0
938
+ except Exception:
939
+ pass
940
+ # Project cond onto uncond subspace (batch-wise alpha)
941
+ bsz = cond.shape[0]
942
+ pos_flat = cond.view(bsz, -1)
943
+ neg_flat = uncond.view(bsz, -1)
944
+ dot = torch.sum(pos_flat * neg_flat, dim=1, keepdim=True)
945
+ denom = torch.sum(neg_flat * neg_flat, dim=1, keepdim=True).clamp_min(1e-8)
946
+ alpha = (dot / denom).view(bsz, *([1] * (cond.dim() - 1)))
947
+ resid = cond - uncond * alpha
948
+ # Adaptive low gain if enabled
949
+ low_gain_eff = float(fdg_low)
950
+ if bool(fdg_low_adaptive) and spec_state["ema"] is not None:
951
+ s = float(spec_state["ema"]) # 0..1 fraction of high-frequency energy
952
+ lmin = float(fdg_low_min)
953
+ lmax = float(fdg_low_max)
954
+ low_gain_eff = max(0.0, min(2.0, lmin + (lmax - lmin) * s))
955
+ if mode == "CFGZeroFD":
956
+ resid = _fdg_filter(resid, low_gain=low_gain_eff, high_gain=fdg_high, sigma=float(fdg_sigma), radius=1)
957
+ # Apply local spatial gain to residual guidance
958
+ lg = _local_gain_for((cond.shape[-2], cond.shape[-1]))
959
+ if lg is not None:
960
+ resid = resid * lg.expand(-1, resid.shape[1], -1, -1)
961
+ noise_pred = uncond * alpha + cond_scale * resid
962
+ return noise_pred
963
+
964
+ # RescaleCFG/FDG path (with optional momentum/perp damping and S-curve shaping)
965
+ delta = cond - uncond
966
+ pd = float(max(0.0, min(1.0, perp_damp)))
967
+ if pd > 0.0 and (prev_delta["t"] is not None) and (prev_delta["t"].shape == delta.shape):
968
+ prev = prev_delta["t"]
969
+ denom = (prev * prev).sum(dim=(1,2,3), keepdim=True).clamp_min(1e-6)
970
+ coeff = ((delta * prev).sum(dim=(1,2,3), keepdim=True) / denom)
971
+ parallel = coeff * prev
972
+ delta = delta - pd * parallel
973
+ beta = float(max(0.0, min(0.95, momentum_beta)))
974
+ if beta > 0.0:
975
+ if prev_delta["t"] is None or prev_delta["t"].shape != delta.shape:
976
+ prev_delta["t"] = delta.detach()
977
+ delta = (1.0 - beta) * delta + beta * prev_delta["t"]
978
+ prev_delta["t"] = delta.detach()
979
+ cond = uncond + delta
980
+ else:
981
+ prev_delta["t"] = delta.detach()
982
+ # After momentum: optionally apply FDG and rebuild cond
983
+ if mode == "RescaleFDG":
984
+ # Adaptive low gain if enabled
985
+ low_gain_eff = float(fdg_low)
986
+ if bool(fdg_low_adaptive) and spec_state["ema"] is not None:
987
+ s = float(spec_state["ema"]) # 0..1
988
+ lmin = float(fdg_low_min)
989
+ lmax = float(fdg_low_max)
990
+ low_gain_eff = max(0.0, min(2.0, lmin + (lmax - lmin) * s))
991
+ delta_fdg = _fdg_filter(delta, low_gain=low_gain_eff, high_gain=fdg_high, sigma=float(fdg_sigma), radius=1)
992
+ # Optional mid-frequency emphasis (band-pass) blended on top
993
+ if bool(midfreq_enable) and abs(float(midfreq_gain)) > 1e-6:
994
+ lo, mid, hi = _fdg_split_three(delta, sigma_lo=float(midfreq_sigma_lo), sigma_hi=float(midfreq_sigma_hi), radius=1)
995
+ # Respect local mask gain if present
996
+ lg = _local_gain_for((cond.shape[-2], cond.shape[-1]))
997
+ if lg is not None:
998
+ mid = mid * lg.expand(-1, mid.shape[1], -1, -1)
999
+ delta_fdg = delta_fdg + float(midfreq_gain) * mid
1000
+ lg = _local_gain_for((cond.shape[-2], cond.shape[-1]))
1001
+ if lg is not None:
1002
+ delta_fdg = delta_fdg * lg.expand(-1, delta_fdg.shape[1], -1, -1)
1003
+ cond = uncond + delta_fdg
1004
+ else:
1005
+ lg = _local_gain_for((cond.shape[-2], cond.shape[-1]))
1006
+ if lg is not None:
1007
+ delta = delta * lg.expand(-1, delta.shape[1], -1, -1)
1008
+ cond = uncond + delta
1009
+
1010
+ cond_scale_eff = cond_scale
1011
+ if cfg_curve > 0.0 and (sigma is not None):
1012
+ s = sigma
1013
+ if s.ndim > 1:
1014
+ s = s.flatten()
1015
+ s_max = float(torch.max(s).item())
1016
+ s_min = float(torch.min(s).item())
1017
+ if sigma_seen["max"] is None:
1018
+ sigma_seen["max"] = s_max
1019
+ sigma_seen["min"] = s_min
1020
+ else:
1021
+ sigma_seen["max"] = max(sigma_seen["max"], s_max)
1022
+ sigma_seen["min"] = min(sigma_seen["min"], s_min)
1023
+ lo = max(1e-6, sigma_seen["min"])
1024
+ hi = max(lo * (1.0 + 1e-6), sigma_seen["max"])
1025
+ t = (torch.log(s + 1e-6) - torch.log(torch.tensor(lo, device=sigma.device))) / (torch.log(torch.tensor(hi, device=sigma.device)) - torch.log(torch.tensor(lo, device=sigma.device)) + 1e-6)
1026
+ t = t.clamp(0.0, 1.0)
1027
+ k = 6.0 * float(cfg_curve)
1028
+ s_curve = torch.tanh((t - 0.5) * k)
1029
+ gain = 1.0 + 0.15 * float(cfg_curve) * s_curve
1030
+ if gain.ndim > 0:
1031
+ gain = gain.mean().item()
1032
+ cond_scale_eff = cond_scale * float(gain)
1033
+
1034
+ # Epsilon scaling (exposure bias correction): early steps get multiplier closer to (1 + eps_scale)
1035
+ eps_mult = 1.0
1036
+ if bool(eps_scale_enable) and (sigma is not None):
1037
+ try:
1038
+ s = sigma
1039
+ if s.ndim > 1:
1040
+ s = s.flatten()
1041
+ s_max = float(torch.max(s).item())
1042
+ s_min = float(torch.min(s).item())
1043
+ if sigma_seen["max"] is None:
1044
+ sigma_seen["max"] = s_max
1045
+ sigma_seen["min"] = s_min
1046
+ else:
1047
+ sigma_seen["max"] = max(sigma_seen["max"], s_max)
1048
+ sigma_seen["min"] = min(sigma_seen["min"], s_min)
1049
+ lo = max(1e-6, sigma_seen["min"])
1050
+ hi = max(lo * (1.0 + 1e-6), sigma_seen["max"])
1051
+ t_lin = (torch.log(s + 1e-6) - torch.log(torch.tensor(lo, device=sigma.device))) / (torch.log(torch.tensor(hi, device=sigma.device)) - torch.log(torch.tensor(lo, device=sigma.device)) + 1e-6)
1052
+ t_lin = t_lin.clamp(0.0, 1.0)
1053
+ w_early = (1.0 - t_lin).mean().item()
1054
+ eps_mult = float(1.0 + eps_scale * w_early)
1055
+ except Exception:
1056
+ eps_mult = float(1.0 + eps_scale)
1057
+
1058
+ if sigma is None or x_orig is None:
1059
+ return uncond + cond_scale * (cond - uncond)
1060
+ sigma_ = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
1061
+ x = x_orig / (sigma_ * sigma_ + 1.0)
1062
+ v_cond = ((x - (x_orig - cond)) * (sigma_ ** 2 + 1.0) ** 0.5) / (sigma_)
1063
+ v_uncond = ((x - (x_orig - uncond)) * (sigma_ ** 2 + 1.0) ** 0.5) / (sigma_)
1064
+ v_cfg = v_uncond + cond_scale_eff * (v_cond - v_uncond)
1065
+ ro_pos = torch.std(v_cond, dim=(1, 2, 3), keepdim=True)
1066
+ ro_cfg = torch.std(v_cfg, dim=(1, 2, 3), keepdim=True).clamp_min(1e-6)
1067
+ v_rescaled = v_cfg * (ro_pos / ro_cfg)
1068
+ v_final = float(rescale_multiplier) * v_rescaled + (1.0 - float(rescale_multiplier)) * v_cfg
1069
+ eps = x_orig - (x - (v_final * eps_mult) * sigma_ / (sigma_ * sigma_ + 1.0) ** 0.5)
1070
+ return eps
1071
+
1072
+ m.set_model_sampler_cfg_function(cfg_func, disable_cfg1_optimization=True)
1073
+
1074
+ # Optional directional post-mix inspired by Mahiro (global, no ONNX)
1075
+ if bool(mahiro_plus_enable):
1076
+ s_clamp = float(max(0.0, min(1.0, mahiro_plus_strength)))
1077
+ mb_state = {"ema": None}
1078
+
1079
+ def _sqrt_sign(x: torch.Tensor) -> torch.Tensor:
1080
+ return x.sign() * torch.sqrt(x.abs().clamp_min(1e-12))
1081
+
1082
+ def _hp_split(x: torch.Tensor, radius: int = 1, sigma: float = 1.0):
1083
+ low = _gaussian_blur_nchw(x, sigma=sigma, radius=radius)
1084
+ high = x - low
1085
+ return low, high
1086
+
1087
+ def _sched_gain(args) -> float:
1088
+ # Gentle mid-steps boost: triangle peak at the middle of schedule
1089
+ try:
1090
+ sigmas = args["model_options"]["transformer_options"]["sample_sigmas"]
1091
+ idx_t = args.get("timestep", None)
1092
+ if idx_t is None:
1093
+ return 1.0
1094
+ matched = (sigmas == idx_t[0]).nonzero()
1095
+ if len(matched) == 0:
1096
+ return 1.0
1097
+ i = float(matched.item())
1098
+ n = float(sigmas.shape[0])
1099
+ if n <= 1:
1100
+ return 1.0
1101
+ phase = i / (n - 1.0)
1102
+ tri = 1.0 - abs(2.0 * phase - 1.0)
1103
+ return float(0.6 + 0.4 * tri) # 0.6 at edges -> 1.0 mid
1104
+ except Exception:
1105
+ return 1.0
1106
+
1107
+ def mahiro_plus_post(args):
1108
+ try:
1109
+ scale = args.get('cond_scale', 1.0)
1110
+ cond_p = args['cond_denoised']
1111
+ uncond_p = args['uncond_denoised']
1112
+ cfg = args['denoised']
1113
+
1114
+ # Orthogonalize positive to negative direction (batch-wise)
1115
+ bsz = cond_p.shape[0]
1116
+ pos_flat = cond_p.view(bsz, -1)
1117
+ neg_flat = uncond_p.view(bsz, -1)
1118
+ dot = torch.sum(pos_flat * neg_flat, dim=1, keepdim=True)
1119
+ denom = torch.sum(neg_flat * neg_flat, dim=1, keepdim=True).clamp_min(1e-8)
1120
+ alpha = (dot / denom).view(bsz, *([1] * (cond_p.dim() - 1)))
1121
+ c_orth = cond_p - uncond_p * alpha
1122
+
1123
+ leap_raw = float(scale) * c_orth
1124
+ # Light high-pass emphasis for detail, protect low-frequency tone
1125
+ low, high = _hp_split(leap_raw, radius=1, sigma=1.0)
1126
+ leap = 0.35 * low + 1.00 * high
1127
+
1128
+ # Directional agreement (global cosine over flattened dims)
1129
+ u_leap = float(scale) * uncond_p
1130
+ merge = 0.5 * (leap + cfg)
1131
+ nu = _sqrt_sign(u_leap).flatten(1)
1132
+ nm = _sqrt_sign(merge).flatten(1)
1133
+ sim = F.cosine_similarity(nu, nm, dim=1).mean()
1134
+ a = torch.clamp((sim + 1.0) * 0.5, 0.0, 1.0)
1135
+ # Small EMA for temporal smoothness
1136
+ if mb_state["ema"] is None:
1137
+ mb_state["ema"] = float(a)
1138
+ else:
1139
+ mb_state["ema"] = 0.8 * float(mb_state["ema"]) + 0.2 * float(a)
1140
+ a_eff = float(mb_state["ema"])
1141
+ w = a_eff * cfg + (1.0 - a_eff) * leap
1142
+
1143
+ # Gentle energy match to CFG
1144
+ dims = tuple(range(1, w.dim()))
1145
+ ro_w = torch.std(w, dim=dims, keepdim=True).clamp_min(1e-6)
1146
+ ro_cfg = torch.std(cfg, dim=dims, keepdim=True).clamp_min(1e-6)
1147
+ w_res = w * (ro_cfg / ro_w)
1148
+
1149
+ # Schedule gain over steps (mid stronger)
1150
+ s_eff = s_clamp * _sched_gain(args)
1151
+ out = (1.0 - s_eff) * cfg + s_eff * w_res
1152
+ return out
1153
+ except Exception:
1154
+ return args['denoised']
1155
+
1156
+ try:
1157
+ m.set_model_sampler_post_cfg_function(mahiro_plus_post)
1158
+ except Exception:
1159
+ pass
1160
+
1161
+ # Quantile clamp stabilizer (per-sample): soft range limit for denoised tensor
1162
+ # Always on, under the hood. Helps prevent rare exploding values.
1163
+ def _qclamp_post(args):
1164
+ try:
1165
+ x = args.get("denoised", None)
1166
+ if x is None:
1167
+ return args["denoised"]
1168
+ dt = x.dtype
1169
+ xf = x.to(dtype=torch.float32)
1170
+ B = xf.shape[0]
1171
+ lo_q, hi_q = 0.001, 0.999
1172
+ out = []
1173
+ for i in range(B):
1174
+ t = xf[i].reshape(-1)
1175
+ try:
1176
+ lo = torch.quantile(t, lo_q)
1177
+ hi = torch.quantile(t, hi_q)
1178
+ except Exception:
1179
+ n = t.numel()
1180
+ k_lo = max(1, int(n * lo_q))
1181
+ k_hi = max(1, int(n * hi_q))
1182
+ lo = torch.kthvalue(t, k_lo).values
1183
+ hi = torch.kthvalue(t, k_hi).values
1184
+ out.append(xf[i].clamp(min=lo, max=hi))
1185
+ y = torch.stack(out, dim=0).to(dtype=dt)
1186
+ return y
1187
+ except Exception:
1188
+ return args["denoised"]
1189
+
1190
+ try:
1191
+ m.set_model_sampler_post_cfg_function(_qclamp_post)
1192
+ except Exception:
1193
+ pass
1194
+
1195
+ return m
1196
+
1197
+
1198
+ # --- AQClip-Lite: adaptive soft quantile clipping in latent space (tile overlap) ---
1199
+ @torch.no_grad()
1200
+ def _aqclip_lite(latent_bchw: torch.Tensor,
1201
+ tile: int = 32,
1202
+ stride: int = 16,
1203
+ alpha: float = 2.0,
1204
+ ema_state: dict | None = None,
1205
+ ema_beta: float = 0.8,
1206
+ H_override: torch.Tensor | None = None) -> tuple[torch.Tensor, dict]:
1207
+ try:
1208
+ z = latent_bchw
1209
+ B, C, H, W = z.shape
1210
+ dev, dt = z.device, z.dtype
1211
+ ksize = max(8, min(int(tile), min(H, W)))
1212
+ kstride = max(1, min(int(stride), ksize))
1213
+
1214
+ # Confidence map: attention entropy override or gradient proxy
1215
+ if (H_override is not None) and isinstance(H_override, torch.Tensor):
1216
+ hsrc = H_override.to(device=dev, dtype=dt)
1217
+ if hsrc.dim() == 3:
1218
+ hsrc = hsrc.unsqueeze(1)
1219
+ gpool = F.avg_pool2d(hsrc, kernel_size=ksize, stride=kstride)
1220
+ else:
1221
+ zm = z.mean(dim=1, keepdim=True)
1222
+ kx = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], device=dev, dtype=dt).view(1, 1, 3, 3)
1223
+ ky = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], device=dev, dtype=dt).view(1, 1, 3, 3)
1224
+ gx = F.conv2d(zm, kx, padding=1)
1225
+ gy = F.conv2d(zm, ky, padding=1)
1226
+ gmag = torch.sqrt(gx * gx + gy * gy)
1227
+ gpool = F.avg_pool2d(gmag, kernel_size=ksize, stride=kstride)
1228
+ gmax = gpool.amax(dim=(2, 3), keepdim=True).clamp_min(1e-6)
1229
+ Hn = (gpool / gmax).squeeze(1) # B,h',w'
1230
+ L = Hn.shape[1] * Hn.shape[2]
1231
+ Hn = Hn.reshape(B, L)
1232
+
1233
+ # Map confidence -> quantiles
1234
+ ql = 0.5 * (Hn ** 2)
1235
+ qh = 1.0 - 0.5 * ((1.0 - Hn) ** 2)
1236
+
1237
+ # Per-tile mean/std
1238
+ unf = F.unfold(z, kernel_size=ksize, stride=kstride) # B, C*ksize*ksize, L
1239
+ M = unf.shape[1]
1240
+ mu = unf.mean(dim=1).to(torch.float32) # B,L
1241
+ var = (unf.to(torch.float32) - mu.unsqueeze(1)).pow(2).mean(dim=1)
1242
+ sigma = (var + 1e-12).sqrt()
1243
+
1244
+ # Normal inverse approximation: ndtri(q) = sqrt(2)*erfinv(2q-1)
1245
+ def _ndtri(q: torch.Tensor) -> torch.Tensor:
1246
+ return (2.0 ** 0.5) * torch.special.erfinv(q.mul(2.0).sub(1.0).clamp(-0.999999, 0.999999))
1247
+ k_neg = _ndtri(ql).abs()
1248
+ k_pos = _ndtri(qh).abs()
1249
+ lo = mu - k_neg * sigma
1250
+ hi = mu + k_pos * sigma
1251
+
1252
+ # EMA smooth
1253
+ if ema_state is None:
1254
+ ema_state = {}
1255
+ b = float(max(0.0, min(0.999, ema_beta)))
1256
+ if 'lo' in ema_state and 'hi' in ema_state and ema_state['lo'].shape == lo.shape:
1257
+ lo = b * ema_state['lo'] + (1.0 - b) * lo
1258
+ hi = b * ema_state['hi'] + (1.0 - b) * hi
1259
+ ema_state['lo'] = lo.detach()
1260
+ ema_state['hi'] = hi.detach()
1261
+
1262
+ # Soft tanh clip (vectorized in unfold domain)
1263
+ mid = (lo + hi) * 0.5
1264
+ half = (hi - lo) * 0.5
1265
+ half = half.clamp_min(1e-6)
1266
+ y = (unf.to(torch.float32) - mid.unsqueeze(1)) / half.unsqueeze(1)
1267
+ y = torch.tanh(float(alpha) * y)
1268
+ unf_clipped = mid.unsqueeze(1) + half.unsqueeze(1) * y
1269
+ unf_clipped = unf_clipped.to(dt)
1270
+
1271
+ out = F.fold(unf_clipped, output_size=(H, W), kernel_size=ksize, stride=kstride)
1272
+ ones = torch.ones((B, M, L), device=dev, dtype=dt)
1273
+ w = F.fold(ones, output_size=(H, W), kernel_size=ksize, stride=kstride).clamp_min(1e-6)
1274
+ out = out / w
1275
+ return out, ema_state
1276
+ except Exception:
1277
+ return latent_bchw, (ema_state or {})
1278
+
1279
+ class ComfyAdaptiveDetailEnhancer25:
1280
+ @classmethod
1281
+ def INPUT_TYPES(cls):
1282
+ return {
1283
+ "required": {
1284
+ "model": ("MODEL", {}),
1285
+ "positive": ("CONDITIONING", {}),
1286
+ "negative": ("CONDITIONING", {}),
1287
+ "vae": ("VAE", {}),
1288
+ "latent": ("LATENT", {}),
1289
+ "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
1290
+ "steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
1291
+ "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1}),
1292
+ "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.0001}),
1293
+ "sampler_name": (_sampler_names(), {"default": _sampler_names()[0]}),
1294
+ "scheduler": (_scheduler_names(), {"default": _scheduler_names()[0]}),
1295
+ "iterations": ("INT", {"default": 1, "min": 1, "max": 1000}),
1296
+ "steps_delta": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.01}),
1297
+ "cfg_delta": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 0.01}),
1298
+ "denoise_delta": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.0001}),
1299
+ "apply_sharpen": ("BOOLEAN", {"default": False}),
1300
+ "apply_upscale": ("BOOLEAN", {"default": False}),
1301
+ "apply_ids": ("BOOLEAN", {"default": False}),
1302
+ "clip_clean": ("BOOLEAN", {"default": False}),
1303
+ "ids_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
1304
+ "upscale_method": (MagicUpscaleModule.upscale_methods, {"default": "lanczos"}),
1305
+ "scale_by": ("FLOAT", {"default": 1.2, "min": 1.0, "max": 8.0, "step": 0.01}),
1306
+ "scale_delta": ("FLOAT", {"default": 0.0, "min": -8.0, "max": 8.0, "step": 0.01}),
1307
+ "noise_offset": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 0.5, "step": 0.01}),
1308
+ "threshold": ("FLOAT", {"default": 0.03, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "RMS latent drift threshold (smaller = more damping)."}),
1309
+ },
1310
+ "optional": {
1311
+ "Sharpnes_strenght": ("FLOAT", {"default": 0.300, "min": 0.0, "max": 1.0, "step": 0.001}),
1312
+ "latent_compare": ("BOOLEAN", {"default": False, "tooltip": "Use latent drift to gently damp params (safer than overwriting latents)."}),
1313
+ "accumulation": (["default", "fp32+fp16", "fp32+fp32"], {"default": "default", "tooltip": "Override SageAttention PV accumulation mode for this node run."}),
1314
+ "reference_clean": ("BOOLEAN", {"default": False, "tooltip": "Use CLIP-Vision similarity to a reference image to stabilize output."}),
1315
+ "reference_image": ("IMAGE", {}),
1316
+ "clip_vision": ("CLIP_VISION", {}),
1317
+ "ref_preview": ("INT", {"default": 224, "min": 64, "max": 512, "step": 16}),
1318
+ "ref_threshold": ("FLOAT", {"default": 0.03, "min": 0.0, "max": 0.2, "step": 0.001}),
1319
+ "ref_cooldown": ("INT", {"default": 1, "min": 1, "max": 8}),
1320
+
1321
+ # ONNX detectors removed
1322
+
1323
+ # Guidance controls
1324
+ "guidance_mode": (["default", "RescaleCFG", "RescaleFDG", "CFGZero*", "CFGZeroFD", "ZeResFDG"], {"default": "RescaleCFG", "tooltip": "Rescale (stable), RescaleFDG (spectral), CFGZero*, CFGZeroFD, or hybrid ZeResFDG."}),
1325
+ "rescale_multiplier": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Blend between rescaled and plain CFG (like comfy RescaleCFG)."}),
1326
+ "momentum_beta": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "tooltip": "EMA momentum in eps-space for (cond-uncond), 0 to disable."}),
1327
+ "cfg_curve": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "S-curve shaping of cond_scale across steps (0=flat)."}),
1328
+ "perp_damp": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Remove a small portion of the component parallel to previous delta (0-1)."}),
1329
+
1330
+ # NAG (Normalized Attention Guidance) toggles
1331
+ "use_nag": ("BOOLEAN", {"default": False, "tooltip": "Apply NAG inside CrossAttention (positive branch) during this node."}),
1332
+ "nag_scale": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 50.0, "step": 0.1}),
1333
+ "nag_tau": ("FLOAT", {"default": 2.5, "min": 0.0, "max": 10.0, "step": 0.01}),
1334
+ "nag_alpha": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}),
1335
+
1336
+ # AQClip-Lite (adaptive latent clipping)
1337
+ "aqclip_enable": ("BOOLEAN", {"default": False, "tooltip": "Adaptive soft tile clipping with overlap (reduces spikes on uncertain regions)."}),
1338
+ "aq_tile": ("INT", {"default": 32, "min": 8, "max": 128, "step": 1}),
1339
+ "aq_stride": ("INT", {"default": 16, "min": 4, "max": 128, "step": 1}),
1340
+ "aq_alpha": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 4.0, "step": 0.1}),
1341
+ "aq_ema_beta": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 0.99, "step": 0.01}),
1342
+ "aq_attn": ("BOOLEAN", {"default": False, "tooltip": "Use attention entropy as confidence (requires patched attention)."}),
1343
+
1344
+ # CFGZero* extras
1345
+ "use_zero_init": ("BOOLEAN", {"default": False, "tooltip": "For CFGZero*, zero out first few steps."}),
1346
+ "zero_init_steps": ("INT", {"default": 0, "min": 0, "max": 20, "step": 1}),
1347
+
1348
+ # FDG controls (placed last to avoid reordering existing fields)
1349
+ "fdg_low": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.01, "tooltip": "Low-frequency gain (<1 to restrain masses)."}),
1350
+ "fdg_high": ("FLOAT", {"default": 1.3, "min": 0.5, "max": 2.5, "step": 0.01, "tooltip": "High-frequency gain (>1 to boost details)."}),
1351
+ "fdg_sigma": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 2.5, "step": 0.05, "tooltip": "Gaussian sigma for FDG low-pass split."}),
1352
+ "ze_res_zero_steps": ("INT", {"default": 2, "min": 0, "max": 20, "step": 1, "tooltip": "Hybrid: number of initial steps to use CFGZeroFD before switching to RescaleFDG."}),
1353
+
1354
+ # Adaptive spectral switch (ZeRes) and adaptive low gain
1355
+ "ze_adaptive": ("BOOLEAN", {"default": False, "tooltip": "Enable spectral switch: CFGZeroFD, RescaleFDG by HF/LF ratio (EMA)."}),
1356
+ "ze_r_switch_hi": ("FLOAT", {"default": 0.60, "min": 0.10, "max": 0.95, "step": 0.01, "tooltip": "Switch to RescaleFDG when EMA fraction of high-frequency."}),
1357
+ "ze_r_switch_lo": ("FLOAT", {"default": 0.45, "min": 0.05, "max": 0.90, "step": 0.01, "tooltip": "Switch back to CFGZeroFD when EMA fraction (hysteresis)."}),
1358
+ "fdg_low_adaptive": ("BOOLEAN", {"default": False, "tooltip": "Adapt fdg_low by HF fraction (EMA)."}),
1359
+ "fdg_low_min": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Lower bound for adaptive fdg_low."}),
1360
+ "fdg_low_max": ("FLOAT", {"default": 0.70, "min": 0.0, "max": 2.0, "step": 0.01, "tooltip": "Upper bound for adaptive fdg_low."}),
1361
+ "fdg_ema_beta": ("FLOAT", {"default": 0.80, "min": 0.0, "max": 0.99, "step": 0.01, "tooltip": "EMA smoothing for spectral ratio (higher = smoother)."}),
1362
+
1363
+ # ONNX local guidance and keypoints removed
1364
+
1365
+ # Muse Blend global directional post-mix
1366
+ "muse_blend": ("BOOLEAN", {"default": False, "tooltip": "Enable Muse Blend (Mahiro+): gentle directional positive blend (global)."}),
1367
+ "muse_blend_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Overall influence of Muse Blend over baseline CFG (0..1)."}),
1368
+ # Exposure Bias Correction (epsilon scaling)
1369
+ "eps_scale_enable": ("BOOLEAN", {"default": False, "tooltip": "Exposure Bias Correction: scale predicted noise early in schedule."}),
1370
+ "eps_scale": ("FLOAT", {"default": 0.005, "min": -1.0, "max": 1.0, "step": 0.0005, "tooltip": "Signed scaling near early steps (recommended ~0.0045; use with care)."}),
1371
+ # KV pruning (self-attention speedup)
1372
+ "kv_prune_enable": ("BOOLEAN", {"default": False, "tooltip": "Speed: prune K/V tokens in self-attention by energy (safe on hi-res blocks)."}),
1373
+ "kv_keep": ("FLOAT", {"default": 0.85, "min": 0.5, "max": 1.0, "step": 0.01, "tooltip": "Fraction of tokens to keep when KV pruning is enabled."}),
1374
+ "kv_min_tokens": ("INT", {"default": 128, "min": 1, "max": 16384, "step": 1, "tooltip": "Minimum sequence length to apply KV pruning."}),
1375
+ "clipseg_enable": ("BOOLEAN", {"default": False, "tooltip": "Use CLIPSeg to build a text-driven mask (e.g., 'eyes | hands | face')."}),
1376
+ "clipseg_text": ("STRING", {"default": "", "multiline": False}),
1377
+ "clipseg_preview": ("INT", {"default": 224, "min": 64, "max": 512, "step": 16}),
1378
+ "clipseg_threshold": ("FLOAT", {"default": 0.40, "min": 0.0, "max": 1.0, "step": 0.05}),
1379
+ "clipseg_blur": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 15.0, "step": 0.1}),
1380
+ "clipseg_dilate": ("INT", {"default": 4, "min": 0, "max": 10, "step": 1}),
1381
+ "clipseg_gain": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01}),
1382
+ "clipseg_blend": (["fuse", "replace", "intersect"], {"default": "fuse", "tooltip": "How to combine CLIPSeg with any pre-mask (if present)."}),
1383
+ "clipseg_ref_gate": ("BOOLEAN", {"default": False, "tooltip": "If reference provided, boost mask when far from reference (CLIP-Vision)."}),
1384
+ "clipseg_ref_threshold": ("FLOAT", {"default": 0.03, "min": 0.0, "max": 0.2, "step": 0.001}),
1385
+
1386
+ # Polish mode (final hi-res refinement)
1387
+ "polish_enable": ("BOOLEAN", {"default": False, "tooltip": "Polish: keep low-frequency shape from reference while allowing high-frequency details to refine."}),
1388
+ "polish_keep_low": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "How much low-frequency (global form, lighting) to take from reference image (0=use current, 1=use reference)."}),
1389
+ "polish_edge_lock": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Edge lock strength: protects edges from sideways drift (0=off, 1=strong)."}),
1390
+ "polish_sigma": ("FLOAT", {"default": 1.0, "min": 0.3, "max": 3.0, "step": 0.1, "tooltip": "Radius for low/high split: larger keeps bigger shapes as 'low' (global form)."}),
1391
+ "polish_start_after": ("INT", {"default": 1, "min": 0, "max": 3, "step": 1, "tooltip": "Enable polish after N iterations (0=immediately)."}),
1392
+ "polish_keep_low_ramp": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Starting share of low-frequency mix; ramps to polish_keep_low over remaining iterations."}),
1393
+
1394
+ },
1395
+ }
1396
+
1397
+ RETURN_TYPES = ("LATENT", "IMAGE", "INT", "FLOAT", "FLOAT", "IMAGE")
1398
+ RETURN_NAMES = ("LATENT", "IMAGE", "steps", "cfg", "denoise", "mask_preview")
1399
+ FUNCTION = "apply_cade2"
1400
+ CATEGORY = "MagicNodes"
1401
+
1402
+ def apply_cade2(self, model, vae, positive, negative, latent, seed, steps, cfg, denoise,
1403
+ sampler_name, scheduler, noise_offset, iterations=1, steps_delta=0.0,
1404
+ cfg_delta=0.0, denoise_delta=0.0, apply_sharpen=False,
1405
+ apply_upscale=False, apply_ids=False, clip_clean=False,
1406
+ ids_strength=0.5, upscale_method="lanczos", scale_by=1.2, scale_delta=0.0,
1407
+ Sharpnes_strenght=0.300, threshold=0.03, latent_compare=False, accumulation="default",
1408
+ reference_clean=False, reference_image=None, clip_vision=None, ref_preview=224, ref_threshold=0.03, ref_cooldown=1,
1409
+ guidance_mode="RescaleCFG", rescale_multiplier=0.7, momentum_beta=0.0, cfg_curve=0.0, perp_damp=0.0,
1410
+ use_nag=False, nag_scale=4.0, nag_tau=2.5, nag_alpha=0.25,
1411
+ aqclip_enable=False, aq_tile=32, aq_stride=16, aq_alpha=2.0, aq_ema_beta=0.8, aq_attn=False,
1412
+ use_zero_init=False, zero_init_steps=0,
1413
+ fdg_low=0.6, fdg_high=1.3, fdg_sigma=1.0, ze_res_zero_steps=2,
1414
+ ze_adaptive=False, ze_r_switch_hi=0.60, ze_r_switch_lo=0.45,
1415
+ fdg_low_adaptive=False, fdg_low_min=0.45, fdg_low_max=0.70, fdg_ema_beta=0.80,
1416
+ muse_blend=False, muse_blend_strength=0.5,
1417
+ eps_scale_enable=False, eps_scale=0.005,
1418
+ clipseg_enable=False, clipseg_text="", clipseg_preview=224,
1419
+ clipseg_threshold=0.40, clipseg_blur=7.0, clipseg_dilate=4,
1420
+ clipseg_gain=1.0, clipseg_blend="fuse", clipseg_ref_gate=False, clipseg_ref_threshold=0.03,
1421
+ polish_enable=False, polish_keep_low=0.4, polish_edge_lock=0.2, polish_sigma=1.0,
1422
+ polish_start_after=1, polish_keep_low_ramp=0.2,
1423
+ kv_prune_enable=False, kv_keep=0.85, kv_min_tokens=128):
1424
+ # Hard reset of any sticky globals from prior runs
1425
+ try:
1426
+ global CURRENT_ONNX_MASK_BCHW
1427
+ CURRENT_ONNX_MASK_BCHW = None
1428
+ except Exception:
1429
+ pass
1430
+
1431
+ image = safe_decode(vae, latent)
1432
+
1433
+ tuned_steps, tuned_cfg, tuned_denoise = AdaptiveSamplerHelper().tune(
1434
+ image, steps, cfg, denoise)
1435
+
1436
+ current_steps = tuned_steps
1437
+ current_cfg = tuned_cfg
1438
+ current_denoise = tuned_denoise
1439
+ # Work on a detached copy to avoid mutating input latent across runs
1440
+ try:
1441
+ current_latent = {"samples": latent["samples"].clone()}
1442
+ except Exception:
1443
+ current_latent = {"samples": latent["samples"]}
1444
+ current_scale = scale_by
1445
+
1446
+ ref_embed = None
1447
+ if reference_clean and (clip_vision is not None) and (reference_image is not None):
1448
+ try:
1449
+ ref_embed = _encode_clip_image(reference_image, clip_vision, ref_preview)
1450
+ except Exception:
1451
+ ref_embed = None
1452
+
1453
+ # Pre-disable any lingering NAG patch from previous runs and set PV accumulation for this node
1454
+ try:
1455
+ sa_patch.enable_crossattention_nag_patch(False)
1456
+ except Exception:
1457
+ pass
1458
+ prev_accum = getattr(sa_patch, "CURRENT_PV_ACCUM", None)
1459
+ sa_patch.CURRENT_PV_ACCUM = None if accumulation == "default" else accumulation
1460
+ # Enable NAG patch if requested
1461
+ try:
1462
+ sa_patch.enable_crossattention_nag_patch(bool(use_nag), float(nag_scale), float(nag_tau), float(nag_alpha))
1463
+ except Exception:
1464
+ pass
1465
+
1466
+ # Enable attention-entropy probe for AQClip Attn-mode
1467
+ try:
1468
+ if hasattr(sa_patch, "enable_attention_entropy_capture"):
1469
+ sa_patch.enable_attention_entropy_capture(bool(aq_attn), max_tokens=1024, max_heads=4)
1470
+ except Exception:
1471
+ pass
1472
+
1473
+ # Visual separation and start marker
1474
+ try:
1475
+ print("")
1476
+ except Exception:
1477
+ pass
1478
+ try:
1479
+ print("\x1b[32m==== Starting main job ====\x1b[0m")
1480
+ except Exception:
1481
+ pass
1482
+
1483
+ # Enable KV pruning (self-attention) if requested
1484
+ try:
1485
+ if hasattr(sa_patch, "set_kv_prune"):
1486
+ sa_patch.set_kv_prune(bool(kv_prune_enable), float(kv_keep), int(kv_min_tokens))
1487
+ except Exception:
1488
+ pass
1489
+
1490
+ mask_last = None
1491
+ try:
1492
+ with torch.inference_mode():
1493
+ __cade_noop = 0 # ensure non-empty with-block
1494
+
1495
+ # Preflight: reset sticky state and build external masks once (CPU-pinned)
1496
+ try:
1497
+ CURRENT_ONNX_MASK_BCHW = None
1498
+ except Exception:
1499
+ pass
1500
+ pre_mask = None
1501
+ pre_area = 0.0
1502
+ # ONNX mask removed
1503
+ # Build CLIPSeg mask once
1504
+ if bool(clipseg_enable) and isinstance(clipseg_text, str) and clipseg_text.strip() != "":
1505
+ try:
1506
+ cmask = _clipseg_build_mask(image, clipseg_text, int(clipseg_preview), float(clipseg_threshold), float(clipseg_blur), int(clipseg_dilate), float(clipseg_gain), None, None, float(clipseg_ref_threshold))
1507
+ if cmask is not None:
1508
+ if pre_mask is None:
1509
+ pre_mask = cmask
1510
+ else:
1511
+ if clipseg_blend == "replace":
1512
+ pre_mask = cmask
1513
+ elif clipseg_blend == "intersect":
1514
+ pre_mask = (pre_mask * cmask).clamp(0, 1)
1515
+ else:
1516
+ pre_mask = (1.0 - (1.0 - pre_mask) * (1.0 - cmask)).clamp(0, 1)
1517
+ except Exception:
1518
+ pass
1519
+ if pre_mask is not None:
1520
+ mask_last = pre_mask
1521
+ om = pre_mask.movedim(-1, 1)
1522
+ pre_area = float(om.mean().item())
1523
+ # One-time gentle damping from area
1524
+ try:
1525
+ if pre_area > 0.005:
1526
+ damp = 1.0 - min(0.10, 0.02 + pre_area * 0.08)
1527
+ current_denoise = max(0.10, current_denoise * damp)
1528
+ current_cfg = max(1.0, current_cfg * (1.0 - 0.005))
1529
+ except Exception:
1530
+ pass
1531
+ # Compact status
1532
+ try:
1533
+ clipseg_status = "on" if bool(clipseg_enable) and isinstance(clipseg_text, str) and clipseg_text.strip() != "" else "off"
1534
+ # print preflight info only in debug sessions (muted by default)
1535
+ if False:
1536
+ print(f"[CADE2.5][preflight] clipseg={clipseg_status} device={'cpu' if _CLIPSEG_FORCE_CPU else _CLIPSEG_DEV} mask_area={pre_area:.4f}")
1537
+ except Exception:
1538
+ pass
1539
+ # Freeze per-iteration external mask rebuild
1540
+ clipseg_enable = False
1541
+ # Depth gate cache for micro-detail injection (reuse per resolution)
1542
+ depth_gate_cache = {"size": None, "mask": None}
1543
+ for i in range(iterations):
1544
+ if i % 2 == 0:
1545
+ clear_gpu_and_ram_cache()
1546
+
1547
+ prev_samples = current_latent["samples"].clone().detach()
1548
+
1549
+ iter_seed = seed + i * 7777
1550
+ if noise_offset > 0.0:
1551
+ # Deterministic noise offset tied to iter_seed
1552
+ fade = 1.0 - (i / max(1, iterations))
1553
+ try:
1554
+ gen = torch.Generator(device='cpu')
1555
+ except Exception:
1556
+ gen = torch.Generator()
1557
+ gen.manual_seed(int(iter_seed) & 0xFFFFFFFF)
1558
+ eps = torch.randn(
1559
+ size=current_latent["samples"].shape,
1560
+ dtype=current_latent["samples"].dtype,
1561
+ device='cpu',
1562
+ generator=gen,
1563
+ ).to(current_latent["samples"].device)
1564
+ current_latent["samples"] += (noise_offset * fade) * eps
1565
+
1566
+ # ONNX pre-sampling detectors removed
1567
+
1568
+ # CLIPSeg mask (optional)
1569
+ try:
1570
+ if bool(clipseg_enable) and isinstance(clipseg_text, str) and clipseg_text.strip() != "":
1571
+ img_prev2 = safe_decode(vae, current_latent)
1572
+ cmask = _clipseg_build_mask(img_prev2, clipseg_text, int(clipseg_preview), float(clipseg_threshold), float(clipseg_blur), int(clipseg_dilate), float(clipseg_gain), ref_embed if bool(clipseg_ref_gate) else None, clip_vision if bool(clipseg_ref_gate) else None, float(clipseg_ref_threshold))
1573
+ if cmask is not None:
1574
+ if mask_last is None:
1575
+ fused = cmask
1576
+ else:
1577
+ if clipseg_blend == "replace":
1578
+ fused = cmask
1579
+ elif clipseg_blend == "intersect":
1580
+ fused = (mask_last * cmask).clamp(0, 1)
1581
+ else:
1582
+ fused = (1.0 - (1.0 - mask_last) * (1.0 - cmask)).clamp(0, 1)
1583
+ mask_last = fused
1584
+ om = fused.movedim(-1, 1)
1585
+ area = float(om.mean().item())
1586
+ if area > 0.005:
1587
+ damp = 1.0 - min(0.10, 0.02 + area * 0.08)
1588
+ current_denoise = max(0.10, current_denoise * damp)
1589
+ current_cfg = max(1.0, current_cfg * (1.0 - 0.005))
1590
+ # No local guidance toggles here; keep optional mask hook clear
1591
+ except Exception:
1592
+ pass
1593
+
1594
+ # Guidance override via cfg_func when requested
1595
+ sampler_model = _wrap_model_with_guidance(
1596
+ model, guidance_mode, rescale_multiplier, momentum_beta, cfg_curve, perp_damp,
1597
+ use_zero_init=bool(use_zero_init), zero_init_steps=int(zero_init_steps),
1598
+ fdg_low=float(fdg_low), fdg_high=float(fdg_high), fdg_sigma=float(fdg_sigma),
1599
+ midfreq_enable=bool(False), midfreq_gain=float(0.0), midfreq_sigma_lo=float(0.8), midfreq_sigma_hi=float(2.0),
1600
+ ze_zero_steps=int(ze_res_zero_steps),
1601
+ ze_adaptive=bool(ze_adaptive), ze_r_switch_hi=float(ze_r_switch_hi), ze_r_switch_lo=float(ze_r_switch_lo),
1602
+ fdg_low_adaptive=bool(fdg_low_adaptive), fdg_low_min=float(fdg_low_min), fdg_low_max=float(fdg_low_max), fdg_ema_beta=float(fdg_ema_beta),
1603
+ mahiro_plus_enable=bool(muse_blend), mahiro_plus_strength=float(muse_blend_strength),
1604
+ eps_scale_enable=bool(eps_scale_enable), eps_scale=float(eps_scale)
1605
+ )
1606
+
1607
+ if str(scheduler) == "MGHybrid":
1608
+ try:
1609
+ # Build ZeSmart hybrid sigmas with safe defaults
1610
+ sigmas = _build_hybrid_sigmas(
1611
+ sampler_model, int(current_steps), str(sampler_name), "hybrid",
1612
+ mix=0.5, denoise=float(current_denoise), jitter=0.01, seed=int(iter_seed),
1613
+ _debug=False, tail_smooth=0.15, auto_hybrid_tail=True, auto_tail_strength=0.4,
1614
+ )
1615
+ # Prepare latent + noise like in MG_ZeSmartSampler
1616
+ lat_img = current_latent["samples"]
1617
+ lat_img = _sample.fix_empty_latent_channels(sampler_model, lat_img)
1618
+ batch_inds = current_latent.get("batch_index", None)
1619
+ noise = _sample.prepare_noise(lat_img, int(iter_seed), batch_inds)
1620
+ noise_mask = current_latent.get("noise_mask", None)
1621
+ callback = nodes.latent_preview.prepare_callback(sampler_model, int(current_steps))
1622
+ disable_pbar = not _utils.PROGRESS_BAR_ENABLED
1623
+ sampler_obj = _samplers.sampler_object(str(sampler_name))
1624
+ samples = _sample.sample_custom(
1625
+ sampler_model, noise, float(current_cfg), sampler_obj, sigmas,
1626
+ positive, negative, lat_img,
1627
+ noise_mask=noise_mask, callback=callback,
1628
+ disable_pbar=disable_pbar, seed=int(iter_seed)
1629
+ )
1630
+ current_latent = {**current_latent}
1631
+ current_latent["samples"] = samples
1632
+ except Exception as e:
1633
+ # Fallback to original path if anything goes wrong
1634
+ print(f"[CADE2.5][MGHybrid] fallback to common_ksampler due to: {e}")
1635
+ current_latent, = nodes.common_ksampler(
1636
+ sampler_model, iter_seed, int(current_steps), current_cfg, sampler_name, _scheduler_names()[0],
1637
+ positive, negative, current_latent, denoise=current_denoise)
1638
+ else:
1639
+ current_latent, = nodes.common_ksampler(
1640
+ sampler_model, iter_seed, int(current_steps), current_cfg, sampler_name, scheduler,
1641
+ positive, negative, current_latent, denoise=current_denoise)
1642
+
1643
+ if bool(latent_compare):
1644
+ latent_diff = current_latent["samples"] - prev_samples
1645
+ rms = torch.sqrt(torch.mean(latent_diff * latent_diff))
1646
+ drift = float(rms.item())
1647
+ if drift > float(threshold):
1648
+ overshoot = max(0.0, drift - float(threshold))
1649
+ damp = 1.0 - min(0.15, overshoot * 2.0)
1650
+ current_denoise = max(0.20, current_denoise * damp)
1651
+ cfg_damp = 0.997 if damp > 0.9 else 0.99
1652
+ current_cfg = max(1.0, current_cfg * cfg_damp)
1653
+
1654
+ # AQClip-Lite: adaptive soft clipping in latent space (before decode)
1655
+ try:
1656
+ if bool(aqclip_enable):
1657
+ if 'aq_state' not in locals():
1658
+ aq_state = None
1659
+ H_override = None
1660
+ if bool(aq_attn) and hasattr(sa_patch, "get_attention_entropy_map"):
1661
+ try:
1662
+ Hm = sa_patch.get_attention_entropy_map(clear=False)
1663
+ if Hm is not None:
1664
+ H_override = F.interpolate(Hm, size=(current_latent["samples"].shape[-2], current_latent["samples"].shape[-1]), mode="bilinear", align_corners=False)
1665
+ except Exception:
1666
+ H_override = None
1667
+ z_new, aq_state = _aqclip_lite(
1668
+ current_latent["samples"],
1669
+ tile=int(aq_tile), stride=int(aq_stride),
1670
+ alpha=float(aq_alpha), ema_state=aq_state, ema_beta=float(aq_ema_beta),
1671
+ H_override=H_override,
1672
+ )
1673
+ current_latent["samples"] = z_new
1674
+ except Exception:
1675
+ pass
1676
+
1677
+ image = safe_decode(vae, current_latent)
1678
+
1679
+ # Polish mode: keep global form (low frequencies) from reference while letting details refine
1680
+ if bool(polish_enable) and (i >= int(polish_start_after)):
1681
+ try:
1682
+ # Prepare tensors
1683
+ img = image
1684
+ ref = reference_image if (reference_image is not None) else img
1685
+ if ref.shape[1] != img.shape[1] or ref.shape[2] != img.shape[2]:
1686
+ # resize reference to match current image
1687
+ ref_n = ref.movedim(-1, 1)
1688
+ ref_n = F.interpolate(ref_n, size=(img.shape[1], img.shape[2]), mode='bilinear', align_corners=False)
1689
+ ref = ref_n.movedim(1, -1)
1690
+ x = img.movedim(-1, 1)
1691
+ r = ref.movedim(-1, 1)
1692
+ # Low/high split via Gaussian blur
1693
+ rad = max(1, int(round(float(polish_sigma) * 2)))
1694
+ low_x = _gaussian_blur_nchw(x, sigma=float(polish_sigma), radius=rad)
1695
+ low_r = _gaussian_blur_nchw(r, sigma=float(polish_sigma), radius=rad)
1696
+ high_x = x - low_x
1697
+ # Mix low from reference and current with ramp
1698
+ # a starts from polish_keep_low_ramp and linearly ramps to polish_keep_low over remaining iterations
1699
+ try:
1700
+ denom = max(1, int(iterations) - int(polish_start_after))
1701
+ t = max(0.0, min(1.0, (i - int(polish_start_after)) / denom))
1702
+ except Exception:
1703
+ t = 1.0
1704
+ a0 = float(polish_keep_low_ramp)
1705
+ at = float(polish_keep_low)
1706
+ a = a0 + (at - a0) * t
1707
+ low_mix = low_r * a + low_x * (1.0 - a)
1708
+ new = low_mix + high_x
1709
+ # Micro-detail injection on tail: very light HF boost gated by edges+depth
1710
+ try:
1711
+ phase = (i + 1) / max(1, int(iterations))
1712
+ # ramp starts late (>=0.70 of iterations), slightly earlier and wider
1713
+ ramp = max(0.0, min(1.0, (phase - 0.70) / 0.30))
1714
+ if ramp > 0.0:
1715
+ # fine-scale high-pass
1716
+ micro = x - _gaussian_blur_nchw(x, sigma=0.6, radius=1)
1717
+ # edge gate: suppress near strong edges to avoid halos
1718
+ gray = x.mean(dim=1, keepdim=True)
1719
+ sobel_x = torch.tensor([[[-1,0,1],[-2,0,2],[-1,0,1]]], dtype=gray.dtype, device=gray.device).unsqueeze(1)
1720
+ sobel_y = torch.tensor([[[-1,-2,-1],[0,0,0],[1,2,1]]], dtype=gray.dtype, device=gray.device).unsqueeze(1)
1721
+ gx = F.conv2d(gray, sobel_x, padding=1)
1722
+ gy = F.conv2d(gray, sobel_y, padding=1)
1723
+ mag = torch.sqrt(gx*gx + gy*gy)
1724
+ m_edge = (mag - mag.amin()) / (mag.amax() - mag.amin() + 1e-8)
1725
+ g_edge = (1.0 - m_edge).clamp(0.0, 1.0).pow(0.65) # prefer flats/meso-areas
1726
+ # depth gate: prefer nearer surfaces when depth is available
1727
+ try:
1728
+ sz = (int(img.shape[1]), int(img.shape[2]))
1729
+ if depth_gate_cache.get("size") != sz or depth_gate_cache.get("mask") is None:
1730
+ model_path = os.path.join(os.path.dirname(__file__), '..', 'depth-anything', 'depth_anything_v2_vitl.pth')
1731
+ dm = _cf_build_depth_map(img, res=512, model_path=model_path, hires_mode=True)
1732
+ depth_gate_cache = {"size": sz, "mask": dm}
1733
+ dm = depth_gate_cache.get("mask")
1734
+ if dm is not None:
1735
+ g_depth = (dm.movedim(-1, 1).clamp(0,1)) ** 1.35
1736
+ else:
1737
+ g_depth = torch.ones_like(g_edge)
1738
+ except Exception:
1739
+ g_depth = torch.ones_like(g_edge)
1740
+ g = (g_edge * g_depth).clamp(0.0, 1.0)
1741
+ micro_boost = 0.018 * ramp # very gentle, slightly higher
1742
+ new = new + micro_boost * (micro * g)
1743
+ except Exception:
1744
+ pass
1745
+ # Edge-lock: protect edges from drift by biasing toward low_mix along edges
1746
+ el = float(polish_edge_lock)
1747
+ if el > 1e-6:
1748
+ # Sobel edge magnitude on grayscale
1749
+ gray = x.mean(dim=1, keepdim=True)
1750
+ sobel_x = torch.tensor([[[-1,0,1],[-2,0,2],[-1,0,1]]], dtype=gray.dtype, device=gray.device).unsqueeze(1)
1751
+ sobel_y = torch.tensor([[[-1,-2,-1],[0,0,0],[1,2,1]]], dtype=gray.dtype, device=gray.device).unsqueeze(1)
1752
+ gx = F.conv2d(gray, sobel_x, padding=1)
1753
+ gy = F.conv2d(gray, sobel_y, padding=1)
1754
+ mag = torch.sqrt(gx*gx + gy*gy)
1755
+ m = (mag - mag.amin()) / (mag.amax() - mag.amin() + 1e-8)
1756
+ # Blend toward low_mix near edges
1757
+ new = new * (1.0 - el*m) + (low_mix) * (el*m)
1758
+ img2 = new.movedim(1, -1).clamp(0,1)
1759
+ # Feed back to latent for next steps
1760
+ current_latent = {"samples": safe_encode(vae, img2)}
1761
+ image = img2
1762
+ except Exception:
1763
+ pass
1764
+
1765
+ # ONNX detectors removed
1766
+
1767
+ if reference_clean and (ref_embed is not None) and (i % max(1, ref_cooldown) == 0):
1768
+ try:
1769
+ cur_embed = _encode_clip_image(image, clip_vision, ref_preview)
1770
+ dist = _clip_cosine_distance(cur_embed, ref_embed)
1771
+ if dist > ref_threshold:
1772
+ current_denoise = max(0.10, current_denoise * 0.9)
1773
+ current_cfg = max(1.0, current_cfg * 0.99)
1774
+ except Exception:
1775
+ pass
1776
+
1777
+ if apply_upscale and current_scale != 1.0:
1778
+ current_latent, image = MagicUpscaleModule().process_upscale(
1779
+ current_latent, vae, upscale_method, current_scale)
1780
+ # After upscale at large sizes, add a tiny HF sprinkle gated by edges+depth
1781
+ try:
1782
+ H, W = int(image.shape[1]), int(image.shape[2])
1783
+ if max(H, W) > 1536:
1784
+ blur = _gaussian_blur(image, radius=1.0, sigma=0.8)
1785
+ hf = (image - blur).clamp(-1, 1)
1786
+ # Edge gate in image space (luma Sobel)
1787
+ lum = (0.2126 * image[..., 0] + 0.7152 * image[..., 1] + 0.0722 * image[..., 2])
1788
+ kx = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], device=lum.device, dtype=lum.dtype).view(1, 1, 3, 3)
1789
+ ky = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], device=lum.device, dtype=lum.dtype).view(1, 1, 3, 3)
1790
+ g = torch.sqrt(F.conv2d(lum.unsqueeze(1), kx, padding=1)**2 + F.conv2d(lum.unsqueeze(1), ky, padding=1)**2).squeeze(1)
1791
+ m = (g - g.amin()) / (g.amax() - g.amin() + 1e-8)
1792
+ g_edge = (1.0 - m).clamp(0,1).pow(0.5).unsqueeze(-1)
1793
+ # Depth gate (once per resolution)
1794
+ try:
1795
+ sz = (H, W)
1796
+ if depth_gate_cache.get("size") != sz or depth_gate_cache.get("mask") is None:
1797
+ model_path = os.path.join(os.path.dirname(__file__), '..', 'depth-anything', 'depth_anything_v2_vitl.pth')
1798
+ dm = _cf_build_depth_map(image, res=512, model_path=model_path, hires_mode=True)
1799
+ depth_gate_cache = {"size": sz, "mask": dm}
1800
+ dm = depth_gate_cache.get("mask")
1801
+ if dm is not None:
1802
+ g_depth = dm.clamp(0,1) ** 1.2
1803
+ else:
1804
+ g_depth = torch.ones_like(g_edge)
1805
+ except Exception:
1806
+ g_depth = torch.ones_like(g_edge)
1807
+ g_tot = (g_edge * g_depth).clamp(0,1)
1808
+ image = (image + 0.045 * hf * g_tot).clamp(0,1)
1809
+ except Exception:
1810
+ pass
1811
+ current_cfg = max(4.0, current_cfg * (1.0 / current_scale))
1812
+ current_denoise = max(0.15, current_denoise * (1.0 / current_scale))
1813
+
1814
+ current_steps = max(1, current_steps - steps_delta)
1815
+ current_cfg = max(0.0, current_cfg - cfg_delta)
1816
+ current_denoise = max(0.0, current_denoise - denoise_delta)
1817
+ current_scale = max(1.0, current_scale - scale_delta)
1818
+
1819
+ if apply_upscale and current_scale != 1.0 and max(image.shape[1:3]) > 1024:
1820
+ current_latent = {"samples": safe_encode(vae, image)}
1821
+
1822
+ finally:
1823
+ # Always disable NAG patch and clear local mask, even on errors
1824
+ try:
1825
+ sa_patch.enable_crossattention_nag_patch(False)
1826
+ except Exception:
1827
+ pass
1828
+ try:
1829
+ sa_patch.CURRENT_PV_ACCUM = prev_accum
1830
+ except Exception:
1831
+ pass
1832
+ try:
1833
+ CURRENT_ONNX_MASK_BCHW = None
1834
+ except Exception:
1835
+ pass
1836
+
1837
+ if apply_ids:
1838
+ image, = IntelligentDetailStabilizer().stabilize(image, ids_strength)
1839
+
1840
+ if apply_sharpen:
1841
+ image, = _sharpen_image(image, 2, 1.0, Sharpnes_strenght)
1842
+
1843
+ # Mask preview as IMAGE (RGB)
1844
+ if mask_last is None:
1845
+ mask_last = torch.zeros((image.shape[0], image.shape[1], image.shape[2], 1), device=image.device, dtype=image.dtype)
1846
+ onnx_mask_img = mask_last.repeat(1, 1, 1, 3).clamp(0, 1)
1847
+
1848
+ # Final pass: remove isolated hot whites ("fireflies") without touching real edges/highlights
1849
+ try:
1850
+ image = _despeckle_fireflies(image, thr=0.998, max_iso=4.0/9.0, grad_gate=0.15)
1851
+ except Exception:
1852
+ pass
1853
+
1854
+ # Cleanup KV pruning state to avoid leaking into other nodes
1855
+ try:
1856
+ if hasattr(sa_patch, "set_kv_prune"):
1857
+ sa_patch.set_kv_prune(False, 1.0, int(kv_min_tokens))
1858
+ except Exception:
1859
+ pass
1860
+
1861
+ return current_latent, image, int(current_steps), float(current_cfg), float(current_denoise), onnx_mask_img
1862
+
1863
+
1864
+
mod/hard/mg_controlfusion.py ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import math
4
+ import torch
5
+ import torch.nn.functional as F
6
+ import numpy as np
7
+
8
+ import comfy.model_management as model_management
9
+
10
+
11
+ _DEPTH_INIT = False
12
+ _DEPTH_MODEL = None
13
+ _DEPTH_PROC = None
14
+
15
+
16
+ def _insert_aux_path():
17
+ try:
18
+ base = os.path.dirname(os.path.dirname(__file__)) # .../custom_nodes
19
+ aux_root = os.path.join(base, 'comfyui_controlnet_aux')
20
+ aux_src = os.path.join(aux_root, 'src')
21
+ for p in (aux_src, aux_root):
22
+ if os.path.isdir(p) and p not in sys.path:
23
+ sys.path.insert(0, p)
24
+ except Exception:
25
+ pass
26
+
27
+
28
+ def _try_init_depth_anything(model_path: str):
29
+ global _DEPTH_INIT, _DEPTH_MODEL, _DEPTH_PROC
30
+ if _DEPTH_INIT:
31
+ return _DEPTH_MODEL is not None
32
+ _DEPTH_INIT = True
33
+ # Prefer our vendored implementation first
34
+ try:
35
+ from ...vendor.depth_anything_v2.dpt import DepthAnythingV2 # type: ignore
36
+ # Guess config from filename
37
+ fname = os.path.basename(model_path or '')
38
+ cfgs = {
39
+ 'depth_anything_v2_vits.pth': dict(encoder='vits', features=64, out_channels=[48,96,192,384]),
40
+ 'depth_anything_v2_vitb.pth': dict(encoder='vitb', features=128, out_channels=[96,192,384,768]),
41
+ 'depth_anything_v2_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
42
+ 'depth_anything_v2_vitg.pth': dict(encoder='vitg', features=384, out_channels=[1536,1536,1536,1536]),
43
+ 'depth_anything_v2_metric_vkitti_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
44
+ 'depth_anything_v2_metric_hypersim_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
45
+ }
46
+ # fallback to vitl if unknown
47
+ cfg = cfgs.get(fname, cfgs['depth_anything_v2_vitl.pth'])
48
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
49
+ m = DepthAnythingV2(**cfg)
50
+ sd = torch.load(model_path, map_location='cpu')
51
+ m.load_state_dict(sd)
52
+ _DEPTH_MODEL = m.to(device).eval()
53
+ _DEPTH_PROC = True
54
+ return True
55
+ except Exception:
56
+ # Try local checkout of comfyui_controlnet_aux (if present)
57
+ _insert_aux_path()
58
+ try:
59
+ from custom_controlnet_aux.depth_anything_v2.dpt import DepthAnythingV2 # type: ignore
60
+ fname = os.path.basename(model_path or '')
61
+ cfgs = {
62
+ 'depth_anything_v2_vits.pth': dict(encoder='vits', features=64, out_channels=[48,96,192,384]),
63
+ 'depth_anything_v2_vitb.pth': dict(encoder='vitb', features=128, out_channels=[96,192,384,768]),
64
+ 'depth_anything_v2_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
65
+ 'depth_anything_v2_vitg.pth': dict(encoder='vitg', features=384, out_channels=[1536,1536,1536,1536]),
66
+ 'depth_anything_v2_metric_vkitti_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
67
+ 'depth_anything_v2_metric_hypersim_vitl.pth': dict(encoder='vitl', features=256, out_channels=[256,512,1024,1024]),
68
+ }
69
+ cfg = cfgs.get(fname, cfgs['depth_anything_v2_vitl.pth'])
70
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
71
+ m = DepthAnythingV2(**cfg)
72
+ sd = torch.load(model_path, map_location='cpu')
73
+ m.load_state_dict(sd)
74
+ _DEPTH_MODEL = m.to(device).eval()
75
+ _DEPTH_PROC = True
76
+ return True
77
+ except Exception:
78
+ # Fallback: packaged auxiliary API
79
+ try:
80
+ from controlnet_aux.depth_anything import DepthAnythingDetector, DepthAnythingV2 # type: ignore
81
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
82
+ _DEPTH_MODEL = DepthAnythingV2(model_path=model_path, device=device)
83
+ _DEPTH_PROC = True
84
+ return True
85
+ except Exception:
86
+ _DEPTH_MODEL = None
87
+ _DEPTH_PROC = False
88
+ return False
89
+
90
+
91
+ def _build_depth_map(image_bhwc: torch.Tensor, res: int, model_path: str, hires_mode: bool = True) -> torch.Tensor:
92
+ B, H, W, C = image_bhwc.shape
93
+ dev = image_bhwc.device
94
+ dtype = image_bhwc.dtype
95
+ # Choose target min-side for processing. In hires mode we allow higher caps and keep aspect.
96
+ # DepthAnything v2 can be memory-hungry on large inputs; cap min-side at 1024
97
+ cap = 1024
98
+ target = int(max(16, min(cap, res)))
99
+ if _try_init_depth_anything(model_path):
100
+ try:
101
+ # to CPU uint8
102
+ img = image_bhwc.detach().to('cpu')
103
+ x = img[0].movedim(-1, 0).unsqueeze(0)
104
+ # keep aspect ratio: scale so that min(H,W) == target
105
+ _, Cc, Ht, Wt = x.shape
106
+ min_side = max(1, min(Ht, Wt))
107
+ scale = float(target) / float(min_side)
108
+ out_h = max(1, int(round(Ht * scale)))
109
+ out_w = max(1, int(round(Wt * scale)))
110
+ x = F.interpolate(x, size=(out_h, out_w), mode='bilinear', align_corners=False)
111
+ # make channels-last and ensure contiguous layout for OpenCV
112
+ arr = (x[0].movedim(0, -1).contiguous().numpy() * 255.0).astype('uint8')
113
+ # Prefer direct DepthAnythingV2 inference if model has infer_image
114
+ if hasattr(_DEPTH_MODEL, 'infer_image'):
115
+ import cv2
116
+ # Drive input_size from desired depth resolution (min side), let DA keep aspect
117
+ input_sz = int(max(224, min(cap, res)))
118
+ depth = _DEPTH_MODEL.infer_image(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR), input_size=input_sz, max_depth=20.0)
119
+ d = np.asarray(depth, dtype=np.float32)
120
+ # Normalize DepthAnythingV2 output (0..max_depth) to 0..1
121
+ d = d / 20.0
122
+ else:
123
+ depth = _DEPTH_MODEL(arr)
124
+ d = np.asarray(depth, dtype=np.float32)
125
+ if d.max() > 1.0:
126
+ d = d / 255.0
127
+ d = torch.from_numpy(d)[None, None] # 1,1,h,w
128
+ d = F.interpolate(d, size=(H, W), mode='bilinear', align_corners=False)
129
+ d = d[0, 0].to(device=dev, dtype=dtype)
130
+ d = d.clamp(0, 1)
131
+ return d
132
+ except Exception:
133
+ pass
134
+ # Fallback pseudo-depth: luminance + gentle blur
135
+ lum = (0.2126 * image_bhwc[..., 0] + 0.7152 * image_bhwc[..., 1] + 0.0722 * image_bhwc[..., 2]).to(dtype=dtype)
136
+ x = lum.movedim(-1, 0).unsqueeze(0) if lum.ndim == 3 else lum.unsqueeze(0).unsqueeze(0)
137
+ x = F.interpolate(x, size=(H, W), mode='bilinear', align_corners=False)
138
+ x = F.avg_pool2d(x, kernel_size=3, stride=1, padding=1)
139
+ return x[0, 0].clamp(0, 1)
140
+
141
+
142
+ def _pyracanny(image_bhwc: torch.Tensor,
143
+ low: int,
144
+ high: int,
145
+ res: int,
146
+ thin_iter: int = 0,
147
+ edge_boost: float = 0.0,
148
+ smart_tune: bool = False,
149
+ smart_boost: float = 0.2,
150
+ preserve_aspect: bool = True) -> torch.Tensor:
151
+ try:
152
+ import cv2
153
+ except Exception:
154
+ # Fallback: simple Sobel magnitude
155
+ x = image_bhwc.movedim(-1, 1)
156
+ xg = x.mean(dim=1, keepdim=True)
157
+ gx = F.conv2d(xg, torch.tensor([[[-1, 0, 1],[-2,0,2],[-1,0,1]]], dtype=x.dtype, device=x.device).unsqueeze(1), padding=1)
158
+ gy = F.conv2d(xg, torch.tensor([[[-1,-2,-1],[0,0,0],[1,2,1]]], dtype=x.dtype, device=x.device).unsqueeze(1), padding=1)
159
+ mag = torch.sqrt(gx*gx + gy*gy)
160
+ mag = (mag - mag.amin())/(mag.amax()-mag.amin()+1e-6)
161
+ return mag[0,0].clamp(0,1)
162
+ B,H,W,C = image_bhwc.shape
163
+ img = (image_bhwc.detach().to('cpu')[0].contiguous().numpy()*255.0).astype('uint8')
164
+ cap = 4096
165
+ target = int(max(64, min(cap, res)))
166
+ if preserve_aspect:
167
+ scale = float(target) / float(max(1, min(H, W)))
168
+ out_h = max(8, int(round(H * scale)))
169
+ out_w = max(8, int(round(W * scale)))
170
+ img_res = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
171
+ else:
172
+ img_res = cv2.resize(img, (target, target), interpolation=cv2.INTER_LINEAR)
173
+ gray = cv2.cvtColor(img_res, cv2.COLOR_RGB2GRAY)
174
+ pyr_scales = [1.0, 0.5, 0.25]
175
+ acc = None
176
+ for s in pyr_scales:
177
+ if preserve_aspect:
178
+ sz = (max(8, int(round(img_res.shape[1]*s))), max(8, int(round(img_res.shape[0]*s))))
179
+ else:
180
+ sz = (max(8, int(target*s)), max(8, int(target*s)))
181
+ g = cv2.resize(gray, sz, interpolation=cv2.INTER_AREA)
182
+ g = cv2.GaussianBlur(g, (5,5), 0)
183
+ e = cv2.Canny(g, threshold1=int(low*s), threshold2=int(high*s))
184
+ e = cv2.resize(e, (W, H), interpolation=cv2.INTER_LINEAR)
185
+ e = (e.astype(np.float32)/255.0)
186
+ acc = e if acc is None else np.maximum(acc, e)
187
+ # Estimate density and sharpness for smart tuning
188
+ edensity_pre = None
189
+ try:
190
+ edensity_pre = float(np.mean(acc)) if acc is not None else None
191
+ except Exception:
192
+ edensity_pre = None
193
+ lap_var = None
194
+ try:
195
+ g32 = gray.astype(np.float32) / 255.0
196
+ lap = cv2.Laplacian(g32, cv2.CV_32F)
197
+ lap_var = float(lap.var())
198
+ except Exception:
199
+ lap_var = None
200
+
201
+ # optional thinning
202
+ try:
203
+ thin_iter_eff = int(thin_iter)
204
+ if smart_tune:
205
+ # simple heuristic: more thinning on high res and dense edges
206
+ auto = 0
207
+ if target >= 1024:
208
+ auto += 1
209
+ if target >= 1400:
210
+ auto += 1
211
+ if edensity_pre is not None and edensity_pre > 0.12:
212
+ auto += 1
213
+ if edensity_pre is not None and edensity_pre < 0.05:
214
+ auto = max(0, auto - 1)
215
+ thin_iter_eff = max(thin_iter_eff, min(3, auto))
216
+ if thin_iter_eff > 0:
217
+ import cv2
218
+ if hasattr(cv2, 'ximgproc') and hasattr(cv2.ximgproc, 'thinning'):
219
+ th = acc.copy()
220
+ th = (th*255).astype('uint8')
221
+ th = cv2.ximgproc.thinning(th)
222
+ acc = th.astype(np.float32)/255.0
223
+ else:
224
+ # simple erosion-based thinning approximation
225
+ kernel = np.ones((3,3), np.uint8)
226
+ t = (acc*255).astype('uint8')
227
+ for _ in range(int(thin_iter_eff)):
228
+ t = cv2.erode(t, kernel, iterations=1)
229
+ acc = t.astype(np.float32)/255.0
230
+ except Exception:
231
+ pass
232
+ # optional edge boost (unsharp on edge map)
233
+ # We fix a gentle boost for micro‑contrast; smart_tune may nudge it slightly
234
+ boost_eff = 0.10
235
+ if smart_tune:
236
+ try:
237
+ lv = 0.0 if lap_var is None else max(0.0, min(1.0, lap_var / 2.0))
238
+ dens = 0.0 if edensity_pre is None else float(max(0.0, min(1.0, edensity_pre)))
239
+ boost_eff = max(0.05, min(0.20, boost_eff + (1.0 - dens) * 0.05 + (1.0 - lv) * 0.02))
240
+ except Exception:
241
+ pass
242
+ if boost_eff and boost_eff != 0.0:
243
+ try:
244
+ import cv2
245
+ blur = cv2.GaussianBlur(acc, (0,0), sigmaX=1.0)
246
+ acc = np.clip(acc + float(boost_eff)*(acc - blur), 0.0, 1.0)
247
+ except Exception:
248
+ pass
249
+ ed = torch.from_numpy(acc).to(device=image_bhwc.device, dtype=image_bhwc.dtype)
250
+ return ed.clamp(0,1)
251
+
252
+
253
+ def _blend(depth: torch.Tensor, edges: torch.Tensor, mode: str, factor: float) -> torch.Tensor:
254
+ depth = depth.clamp(0,1)
255
+ edges = edges.clamp(0,1)
256
+ if mode == 'max':
257
+ return torch.maximum(depth, edges)
258
+ if mode == 'edge_over_depth':
259
+ # edges override depth (edge=1) while preserving depth elsewhere
260
+ return (depth * (1.0 - edges) + edges).clamp(0,1)
261
+ # normal
262
+ f = float(max(0.0, min(1.0, factor)))
263
+ return (depth*(1.0-f) + edges*f).clamp(0,1)
264
+
265
+
266
+ def _apply_controlnet_separate(positive, negative, control_net, image_bhwc: torch.Tensor,
267
+ strength_pos: float, strength_neg: float,
268
+ start_percent: float, end_percent: float, vae=None,
269
+ apply_to_uncond: bool = False,
270
+ stack_prev_control: bool = False):
271
+ control_hint = image_bhwc.movedim(-1,1)
272
+ out_pos = []
273
+ out_neg = []
274
+ # POS
275
+ for t in positive:
276
+ d = t[1].copy()
277
+ prev = d.get('control', None) if stack_prev_control else None
278
+ c_net = control_net.copy().set_cond_hint(control_hint, float(strength_pos), (start_percent, end_percent), vae=vae, extra_concat=[])
279
+ c_net.set_previous_controlnet(prev)
280
+ d['control'] = c_net
281
+ d['control_apply_to_uncond'] = bool(apply_to_uncond)
282
+ out_pos.append([t[0], d])
283
+ # NEG
284
+ for t in negative:
285
+ d = t[1].copy()
286
+ prev = d.get('control', None) if stack_prev_control else None
287
+ c_net = control_net.copy().set_cond_hint(control_hint, float(strength_neg), (start_percent, end_percent), vae=vae, extra_concat=[])
288
+ c_net.set_previous_controlnet(prev)
289
+ d['control'] = c_net
290
+ d['control_apply_to_uncond'] = bool(apply_to_uncond)
291
+ out_neg.append([t[0], d])
292
+ return out_pos, out_neg
293
+
294
+
295
+ class MG_ControlFusion:
296
+ @classmethod
297
+ def INPUT_TYPES(cls):
298
+ return {
299
+ "required": {
300
+ "image": ("IMAGE", {"tooltip": "Input RGB image (B,H,W,3) in 0..1."}),
301
+ "positive": ("CONDITIONING", {"tooltip": "Positive conditioning to apply ControlNet to."}),
302
+ "negative": ("CONDITIONING", {"tooltip": "Negative conditioning to apply ControlNet to."}),
303
+ "control_net": ("CONTROL_NET", {"tooltip": "ControlNet module receiving the fused mask as hint."}),
304
+ "vae": ("VAE", {"tooltip": "VAE used by ControlNet when encoding the hint."}),
305
+ },
306
+ "optional": {
307
+ "enable_depth": ("BOOLEAN", {"default": True, "tooltip": "Enable depth map fusion (Depth Anything v2 if available)."}),
308
+ "depth_model_path": ("STRING", {"default": os.path.join(os.path.dirname(os.path.dirname(__file__)), 'MagicNodes','depth-anything','depth_anything_v2_vitl.pth') if False else os.path.join(os.path.dirname(__file__), '..','depth-anything','depth_anything_v2_vitl.pth'), "tooltip": "Path to Depth Anything v2 .pth weights (vits/vitb/vitl/vitg)."}),
309
+ "depth_resolution": ("INT", {"default": 768, "min": 64, "max": 1024, "step": 64, "tooltip": "Depth min-side resolution (cap 1024). In Hi‑Res mode drives DepthAnything input_size."}),
310
+ "enable_pyra": ("BOOLEAN", {"default": True, "tooltip": "Enable PyraCanny edge detector."}),
311
+ "pyra_low": ("INT", {"default": 109, "min": 0, "max": 255, "tooltip": "Canny low threshold (0..255)."}),
312
+ "pyra_high": ("INT", {"default": 147, "min": 0, "max": 255, "tooltip": "Canny high threshold (0..255)."}),
313
+ "pyra_resolution": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 64, "tooltip": "Working resolution for edges (min side, keeps aspect)."}),
314
+ "edge_thin_iter": ("INT", {"default": 0, "min": 0, "max": 10, "step": 1, "tooltip": "Thinning iterations for edges (skeletonize). 0 = off."}),
315
+ "edge_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Opacity for edges before blending (0..1)."}),
316
+ "edge_boost": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Deprecated: internal boost fixed (~0.10); use edge_alpha instead."}),
317
+ "smart_tune": ("BOOLEAN", {"default": False, "tooltip": "Auto-adjust thinning/boost from image edge density and sharpness."}),
318
+ "smart_boost": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Scale for auto edge boost when Smart Tune is on."}),
319
+ "blend_mode": (["normal","max","edge_over_depth"], {"default": "normal", "tooltip": "Depth+edges merge: normal (mix), max (strongest), edge_over_depth (edges overlay)."}),
320
+ "blend_factor": ("FLOAT", {"default": 0.02, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Blend strength for edges into depth (depends on mode)."}),
321
+ "strength_pos": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01, "tooltip": "ControlNet strength for positive branch."}),
322
+ "strength_neg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01, "tooltip": "ControlNet strength for negative branch."}),
323
+ "start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Start percentage along the sampling schedule."}),
324
+ "end_percent": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "End percentage along the sampling schedule."}),
325
+ "preview_res": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 64, "tooltip": "Preview minimum side (keeps aspect ratio)."}),
326
+ "mask_brightness": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Preview brightness multiplier (visualization only)."}),
327
+ "preview_show_strength": ("BOOLEAN", {"default": True, "tooltip": "Multiply preview by ControlNet strength for visualization."}),
328
+ "preview_strength_branch": (["positive","negative","max","avg"], {"default": "max", "tooltip": "Which strength to reflect in preview (display only)."}),
329
+ "hires_mask_auto": ("BOOLEAN", {"default": True, "tooltip": "High‑res mask: keep aspect ratio, scale by minimal side for depth/edges, and drive DepthAnything with your depth_resolution (no 2K cap)."}),
330
+ "apply_to_uncond": ("BOOLEAN", {"default": False, "tooltip": "Apply ControlNet hint to the unconditional branch as well (stronger global hold on very large images)."}),
331
+ "stack_prev_control": ("BOOLEAN", {"default": False, "tooltip": "Chain with any previously attached ControlNet in the conditioning (advanced). Off = replace to avoid memory bloat."}),
332
+ # Split apply: chain Depth and Edges with separate schedules/strengths (fixed order: depth -> edges)
333
+ "split_apply": ("BOOLEAN", {"default": False, "tooltip": "Apply Depth and Edges as two chained ControlNets (fixed order: depth then edges)."}),
334
+ "edge_start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Edges start percent (when split is enabled)."}),
335
+ "edge_end_percent": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Edges end percent (when split is enabled)."}),
336
+ "depth_start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Depth start percent (when split is enabled)."}),
337
+ "depth_end_percent": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001, "tooltip": "Depth end percent (when split is enabled)."}),
338
+ "edge_strength_mul": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01, "tooltip": "Multiply global strength for Edges when split is enabled."}),
339
+ "depth_strength_mul": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01, "tooltip": "Multiply global strength for Depth when split is enabled."}),
340
+ # Extra edge controls (bottom)
341
+ "edge_width": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 1.5, "step": 0.05, "tooltip": "Edge thickness adjust: negative thins, positive thickens."}),
342
+ "edge_smooth": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "Small smooth on edges to reduce pixelation (0..1)."}),
343
+ "edge_single_line": ("BOOLEAN", {"default": False, "tooltip": "Try to collapse double outlines into a single centerline."}),
344
+ "edge_single_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Strength of single-line collapse (0..1). 0 = off, 1 = strong."}),
345
+ "edge_depth_gate": ("BOOLEAN", {"default": False, "tooltip": "Weigh edges by depth so distant lines are fainter."}),
346
+ "edge_depth_gamma": ("FLOAT", {"default": 1.5, "min": 0.2, "max": 4.0, "step": 0.1, "tooltip": "Gamma for depth gating: edges *= (1−depth)^gamma."}),
347
+ }
348
+ }
349
+
350
+ RETURN_TYPES = ("CONDITIONING","CONDITIONING","IMAGE")
351
+ RETURN_NAMES = ("positive","negative","mask_preview")
352
+ FUNCTION = "apply"
353
+ CATEGORY = "MagicNodes"
354
+
355
+ def apply(self, image, positive, negative, control_net, vae,
356
+ enable_depth=True, depth_model_path="", depth_resolution=1024,
357
+ enable_pyra=True, pyra_low=109, pyra_high=147, pyra_resolution=1024,
358
+ edge_thin_iter=0, edge_alpha=1.0, edge_boost=0.0,
359
+ smart_tune=False, smart_boost=0.2,
360
+ blend_mode="normal", blend_factor=0.02,
361
+ strength_pos=1.0, strength_neg=1.0, start_percent=0.0, end_percent=1.0,
362
+ preview_res=1024, mask_brightness=1.0,
363
+ preview_show_strength=True, preview_strength_branch="max",
364
+ hires_mask_auto=True, apply_to_uncond=False, stack_prev_control=False,
365
+ edge_width=0.0, edge_smooth=0.0, edge_single_line=False, edge_single_strength=0.0,
366
+ edge_depth_gate=False, edge_depth_gamma=1.5,
367
+ split_apply=False, edge_start_percent=0.0, edge_end_percent=0.6,
368
+ depth_start_percent=0.0, depth_end_percent=1.0,
369
+ edge_strength_mul=1.0, depth_strength_mul=1.0):
370
+
371
+ dev = image.device
372
+ dtype = image.dtype
373
+ B,H,W,C = image.shape
374
+ # Build depth/edges
375
+ depth = None
376
+ edges = None
377
+ if enable_depth:
378
+ model_path = depth_model_path or os.path.join(os.path.dirname(__file__), '..','depth-anything','depth_anything_v2_vitl.pth')
379
+ depth = _build_depth_map(image, int(depth_resolution), model_path, bool(hires_mask_auto))
380
+ if enable_pyra:
381
+ edges = _pyracanny(image,
382
+ int(pyra_low), int(pyra_high), int(pyra_resolution),
383
+ int(edge_thin_iter), float(edge_boost),
384
+ bool(smart_tune), float(smart_boost), bool(hires_mask_auto))
385
+ if depth is None and edges is None:
386
+ # Nothing to do: return inputs and zero preview
387
+ prev = torch.zeros((B, max(H,1), max(W,1), 3), device=dev, dtype=dtype)
388
+ return positive, negative, prev
389
+
390
+ if depth is None:
391
+ depth = torch.zeros_like(edges)
392
+ if edges is None:
393
+ edges = torch.zeros_like(depth)
394
+
395
+ # Edge post-process: width/single-line/smooth
396
+ def _edges_post(acc_t: torch.Tensor) -> torch.Tensor:
397
+ try:
398
+ import cv2, numpy as _np
399
+ acc = acc_t.detach().to('cpu').numpy()
400
+ img = (acc*255.0).astype(_np.uint8)
401
+ k = _np.ones((3,3), _np.uint8)
402
+ # Adjust thickness
403
+ w = float(edge_width)
404
+ if abs(w) > 1e-6:
405
+ it = int(abs(w))
406
+ frac = abs(w) - it
407
+ op = cv2.dilate if w > 0 else cv2.erode
408
+ y = img.copy()
409
+ for _ in range(max(0, it)):
410
+ y = op(y, k, iterations=1)
411
+ if frac > 1e-6:
412
+ y2 = op(y, k, iterations=1)
413
+ y = ((1.0-frac)*y.astype(_np.float32) + frac*y2.astype(_np.float32)).astype(_np.uint8)
414
+ img = y
415
+ # Collapse double lines to single centerline
416
+ if bool(edge_single_line) and float(edge_single_strength) > 1e-6:
417
+ try:
418
+ s = float(edge_single_strength)
419
+ close = cv2.morphologyEx(img, cv2.MORPH_CLOSE, k, iterations=1)
420
+ if hasattr(cv2, 'ximgproc') and hasattr(cv2.ximgproc, 'thinning'):
421
+ sk = cv2.ximgproc.thinning(close)
422
+ else:
423
+ # limited-iteration morphological skeletonization
424
+ iters = max(1, int(round(2 + 6*s)))
425
+ sk = _np.zeros_like(close)
426
+ src = close.copy()
427
+ elem = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
428
+ for _ in range(iters):
429
+ er = cv2.erode(src, elem, iterations=1)
430
+ op = cv2.morphologyEx(er, cv2.MORPH_OPEN, elem)
431
+ tmp = cv2.subtract(er, op)
432
+ sk = cv2.bitwise_or(sk, tmp)
433
+ src = er
434
+ if not _np.any(src):
435
+ break
436
+ # Blend skeleton back with original according to strength
437
+ img = ((_np.float32(1.0 - s) * img.astype(_np.float32)) + (_np.float32(s) * sk.astype(_np.float32))).astype(_np.uint8)
438
+ except Exception:
439
+ pass
440
+ # Smooth
441
+ if float(edge_smooth) > 1e-6:
442
+ sigma = max(0.1, min(2.0, float(edge_smooth) * 1.2))
443
+ img = cv2.GaussianBlur(img, (0,0), sigmaX=sigma)
444
+ out = torch.from_numpy((img.astype(_np.float32)/255.0)).to(device=acc_t.device, dtype=acc_t.dtype)
445
+ return out.clamp(0,1)
446
+ except Exception:
447
+ # Torch fallback: light blur-only
448
+ if float(edge_smooth) > 1e-6:
449
+ s = max(1, int(round(float(edge_smooth)*2)))
450
+ return F.avg_pool2d(acc_t.unsqueeze(0).unsqueeze(0), kernel_size=2*s+1, stride=1, padding=s)[0,0].clamp(0,1)
451
+ return acc_t
452
+
453
+ edges = _edges_post(edges)
454
+
455
+ # Depth gating of edges
456
+ if bool(edge_depth_gate):
457
+ # Inverted gating per feedback: use depth^gamma (nearer = stronger if depth is larger)
458
+ g = (depth.clamp(0,1)) ** float(edge_depth_gamma)
459
+ edges = (edges * g).clamp(0,1)
460
+
461
+ # Apply edge alpha before blending
462
+ edges = (edges * float(edge_alpha)).clamp(0,1)
463
+
464
+ fused = _blend(depth, edges, str(blend_mode), float(blend_factor))
465
+ # Apply as split (Edges then Depth) or single fused hint
466
+ if bool(split_apply):
467
+ # Fixed order for determinism: Depth first, then Edges
468
+ hint_edges = edges.unsqueeze(-1).repeat(1,1,1,3)
469
+ hint_depth = depth.unsqueeze(-1).repeat(1,1,1,3)
470
+ # Depth first
471
+ pos_mid, neg_mid = _apply_controlnet_separate(
472
+ positive, negative, control_net, hint_depth,
473
+ float(strength_pos) * float(depth_strength_mul),
474
+ float(strength_neg) * float(depth_strength_mul),
475
+ float(depth_start_percent), float(depth_end_percent), vae,
476
+ bool(apply_to_uncond), True
477
+ )
478
+ # Then edges
479
+ pos_out, neg_out = _apply_controlnet_separate(
480
+ pos_mid, neg_mid, control_net, hint_edges,
481
+ float(strength_pos) * float(edge_strength_mul),
482
+ float(strength_neg) * float(edge_strength_mul),
483
+ float(edge_start_percent), float(edge_end_percent), vae,
484
+ bool(apply_to_uncond), True
485
+ )
486
+ else:
487
+ hint = fused.unsqueeze(-1).repeat(1,1,1,3)
488
+ pos_out, neg_out = _apply_controlnet_separate(
489
+ positive, negative, control_net, hint,
490
+ float(strength_pos), float(strength_neg),
491
+ float(start_percent), float(end_percent), vae,
492
+ bool(apply_to_uncond), bool(stack_prev_control)
493
+ )
494
+ # Build preview: keep aspect ratio, set minimal side
495
+ prev_res = int(max(256, min(2048, preview_res)))
496
+ scale = prev_res / float(min(H, W))
497
+ out_h = max(1, int(round(H * scale)))
498
+ out_w = max(1, int(round(W * scale)))
499
+ prev = F.interpolate(fused.unsqueeze(0).unsqueeze(0), size=(out_h, out_w), mode='bilinear', align_corners=False)[0,0]
500
+ # Optionally reflect ControlNet strength in preview (display only)
501
+ if bool(preview_show_strength):
502
+ br = str(preview_strength_branch)
503
+ sp = float(strength_pos)
504
+ sn = float(strength_neg)
505
+ if br == 'negative':
506
+ s_vis = sn
507
+ elif br == 'max':
508
+ s_vis = max(sp, sn)
509
+ elif br == 'avg':
510
+ s_vis = 0.5 * (sp + sn)
511
+ else:
512
+ s_vis = sp
513
+ # clamp for display range
514
+ s_vis = max(0.0, min(1.0, s_vis))
515
+ prev = prev * s_vis
516
+ # Apply visualization brightness only for preview
517
+ prev = (prev * float(mask_brightness)).clamp(0.0, 1.0)
518
+ prev = prev.unsqueeze(-1).repeat(1,1,3).to(device=dev, dtype=dtype).unsqueeze(0)
519
+ return (pos_out, neg_out, prev)
mod/hard/mg_ids.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+
6
+ try:
7
+ from scipy.ndimage import gaussian_filter as _scipy_gaussian_filter
8
+ _HAVE_SCIPY = True
9
+ except Exception:
10
+ _HAVE_SCIPY = False
11
+
12
+
13
+ def _torch_gaussian_blur(image: torch.Tensor, sigma: float) -> torch.Tensor:
14
+ # image: BHWC in [0,1]
15
+ if sigma <= 0.0:
16
+ return image
17
+ device = image.device
18
+ dtype = image.dtype
19
+ radius = max(1, int(3.0 * float(sigma)))
20
+ ksize = radius * 2 + 1
21
+ x = torch.arange(-radius, radius + 1, device=device, dtype=dtype)
22
+ g1 = torch.exp(-(x * x) / (2.0 * (sigma ** 2)))
23
+ g1 = (g1 / g1.sum()).view(1, 1, 1, -1)
24
+ g2 = g1.transpose(2, 3)
25
+ xch = image.movedim(-1, 1) # BCHW
26
+ pad = (radius, radius, radius, radius)
27
+ out = torch.nn.functional.conv2d(torch.nn.functional.pad(xch, pad, mode="reflect"), g1.repeat(xch.shape[1], 1, 1, 1), groups=xch.shape[1])
28
+ out = torch.nn.functional.conv2d(torch.nn.functional.pad(out, pad, mode="reflect"), g2.repeat(out.shape[1], 1, 1, 1), groups=out.shape[1])
29
+ return out.movedim(1, -1)
30
+
31
+
32
+ class IntelligentDetailStabilizer:
33
+ """Alias-preserving move of IDS into mod/ as mg_ids.py.
34
+ Keeps class/key name for backward compatibility.
35
+ """
36
+
37
+ @classmethod
38
+ def INPUT_TYPES(cls):
39
+ return {
40
+ "required": {
41
+ "image": ("IMAGE", {}),
42
+ "ids_strength": (
43
+ "FLOAT",
44
+ {"default": 0.5, "min": -1.0, "max": 1.0, "step": 0.01},
45
+ ),
46
+ }
47
+ }
48
+
49
+ RETURN_TYPES = ("IMAGE",)
50
+ RETURN_NAMES = ("IMAGE",)
51
+ FUNCTION = "stabilize"
52
+ CATEGORY = "MagicNodes"
53
+
54
+ def stabilize(self, image: torch.Tensor, ids_strength: float = 0.5):
55
+ sigma = max(float(ids_strength) * 2.0, 1e-3)
56
+ if _HAVE_SCIPY:
57
+ img_np = image.detach().cpu().numpy()
58
+ denoised = _scipy_gaussian_filter(img_np, sigma=(0, sigma, sigma, 0))
59
+ blurred = _scipy_gaussian_filter(denoised, sigma=(0, 1.0, 1.0, 0))
60
+ sharpen = denoised + ids_strength * (denoised - blurred)
61
+ sharpen = np.clip(sharpen, 0.0, 1.0)
62
+ out = torch.from_numpy(sharpen).to(image.device, dtype=image.dtype)
63
+ else:
64
+ denoised = _torch_gaussian_blur(image, sigma=sigma)
65
+ blurred = _torch_gaussian_blur(denoised, sigma=1.0)
66
+ out = (denoised + ids_strength * (denoised - blurred)).clamp(0, 1)
67
+ return (out,)
mod/hard/mg_upscale_module.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import comfy.utils
2
+ import torch
3
+ import gc
4
+ import logging
5
+ import comfy.model_management as model_management
6
+
7
+
8
+ def clear_gpu_and_ram_cache():
9
+ gc.collect()
10
+ if torch.cuda.is_available():
11
+ torch.cuda.empty_cache()
12
+ torch.cuda.ipc_collect()
13
+
14
+
15
+ def _smart_decode(vae, latent, tile_size=512):
16
+ try:
17
+ images = vae.decode(latent["samples"])
18
+ except model_management.OOM_EXCEPTION:
19
+ logging.warning("VAE decode OOM, using tiled decode")
20
+ compression = vae.spacial_compression_decode()
21
+ images = vae.decode_tiled(
22
+ latent["samples"],
23
+ tile_x=tile_size // compression,
24
+ tile_y=tile_size // compression,
25
+ overlap=(tile_size // 4) // compression,
26
+ )
27
+ if len(images.shape) == 5:
28
+ images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1])
29
+ return images
30
+
31
+
32
+ class MagicUpscaleModule:
33
+ """Moved into mod/ as mg_upscale_module keeping class/key name."""
34
+ upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
35
+
36
+ @classmethod
37
+ def INPUT_TYPES(cls):
38
+ return {
39
+ "required": {
40
+ "samples": ("LATENT", {}),
41
+ "vae": ("VAE", {}),
42
+ "upscale_method": (cls.upscale_methods, {"default": "bilinear"}),
43
+ "scale_by": ("FLOAT", {"default": 1.2, "min": 0.01, "max": 8.0, "step": 0.01}),
44
+ }
45
+ }
46
+
47
+ RETURN_TYPES = ("LATENT", "IMAGE")
48
+ RETURN_NAMES = ("LATENT", "Upscaled Image")
49
+ FUNCTION = "process_upscale"
50
+ CATEGORY = "MagicNodes"
51
+
52
+ def process_upscale(self, samples, vae, upscale_method, scale_by):
53
+ clear_gpu_and_ram_cache()
54
+ images = _smart_decode(vae, samples)
55
+ samples_t = images.movedim(-1, 1)
56
+ width = round(samples_t.shape[3] * scale_by)
57
+ height = round(samples_t.shape[2] * scale_by)
58
+ # Align to VAE stride to avoid border artifacts/shape drift
59
+ try:
60
+ stride = int(vae.spacial_compression_decode())
61
+ except Exception:
62
+ stride = 8
63
+ if stride <= 0:
64
+ stride = 8
65
+ def _align_up(x, s):
66
+ return int(((x + s - 1) // s) * s)
67
+ width_al = _align_up(width, stride)
68
+ height_al = _align_up(height, stride)
69
+ up = comfy.utils.common_upscale(samples_t, width_al, height_al, upscale_method, "disabled")
70
+ up = up.movedim(1, -1)
71
+ encoded = vae.encode(up[:, :, :, :3])
72
+ return ({"samples": encoded}, up)
mod/hard/mg_zesmart_sampler_v1_1.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import torch
5
+ import torch.nn.functional as F # noqa: F401
6
+
7
+ import comfy.utils as _utils
8
+ import comfy.sample as _sample
9
+ import comfy.samplers as _samplers
10
+ from comfy.k_diffusion import sampling as _kds
11
+
12
+ import nodes # latent preview callback
13
+
14
+
15
+ def _smoothstep01(x: torch.Tensor) -> torch.Tensor:
16
+ return x * x * (3.0 - 2.0 * x)
17
+
18
+
19
+ def _build_hybrid_sigmas(model, steps: int, base_sampler: str, mode: str,
20
+ mix: float, denoise: float, jitter: float, seed: int,
21
+ _debug: bool = False, tail_smooth: float = 0.0,
22
+ auto_hybrid_tail: bool = True, auto_tail_strength: float = 0.35):
23
+ """Return 1D tensor of sigmas (len == steps+1), strictly descending and ending with 0.
24
+
25
+ mode: 'karras' | 'beta' | 'hybrid'. If 'hybrid', blend tail toward beta by `mix`.
26
+ We DO NOT apply 'drop penultimate' until the very end to preserve denoise math.
27
+ """
28
+ ms = model.get_model_object("model_sampling")
29
+ steps = int(steps)
30
+ assert steps >= 1
31
+
32
+ # --- base tracks ---
33
+ sig_k = _samplers.calculate_sigmas(ms, "karras", steps)
34
+ sig_b = _samplers.calculate_sigmas(ms, "beta", steps)
35
+
36
+ mode = str(mode).lower()
37
+ if mode == "karras":
38
+ sig = sig_k
39
+ elif mode == "beta":
40
+ sig = sig_b
41
+ else:
42
+ n = sig_k.shape[0]
43
+ t = torch.linspace(0.0, 1.0, n, device=sig_k.device, dtype=sig_k.dtype)
44
+ m = float(max(0.0, min(1.0, mix)))
45
+ eps = 1e-6 if m < 1e-6 else m
46
+ w = torch.clamp((t - (1.0 - m)) / eps, 0.0, 1.0)
47
+ w = _smoothstep01(w)
48
+ sig = sig_k * (1.0 - w) + sig_b * w
49
+
50
+ # --- Comfy denoise semantics: recompute a "full" track and take the tail of desired length ---
51
+ sig_k_base = sig_k
52
+ sig_b_base = sig_b
53
+ if denoise is not None and 0.0 < float(denoise) < 0.999999:
54
+ new_steps = max(1, int(steps / max(1e-6, float(denoise))))
55
+ sk = _samplers.calculate_sigmas(ms, "karras", new_steps)
56
+ sb = _samplers.calculate_sigmas(ms, "beta", new_steps)
57
+ if mode == "karras":
58
+ sig_full = sk
59
+ elif mode == "beta":
60
+ sig_full = sb
61
+ else:
62
+ n2 = sk.shape[0]
63
+ t2 = torch.linspace(0.0, 1.0, n2, device=sk.device, dtype=sk.dtype)
64
+ m = float(max(0.0, min(1.0, mix)))
65
+ eps = 1e-6 if m < 1e-6 else m
66
+ w2 = torch.clamp((t2 - (1.0 - m)) / eps, 0.0, 1.0)
67
+ w2 = _smoothstep01(w2)
68
+ sig_full = sk * (1.0 - w2) + sb * w2
69
+ need = steps + 1
70
+ if sig_full.shape[0] >= need:
71
+ sig = sig_full[-need:]
72
+ sig_k_base = sk[-need:]
73
+ sig_b_base = sb[-need:]
74
+ else:
75
+ # Worst case: trust what we got; we will still guarantee the last sigma is zero later
76
+ sig = sig_full
77
+ tail = min(need, sk.shape[0])
78
+ sig_k_base = sk[-tail:]
79
+ sig_b_base = sb[-tail:]
80
+
81
+ # --- auto-hybrid tail: blend beta into the tail when the steps become brittle ---
82
+ if bool(auto_hybrid_tail) and sig.numel() > 2:
83
+ n = sig.shape[0]
84
+ t = torch.linspace(0.0, 1.0, n, device=sig.device, dtype=sig.dtype)
85
+ m = float(max(0.0, min(1.0, mix)))
86
+ if mode == "hybrid":
87
+ eps = 1e-6 if m < 1e-6 else m
88
+ w_m = torch.clamp((t - (1.0 - m)) / eps, 0.0, 1.0)
89
+ w_m = _smoothstep01(w_m)
90
+ elif mode == "beta":
91
+ w_m = torch.ones_like(t)
92
+ else:
93
+ w_m = torch.zeros_like(t)
94
+ dif = (sig[1:] - sig[:-1]).abs() / sig[:-1].abs().clamp_min(1e-8)
95
+ dif = torch.cat([dif, dif[-1:]], dim=0)
96
+ dif = (dif - dif.min()) / (dif.max() - dif.min() + 1e-8)
97
+ ramp = _smoothstep01(torch.clamp((t - 0.7) / 0.3, 0.0, 1.0))
98
+ w_a = dif * ramp
99
+ g = float(max(0.0, min(1.0, auto_tail_strength)))
100
+ u = w_m + g * w_a - w_m * g * w_a
101
+ sig = sig_k_base * (1.0 - u) + sig_b_base * u
102
+
103
+ # --- tiny schedule jitter ---
104
+ j = float(max(0.0, min(0.1, float(jitter))))
105
+ if j > 0.0 and sig.numel() > 1:
106
+ gen = torch.Generator(device='cpu')
107
+ gen.manual_seed(int(seed) ^ 0x5EEDCAFE)
108
+ noise = torch.randn(sig.shape, generator=gen, device='cpu').to(sig.device, sig.dtype)
109
+ amp = j * float(sig[0].item() - sig[-1].item()) * 1e-3
110
+ sig = sig + noise * amp
111
+ sig, _ = torch.sort(sig, descending=True)
112
+
113
+ # --- hard guarantee of ending with exact zero ---
114
+ if sig[-1].abs() > 1e-12:
115
+ sig = torch.cat([sig[:-1], sig.new_zeros(1)], dim=0)
116
+
117
+ # --- and only now drop-penultimate for respective samplers ---
118
+ # --- gentle smoothing of sigma tail (adaptive, safe for monotonic decrease) ---
119
+ ts = float(max(0.0, min(1.0, tail_smooth)))
120
+ if ts > 0.0 and sig.numel() > 2:
121
+ s = sig.clone()
122
+ n = int(s.shape[0])
123
+ t = torch.linspace(0.0, 1.0, n, device=s.device, dtype=s.dtype)
124
+ w = (t.pow(2) * ts).clamp(0.0, 1.0)
125
+ for i in range(n - 2, -1, -1):
126
+ a = float(min(0.5, 0.5 * w[i].item()))
127
+ s[i] = (1.0 - a) * s[i] + a * s[i + 1]
128
+ sig = s
129
+
130
+ if base_sampler in _samplers.KSampler.DISCARD_PENULTIMATE_SIGMA_SAMPLERS and sig.numel() >= 2:
131
+ sig = torch.cat([sig[:-2], sig[-1:]], dim=0)
132
+
133
+ sig = sig.to(model.load_device)
134
+
135
+ # Lightweight debug: schedule summary
136
+ if _debug:
137
+ try:
138
+ desc_ok = bool((sig[:-1] > sig[1:]).all().item()) if sig.numel() > 1 else True
139
+ head = ", ".join(f"{float(v):.4g}" for v in sig[:3].tolist()) if sig.numel() >= 3 else \
140
+ ", ".join(f"{float(v):.4g}" for v in sig.tolist())
141
+ tail = ", ".join(f"{float(v):.4g}" for v in sig[-3:].tolist()) if sig.numel() >= 3 else head
142
+ print(f"[ZeSmart][dbg] sigmas len={sig.numel()} desc={desc_ok} first={float(sig[0]):.6g} last={float(sig[-1]):.6g}")
143
+ print(f"[ZeSmart][dbg] head: [{head}] tail: [{tail}]")
144
+ except Exception:
145
+ pass
146
+
147
+ return sig
148
+
149
+
150
+ class MG_ZeSmartSampler:
151
+ @classmethod
152
+ def INPUT_TYPES(cls):
153
+ return {
154
+ "required": {
155
+ "model": ("MODEL", {}),
156
+ "seed": ("INT", {"default": 0, "min": 0, "max": 2**63-1, "control_after_generate": True}),
157
+ "steps": ("INT", {"default": 20, "min": 1, "max": 4096}),
158
+ "cfg": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 50.0, "step": 0.1}),
159
+ "base_sampler": (_samplers.KSampler.SAMPLERS, {"default": "dpmpp_2m"}),
160
+ "schedule": (["karras", "beta", "hybrid"], {"default": "hybrid", "tooltip": "Sigma curve: karras — soft start; beta — stable tail; hybrid — their mix."}),
161
+ "positive": ("CONDITIONING", {}),
162
+ "negative": ("CONDITIONING", {}),
163
+ "latent": ("LATENT", {}),
164
+ },
165
+ "optional": {
166
+ "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Path shortening: 1.0 = full; <1.0 = take the last steps only. Useful for inpaint/mixing."}),
167
+ "hybrid_mix": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "For schedule=hybrid: tail fraction blended toward beta (0=karras, 1=beta)."}),
168
+ "jitter_sigma": ("FLOAT", {"default": 0.01, "min": 0.0, "max": 0.1, "step": 0.001, "tooltip": "Tiny sigma jitter to kill moiré/banding on backgrounds. 0–0.02 is usually enough."}),
169
+ "tail_smooth": ("FLOAT", {"default": 0.15, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Smooth the sigma tail — reduces wobble/banding. Too high may soften details."}),
170
+ "auto_hybrid_tail": ("BOOLEAN", {"default": True, "tooltip": "Auto‑blend beta on the tail when steps become brittle."}),
171
+ "auto_tail_strength": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Strength of auto beta‑mix on the tail (0=off, 1=max)."}),
172
+ "debug_probe": ("BOOLEAN", {"default": False, "tooltip": "Print sigma summary (length, first/last, head/tail)."}),
173
+ }
174
+ }
175
+
176
+ RETURN_TYPES = ("LATENT",)
177
+ RETURN_NAMES = ("LATENT",)
178
+ FUNCTION = "apply"
179
+ CATEGORY = "MagicNodes/Experimental"
180
+
181
+ def apply(self, model, seed, steps, cfg, base_sampler, schedule,
182
+ positive, negative, latent, denoise=1.0, hybrid_mix=0.5,
183
+ jitter_sigma=0.02, tail_smooth=0.07,
184
+ auto_hybrid_tail=True, auto_tail_strength=0.3,
185
+ debug_probe=False):
186
+ # Prepare latent + noise
187
+ lat_img = latent["samples"]
188
+ lat_img = _sample.fix_empty_latent_channels(model, lat_img)
189
+ batch_inds = latent.get("batch_index", None)
190
+ noise = _sample.prepare_noise(lat_img, seed, batch_inds)
191
+ noise_mask = latent.get("noise_mask", None)
192
+
193
+ # Custom sigmas
194
+ sigmas = _build_hybrid_sigmas(model, int(steps), str(base_sampler), str(schedule),
195
+ float(hybrid_mix), float(denoise), float(jitter_sigma), int(seed),
196
+ _debug=bool(debug_probe), tail_smooth=float(tail_smooth),
197
+ auto_hybrid_tail=bool(auto_hybrid_tail),
198
+ auto_tail_strength=float(auto_tail_strength))
199
+
200
+ # Use native sampler; all tweaks happen in sigma schedule only.
201
+ sampler_obj = _samplers.sampler_object(str(base_sampler))
202
+ callback = nodes.latent_preview.prepare_callback(model, int(steps))
203
+ disable_pbar = not _utils.PROGRESS_BAR_ENABLED
204
+ samples = _sample.sample_custom(model, noise, float(cfg), sampler_obj, sigmas,
205
+ positive, negative, lat_img,
206
+ noise_mask=noise_mask, callback=callback,
207
+ disable_pbar=disable_pbar, seed=seed)
208
+ out = {**latent}
209
+ out["samples"] = samples
210
+ return (out,)
mod/mg_combinode.py ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import comfy.sd
2
+ import comfy.clip_vision
3
+ import folder_paths
4
+ import comfy.utils
5
+ import torch
6
+ import random
7
+ from datetime import datetime
8
+ import random
9
+ import gc
10
+ import os
11
+ import json
12
+ import re
13
+
14
+ from .hard.mg_upscale_module import clear_gpu_and_ram_cache
15
+
16
+ # Module level caches to reuse loaded models and LoRAs between invocations
17
+ _checkpoint_cache = {}
18
+ _loaded_checkpoint = None
19
+ _lora_cache = {}
20
+ _active_lora_names = set()
21
+
22
+
23
+ def _clear_unused_loras(active_names):
24
+ """Remove unused LoRAs from cache and clear GPU memory."""
25
+ unused = [n for n in _lora_cache if n not in active_names]
26
+ for n in unused:
27
+ del _lora_cache[n]
28
+ if unused:
29
+ gc.collect()
30
+ if torch.cuda.is_available():
31
+ torch.cuda.empty_cache()
32
+
33
+
34
+ def _load_checkpoint(path):
35
+ """Load checkpoint from cache or disk."""
36
+ if path in _checkpoint_cache:
37
+ return _checkpoint_cache[path]
38
+ model, clip, vae = comfy.sd.load_checkpoint_guess_config(
39
+ path,
40
+ output_vae=True,
41
+ output_clip=True,
42
+ embedding_directory=folder_paths.get_folder_paths("embeddings"),
43
+ )[:3]
44
+ _checkpoint_cache[path] = (model, clip, vae)
45
+ return model, clip, vae
46
+
47
+
48
+ def _unload_old_checkpoint(current_path):
49
+ """Unload checkpoint if it's different from the current one."""
50
+ global _loaded_checkpoint
51
+ if _loaded_checkpoint and _loaded_checkpoint != current_path:
52
+ _checkpoint_cache.pop(_loaded_checkpoint, None)
53
+ gc.collect()
54
+ if torch.cuda.is_available():
55
+ torch.cuda.empty_cache()
56
+ _loaded_checkpoint = current_path
57
+
58
+
59
+
60
+ class MagicNodesCombiNode:
61
+ @classmethod
62
+ def INPUT_TYPES(cls):
63
+ return {
64
+ "required": {
65
+
66
+
67
+
68
+ # --- Checkpoint ---
69
+ "use_checkpoint": ("BOOLEAN", {"default": True}),
70
+ "checkpoint": (folder_paths.get_filename_list("checkpoints"), {}),
71
+ "clear_cache": ("BOOLEAN", {"default": False}),
72
+
73
+ # --- LoRA 1 ---
74
+ "use_lora_1": ("BOOLEAN", {"default": True}),
75
+ "lora_1": (folder_paths.get_filename_list("loras"), {}),
76
+ "strength_model_1": ("FLOAT", {"default": 1.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
77
+ "strength_clip_1": ("FLOAT", {"default": 1.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
78
+
79
+ # --- LoRA 2 ---
80
+ "use_lora_2": ("BOOLEAN", {"default": False}),
81
+ "lora_2": (folder_paths.get_filename_list("loras"), {}),
82
+ "strength_model_2": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
83
+ "strength_clip_2": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
84
+
85
+ # --- LoRA 3 ---
86
+ "use_lora_3": ("BOOLEAN", {"default": False}),
87
+ "lora_3": (folder_paths.get_filename_list("loras"), {}),
88
+ "strength_model_3": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
89
+ "strength_clip_3": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
90
+
91
+ # --- LoRA 4 ---
92
+ "use_lora_4": ("BOOLEAN", {"default": False}),
93
+ "lora_4": (folder_paths.get_filename_list("loras"), {}),
94
+ "strength_model_4": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
95
+ "strength_clip_4": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
96
+
97
+ # --- LoRA 5 ---
98
+ "use_lora_5": ("BOOLEAN", {"default": False}),
99
+ "lora_5": (folder_paths.get_filename_list("loras"), {}),
100
+ "strength_model_5": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
101
+ "strength_clip_5": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
102
+
103
+ # --- LoRA 6 ---
104
+ "use_lora_6": ("BOOLEAN", {"default": False}),
105
+ "lora_6": (folder_paths.get_filename_list("loras"), {}),
106
+ "strength_model_6": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
107
+ "strength_clip_6": ("FLOAT", {"default": 0.0, "min": -1.5, "max": 1.5, "step": 0.01,}),
108
+ },
109
+ "optional": {
110
+ "model_in": ("MODEL", {}),
111
+ "clip_in": ("CLIP", {}),
112
+ "vae_in": ("VAE", {}),
113
+
114
+ # --- Prompts --- (controlled dynamic expansion inside node for determinism)
115
+ "positive_prompt": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": False}),
116
+ "negative_prompt": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": False}),
117
+
118
+ # Optional external conditioning (bypass internal text encode)
119
+ "positive_in": ("CONDITIONING", {}),
120
+ "negative_in": ("CONDITIONING", {}),
121
+
122
+ # --- CLIP Layers ---
123
+ "clip_set_last_layer_positive": ("INT", {"default": -2, "min": -20, "max": 0}),
124
+ "clip_set_last_layer_negative": ("INT", {"default": -2, "min": -20, "max": 0}),
125
+
126
+ # --- Recipes ---
127
+ "recipe_slot": (["Off", "Slot 1", "Slot 2", "Slot 3", "Slot 4"], {"default": "Off", "tooltip": "Choose slot to save/load assembled setup."}),
128
+ "recipe_save": ("BOOLEAN", {"default": False, "tooltip": "Save current setup into the selected slot."}),
129
+ "recipe_use": ("BOOLEAN", {"default": False, "tooltip": "Load and apply setup from the selected slot for this run."}),
130
+
131
+ # --- Standard pipeline (match classic node order for CLIP) ---
132
+ "standard_pipeline": ("BOOLEAN", {"default": False, "tooltip": "Use vanilla order for CLIP: Set Last Layer -> Load LoRA -> Encode (same CLIP logic as standard ComfyUI)."}),
133
+
134
+ # CLIP LoRA gains per branch (effective only when standard_pipeline=true)
135
+ "clip_lora_pos_gain": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01, "tooltip": "Multiplier for CLIP-LoRA strength on positive branch (standard pipeline)."}),
136
+ "clip_lora_neg_gain": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.01, "tooltip": "Multiplier for CLIP-LoRA strength on negative branch (standard pipeline)."}),
137
+
138
+ # Deterministic dynamic prompts
139
+ "dynamic_pos": ("BOOLEAN", {"default": False, "tooltip": "Deterministically expand choices in positive prompt (uses dyn_seed)."}),
140
+ "dynamic_neg": ("BOOLEAN", {"default": False, "tooltip": "Deterministically expand choices in negative prompt (uses dyn_seed)."}),
141
+ "dyn_seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFF, "tooltip": "Seed for dynamic prompt expansion (same seed used for both prompts)."}),
142
+ "dynamic_break_freeze": ("BOOLEAN", {"default": True, "tooltip": "If enabled, do not expand choices before the first |BREAK| marker; dynamic applies only after it."}),
143
+ "show_expanded_prompts": ("BOOLEAN", {"default": False, "tooltip": "Print expanded Positive/Negative prompts to console when dynamic is enabled."}),
144
+ "save_expanded_prompts": ("BOOLEAN", {"default": False, "tooltip": "Save expanded prompts to mod/dynPrompt/SEED_dd_mm_yyyy.txt when dynamic is enabled."}),
145
+ }
146
+ }
147
+
148
+
149
+ RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "CONDITIONING", "VAE")
150
+ RETURN_NAMES = ("MODEL", "CLIP", "Positive", "Negative", "VAE")
151
+ #RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "VAE")
152
+ #RETURN_NAMES = ("MODEL", "Positive", "Negative", "VAE")
153
+ FUNCTION = "apply_magic_node"
154
+ CATEGORY = "MagicNodes"
155
+
156
+ def apply_magic_node(self, model_in=None, clip_in=None, checkpoint=None,
157
+ use_checkpoint=True, clear_cache=False,
158
+ use_lora_1=True, lora_1=None, strength_model_1=1.0, strength_clip_1=1.0,
159
+ use_lora_2=False, lora_2=None, strength_model_2=0.0, strength_clip_2=0.0,
160
+ use_lora_3=False, lora_3=None, strength_model_3=0.0, strength_clip_3=0.0,
161
+ use_lora_4=False, lora_4=None, strength_model_4=0.0, strength_clip_4=0.0,
162
+ use_lora_5=False, lora_5=None, strength_model_5=0.0, strength_clip_5=0.0,
163
+ use_lora_6=False, lora_6=None, strength_model_6=0.0, strength_clip_6=0.0,
164
+ positive_prompt="", negative_prompt="",
165
+ clip_set_last_layer_positive=-2, clip_set_last_layer_negative=-2,
166
+ vae_in=None,
167
+ recipe_slot="Off", recipe_save=False, recipe_use=False,
168
+ standard_pipeline=False,
169
+ clip_lora_pos_gain=1.0, clip_lora_neg_gain=1.0,
170
+ positive_in=None, negative_in=None,
171
+ dynamic_pos=False, dynamic_neg=False, dyn_seed=0, dynamic_break_freeze=True,
172
+ show_expanded_prompts=False, save_expanded_prompts=False):
173
+
174
+ global _loaded_checkpoint
175
+
176
+ # hard scrub of checkpoint cache each call (prevent hidden state)
177
+ _checkpoint_cache.clear()
178
+ if clear_cache:
179
+ _lora_cache.clear()
180
+ gc.collect()
181
+ if torch.cuda.is_available():
182
+ torch.cuda.empty_cache()
183
+
184
+ # Recipe helpers
185
+ def _recipes_path():
186
+ base = os.path.join(os.path.dirname(__file__), "state")
187
+ os.makedirs(base, exist_ok=True)
188
+ return os.path.join(base, "combinode_recipes.json")
189
+ def _recipes_load():
190
+ try:
191
+ with open(_recipes_path(), "r", encoding="utf-8") as f:
192
+ return json.load(f)
193
+ except Exception:
194
+ return {}
195
+ def _recipes_save(data: dict):
196
+ try:
197
+ with open(_recipes_path(), "w", encoding="utf-8") as f:
198
+ json.dump(data, f, ensure_ascii=False, indent=2)
199
+ except Exception:
200
+ pass
201
+
202
+ # Apply recipe if requested
203
+ slot_idx = {"Off": 0, "Slot 1": 1, "Slot 2": 2, "Slot 3": 3, "Slot 4": 4}.get(str(recipe_slot), 0)
204
+ if slot_idx and bool(recipe_use):
205
+ rec = _recipes_load().get(str(slot_idx), None)
206
+ if rec is not None:
207
+ try:
208
+ use_checkpoint = rec.get("use_checkpoint", use_checkpoint)
209
+ checkpoint = rec.get("checkpoint", checkpoint)
210
+ clip_set_last_layer_positive = rec.get("clip_pos", clip_set_last_layer_positive)
211
+ clip_set_last_layer_negative = rec.get("clip_neg", clip_set_last_layer_negative)
212
+ positive_prompt = rec.get("pos_text", positive_prompt)
213
+ negative_prompt = rec.get("neg_text", negative_prompt)
214
+ rls = rec.get("loras", [])
215
+ if len(rls) >= 4:
216
+ (use_lora_1, lora_1, strength_model_1, strength_clip_1) = rls[0]
217
+ (use_lora_2, lora_2, strength_model_2, strength_clip_2) = rls[1]
218
+ (use_lora_3, lora_3, strength_model_3, strength_clip_3) = rls[2]
219
+ (use_lora_4, lora_4, strength_model_4, strength_clip_4) = rls[3]
220
+ if len(rls) >= 5:
221
+ (use_lora_5, lora_5, strength_model_5, strength_clip_5) = rls[4]
222
+ if len(rls) >= 6:
223
+ (use_lora_6, lora_6, strength_model_6, strength_clip_6) = rls[5]
224
+ print(f"[CombiNode] Loaded recipe Slot {slot_idx}.")
225
+ except Exception:
226
+ print(f"[CombiNode] Failed to apply recipe Slot {slot_idx}.")
227
+
228
+ # Prompt normalization helper (keeps '|' intact)
229
+ def _norm_prompt(s: str) -> str:
230
+ if not isinstance(s, str) or not s:
231
+ return s or ""
232
+ s2 = s.replace("\r", " ").replace("\n", " ")
233
+ s2 = re.sub(r"\s+", " ", s2)
234
+ s2 = re.sub(r"\s*,\s*", ", ", s2)
235
+ s2 = re.sub(r"(,\s*){2,}", ", ", s2)
236
+ return s2.strip()
237
+
238
+ # Deterministic dynamic prompt expansion: supports {...}, (...), [...] with '|' choices
239
+ def _expand_dynamic(text: str, seed_val: int, freeze_before_break: bool = True) -> str:
240
+ if not isinstance(text, str) or (text.find('|') < 0):
241
+ return text
242
+ # Honor |BREAK|: keep first segment intact when requested
243
+ if freeze_before_break and ('|BREAK|' in text):
244
+ pre, post = text.split('|BREAK|', 1)
245
+ return pre + '|BREAK|' + _expand_dynamic(post, seed_val, freeze_before_break=False)
246
+ rng = random.Random(int(seed_val) & 0xFFFFFFFF)
247
+ def _expand_pattern(t: str, pat: re.Pattern) -> str:
248
+ prev = None
249
+ cur = t
250
+ while prev != cur:
251
+ prev = cur
252
+ def repl(m):
253
+ body = m.group(1)
254
+ choices = [c.strip() for c in body.split('|') if c.strip()]
255
+ if not choices:
256
+ return m.group(0)
257
+ return rng.choice(choices)
258
+ cur = pat.sub(repl, cur)
259
+ return cur
260
+ for rx in (
261
+ re.compile(r"\{([^{}]+)\}"),
262
+ re.compile(r"\(([^()]+)\)"),
263
+ re.compile(r"\[([^\[\]]+)\]"),
264
+ ):
265
+ text = _expand_pattern(text, rx)
266
+ return text
267
+
268
+ # Precompute expanded (or original) texts once
269
+ pos_text_expanded = _norm_prompt(_expand_dynamic(positive_prompt, int(dyn_seed), bool(dynamic_break_freeze)) if bool(dynamic_pos) else positive_prompt)
270
+ neg_text_expanded = _norm_prompt(_expand_dynamic(negative_prompt, int(dyn_seed), bool(dynamic_break_freeze)) if bool(dynamic_neg) else negative_prompt)
271
+
272
+ if use_checkpoint and checkpoint:
273
+ checkpoint_path = folder_paths.get_full_path_or_raise("checkpoints", checkpoint)
274
+ _unload_old_checkpoint(checkpoint_path)
275
+ base_model, base_clip, vae = _load_checkpoint(checkpoint_path)
276
+ model = base_model.clone()
277
+ clip = base_clip.clone()
278
+ clip_clean = base_clip.clone() # keep pristine CLIP for standard pipeline path
279
+
280
+ elif model_in and clip_in:
281
+ _unload_old_checkpoint(None)
282
+ model = model_in.clone()
283
+ clip = clip_in.clone()
284
+ clip_clean = clip_in.clone()
285
+ vae = vae_in
286
+ else:
287
+ raise Exception("No model selected!")
288
+
289
+ # single clear at start is enough; avoid double-clearing here
290
+
291
+ # Применение цепочки LoRA
292
+ loras = [
293
+ (use_lora_1, lora_1, strength_model_1, strength_clip_1),
294
+ (use_lora_2, lora_2, strength_model_2, strength_clip_2),
295
+ (use_lora_3, lora_3, strength_model_3, strength_clip_3),
296
+ (use_lora_4, lora_4, strength_model_4, strength_clip_4),
297
+ (use_lora_5, lora_5, strength_model_5, strength_clip_5),
298
+ (use_lora_6, lora_6, strength_model_6, strength_clip_6),
299
+ ]
300
+
301
+ active_lora_paths = []
302
+ lora_stack = [] # list of (lora_file, sc, sm)
303
+ defer_clip = bool(standard_pipeline)
304
+ for use_lora, lora_name, sm, sc in loras:
305
+ if use_lora and lora_name:
306
+ lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
307
+ active_lora_paths.append(lora_path)
308
+ # keep lora object to avoid reloading
309
+ if lora_path in _lora_cache:
310
+ lora_file = _lora_cache[lora_path]
311
+ else:
312
+ lora_file = comfy.utils.load_torch_file(lora_path, safe_load=True)
313
+ _lora_cache[lora_path] = lora_file
314
+ lora_stack.append((lora_file, float(sc), float(sm)))
315
+ sc_apply = 0.0 if defer_clip else sc
316
+ model, clip = comfy.sd.load_lora_for_models(model, clip, lora_file, sm, sc_apply)
317
+
318
+ _clear_unused_loras(active_lora_paths)
319
+ # Warn about duplicate LoRA selections across slots
320
+ try:
321
+ counts = {}
322
+ for p in active_lora_paths:
323
+ counts[p] = counts.get(p, 0) + 1
324
+ dups = [p for p, c in counts.items() if c > 1]
325
+ if dups:
326
+ print(f"[CombiNode] Duplicate LoRA detected across slots: {len(dups)} file(s).")
327
+ except Exception:
328
+ pass
329
+
330
+ # Embeddings Positive и Negative
331
+ # Standard pipeline: optionally use a shared CLIP after clip_layer + CLIP-LoRA
332
+ # Select CLIP source for encoding: pristine when standard pipeline is enabled
333
+ src_clip = clip_clean if bool(standard_pipeline) else clip
334
+
335
+ pos_gain = float(clip_lora_pos_gain)
336
+ neg_gain = float(clip_lora_neg_gain)
337
+ skips_equal = int(clip_set_last_layer_positive) == int(clip_set_last_layer_negative)
338
+ # Use shared CLIP only if gains are equal and skips equal
339
+ use_shared = bool(standard_pipeline) and skips_equal and (abs(pos_gain - neg_gain) < 1e-6)
340
+
341
+ if (positive_in is None) and (negative_in is None) and use_shared:
342
+ shared_clip = src_clip.clone()
343
+ shared_clip.clip_layer(clip_set_last_layer_positive)
344
+ for lora_file, sc, sm in lora_stack:
345
+ try:
346
+ _m_unused, shared_clip = comfy.sd.load_lora_for_models(model, shared_clip, lora_file, 0.0, sc * pos_gain)
347
+ except Exception:
348
+ pass
349
+ tokens_pos = shared_clip.tokenize(pos_text_expanded)
350
+ cond_pos = shared_clip.encode_from_tokens_scheduled(tokens_pos)
351
+ tokens_neg = shared_clip.tokenize(neg_text_expanded)
352
+ cond_neg = shared_clip.encode_from_tokens_scheduled(tokens_neg)
353
+ else:
354
+ # CLIP Set Last Layer + Positive conditioning
355
+ clip_pos = src_clip.clone()
356
+ clip_pos.clip_layer(clip_set_last_layer_positive)
357
+ if bool(standard_pipeline):
358
+ for lora_file, sc, sm in lora_stack:
359
+ try:
360
+ _m_unused, clip_pos = comfy.sd.load_lora_for_models(model, clip_pos, lora_file, 0.0, sc * pos_gain)
361
+ except Exception:
362
+ pass
363
+ if positive_in is not None:
364
+ cond_pos = positive_in
365
+ else:
366
+ tokens_pos = clip_pos.tokenize(pos_text_expanded)
367
+ cond_pos = clip_pos.encode_from_tokens_scheduled(tokens_pos)
368
+
369
+ # CLIP Set Last Layer + Negative conditioning
370
+ clip_neg = src_clip.clone()
371
+ clip_neg.clip_layer(clip_set_last_layer_negative)
372
+ if bool(standard_pipeline):
373
+ for lora_file, sc, sm in lora_stack:
374
+ try:
375
+ _m_unused, clip_neg = comfy.sd.load_lora_for_models(model, clip_neg, lora_file, 0.0, sc * neg_gain)
376
+ except Exception:
377
+ pass
378
+ if negative_in is not None:
379
+ cond_neg = negative_in
380
+ else:
381
+ tokens_neg = clip_neg.tokenize(neg_text_expanded)
382
+ cond_neg = clip_neg.encode_from_tokens_scheduled(tokens_neg)
383
+
384
+ # Optional: show/save expanded prompts if dynamic used anywhere
385
+ dyn_used = bool(dynamic_pos) or bool(dynamic_neg)
386
+ if dyn_used and (bool(show_expanded_prompts) or bool(save_expanded_prompts)):
387
+ # Console print
388
+ if bool(show_expanded_prompts):
389
+ try:
390
+ print(f"[CombiNode] Expanded prompts (dyn_seed={int(dyn_seed)}):")
391
+ def _print_block(name, src, expanded):
392
+ print(name + ":")
393
+ if bool(dynamic_break_freeze) and ('|BREAK|' in src) and ((name=="Positive" and bool(dynamic_pos)) or (name=="Negative" and bool(dynamic_neg))):
394
+ print(" static")
395
+ print(" " + expanded)
396
+ _print_block("Positive", positive_prompt, pos_text_expanded)
397
+ _print_block("Negative", negative_prompt, neg_text_expanded)
398
+ except Exception:
399
+ pass
400
+ # File save
401
+ if bool(save_expanded_prompts):
402
+ try:
403
+ base = os.path.join(os.path.dirname(__file__), "dynPrompt")
404
+ os.makedirs(base, exist_ok=True)
405
+ now = datetime.now()
406
+ fname = f"{int(dyn_seed)}_{now.day:02d}_{now.month:02d}_{now.year}.txt"
407
+ path = os.path.join(base, fname)
408
+ lines = []
409
+ def _append_block(name, src, expanded):
410
+ lines.append(name + ":\n")
411
+ if bool(dynamic_break_freeze) and ('|BREAK|' in src) and ((name=="Positive" and bool(dynamic_pos)) or (name=="Negative" and bool(dynamic_neg))):
412
+ lines.append("static\n")
413
+ lines.append(expanded + "\n\n")
414
+ _append_block("Positive", positive_prompt, pos_text_expanded)
415
+ _append_block("Negative", negative_prompt, neg_text_expanded)
416
+ with open(path, 'w', encoding='utf-8') as f:
417
+ f.writelines(lines)
418
+ except Exception:
419
+ pass
420
+
421
+ # Save recipe if requested
422
+ if slot_idx and bool(recipe_save):
423
+ data = _recipes_load()
424
+ data[str(slot_idx)] = {
425
+ "use_checkpoint": bool(use_checkpoint),
426
+ "checkpoint": checkpoint,
427
+ "clip_pos": int(clip_set_last_layer_positive),
428
+ "clip_neg": int(clip_set_last_layer_negative),
429
+ "pos_text": str(positive_prompt),
430
+ "neg_text": str(negative_prompt),
431
+ "loras": [
432
+ [bool(use_lora_1), lora_1, float(strength_model_1), float(strength_clip_1)],
433
+ [bool(use_lora_2), lora_2, float(strength_model_2), float(strength_clip_2)],
434
+ [bool(use_lora_3), lora_3, float(strength_model_3), float(strength_clip_3)],
435
+ [bool(use_lora_4), lora_4, float(strength_model_4), float(strength_clip_4)],
436
+ [bool(use_lora_5), lora_5, float(strength_model_5), float(strength_clip_5)],
437
+ [bool(use_lora_6), lora_6, float(strength_model_6), float(strength_clip_6)],
438
+ ],
439
+ }
440
+ _recipes_save(data)
441
+ print(f"[CombiNode] Saved recipe Slot {slot_idx}.")
442
+
443
+ # Return the CLIP instance consistent with encoding path
444
+ return (model, src_clip if bool(standard_pipeline) else clip, cond_pos, cond_neg, vae)
445
+
446
+
447
+
448
+