Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05667f6517 | ||
|
|
ab7cebcebc | ||
|
|
e4eabfefd5 | ||
|
|
bbe86060c6 | ||
|
|
be4bd9bb16 | ||
|
|
913cc7ba55 | ||
|
|
04dc35af00 | ||
|
|
7b1efef47e | ||
|
|
ec49b685db | ||
|
|
2ae545d64d | ||
|
|
81b7a8fe63 | ||
|
|
2fc10b0c5e | ||
|
|
e7ff15d5a7 | ||
|
|
5bc3200832 | ||
|
|
c484e9d236 |
@@ -0,0 +1,62 @@
|
||||
name: quality gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SIMPLE_SYRUP_TEST_COMFY_CPU: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.14.0
|
||||
cache: npm
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install ComfyUI host dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git "$RUNNER_TEMP/ComfyUI"
|
||||
rsync -a --exclude=".git" "$RUNNER_TEMP/ComfyUI/" "$GITHUB_WORKSPACE/../.."/
|
||||
pip install -r "$GITHUB_WORKSPACE/../../requirements.txt"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install -e . pytest pytest-xdist ruff mypy
|
||||
|
||||
- name: Check architecture governance
|
||||
run: python -m tools.check_architecture
|
||||
|
||||
- name: Check test governance
|
||||
run: python -m tools.check_test_governance
|
||||
|
||||
- name: Verify Python formatting
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Verify Python lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Verify Python types
|
||||
run: mypy --strict simple_syrup tests
|
||||
|
||||
- name: Verify Python tests
|
||||
run: pytest -n auto -q -m "not external_artifact"
|
||||
|
||||
- name: Verify frontend
|
||||
run: npm run check:web
|
||||
@@ -50,13 +50,15 @@ jobs:
|
||||
"transformers${{ matrix.version }}"
|
||||
|
||||
- name: Verify GroundingDINO BERT compatibility
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/tests
|
||||
run: >-
|
||||
python -m pytest -q
|
||||
--noconftest
|
||||
--rootdir=tests
|
||||
--confcutdir=tests
|
||||
tests/test_grounding_dino_bert_adapter.py
|
||||
tests/test_grounding_dino_text_token_masks.py
|
||||
tests/segmentation/detection/test_grounding_dino_bert_adapter.py
|
||||
tests/segmentation/detection/test_grounding_dino_text_token_masks.py
|
||||
|
||||
release:
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -98,6 +100,12 @@ jobs:
|
||||
- name: Verify Python formatting
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Check architecture governance
|
||||
run: python -m tools.check_architecture
|
||||
|
||||
- name: Check test governance
|
||||
run: python -m tools.check_test_governance
|
||||
|
||||
- name: Verify Python lint
|
||||
run: ruff check .
|
||||
|
||||
@@ -114,6 +122,10 @@ jobs:
|
||||
id: release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GIT_AUTHOR_NAME: Daisy
|
||||
GIT_AUTHOR_EMAIL: daisy@artificialsweetener.ai
|
||||
GIT_COMMITTER_NAME: Daisy
|
||||
GIT_COMMITTER_EMAIL: daisy@artificialsweetener.ai
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: architecture-governance
|
||||
name: Enforce architecture governance
|
||||
entry: ..\..\venv\Scripts\python.exe -m tools.check_architecture
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: test-governance
|
||||
name: Enforce test governance
|
||||
entry: ..\..\venv\Scripts\python.exe -m tools.check_test_governance
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
exclude: '(\.bat$|\.cmd$|\.ps1$)'
|
||||
- id: trailing-whitespace
|
||||
- id: check-merge-conflict
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
exclude: '(^web/dist/|tsconfig\.json$)'
|
||||
- id: check-toml
|
||||
@@ -35,6 +35,8 @@ Engineering priority is strict architecture, strong separation of concerns, comp
|
||||
### Required Command Forms
|
||||
|
||||
- Tests: `..\..\venv\Scripts\python.exe -m pytest -n auto -q`
|
||||
- Architecture: `..\..\venv\Scripts\python.exe -m tools.check_architecture`
|
||||
- Test governance: `..\..\venv\Scripts\python.exe -m tools.check_test_governance`
|
||||
- Lint: `..\..\venv\Scripts\ruff.exe check .`
|
||||
- Format: `..\..\venv\Scripts\ruff.exe format .`
|
||||
- Type check: `..\..\venv\Scripts\mypy.exe --strict simple_syrup tests`
|
||||
@@ -94,6 +96,34 @@ If a required tool is missing from `..\..\venv`, install or update development d
|
||||
- Reorganize modules when it improves architecture.
|
||||
- Align touched modules with the ownership and dependency rules in this file.
|
||||
|
||||
## Architecture Governance
|
||||
|
||||
- Repository governance lives under `governance/`.
|
||||
- `governance/architecture/policy.toml` defines every authored-code root,
|
||||
extension, exclusion, and the 350-line soft and 500-line hard structural
|
||||
thresholds.
|
||||
- `governance/architecture/debt.toml` records exact assessed mixed ownership.
|
||||
- `governance/architecture/waivers.toml` records exact bounded hard-gate
|
||||
exceptions.
|
||||
- `governance/architecture/import_debt.toml` records exact current dependency-
|
||||
direction violations; new violations are prohibited.
|
||||
- `governance/architecture/soft_reviews.toml` records the current human
|
||||
disposition of every file between the soft and hard thresholds.
|
||||
- Every hard-gate file requires source-level ownership review.
|
||||
- Use a structural waiver only for one cohesive authoritative owner whose
|
||||
invariants would be divided by extraction.
|
||||
- Mixed ownership requires debt and a linked remediation waiver naming the
|
||||
next extraction and a lower next limit.
|
||||
- Waivers and debt are fingerprinted current state, not historical ledgers.
|
||||
- Delete resolved records; do not extend dates or limits merely to pass the
|
||||
checker.
|
||||
- `governance/testing/policy.toml` defines Python and frontend test-layout and
|
||||
reliability discovery.
|
||||
- Every test-governance candidate requires an exact classification or
|
||||
debt-remediation disposition.
|
||||
- Run both governance checkers after changing authored structure, test
|
||||
placement, isolation, timing, resources, or reviewed state.
|
||||
|
||||
## ComfyUI Node Rules
|
||||
|
||||
- Public node identifiers are compatibility-sensitive.
|
||||
|
||||
@@ -1,3 +1,50 @@
|
||||
# [1.13.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.12.0...v1.13.0) (2026-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **negpip:** support Krea attention on ComfyUI 0.28 ([e4eabfe](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e4eabfefd540f3a6066c29761cefb8f8b3c80f68))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **sampling:** add noise inversion and composable sampler options ([be4bd9b](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/be4bd9bb162f6ed4252e0c4d4ef0f3efebe8c674))
|
||||
* **sampling:** refine sampler options and inversion controls ([ab7cebc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/ab7cebcebc4d5dfa95aa8d834456af1778fe56a6))
|
||||
|
||||
# [1.12.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.1...v1.12.0) (2026-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** satisfy RES4LYF publication contracts ([04dc35a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/04dc35af00163689509cace1541a8e7d33b5053e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **sampling:** add RES4LYF sampler methods and schedules ([7b1efef](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7b1efef47e20b9bf6f032dd57971899d99881822))
|
||||
|
||||
## [1.11.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.0...v1.11.1) (2026-09-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** attribute automation to Daisy ([2ae545d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/2ae545d64d70a454f635ee647fb7e6a1c3500b9b))
|
||||
|
||||
# [1.11.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.1...v1.11.0) (2026-09-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **sampling:** make negative conditioning optional ([0bc81dc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0bc81dc4d00a13d42c65440da2058e94a505e8d5))
|
||||
|
||||
## [1.10.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.0...v1.10.1) (2026-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** expose test support to compatibility jobs ([0517f71](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0517f71da891414e9773c5cd47879ff748492e70))
|
||||
* **governance:** enforce SugarSubstitute quality standards ([bbaed2c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/bbaed2c90656cd4e65e5bfcd4c0451f90ec2d7c7))
|
||||
|
||||
# [1.10.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.3...v1.10.0) (2026-09-21)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ The pack now covers model loading, regional prompting and segmentation, high-res
|
||||
- ADetailer-style `[SEP]` prompt batches, masked conditioning, and regional samplers, with optional Prompt Control scheduling and LoRA hooks.
|
||||
- WD14 and external vision LLM tagging that stays aligned with the right regions.
|
||||
- Ordered image and mask loading, GPU Lanczos resizing, tiled VAE options, and provenance-aware latent tools.
|
||||
- WebUI-inspired sampling extras including seed variation, A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and beta57.
|
||||
- WebUI-inspired sampling extras including seed variation, A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and RES4LYF sampler methods and schedules.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -152,7 +152,7 @@ The external LLM nodes use a configured OpenAI-compatible provider. **Tag SEGS w
|
||||
|
||||
**Simple VAE Encode** can reuse the source latent when the graph proves that its image came directly from an unmodified `VAEDecode`. **Upscale Latent From Image** uses the same provenance to find and resize the original latent. Loading, editing, cropping, detailing, or resizing the image breaks that provenance. These nodes follow the graph instead of trying to identify a latent from the finished tensor.
|
||||
|
||||
**KSampler (Extras)** adds the A1111/k-diffusion-style `euler_a_a1111` sampler, AYS SD1 and SDXL schedules, GITS, the `automatic_a1111` scheduler, and a local implementation of the RES4LYF beta57 preset. It keeps Comfy's regular seed handling, partial denoise behavior, progress callbacks, and conditioning inputs.
|
||||
**KSampler (Extras)** adds the A1111/k-diffusion-style `euler_a_a1111` sampler, AYS SD1 and SDXL schedules, GITS, `automatic_a1111`, and the RES4LYF beta57 and `bong_tangent` schedules. Its sampler dropdown includes 118 RES4LYF methods, including `exponential/ddim`. These methods are also available in the contextual, tiled, and Attention Coupling KSamplers. RES4LYF methods use their upstream default initial noise; Comfy samplers keep Comfy's normal noise path.
|
||||
|
||||
**Seed Variation** patches a MODEL so Comfy-native samplers mix their normal initial noise toward a second deterministic seed. Strength `0` keeps the sampler seed unchanged, while strength `1` uses variation-seed initial noise. Ancestral and SDE samplers continue to use the sampler seed for additional noise introduced after initialization.
|
||||
|
||||
@@ -182,6 +182,8 @@ SimpleSyrup currently interoperates with:
|
||||
|
||||
AGPL-3.0-or-later is a strong copyleft license. If you convey SimpleSyrup or a modified version, you must provide the corresponding source. If users interact with a modified version over a network, you must offer those users the corresponding source for that version.
|
||||
|
||||
The vendored RES4LYF license copy includes its upstream commercial-service paragraph before the GNU AGPL v3 text. Read the [RES4LYF license copy](third_party/licenses/res4lyf.LICENSE.txt) and [third-party notices](third_party/NOTICE.md) for the terms and provenance recorded with that code.
|
||||
|
||||
SimpleSyrup owes a lot to other projects:
|
||||
|
||||
- [ComfyUI](https://github.com/Comfy-Org/ComfyUI) provides the engine and graph ecosystem this pack runs on.
|
||||
@@ -190,7 +192,7 @@ SimpleSyrup owes a lot to other projects:
|
||||
- [ComfyUI Prompt Control](https://github.com/asagi4/comfyui-prompt-control) provides the scheduled prompt and LoRA-hook behavior used by the optional integration.
|
||||
- [ComfyUI Layer Style Advance](https://github.com/chflame163/ComfyUI_LayerStyle_Advance) provides the SAM model bundle SimpleSyrup can adapt.
|
||||
- [Tiled Diffusion & VAE for AUTOMATIC1111](https://github.com/pkuliyi2015/multidiffusion-upscaler-for-automatic1111) informed the practical tiled diffusion and Mixture of Diffusers behavior reimplemented here.
|
||||
- [RES4LYF](https://github.com/ClownsharkBatwing/RES4LYF) is the source of the beta57 scheduler preset reimplemented here.
|
||||
- [RES4LYF](https://github.com/ClownsharkBatwing/RES4LYF) by ClownsharkBatwing and contributors provides the Runge-Kutta and exponential sampler methods included here, along with the `bong_tangent` schedule and the beta57 preset.
|
||||
- [ComfyUI-ppm](https://github.com/pamparamm/ComfyUI-ppm) by pamparamm provides the ModelPatcher-based NegPiP behavior adapted here and builds on the [ComfyUI port](https://github.com/laksjdjf/cd-tuner_negpip-ComfyUI) by laksjdjf and the [original WebUI implementation](https://github.com/hako-mikan/sd-webui-negpip) by hako-mikan.
|
||||
|
||||
SimpleSyrup also vendors or reimplements selected third-party behavior for SAM-HQ, MobileSAM, GroundingDINO, AUTOMATIC1111 sampler behavior, k-diffusion, and tiled diffusion. See [third_party/NOTICE.md](third_party/NOTICE.md) for the complete notices.
|
||||
|
||||
+12
-10
@@ -12,22 +12,24 @@ from . import simple_syrup as _simple_syrup_package
|
||||
|
||||
sys.modules.setdefault("simple_syrup", _simple_syrup_package)
|
||||
|
||||
from .simple_syrup.integration.external_llm_routes import ( # noqa: E402
|
||||
register_external_llm_routes,
|
||||
)
|
||||
from .simple_syrup.integration.mask_batch_preview_routes import ( # noqa: E402
|
||||
register_mask_batch_preview_routes,
|
||||
)
|
||||
from .simple_syrup.integration.quant_cache_routes import ( # noqa: E402
|
||||
register_quant_cache_routes,
|
||||
)
|
||||
from .simple_syrup.integration.settings_routes import ( # noqa: E402
|
||||
register_settings_routes,
|
||||
)
|
||||
from .simple_syrup.runtime.attention_region_prompt_handler import ( # noqa: E402
|
||||
register_attention_region_prompt_handler,
|
||||
)
|
||||
from .simple_syrup.runtime.comfy_safetensors_dtypes import ( # noqa: E402
|
||||
register_comfy_safetensors_dtypes,
|
||||
)
|
||||
from .simple_syrup.runtime.external_llm_routes import ( # noqa: E402
|
||||
register_external_llm_routes,
|
||||
)
|
||||
from .simple_syrup.runtime.mask_batch_preview_routes import ( # noqa: E402
|
||||
register_mask_batch_preview_routes,
|
||||
)
|
||||
from .simple_syrup.runtime.quant_cache_routes import ( # noqa: E402
|
||||
register_quant_cache_routes,
|
||||
)
|
||||
from .simple_syrup.runtime.settings_routes import register_settings_routes # noqa: E402
|
||||
|
||||
WEB_DIRECTORY = "./web/dist"
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema_version = 1
|
||||
debts = []
|
||||
@@ -0,0 +1 @@
|
||||
schema_version = 1
|
||||
@@ -0,0 +1,41 @@
|
||||
schema_version = 2
|
||||
|
||||
[structure]
|
||||
soft_lines = 350
|
||||
hard_lines = 500
|
||||
source_roots = [
|
||||
"simple_syrup/domain",
|
||||
"simple_syrup/image",
|
||||
"simple_syrup/integration",
|
||||
"simple_syrup/masking",
|
||||
"simple_syrup/nodes",
|
||||
"simple_syrup/nodes_v3",
|
||||
"simple_syrup/runtime",
|
||||
"simple_syrup/services",
|
||||
"simple_syrup/shared",
|
||||
"tests",
|
||||
"tools",
|
||||
"scripts",
|
||||
"web/src",
|
||||
"web/tests",
|
||||
]
|
||||
source_files = [
|
||||
"__init__.py",
|
||||
".releaserc.cjs",
|
||||
"eslint.config.js",
|
||||
"simple_syrup/__init__.py",
|
||||
"vitest.config.ts",
|
||||
]
|
||||
source_extensions = [
|
||||
".cjs",
|
||||
".js",
|
||||
".mjs",
|
||||
".py",
|
||||
".pyi",
|
||||
".ts",
|
||||
]
|
||||
excluded_paths = []
|
||||
|
||||
[registries]
|
||||
debt = "governance/architecture/debt.toml"
|
||||
waivers = "governance/architecture/waivers.toml"
|
||||
@@ -0,0 +1,58 @@
|
||||
schema_version = 1
|
||||
review_by = 2027-03-31
|
||||
fingerprint = "sha256:a71fe87163eb7585fafbca08b585954be79abd884e3a1df38bc041c2bc934764"
|
||||
|
||||
cohesive_paths = [
|
||||
"simple_syrup/masking/prompt_segs_with_sam_service.py",
|
||||
"simple_syrup/nodes/prompt_segs_with_sam.py",
|
||||
"simple_syrup/runtime/attention_region_affinity.py",
|
||||
"simple_syrup/runtime/attention_region_capture.py",
|
||||
"simple_syrup/runtime/attention_sampler_lineage.py",
|
||||
"simple_syrup/runtime/regional_lora/anima_module_surface.py",
|
||||
"simple_syrup/runtime/spatial_model_arguments.py",
|
||||
"simple_syrup/services/concept_attention_evidence.py",
|
||||
"tests/comfy_integration/test_comfy_regional_adapter_resolver.py",
|
||||
"tests/comfy_integration/test_comfy_regional_conditioning_processing.py",
|
||||
"tests/models/loading/test_checkpoint_quantizer.py",
|
||||
"tests/models/patching/test_model_patcher_mutations.py",
|
||||
"tests/prompting/prompt_control/test_prompt_control_schedule_encode_graph.py",
|
||||
"tests/regional_generation/anima/test_anima_activation_context.py",
|
||||
"tests/regional_generation/anima/test_anima_full_tile_lora_equivalence.py",
|
||||
"tests/regional_generation/anima/test_anima_loader.py",
|
||||
"tests/regional_generation/anima/test_anima_multi_lora_composition.py",
|
||||
"tests/regional_generation/anima/test_anima_regional_diagnostics.py",
|
||||
"tests/regional_generation/anima/test_anima_regional_diagnostics_wrapper.py",
|
||||
"tests/regional_generation/anima/test_anima_regional_permutation_diagnostics.py",
|
||||
"tests/regional_generation/anima/test_anima_single_adapter_mutations.py",
|
||||
"tests/regional_generation/attention_coupling/test_attention_coupling_model_preparation_service.py",
|
||||
"tests/regional_generation/attention_regions/test_attention_region_capture.py",
|
||||
"tests/regional_generation/attention_regions/test_attention_region_completion.py",
|
||||
"tests/regional_generation/attention_regions/test_attention_region_components.py",
|
||||
"tests/regional_generation/attention_regions/test_attention_region_geometry.py",
|
||||
"tests/regional_generation/regional/test_regional_attention_batching.py",
|
||||
"tests/regional_generation/regional/test_regional_linear_execution.py",
|
||||
"tests/regional_generation/regional/test_regional_model_patch_interop.py",
|
||||
"tests/regional_generation/regional/test_regional_multidiffusion_sampling.py",
|
||||
"tests/regional_generation/spatial/test_contextual_model_wrapper.py",
|
||||
"tests/sampling/test_multidiffusion_sampling.py",
|
||||
"tests/sampling/test_sampling_scheduler_references.py",
|
||||
"tests/sampling/test_sampling_schedulers.py",
|
||||
"tests/segmentation/detection/test_ultralytics_loader.py",
|
||||
"tests/segmentation/segs/test_detail_segs_as_regions_service.py",
|
||||
"tests/segmentation/segs/test_prompt_segs_with_sam_node.py",
|
||||
"tools/architecture_governance/validation.py",
|
||||
"tools/attention_coupling_benchmark/comfy_probe/negpip_runtime.py",
|
||||
"tools/negpip_integration/run.py",
|
||||
"tools/prompt_control_attention_coupling_integration/validation.py",
|
||||
"tools/run_global_prompt_lora_proof.py",
|
||||
"tools/test_governance/semantic_patterns.py",
|
||||
"tools/test_governance/validation.py",
|
||||
"web/src/orderedMediaNode.ts",
|
||||
"web/src/orderedMediaPreviewActions.ts",
|
||||
"web/tests/media/orderedMediaPreviewActions.test.ts",
|
||||
]
|
||||
|
||||
debt_paths = [
|
||||
]
|
||||
|
||||
remediations = []
|
||||
@@ -0,0 +1,67 @@
|
||||
schema_version = 1
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-WAIVER-S001"
|
||||
owner = "model catalog"
|
||||
rule = "STRUCT003"
|
||||
path = "simple_syrup/runtime/model_catalog.py"
|
||||
kind = "structural"
|
||||
justification = "This module is the single immutable catalog authority for supported model families and artifacts. Most of its size is declarative checksums, repository identities, filenames, and URLs; its small query surface and entry constructors enforce one catalog schema and change with that same metadata contract. Splitting entries by provider would scatter uniqueness and lookup review without separating behavior or ownership."
|
||||
issue = "chore:SSY-WAIVER-S001"
|
||||
review_by = 2027-03-31
|
||||
max_lines = 687
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-WAIVER-S003"
|
||||
owner = "Anima cross-attention patch contracts"
|
||||
rule = "STRUCT003"
|
||||
path = "tests/regional_generation/anima/test_anima_cross_attention.py"
|
||||
kind = "structural"
|
||||
justification = "This module is one integration contract for AnimaRegionalCrossAttentionPatch: it installs the exact Anima module surface, supplies one deterministic attention double, drives branch/mask/context alignment, verifies failure restoration, and proves all 28 clone-local patches. The sizable builders encode a single valid execution context and are not independent production responsibilities."
|
||||
issue = "chore:SSY-WAIVER-S003"
|
||||
review_by = 2027-03-31
|
||||
max_lines = 661
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-WAIVER-S004"
|
||||
owner = "Anima multi-LoRA fidelity contracts"
|
||||
rule = "STRUCT003"
|
||||
path = "tests/regional_generation/anima/test_anima_multi_lora_fidelity.py"
|
||||
kind = "structural"
|
||||
justification = "This module owns one numerical fidelity matrix for ordered multi-LoRA composition across schedules, branches, regions, target families, and the complete Anima surface. Its execution and reference helpers intentionally remain adjacent so every permutation is compared through the same independently calculated oracle; splitting by scenario would duplicate or conceal that shared proof authority."
|
||||
issue = "chore:SSY-WAIVER-S004"
|
||||
review_by = 2027-03-31
|
||||
max_lines = 678
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-WAIVER-S005"
|
||||
owner = "regional convolution execution contracts"
|
||||
rule = "STRUCT003"
|
||||
path = "tests/regional_generation/regional/test_regional_convolution_execution.py"
|
||||
kind = "structural"
|
||||
justification = "This module is the complete numerical contract for RegionalConvolutionExecutor across direct, pointwise, LoCon, strided, grouped, tiled-batch, ordered-adapter, and low-precision execution. Its fixture builds the same execution plan and independent convolution reference for every case, so the tests share one owner, oracle, dependency surface, and change cadence."
|
||||
issue = "chore:SSY-WAIVER-S005"
|
||||
review_by = 2027-03-31
|
||||
max_lines = 556
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-WAIVER-S006"
|
||||
owner = "Ultralytics detection node contracts"
|
||||
rule = "STRUCT003"
|
||||
path = "tests/segmentation/detection/test_detect_segs_with_ultralytics_node.py"
|
||||
kind = "structural"
|
||||
justification = "This module owns the workflow-facing contract of one Comfy node, including its schema, exact input order, batch behavior, sorting/ranking limits, union mode, and output shape. The service and builder doubles are deliberately local representations of that node boundary; every test changes with the same node API and persisted workflow contract."
|
||||
issue = "chore:SSY-WAIVER-S006"
|
||||
review_by = 2027-03-31
|
||||
max_lines = 592
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-WAIVER-S007"
|
||||
owner = "scale-factor detail service contracts"
|
||||
rule = "STRUCT003"
|
||||
path = "tests/segmentation/segs/test_detail_segs_by_scale_factor_service.py"
|
||||
kind = "structural"
|
||||
justification = "This module is the end-to-end behavioral contract for DetailSEGSByScaleFactorService, whose single orchestration transaction selects per-segment conditioning, sizes and resizes crops, applies masks, samples, decodes, and pastes results. Its sampler and resizer doubles record that one transaction; splitting them would duplicate setup without creating a distinct behavior owner."
|
||||
issue = "chore:SSY-WAIVER-S007"
|
||||
review_by = 2027-03-31
|
||||
max_lines = 532
|
||||
@@ -0,0 +1,2 @@
|
||||
schema_version = 1
|
||||
debts = []
|
||||
@@ -0,0 +1,27 @@
|
||||
schema_version = 1
|
||||
|
||||
[scope]
|
||||
test_root = "tests"
|
||||
semantic_support_roots = ["tools"]
|
||||
root_source_extensions = [".py", ".pyi"]
|
||||
allowed_root_source_paths = [
|
||||
"tests/ci_test_policy.py",
|
||||
"tests/conftest.py",
|
||||
]
|
||||
|
||||
[discovery]
|
||||
serial_policy = "tests/ci_test_policy.py"
|
||||
wait_calls = ["QTest.qWait", "time.sleep"]
|
||||
wall_clock_calls = [
|
||||
"QElapsedTimer",
|
||||
"monotonic",
|
||||
"perf_counter",
|
||||
"time.monotonic",
|
||||
"time.perf_counter",
|
||||
]
|
||||
xdist_environment_name = "PYTEST_XDIST_WORKER"
|
||||
repository_scratch_name = ".pytest-tmp"
|
||||
|
||||
[registries]
|
||||
debt = "governance/testing/debt.toml"
|
||||
waivers = "governance/testing/waivers.toml"
|
||||
@@ -0,0 +1,153 @@
|
||||
schema_version = 1
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C001"
|
||||
owner = "pytest CUDA isolation bootstrap"
|
||||
kind = "classification"
|
||||
disposition = "framework_infrastructure"
|
||||
rule = "ENV001"
|
||||
candidates = ["ENV001|tests/conftest.py|<module>:environment-mutation:1"]
|
||||
paths = ["tests/conftest.py"]
|
||||
fingerprint = "sha256:481069f16237eff312f817f1e4dd3213e804eee0f3341e3f6c4aa2ba73066af3"
|
||||
rationale = "The root pytest bootstrap disables CUDA visibility before Torch and ComfyUI are imported unless the maintainer explicitly enables hardware tests. Every xdist worker receives the same inherited setting before collection, so this is suite framework configuration rather than mutable test-owned state."
|
||||
issue = "chore:SSY-TEST-WAIVER-C001"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C002"
|
||||
owner = "native checkpoint quantization proofs"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = [
|
||||
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:1",
|
||||
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:2",
|
||||
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:3",
|
||||
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:4",
|
||||
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:5",
|
||||
]
|
||||
paths = ["tests/models/loading/test_checkpoint_quantizer.py"]
|
||||
fingerprint = "sha256:bc2bee06ddb6cf41ba66497855e7349e5aa5dd5292f732284a812a615fa65c49"
|
||||
rationale = "These proofs exercise installed ComfyUI NVFP4/MXFP8 kernels, GPU compute capability, and optional comfy-aimdo reload behavior. CPU fake-boundary tests in the same module always run; only the native serialization contracts are skipped when their external runtime or hardware capability does not exist."
|
||||
issue = "chore:SSY-TEST-WAIVER-C002"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C003"
|
||||
owner = "CUDA Anima projection precision proof"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_projection_batch.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/regional_generation/anima/test_anima_projection_batch.py"]
|
||||
fingerprint = "sha256:cb5e280524450a58144b350799129b3c27a6d4ce430867de582da19303987c33"
|
||||
rationale = "This exact comparison bounds BF16 batched projection error on CUDA tensors against independently generated projections. Its behavior depends on the installed CUDA execution path and cannot truthfully be substituted by CPU arithmetic; all device-independent projection contracts remain mandatory."
|
||||
issue = "chore:SSY-TEST-WAIVER-C003"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C004"
|
||||
owner = "native Anima quantization workflow"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_quantization_workflow.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/regional_generation/anima/test_anima_quantization_workflow.py"]
|
||||
fingerprint = "sha256:ff02416ea8c16112605b221975425a6f88013f66e2a8bc66bdc2d1a0ecfa5053"
|
||||
rationale = "The workflow proof intentionally uses the installed ComfyUI NVFP4 implementation and the active GPU's native compute support before loading the generated Anima artifact. It remains optional only where that hardware capability is absent; the portable resolver and policy tests still run everywhere."
|
||||
issue = "chore:SSY-TEST-WAIVER-C004"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C005"
|
||||
owner = "installed Anima CUDA smoke"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_regional_model_smoke.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/regional_generation/anima/test_anima_regional_model_smoke.py"]
|
||||
fingerprint = "sha256:73b3bde9d6009b71608aec73dde862d963a3a723d5ad5d57c130428a4c1511c6"
|
||||
rationale = "This smoke test constructs the installed Comfy Anima model and executes its complete patched forward on CUDA tensors. It proves the native device/runtime integration and is skipped only without CUDA; deterministic component and surface contracts cover the same code boundaries on every host."
|
||||
issue = "chore:SSY-TEST-WAIVER-C005"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C006"
|
||||
owner = "regional convolution CUDA precision"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_convolution_execution.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/regional_generation/regional/test_regional_convolution_execution.py"]
|
||||
fingerprint = "sha256:f197de881034f1c11b46ce290f3b6c515fe251d5bdef7efb419f43ad7257bf40"
|
||||
rationale = "The optional parameterized cases prove FP16 and BF16 regional convolution behavior through the installed CUDA kernels. CPU tests in the same contract cover dimensions, grouping, stride, masking, ordering, and reference math; only device-specific low-precision execution requires CUDA."
|
||||
issue = "chore:SSY-TEST-WAIVER-C006"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C007"
|
||||
owner = "regional linear CUDA precision"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = [
|
||||
"OPTIONAL001|tests/regional_generation/regional/test_regional_linear_execution.py|<module>:optional-proof:1",
|
||||
"OPTIONAL001|tests/regional_generation/regional/test_regional_linear_execution.py|<module>:optional-proof:2",
|
||||
]
|
||||
paths = ["tests/regional_generation/regional/test_regional_linear_execution.py"]
|
||||
fingerprint = "sha256:d8a833ef160838b80db21d7240d789879deb8a4dc39f89d52145b1ebf580f765"
|
||||
rationale = "These cases validate installed CUDA FP16/BF16 projection rounding and compatible-adapter accumulation on the actual device execution path. The module's CPU contracts always prove masking, ordering, preparation, and reference deltas; the classified cases add hardware-specific numerical evidence."
|
||||
issue = "chore:SSY-TEST-WAIVER-C007"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C008"
|
||||
owner = "fused regional LoRA CUDA kernel"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_lora_fused_active_accumulation.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/regional_generation/regional/test_regional_lora_fused_active_accumulation.py"]
|
||||
fingerprint = "sha256:2053ff281c4e707c99db6424074ef3526c901ad84f0f750e044c35116981cd9c"
|
||||
rationale = "The entire module qualifies the CUDA-only fused regional LoRA accumulator across low-precision dtypes, adapter counts, and indexed paths. There is no CPU implementation to exercise, while the non-fused accumulation owner has mandatory portable reference coverage."
|
||||
issue = "chore:SSY-TEST-WAIVER-C008"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C009"
|
||||
owner = "fused multiplier CUDA transport"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_lora_fused_multiplier_transport.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/regional_generation/regional/test_regional_lora_fused_multiplier_transport.py"]
|
||||
fingerprint = "sha256:56ef4217551bdccc97fc6277bd2ade11ac10b9512ee3725c46b210044f380f21"
|
||||
rationale = "This module proves multiple adapter multipliers reach the CUDA fused kernel without an intermediate stack. The production behavior exists only for a CUDA-capable device, and portable composition tests independently cover ordering and multiplier semantics outside this native optimization."
|
||||
issue = "chore:SSY-TEST-WAIVER-C009"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C010"
|
||||
owner = "ordered tensor CUDA accumulation"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "OPTIONAL001"
|
||||
candidates = ["OPTIONAL001|tests/sampling/test_ordered_tensor_accumulation.py|<module>:optional-proof:1"]
|
||||
paths = ["tests/sampling/test_ordered_tensor_accumulation.py"]
|
||||
fingerprint = "sha256:b6306323fd555706f0b7525078acfa199c657f47f96432ce320da9057fc41709"
|
||||
rationale = "The parameter matrix compares stepwise CUDA accumulation and its exact low-precision rounding across one and multiple Triton launches. Mandatory CPU contracts prove ordered in-place accumulation; only the GPU kernel and device dtypes are capability-gated."
|
||||
issue = "chore:SSY-TEST-WAIVER-C010"
|
||||
review_by = 2027-03-31
|
||||
|
||||
[[waivers]]
|
||||
id = "SSY-TEST-WAIVER-C011"
|
||||
owner = "managed Windows Comfy process lifetime"
|
||||
kind = "classification"
|
||||
disposition = "platform_native"
|
||||
rule = "PROCESS001"
|
||||
candidates = ["PROCESS001|tools/comfy_integration/server_process.py|<module>:unscoped-child-process:1"]
|
||||
paths = ["tools/comfy_integration/server_process.py"]
|
||||
fingerprint = "sha256:6892815fbffe96d59bbb3f9069e44bfcdb9d6ea39e3f17f844695408af4233c0"
|
||||
rationale = "WindowsComfyProcess intentionally transfers the created Popen and log handles into an explicit long-lived owner because the integration run must use the server after start returns. Its stop method signals the exact process group, bounds both graceful and forced waits, retries bounded taskkill calls, and closes both logs in finally."
|
||||
issue = "chore:SSY-TEST-WAIVER-C011"
|
||||
review_by = 2027-03-31
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "simple-syrup-comfyui",
|
||||
"version": "1.10.0",
|
||||
"version": "1.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simple-syrup-comfyui",
|
||||
"version": "1.10.0",
|
||||
"version": "1.13.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simple-syrup-comfyui",
|
||||
"version": "1.10.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
+8
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "SimpleSyrup"
|
||||
description = "Workflow-focused ComfyUI extensions for image generation."
|
||||
version = "1.10.0"
|
||||
version = "1.13.0"
|
||||
license = "AGPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.11"
|
||||
@@ -36,6 +36,7 @@ line-length = 88
|
||||
target-version = "py311"
|
||||
extend-exclude = [
|
||||
"simple_syrup/third_party/groundingdino_runtime",
|
||||
"simple_syrup/third_party/res4lyf_runtime",
|
||||
"simple_syrup/third_party/sam_hq_runtime",
|
||||
]
|
||||
|
||||
@@ -59,6 +60,7 @@ explicit_package_bases = true
|
||||
mypy_path = ["tests"]
|
||||
exclude = [
|
||||
"simple_syrup/third_party/groundingdino_runtime",
|
||||
"simple_syrup/third_party/res4lyf_runtime",
|
||||
"simple_syrup/third_party/sam_hq_runtime",
|
||||
]
|
||||
|
||||
@@ -66,9 +68,14 @@ exclude = [
|
||||
module = ["comfy.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["simple_syrup.third_party.res4lyf_runtime.*"]
|
||||
follow_imports = "skip"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = [".", "../.."]
|
||||
testpaths = ["tests"]
|
||||
addopts = ["--strict-markers", "--import-mode=importlib"]
|
||||
markers = [
|
||||
"external_artifact: requires a locally installed external source or generated benchmark artifact",
|
||||
]
|
||||
|
||||
@@ -7,3 +7,5 @@ addict>=2.4.0
|
||||
yapf>=0.43.0
|
||||
huggingface-hub>=0.34.0
|
||||
keyring>=25.0.0
|
||||
mpmath>=1.3.0
|
||||
PyWavelets>=1.6.0
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "1.10.0"
|
||||
__version__ = "1.13.0"
|
||||
|
||||
__all__: list[str] = ["__version__"]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Define validated Attention Coupling preparation data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .raw_regional_attention import RawRegionalAttentionPlan
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttentionCouplingPreparation:
|
||||
"""Retain the full raw plan and base-only ordinary sampler inputs."""
|
||||
|
||||
plan: RawRegionalAttentionPlan
|
||||
positive: object
|
||||
negative: object
|
||||
@@ -27,16 +27,37 @@ class ContextualDiffusionControls:
|
||||
global_weight: float
|
||||
global_steps: int
|
||||
global_decay: float
|
||||
latent_tile_width: int | None = None
|
||||
latent_tile_height: int | None = None
|
||||
|
||||
@property
|
||||
def tile_width(self) -> int:
|
||||
"""Use explicit local geometry or the convenience node's context size."""
|
||||
return self.latent_tile_width or self.latent_context_size
|
||||
|
||||
@property
|
||||
def tile_height(self) -> int:
|
||||
"""Keep the global context independent of a rectangular local tile."""
|
||||
return self.latent_tile_height or self.latent_context_size
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Reject controls that cannot produce a stable bounded context plan."""
|
||||
|
||||
if self.latent_context_size < 16:
|
||||
raise ValueError("latent_context_size must be at least 16 latent pixels.")
|
||||
if not 0 <= self.latent_context_overlap < self.latent_context_size:
|
||||
for value in (self.latent_tile_width, self.latent_tile_height):
|
||||
if value is not None and (type(value) is not int or value < 16):
|
||||
raise ValueError(
|
||||
"Local tile dimensions must be at least 16 latent pixels."
|
||||
)
|
||||
if (
|
||||
not 0
|
||||
<= self.latent_context_overlap
|
||||
< min(self.tile_width, self.tile_height)
|
||||
):
|
||||
raise ValueError(
|
||||
"latent_context_overlap must be non-negative and smaller than "
|
||||
"latent_context_size."
|
||||
"both local tile dimensions."
|
||||
)
|
||||
if self.latent_context_batch_size < 1:
|
||||
raise ValueError("latent_context_batch_size must be at least 1.")
|
||||
@@ -65,6 +86,7 @@ def build_contextual_diffusion_plan(
|
||||
controls: ContextualDiffusionControls,
|
||||
segs: NativeSegs | None,
|
||||
region_masks: torch.Tensor | None = None,
|
||||
segs_canvas: tuple[int, int] | None = None,
|
||||
) -> ContextualDiffusionPlan:
|
||||
"""Return a global context plus the regular or SEGS-guided context plan."""
|
||||
|
||||
@@ -89,27 +111,29 @@ def build_contextual_diffusion_plan(
|
||||
segs=segs,
|
||||
latent_width=latent_width,
|
||||
latent_height=latent_height,
|
||||
tile_width=controls.latent_context_size,
|
||||
tile_height=controls.latent_context_size,
|
||||
tile_width=controls.tile_width,
|
||||
tile_height=controls.tile_height,
|
||||
overlap=controls.latent_context_overlap,
|
||||
tile_batch_size=controls.latent_context_batch_size,
|
||||
segs_canvas=segs_canvas,
|
||||
)
|
||||
elif segs is not None:
|
||||
tile_plan = build_segs_guided_tiled_diffusion_plan(
|
||||
segs=segs,
|
||||
latent_width=latent_width,
|
||||
latent_height=latent_height,
|
||||
tile_width=controls.latent_context_size,
|
||||
tile_height=controls.latent_context_size,
|
||||
tile_width=controls.tile_width,
|
||||
tile_height=controls.tile_height,
|
||||
overlap=controls.latent_context_overlap,
|
||||
tile_batch_size=controls.latent_context_batch_size,
|
||||
segs_canvas=segs_canvas,
|
||||
)
|
||||
else:
|
||||
tile_plan = build_tiled_diffusion_plan(
|
||||
latent_width=latent_width,
|
||||
latent_height=latent_height,
|
||||
tile_width=controls.latent_context_size,
|
||||
tile_height=controls.latent_context_size,
|
||||
tile_width=controls.tile_width,
|
||||
tile_height=controls.tile_height,
|
||||
overlap=controls.latent_context_overlap,
|
||||
tile_batch_size=controls.latent_context_batch_size,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Integrate source-derived inversion states without ComfyUI dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
|
||||
from .noise_inversion import INVERSION_METHODS, InversionMethod
|
||||
|
||||
InversionVelocity = Callable[[torch.Tensor, torch.Tensor, int], torch.Tensor]
|
||||
SpatialResize = Callable[[torch.Tensor, int, int], torch.Tensor]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InversionSolverEvidence:
|
||||
"""Count the actual denoiser evaluations performed by an integration stage."""
|
||||
|
||||
evaluations: int = 0
|
||||
|
||||
|
||||
def integrate_inversion(
|
||||
source: torch.Tensor,
|
||||
sigmas: torch.Tensor,
|
||||
evaluate: InversionVelocity,
|
||||
*,
|
||||
method: InversionMethod,
|
||||
evidence: InversionSolverEvidence | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Advance a finite state over strictly increasing positive inversion sigmas."""
|
||||
if method not in INVERSION_METHODS:
|
||||
raise ValueError("Inversion method must be euler or heun.")
|
||||
if sigmas.ndim != 1 or len(sigmas) < 2 or not bool(torch.isfinite(sigmas).all()):
|
||||
raise ValueError("A finite one-dimensional inversion schedule is required.")
|
||||
if not bool(torch.all(sigmas > 0)) or not bool(torch.all(sigmas[1:] > sigmas[:-1])):
|
||||
raise ValueError("Inversion sigmas must be positive and increasing.")
|
||||
if not source.is_floating_point() or not bool(torch.isfinite(source).all()):
|
||||
raise ValueError("Inversion source must contain finite floating-point values.")
|
||||
record = evidence if evidence is not None else InversionSolverEvidence()
|
||||
state = source.clone()
|
||||
|
||||
def velocity(x: torch.Tensor, sigma: torch.Tensor, index: int) -> torch.Tensor:
|
||||
"""Count every denoiser evaluation and reject corrupted predictions."""
|
||||
record.evaluations += 1
|
||||
value = evaluate(x, sigma, index)
|
||||
if value.shape != x.shape or not bool(torch.isfinite(value).all()):
|
||||
raise FloatingPointError("Invalid inversion velocity shape or values.")
|
||||
return value
|
||||
|
||||
for index in range(len(sigmas) - 1):
|
||||
current, following = sigmas[index], sigmas[index + 1]
|
||||
delta = following - current
|
||||
estimate = velocity(state, current, index)
|
||||
if method == "heun":
|
||||
corrected = velocity(state + delta * estimate, following, index)
|
||||
estimate = (estimate + corrected) / 2
|
||||
state = state + delta * estimate
|
||||
if not bool(torch.isfinite(state).all()):
|
||||
raise FloatingPointError(f"Non-finite inversion state at step {index}.")
|
||||
return state
|
||||
|
||||
|
||||
def lift_inversion_displacement(
|
||||
full_source: torch.Tensor,
|
||||
coarse_source: torch.Tensor,
|
||||
coarse_endpoint: torch.Tensor,
|
||||
*,
|
||||
resize: SpatialResize,
|
||||
) -> torch.Tensor:
|
||||
"""Lift only the inferred change so existing full-size detail survives transfer."""
|
||||
if coarse_source.shape != coarse_endpoint.shape:
|
||||
raise ValueError("Coarse inversion source and endpoint shapes must match.")
|
||||
if full_source.shape[:-2] != coarse_source.shape[:-2]:
|
||||
raise ValueError(
|
||||
"Inversion transfer must preserve batch and channel dimensions."
|
||||
)
|
||||
height, width = full_source.shape[-2:]
|
||||
lifted = resize(coarse_endpoint - coarse_source, height, width)
|
||||
if lifted.shape != full_source.shape:
|
||||
raise ValueError("Inversion displacement resize produced an invalid shape.")
|
||||
endpoint = full_source + lifted
|
||||
if not bool(torch.isfinite(endpoint).all()):
|
||||
raise FloatingPointError("Inversion transfer produced non-finite values.")
|
||||
return endpoint
|
||||
@@ -0,0 +1,68 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Define validated source-preserving noise inversion configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
InversionMethod = Literal["euler", "heun"]
|
||||
INVERSION_METHODS: tuple[InversionMethod, ...] = ("euler", "heun")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NoiseInversionOptions:
|
||||
"""Configure reduced-resolution inversion and an optional full-size finish.
|
||||
|
||||
The transition is a fraction of the target inversion sigma, not the forward
|
||||
denoise steps. A full-size inversion uses ``steps`` and needs no transfer.
|
||||
"""
|
||||
|
||||
method: InversionMethod = "euler"
|
||||
resolution_scale: float = 0.5
|
||||
steps: int = 2
|
||||
switch_fraction: float = 0.75
|
||||
finishing_steps: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject invalid or internally incomplete inversion recipes."""
|
||||
if self.method not in INVERSION_METHODS:
|
||||
raise ValueError("Inversion method must be euler or heun.")
|
||||
if (
|
||||
not math.isfinite(self.resolution_scale)
|
||||
or not 0 < self.resolution_scale <= 1
|
||||
):
|
||||
raise ValueError("Inversion resolution scale must be in (0, 1].")
|
||||
if type(self.steps) is not int or not 1 <= self.steps <= 64:
|
||||
raise ValueError("Inversion steps must be an integer between 1 and 64.")
|
||||
if type(self.finishing_steps) is not int or not 0 <= self.finishing_steps <= 64:
|
||||
raise ValueError("Inversion finishing steps must be between 0 and 64.")
|
||||
if not math.isfinite(self.switch_fraction) or not 0 < self.switch_fraction <= 1:
|
||||
raise ValueError("Inversion transition must be in (0, 1].")
|
||||
if self.resolution_scale < 1 and self.finishing_steps > 0:
|
||||
if self.switch_fraction == 1:
|
||||
raise ValueError("A full-size finish requires a transition below 100%.")
|
||||
|
||||
@property
|
||||
def coarse_target_fraction(self) -> float:
|
||||
"""Reach the full target unless an enabled full-size stage follows transfer."""
|
||||
return (
|
||||
self.switch_fraction
|
||||
if self.resolution_scale < 1 and self.finishing_steps
|
||||
else 1.0
|
||||
)
|
||||
|
||||
def coarse_shape(self, height: int, width: int) -> tuple[int, int]:
|
||||
"""Preserve full dimensions or align reduced transformer grids to even sizes."""
|
||||
if height < 1 or width < 1:
|
||||
raise ValueError("Inversion source dimensions must be positive.")
|
||||
if self.resolution_scale == 1:
|
||||
return height, width
|
||||
return (
|
||||
max(2, round(height * self.resolution_scale / 2) * 2),
|
||||
max(2, round(width * self.resolution_scale / 2) * 2),
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Project semantic regional-detailing ownership into each inversion resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import torch.nn.functional as functional
|
||||
|
||||
from .regional_detailing import LatentBox, LatentRegion
|
||||
|
||||
|
||||
def project_inversion_regions(
|
||||
regions: tuple[LatentRegion, ...],
|
||||
*,
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
) -> tuple[LatentRegion, ...]:
|
||||
"""Preserve region identity and conditioning while scaling masks and bounds."""
|
||||
if min(source_width, source_height, target_width, target_height) < 1:
|
||||
raise ValueError("Regional inversion canvases must have positive dimensions.")
|
||||
if (source_width, source_height) == (target_width, target_height):
|
||||
return regions
|
||||
projected: list[LatentRegion] = []
|
||||
for region in regions:
|
||||
box = region.latent_box
|
||||
if tuple(region.latent_mask.shape) != (source_height, source_width):
|
||||
raise ValueError("Regional inversion mask must match its canonical canvas.")
|
||||
if not (
|
||||
0 <= box.x < box.x + box.width <= source_width
|
||||
and 0 <= box.y < box.y + box.height <= source_height
|
||||
):
|
||||
raise ValueError("Regional inversion bounds must remain inside the canvas.")
|
||||
left = math.floor(box.x * target_width / source_width)
|
||||
top = math.floor(box.y * target_height / source_height)
|
||||
right = math.ceil((box.x + box.width) * target_width / source_width)
|
||||
bottom = math.ceil((box.y + box.height) * target_height / source_height)
|
||||
mask = functional.interpolate(
|
||||
region.latent_mask[None, None].float(),
|
||||
size=(target_height, target_width),
|
||||
mode="nearest",
|
||||
)[0, 0].to(region.latent_mask)
|
||||
projected.append(
|
||||
LatentRegion(
|
||||
region.index,
|
||||
region.label,
|
||||
LatentBox(left, top, right - left, bottom - top),
|
||||
mask,
|
||||
region.positive,
|
||||
)
|
||||
)
|
||||
return tuple(projected)
|
||||
@@ -26,6 +26,7 @@ def build_region_constrained_tiled_diffusion_plan(
|
||||
tile_height: int,
|
||||
overlap: int,
|
||||
tile_batch_size: int,
|
||||
segs_canvas: tuple[int, int] | None = None,
|
||||
) -> TiledDiffusionPlan:
|
||||
"""Build tiles split wherever regional composition or optional SEGS change."""
|
||||
|
||||
@@ -37,7 +38,8 @@ def build_region_constrained_tiled_diffusion_plan(
|
||||
ownership_masks = region_ownership
|
||||
if segs is not None:
|
||||
native_segs = coerce_segs(segs)
|
||||
validate_segs_aspect_ratio(native_segs, latent_height, latent_width)
|
||||
canvas_height, canvas_width = segs_canvas or (latent_height, latent_width)
|
||||
validate_segs_aspect_ratio(native_segs, canvas_height, canvas_width)
|
||||
semantic_ownership = segs_ownership_masks(
|
||||
native_segs,
|
||||
latent_height=latent_height,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Assemble immutable, order-independent sampler capability configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TypeAlias
|
||||
|
||||
from .noise_inversion import NoiseInversionOptions
|
||||
from .regional_prompting import validate_regional_prompt_weight
|
||||
from .tiled_diffusion import validate_tiled_diffusion_mode
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TilingOptions:
|
||||
"""Own the single local tile layout and blending configuration."""
|
||||
|
||||
diffusion_mode: str = "multidiffusion"
|
||||
width: int = 128
|
||||
height: int = 128
|
||||
overlap: int = 32
|
||||
batch_size: int = 4
|
||||
differential_diffusion: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject tile settings that cannot form a bounded prediction plan."""
|
||||
validate_tiled_diffusion_mode(self.diffusion_mode)
|
||||
for name, value in (("width", self.width), ("height", self.height)):
|
||||
if type(value) is not int or not 16 <= value <= 512:
|
||||
raise ValueError(
|
||||
f"Tile {name} must be between 16 and 512 latent pixels."
|
||||
)
|
||||
if type(self.overlap) is not int or not 0 <= self.overlap < min(
|
||||
self.width, self.height
|
||||
):
|
||||
raise ValueError(
|
||||
"Tile overlap must be nonnegative and smaller than both dimensions."
|
||||
)
|
||||
if type(self.batch_size) is not int or self.batch_size < 1:
|
||||
raise ValueError("Tile batch size must be a positive integer.")
|
||||
if type(self.differential_diffusion) is not bool:
|
||||
raise TypeError("Differential diffusion must be a boolean.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContextualDiffusionOptions:
|
||||
"""Own global context and square local sampling geometry independently of tiling."""
|
||||
|
||||
context_size: int = 96
|
||||
global_weight: float = 1.0
|
||||
global_steps: int = 1
|
||||
global_decay: float = 0.5
|
||||
diffusion_mode: str = "multidiffusion"
|
||||
overlap: int = 32
|
||||
batch_size: int = 4
|
||||
differential_diffusion: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject invalid global schedules and square local sampling settings."""
|
||||
if type(self.context_size) is not int or not 16 <= self.context_size <= 512:
|
||||
raise ValueError("Context size must be between 16 and 512 latent pixels.")
|
||||
if not math.isfinite(self.global_weight) or not 0 <= self.global_weight <= 2:
|
||||
raise ValueError("Global context weight must be between 0 and 2.")
|
||||
if type(self.global_steps) is not int or self.global_steps < 0:
|
||||
raise ValueError("Global context steps must be a nonnegative integer.")
|
||||
if not math.isfinite(self.global_decay) or not 0 <= self.global_decay <= 1:
|
||||
raise ValueError("Global context decay must be between 0 and 1.")
|
||||
self.local_tiling()
|
||||
|
||||
def local_tiling(self) -> TilingOptions:
|
||||
"""Use context size for both dimensions of the sole local sampling plan."""
|
||||
return TilingOptions(
|
||||
diffusion_mode=self.diffusion_mode,
|
||||
width=self.context_size,
|
||||
height=self.context_size,
|
||||
overlap=self.overlap,
|
||||
batch_size=self.batch_size,
|
||||
differential_diffusion=self.differential_diffusion,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttentionCouplingOptions:
|
||||
"""Configure regional attention strength without binding masks or a model."""
|
||||
|
||||
regional_prompt_weight: float = 1.0
|
||||
region_mask_feather: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Require valid regional attention strengths and feathering controls."""
|
||||
validate_regional_prompt_weight(self.regional_prompt_weight)
|
||||
if type(self.region_mask_feather) is not int or self.region_mask_feather < 0:
|
||||
raise ValueError("Region mask feather must be a nonnegative integer.")
|
||||
|
||||
|
||||
SamplerCapability: TypeAlias = (
|
||||
TilingOptions
|
||||
| ContextualDiffusionOptions
|
||||
| NoiseInversionOptions
|
||||
| AttentionCouplingOptions
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SamplerOptions:
|
||||
"""Own one immutable setting per capability, independent of graph order."""
|
||||
|
||||
tiling: TilingOptions | None = None
|
||||
contextual_diffusion: ContextualDiffusionOptions | None = None
|
||||
noise_inversion: NoiseInversionOptions | None = None
|
||||
attention_coupling: AttentionCouplingOptions | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject malformed connection payloads at the typed configuration boundary."""
|
||||
for name, expected in (
|
||||
("tiling", TilingOptions),
|
||||
("contextual_diffusion", ContextualDiffusionOptions),
|
||||
("noise_inversion", NoiseInversionOptions),
|
||||
("attention_coupling", AttentionCouplingOptions),
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and not isinstance(value, expected):
|
||||
raise TypeError(f"Sampler option {name} must be {expected.__name__}.")
|
||||
|
||||
def with_capability(self, capability: SamplerCapability) -> SamplerOptions:
|
||||
"""Return a fresh configuration or reject an ambiguous duplicate feature."""
|
||||
names: dict[type[object], str] = {
|
||||
TilingOptions: "tiling",
|
||||
ContextualDiffusionOptions: "contextual_diffusion",
|
||||
NoiseInversionOptions: "noise_inversion",
|
||||
AttentionCouplingOptions: "attention_coupling",
|
||||
}
|
||||
name = names.get(type(capability))
|
||||
if name is None:
|
||||
raise TypeError("Unsupported sampler capability configuration.")
|
||||
if getattr(self, name) is not None:
|
||||
raise ValueError(
|
||||
f"Duplicate sampler capability: {name}. Bypass or remove one node."
|
||||
)
|
||||
if isinstance(capability, TilingOptions):
|
||||
return replace(self, tiling=capability)
|
||||
if isinstance(capability, ContextualDiffusionOptions):
|
||||
return replace(self, contextual_diffusion=capability)
|
||||
if isinstance(capability, NoiseInversionOptions):
|
||||
return replace(self, noise_inversion=capability)
|
||||
return replace(self, attention_coupling=capability)
|
||||
|
||||
|
||||
def append_sampler_capability(
|
||||
options: SamplerOptions | None, capability: SamplerCapability | None
|
||||
) -> SamplerOptions:
|
||||
"""Append a capability or pass through a disabled contribution after validation."""
|
||||
if options is not None and not isinstance(options, SamplerOptions):
|
||||
raise TypeError(
|
||||
"Options input must be a SimpleSyrup sampler options connection."
|
||||
)
|
||||
current = options if options is not None else SamplerOptions()
|
||||
return current if capability is None else current.with_capability(capability)
|
||||
@@ -0,0 +1,51 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Define renderer-neutral SEG preview documents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
|
||||
from .segs import CropRegion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AtlasPlacement:
|
||||
"""Locate one region mask inside the packed mask atlas."""
|
||||
|
||||
left: int
|
||||
top: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SegPreviewRegion:
|
||||
"""Describe one interactive region and its packed mask geometry."""
|
||||
|
||||
region_id: str
|
||||
index: int
|
||||
label: str
|
||||
confidence: float
|
||||
active_area: int
|
||||
color: str
|
||||
crop: CropRegion
|
||||
atlas: AtlasPlacement
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SegPreviewDocument:
|
||||
"""Carry bounded image assets and interaction metadata to a UI adapter."""
|
||||
|
||||
source_width: int
|
||||
source_height: int
|
||||
preview_width: int
|
||||
preview_height: int
|
||||
image: torch.Tensor
|
||||
atlas: torch.Tensor
|
||||
region_images: tuple[torch.Tensor, ...]
|
||||
regions: tuple[SegPreviewRegion, ...]
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ..domain.segs import BoundingBox, CropRegion
|
||||
from .segs import BoundingBox, CropRegion
|
||||
|
||||
|
||||
def validate_single_image(image: object, operation: str) -> torch.Tensor:
|
||||
@@ -22,16 +22,20 @@ def build_segs_guided_tiled_diffusion_plan(
|
||||
tile_height: int,
|
||||
overlap: int,
|
||||
tile_batch_size: int,
|
||||
segs_canvas: tuple[int, int] | None = None,
|
||||
) -> TiledDiffusionPlan:
|
||||
"""Build bounded sampling windows whose irregular cores follow supplied SEGS.
|
||||
|
||||
Every latent pixel receives exactly one ownership core. Each core is sampled
|
||||
through a rectangular window, while its local blend mask retains the irregular
|
||||
boundary and shares a feathered overlap with neighboring cores.
|
||||
A reduced inversion stage validates proportions against its original canvas
|
||||
because rounding the reduced dimensions can change their aspect ratio.
|
||||
"""
|
||||
|
||||
native_segs = coerce_segs(segs)
|
||||
validate_segs_aspect_ratio(native_segs, latent_height, latent_width)
|
||||
canvas_height, canvas_width = segs_canvas or (latent_height, latent_width)
|
||||
validate_segs_aspect_ratio(native_segs, canvas_height, canvas_width)
|
||||
ownership_masks = segs_ownership_masks(
|
||||
native_segs,
|
||||
latent_height=latent_height,
|
||||
|
||||
@@ -11,11 +11,6 @@ from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
|
||||
from ..masking.segs_mask_ops import (
|
||||
crop_region_for_bbox,
|
||||
resize_mask,
|
||||
validate_single_image,
|
||||
)
|
||||
from ..shared.logging import get_logger
|
||||
from .segs import (
|
||||
BoundingBox,
|
||||
@@ -23,6 +18,11 @@ from .segs import (
|
||||
NativeSegs,
|
||||
Segment,
|
||||
)
|
||||
from .segs_mask_ops import (
|
||||
crop_region_for_bbox,
|
||||
resize_mask,
|
||||
validate_single_image,
|
||||
)
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Own inbound ComfyUI integration and transport composition."""
|
||||
+1
-1
@@ -14,9 +14,9 @@ from typing import Any, Protocol, cast
|
||||
from aiohttp import web
|
||||
|
||||
from ..domain.external_llm import ExternalLLMConfigError, ExternalLLMProviderError
|
||||
from ..runtime.external_llm_keyring import ExternalLLMKeyringError
|
||||
from ..services.external_llm_prompt_service import ExternalLLMPromptService
|
||||
from ..shared.logging import get_logger
|
||||
from .external_llm_keyring import ExternalLLMKeyringError
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"
|
||||
+1
-1
@@ -12,13 +12,13 @@ from typing import Any, Protocol, cast
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..runtime.quant_cache_settings import SettingsQuantCacheLimitProvider
|
||||
from ..services.quant_cache_service import (
|
||||
QuantCacheEvictionResult,
|
||||
QuantCacheService,
|
||||
QuantCacheStatus,
|
||||
)
|
||||
from ..services.quantized_model_boundaries import QuantCacheLimitProvider
|
||||
from .quant_cache_settings import SettingsQuantCacheLimitProvider
|
||||
|
||||
QUANT_CACHE_ROUTE = "/simple-syrup/quant-cache"
|
||||
Handler = Callable[[Any], Coroutine[Any, Any, web.Response]]
|
||||
+3
-3
@@ -12,12 +12,12 @@ from typing import Any, Protocol, cast
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
from .settings import (
|
||||
from ..runtime.settings import (
|
||||
SimpleSyrupSettings,
|
||||
SimpleSyrupSettingsError,
|
||||
)
|
||||
from .settings_repository import SimpleSyrupSettingsRepository
|
||||
from ..runtime.settings_repository import SimpleSyrupSettingsRepository
|
||||
from ..shared.logging import get_logger
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
SETTINGS_ROUTE = "/simple-syrup/settings"
|
||||
@@ -15,8 +15,7 @@ from ..domain.segs import (
|
||||
NativeSegs,
|
||||
Segment,
|
||||
)
|
||||
from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask
|
||||
from ..masking.segs_mask_ops import (
|
||||
from ..domain.segs_mask_ops import (
|
||||
crop_image,
|
||||
crop_mask,
|
||||
crop_region_for_bbox,
|
||||
@@ -24,6 +23,7 @@ from ..masking.segs_mask_ops import (
|
||||
normalize_mask,
|
||||
validate_single_image,
|
||||
)
|
||||
from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask
|
||||
from ..runtime.sam_segmenter import SAMBoxSegmenter, SAMModelSegmenter
|
||||
from ..runtime.text_box_detector import (
|
||||
GroundingDINOTextBoxDetector,
|
||||
|
||||
@@ -21,8 +21,8 @@ from ..domain.regional_detailing import (
|
||||
SegmentConditioningPair,
|
||||
)
|
||||
from ..domain.segs import CropRegion
|
||||
from ..domain.segs_mask_ops import feather_mask, resize_mask
|
||||
from .detailer_masks import gaussian_feather_mask
|
||||
from .segs_mask_ops import feather_mask, resize_mask
|
||||
|
||||
OPERATION = "Detail SEGS as Regions"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, ClassVar
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.segs import coerce_segs_group
|
||||
from ..nodes import tooltips
|
||||
from ..nodes.detailer_input_adapters import (
|
||||
@@ -64,10 +65,6 @@ class DetailSEGSAsRegions:
|
||||
"image": ("IMAGE", {"tooltip": tooltips.DETAIL_IMAGE}),
|
||||
"model": ("MODEL", {"tooltip": tooltips.DETAIL_MODEL}),
|
||||
"vae": ("VAE", {"tooltip": tooltips.DETAIL_VAE}),
|
||||
"negative": (
|
||||
"CONDITIONING",
|
||||
{"tooltip": tooltips.REGIONAL_GLOBAL_NEGATIVE},
|
||||
),
|
||||
"positive": (
|
||||
"CONDITIONING",
|
||||
{"tooltip": tooltips.REGIONAL_GLOBAL_POSITIVE},
|
||||
@@ -187,7 +184,13 @@ class DetailSEGSAsRegions:
|
||||
"tooltip": tooltips.DETAIL_TILED_DECODE,
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"negative": (
|
||||
"CONDITIONING",
|
||||
{"tooltip": tooltips.REGIONAL_GLOBAL_NEGATIVE},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def detail(
|
||||
@@ -195,24 +198,25 @@ class DetailSEGSAsRegions:
|
||||
image: object,
|
||||
model: Any,
|
||||
vae: Any,
|
||||
negative: Any,
|
||||
positive: Any,
|
||||
segs: object,
|
||||
region_positive: object,
|
||||
global_prompt_weight: object,
|
||||
scale_factor: object,
|
||||
upscale_method: object,
|
||||
seed: object,
|
||||
steps: object,
|
||||
cfg: object,
|
||||
sampler_name: object,
|
||||
scheduler: object,
|
||||
denoise: object,
|
||||
feather: object,
|
||||
noise_mask: object,
|
||||
noise_mask_feather: object,
|
||||
tiled_encode: object,
|
||||
tiled_decode: object,
|
||||
negative: Any | None = None,
|
||||
positive: Any = None,
|
||||
segs: object = None,
|
||||
region_positive: object = None,
|
||||
global_prompt_weight: object = 0.25,
|
||||
scale_factor: object = 1.0,
|
||||
upscale_method: object = "lanczos",
|
||||
seed: object = 0,
|
||||
steps: object = 20,
|
||||
cfg: object = 8.0,
|
||||
sampler_name: object = "euler",
|
||||
scheduler: object = "normal",
|
||||
denoise: object = 0.5,
|
||||
feather: object = 5,
|
||||
noise_mask: object = True,
|
||||
noise_mask_feather: object = 20,
|
||||
tiled_encode: object = False,
|
||||
tiled_decode: object = False,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
) -> tuple[object]:
|
||||
"""Run regional detailing and return the detailed image."""
|
||||
|
||||
@@ -235,6 +239,7 @@ class DetailSEGSAsRegions:
|
||||
strict=True,
|
||||
):
|
||||
result = service.detail(
|
||||
noise_inversion=noise_inversion,
|
||||
image=single_image,
|
||||
segs=single_segs,
|
||||
model=single_input(model, "model", list_mode, OPERATION),
|
||||
|
||||
@@ -61,10 +61,6 @@ class DetailSEGSByScaleFactor:
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.DETAIL_POSITIVE},
|
||||
),
|
||||
"negative": (
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.DETAIL_NEGATIVE},
|
||||
),
|
||||
"scale_factor": (
|
||||
"FLOAT",
|
||||
scale_factor_options(default=1.5),
|
||||
@@ -171,7 +167,13 @@ class DetailSEGSByScaleFactor:
|
||||
"tooltip": tooltips.DETAIL_TILED_DECODE,
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"negative": (
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.DETAIL_NEGATIVE},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def detail(
|
||||
@@ -181,21 +183,21 @@ class DetailSEGSByScaleFactor:
|
||||
model: Any,
|
||||
vae: Any,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
scale_factor: object,
|
||||
upscale_method: object,
|
||||
clamp_size: object,
|
||||
seed: object,
|
||||
steps: object,
|
||||
cfg: object,
|
||||
sampler_name: object,
|
||||
scheduler: object,
|
||||
denoise: object,
|
||||
feather: object,
|
||||
noise_mask: object,
|
||||
noise_mask_feather: object,
|
||||
tiled_encode: object,
|
||||
tiled_decode: object,
|
||||
negative: Any | None = None,
|
||||
scale_factor: object = 1.5,
|
||||
upscale_method: object = "lanczos",
|
||||
clamp_size: object = 0,
|
||||
seed: object = 0,
|
||||
steps: object = 20,
|
||||
cfg: object = 8.0,
|
||||
sampler_name: object = "euler",
|
||||
scheduler: object = "normal",
|
||||
denoise: object = 0.5,
|
||||
feather: object = 5,
|
||||
noise_mask: object = True,
|
||||
noise_mask_feather: object = 20,
|
||||
tiled_encode: object = False,
|
||||
tiled_decode: object = False,
|
||||
) -> tuple[object]:
|
||||
"""Run scale-factor detailing and return the detailed image."""
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, ClassVar
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.segs import coerce_segs_group
|
||||
from ..domain.tiled_diffusion import TILED_DIFFUSION_MODES
|
||||
from ..nodes import tooltips
|
||||
@@ -70,10 +71,6 @@ class DetailSEGSByScaleFactorTiledDiffusion:
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.DETAIL_POSITIVE},
|
||||
),
|
||||
"negative": (
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.DETAIL_NEGATIVE},
|
||||
),
|
||||
"scale_factor": (
|
||||
"FLOAT",
|
||||
scale_factor_options(default=1.5),
|
||||
@@ -231,7 +228,13 @@ class DetailSEGSByScaleFactorTiledDiffusion:
|
||||
"tooltip": tooltips.LATENT_TILE_BATCH_SIZE,
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"negative": (
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.DETAIL_NEGATIVE},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def detail(
|
||||
@@ -241,26 +244,27 @@ class DetailSEGSByScaleFactorTiledDiffusion:
|
||||
model: Any,
|
||||
vae: Any,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
scale_factor: object,
|
||||
upscale_method: object,
|
||||
clamp_size: object,
|
||||
seed: object,
|
||||
steps: object,
|
||||
cfg: object,
|
||||
sampler_name: object,
|
||||
scheduler: object,
|
||||
denoise: object,
|
||||
feather: object,
|
||||
noise_mask: object,
|
||||
noise_mask_feather: object,
|
||||
tiled_encode: object,
|
||||
tiled_decode: object,
|
||||
diffusion_mode: object,
|
||||
latent_tile_width: object,
|
||||
latent_tile_height: object,
|
||||
latent_tile_overlap: object,
|
||||
latent_tile_batch_size: object,
|
||||
negative: Any | None = None,
|
||||
scale_factor: object = 1.5,
|
||||
upscale_method: object = "lanczos",
|
||||
clamp_size: object = 0,
|
||||
seed: object = 0,
|
||||
steps: object = 20,
|
||||
cfg: object = 8.0,
|
||||
sampler_name: object = "euler",
|
||||
scheduler: object = "normal",
|
||||
denoise: object = 0.5,
|
||||
feather: object = 5,
|
||||
noise_mask: object = True,
|
||||
noise_mask_feather: object = 20,
|
||||
tiled_encode: object = False,
|
||||
tiled_decode: object = False,
|
||||
diffusion_mode: object = "multidiffusion",
|
||||
latent_tile_width: object = 128,
|
||||
latent_tile_height: object = 128,
|
||||
latent_tile_overlap: object = 16,
|
||||
latent_tile_batch_size: object = 4,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
) -> tuple[object]:
|
||||
"""Run tiled diffusion scale-factor detailing and return the image."""
|
||||
|
||||
@@ -273,6 +277,7 @@ class DetailSEGSByScaleFactorTiledDiffusion:
|
||||
outputs: list[torch.Tensor] = []
|
||||
for single_image, single_segs in zip(images, segs_group, strict=True):
|
||||
result = service.detail(
|
||||
noise_inversion=noise_inversion,
|
||||
image=single_image,
|
||||
segs=single_segs,
|
||||
model=single_input(model, "model", list_mode, OPERATION),
|
||||
|
||||
@@ -12,7 +12,7 @@ import torch
|
||||
|
||||
from ..domain.conditioning_batch import ConditioningBatch
|
||||
from ..domain.segs import NativeSegs
|
||||
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
|
||||
|
||||
def image_inputs(image: object, operation_name: str) -> tuple[torch.Tensor, ...]:
|
||||
|
||||
@@ -16,8 +16,8 @@ from ..domain.segs import (
|
||||
SORT_ORDER_OPTIONS,
|
||||
NativeSegs,
|
||||
)
|
||||
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..runtime.ultralytics_loader import UltralyticsDetectorModel
|
||||
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..runtime.ultralytics_model_adapter import UltralyticsDetectorModel
|
||||
from ..services.segs_detection_service import (
|
||||
SegsDetectionService,
|
||||
)
|
||||
|
||||
@@ -78,10 +78,6 @@ class KSamplerExtras:
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.POSITIVE_CONDITIONING},
|
||||
),
|
||||
"negative": (
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.NEGATIVE_CONDITIONING},
|
||||
),
|
||||
"latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}),
|
||||
"denoise": (
|
||||
"FLOAT",
|
||||
@@ -93,7 +89,13 @@ class KSamplerExtras:
|
||||
"tooltip": tooltips.DENOISE_STRENGTH,
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"negative": (
|
||||
"CONDITIONING,CONDITIONING_BATCH",
|
||||
{"tooltip": tooltips.NEGATIVE_CONDITIONING},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def sample(
|
||||
@@ -105,12 +107,14 @@ class KSamplerExtras:
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
latent_image: Latent,
|
||||
negative: Any | None = None,
|
||||
latent_image: Latent | None = None,
|
||||
denoise: float = 1.0,
|
||||
) -> tuple[Latent]:
|
||||
"""Sample a latent with ComfyUI samplers and extra scheduler sigmas."""
|
||||
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Extras requires a latent_image input.")
|
||||
output = self.service_class().sample(
|
||||
model=model,
|
||||
seed=seed,
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..runtime.model_downloads import ComfyProgressReporter
|
||||
from ..runtime.ultralytics_loader import UltralyticsLoaderService
|
||||
from ..services.ultralytics_loader_service import UltralyticsLoaderService
|
||||
|
||||
|
||||
class LoadUltralyticsModel:
|
||||
|
||||
@@ -16,9 +16,9 @@ from ..domain.segs import (
|
||||
SORT_ORDER_OPTIONS,
|
||||
NativeSegs,
|
||||
)
|
||||
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..masking.mask_ops import DETAIL_METHODS
|
||||
from ..masking.prompt_segs_with_sam_service import PromptSEGSWithSAMService
|
||||
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..services.segs_output_service import (
|
||||
CombinedSegsResult,
|
||||
build_combined_segs_result,
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..runtime.prompt_control_schedule_encode_graph import (
|
||||
from ..services.prompt_control_schedule_encode_graph import (
|
||||
PromptControlScheduleEncodeGraphBuilder,
|
||||
)
|
||||
|
||||
@@ -74,20 +74,21 @@ class ScheduleAndEncodePromptsWithPromptControl:
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"negative_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": (
|
||||
"Negative Prompt-Control text; [SEP] or [SEP|name] "
|
||||
"creates ordered entries, and global text fills missing "
|
||||
"negative regions."
|
||||
"Optional negative Prompt-Control text; [SEP] or "
|
||||
"[SEP|name] creates ordered entries, and global text "
|
||||
"fills missing negative regions. Leave disconnected "
|
||||
"to encode an empty negative prompt."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"encode_style": (
|
||||
"STRING",
|
||||
{
|
||||
@@ -107,7 +108,7 @@ class ScheduleAndEncodePromptsWithPromptControl:
|
||||
model: Any,
|
||||
clip: Any,
|
||||
positive_prompt: str,
|
||||
negative_prompt: str,
|
||||
negative_prompt: str = "",
|
||||
encode_style: str = "",
|
||||
) -> Any:
|
||||
"""Build lazy Prompt-Control graph expansion for prompts."""
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any, ClassVar
|
||||
|
||||
import torch
|
||||
|
||||
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..runtime.progress import PhaseProgressReporter, create_comfy_phase_progress
|
||||
from ..services.segs_from_sam_output_service import SEGSFromSAMOutputService
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ SCHEDULER = (
|
||||
)
|
||||
POSITIVE_CONDITIONING = "Positive conditioning that guides what the sampler should add."
|
||||
NEGATIVE_CONDITIONING = (
|
||||
"Negative conditioning that guides what the sampler should avoid."
|
||||
"Optional conditioning that guides what the sampler should avoid. Leave "
|
||||
"disconnected for positive-only sampling without CFG."
|
||||
)
|
||||
LATENT_IMAGE = "Latent input whose samples will be denoised."
|
||||
DENOISE_STRENGTH = (
|
||||
@@ -226,8 +227,8 @@ DETAIL_POSITIVE = (
|
||||
"order."
|
||||
)
|
||||
DETAIL_NEGATIVE = (
|
||||
"Negative conditioning for detailing. A conditioning batch is matched to SEGS "
|
||||
"order."
|
||||
"Optional negative conditioning for detailing; leave disconnected for "
|
||||
"positive-only sampling. A conditioning batch is matched to SEGS order."
|
||||
)
|
||||
DETAIL_SCALE_FACTOR = (
|
||||
"Crop enlargement multiplier. Larger values give the sampler more detail room "
|
||||
@@ -266,7 +267,8 @@ DETAIL_IMAGE_OUTPUT = "Image with the detailed regions blended back into place."
|
||||
SCALE_FACTOR_OUTPUT = "Multiplier used to scale a connected target."
|
||||
|
||||
REGIONAL_GLOBAL_NEGATIVE = (
|
||||
"Negative conditioning applied across the full regional pass."
|
||||
"Optional negative conditioning applied across the full regional pass; leave "
|
||||
"disconnected for positive-only sampling."
|
||||
)
|
||||
REGIONAL_GLOBAL_POSITIVE = (
|
||||
"Positive conditioning that gives full-image context to the regional pass."
|
||||
|
||||
@@ -14,13 +14,16 @@ def get_nodes() -> list[type[object]]:
|
||||
|
||||
from .all_prompt_attention_segs import AllPromptAttentionSEGSV3
|
||||
from .attention_capture_model import AttentionCaptureModelV3
|
||||
from .attention_coupling_options import AttentionCouplingOptionsV3
|
||||
from .attention_masked_conditioning import AttentionMaskedConditioningV3
|
||||
from .attention_region_mask import AttentionRegionMaskV3
|
||||
from .batch_region_conditioning import BatchRegionConditioningV3
|
||||
from .batch_segs import BatchSEGSV3
|
||||
from .compose_regional_conditioning import ComposeRegionalConditioningV3
|
||||
from .concept_attention_segs import ConceptAttentionSEGSV3
|
||||
from .contextual_diffusion_options import ContextualDiffusionOptionsV3
|
||||
from .external_llm_prompt import ExternalLLMPromptV3
|
||||
from .ksampler import KSamplerV3
|
||||
from .ksampler_attention_coupling import KSamplerAttentionCouplingV3
|
||||
from .ksampler_contextual_attention_coupling import (
|
||||
KSamplerContextualAttentionCouplingV3,
|
||||
@@ -62,6 +65,7 @@ def get_nodes() -> list[type[object]]:
|
||||
from .load_image_list import LoadImageListV3
|
||||
from .load_mask_batch import LoadMaskBatchV3
|
||||
from .mask_to_segs import MaskToSEGSV3
|
||||
from .noise_inversion_options import NoiseInversionOptionsV3
|
||||
from .scale_factor import ScaleFactorV3
|
||||
from .seed_variation import SeedVariationV3
|
||||
from .simple_load_checkpoint import SimpleLoadCheckpointV3
|
||||
@@ -71,12 +75,18 @@ def get_nodes() -> list[type[object]]:
|
||||
from .tag_segs_with_external_llm import TagSEGSWithExternalLLMV3
|
||||
from .tag_segs_with_wd14 import TagSEGSWithWD14V3
|
||||
from .tile_and_tag_segs import TileAndTagSEGSV3
|
||||
from .tiling_options import TilingOptionsV3
|
||||
from .vae_decode_options import VAEDecodeOptionsV3
|
||||
from .vae_encode_options import VAEEncodeOptionsV3
|
||||
from .wd14_tagger_loader import WD14TaggerLoaderV3
|
||||
|
||||
nodes: list[type[object]] = [
|
||||
AllPromptAttentionSEGSV3,
|
||||
AttentionCouplingOptionsV3,
|
||||
ContextualDiffusionOptionsV3,
|
||||
NoiseInversionOptionsV3,
|
||||
TilingOptionsV3,
|
||||
KSamplerV3,
|
||||
AttentionCaptureModelV3,
|
||||
AttentionMaskedConditioningV3,
|
||||
AttentionRegionMaskV3,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Configure regional attention without applying MODEL patches in the options graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..domain.sampler_options import (
|
||||
AttentionCouplingOptions,
|
||||
SamplerOptions,
|
||||
append_sampler_capability,
|
||||
)
|
||||
from .ksampler_schema import attention_coupling_ksampler_inputs
|
||||
from .sampler_options_schema import (
|
||||
COMFY_IO,
|
||||
OptionsNodeBase,
|
||||
options_input,
|
||||
options_output,
|
||||
)
|
||||
|
||||
|
||||
class AttentionCouplingOptionsV3(OptionsNodeBase):
|
||||
"""Configure the sampler's regional attention strength and mask feathering."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Expose strength and feathering without binding region payloads."""
|
||||
controls = attention_coupling_ksampler_inputs(COMFY_IO)
|
||||
return COMFY_IO.Schema(
|
||||
node_id="SimpleSyrup.AttentionCouplingOptions",
|
||||
display_name="Attention Coupling Options",
|
||||
category="SimpleSyrup/Sampling/Options",
|
||||
description=(
|
||||
"Routes global-first sampler conditioning to ordered image "
|
||||
"regions through regional attention and LoRA hooks."
|
||||
),
|
||||
inputs=[
|
||||
options_input(COMFY_IO),
|
||||
*[
|
||||
control
|
||||
for control in controls
|
||||
if control.id in {"regional_prompt_weight", "region_mask_feather"}
|
||||
],
|
||||
],
|
||||
outputs=[options_output(COMFY_IO)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
regional_prompt_weight: float = 1.0,
|
||||
region_mask_feather: int = 0,
|
||||
options: SamplerOptions | None = None,
|
||||
) -> tuple[SamplerOptions]:
|
||||
"""Append regional attention controls while deferring model preparation."""
|
||||
return (
|
||||
append_sampler_capability(
|
||||
options,
|
||||
AttentionCouplingOptions(
|
||||
regional_prompt_weight=regional_prompt_weight,
|
||||
region_mask_feather=region_mask_feather,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Configure complete contextual sampling with one square local context plan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..domain.sampler_options import (
|
||||
ContextualDiffusionOptions,
|
||||
SamplerOptions,
|
||||
append_sampler_capability,
|
||||
)
|
||||
from .ksampler_schema import contextual_diffusion_inputs
|
||||
from .sampler_options_schema import (
|
||||
COMFY_IO,
|
||||
OptionsNodeBase,
|
||||
options_input,
|
||||
options_output,
|
||||
)
|
||||
|
||||
|
||||
class ContextualDiffusionOptionsV3(OptionsNodeBase):
|
||||
"""Schedule global scene authority over one local tile prediction."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Append local controls after existing widgets to preserve saved values."""
|
||||
controls = {
|
||||
control.id: control for control in contextual_diffusion_inputs(COMFY_IO)
|
||||
}
|
||||
return COMFY_IO.Schema(
|
||||
node_id="SimpleSyrup.ContextualDiffusionOptions",
|
||||
display_name="Contextual Diffusion Options",
|
||||
category="SimpleSyrup/Sampling/Options",
|
||||
description=(
|
||||
"Samples local contexts with global scene guidance; "
|
||||
"takes precedence over connected Tiling Options."
|
||||
),
|
||||
inputs=[
|
||||
options_input(COMFY_IO),
|
||||
controls["latent_context_size"],
|
||||
controls["global_weight"],
|
||||
controls["global_steps"],
|
||||
controls["global_decay"],
|
||||
controls["diffusion_mode"],
|
||||
controls["latent_context_overlap"],
|
||||
controls["latent_context_batch_size"],
|
||||
COMFY_IO.Boolean.Input(
|
||||
"differential_diffusion",
|
||||
default=False,
|
||||
tooltip=(
|
||||
"Uses the noise mask to vary denoising strength spatially; "
|
||||
"preserves existing model mask behavior."
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[options_output(COMFY_IO)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
latent_context_size: int = 96,
|
||||
global_weight: float = 1.0,
|
||||
global_steps: int = 1,
|
||||
global_decay: float = 0.5,
|
||||
options: SamplerOptions | None = None,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_context_overlap: int = 32,
|
||||
latent_context_batch_size: int = 4,
|
||||
differential_diffusion: bool = False,
|
||||
) -> tuple[SamplerOptions]:
|
||||
"""Append complete context settings without inheriting a Tiling contribution."""
|
||||
return (
|
||||
append_sampler_capability(
|
||||
options,
|
||||
ContextualDiffusionOptions(
|
||||
context_size=latent_context_size,
|
||||
global_weight=global_weight,
|
||||
global_steps=global_steps,
|
||||
global_decay=global_decay,
|
||||
diffusion_mode=diffusion_mode,
|
||||
overlap=latent_context_overlap,
|
||||
batch_size=latent_context_batch_size,
|
||||
differential_diffusion=differential_diffusion,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -10,7 +10,7 @@ from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR
|
||||
from ..runtime.prompt_control_batch_graph import PromptControlBatchGraphBuilder
|
||||
from ..services.prompt_control_batch_graph import PromptControlBatchGraphBuilder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Expose one native KSampler consuming a composable capability configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..domain.sampler_options import SamplerOptions
|
||||
from ..nodes import tooltips
|
||||
from ..services.sampler_options_sampling_service import SamplerOptionsSamplingService
|
||||
from .ksampler_schema import ksampler_inputs
|
||||
from .sampler_options_schema import COMFY_IO, OptionsNodeBase, options_input
|
||||
|
||||
|
||||
class KSamplerV3(OptionsNodeBase):
|
||||
"""Execute tiling, context, inversion and attention through shared authorities."""
|
||||
|
||||
service_class: ClassVar[type[SamplerOptionsSamplingService]] = (
|
||||
SamplerOptionsSamplingService
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Declare sampling controls, capabilities and optional spatial regions."""
|
||||
return COMFY_IO.Schema(
|
||||
node_id="SimpleSyrup.KSampler",
|
||||
display_name="KSampler (SimpleSyrup)",
|
||||
category="SimpleSyrup/Sampling",
|
||||
description=(
|
||||
"Samples latents with connected sampler options for tiling, "
|
||||
"Contextual Diffusion, noise inversion and Attention Coupling."
|
||||
),
|
||||
inputs=[
|
||||
*ksampler_inputs(COMFY_IO, steps_default=20, cfg_default=8.0),
|
||||
options_input(COMFY_IO),
|
||||
COMFY_IO.SEGS.Input(
|
||||
"segs",
|
||||
optional=True,
|
||||
tooltip=(
|
||||
"Guides local sampling regions when Tiling or Contextual "
|
||||
"Diffusion options are connected; ignored otherwise."
|
||||
),
|
||||
),
|
||||
COMFY_IO.Mask.Input(
|
||||
"region_masks",
|
||||
optional=True,
|
||||
tooltip=(
|
||||
"Ordered masks paired with global-first conditioning batches "
|
||||
"when Attention Coupling options are connected; "
|
||||
"ignored otherwise."
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
COMFY_IO.Latent.Output(
|
||||
"latent", tooltip=tooltips.DENOISED_LATENT_OUTPUT
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
model: Any,
|
||||
seed: int,
|
||||
steps: int,
|
||||
cfg: float,
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: object,
|
||||
negative: object | None = None,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
options: SamplerOptions | None = None,
|
||||
segs: object | None = None,
|
||||
region_masks: object | None = None,
|
||||
) -> tuple[dict[str, Any]]:
|
||||
"""Delegate sampling without mutating capability configuration."""
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler requires latent_image.")
|
||||
return (
|
||||
cls.service_class().sample(
|
||||
model=model,
|
||||
seed=seed,
|
||||
steps=steps,
|
||||
cfg=cfg,
|
||||
sampler_name=sampler_name,
|
||||
scheduler=scheduler,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_image,
|
||||
denoise=denoise,
|
||||
options=options,
|
||||
segs=segs,
|
||||
region_masks=region_masks,
|
||||
),
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from .ksampler_schema import (
|
||||
ATTENTION_COUPLING_REGIONAL_PROMPT_WEIGHT_DEFAULT,
|
||||
attention_coupling_ksampler_inputs,
|
||||
)
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -68,10 +69,13 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
|
||||
"anima regional prompt",
|
||||
"sdxl regional prompt",
|
||||
],
|
||||
inputs=attention_coupling_ksampler_inputs(
|
||||
_comfy_io,
|
||||
region_masks_optional=True,
|
||||
),
|
||||
inputs=[
|
||||
*attention_coupling_ksampler_inputs(
|
||||
_comfy_io,
|
||||
region_masks_optional=True,
|
||||
),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
"latent",
|
||||
@@ -90,18 +94,32 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: object,
|
||||
negative: object,
|
||||
latent_image: dict[str, Any],
|
||||
denoise: float,
|
||||
negative: object | None = None,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = (
|
||||
ATTENTION_COUPLING_REGIONAL_PROMPT_WEIGHT_DEFAULT
|
||||
),
|
||||
region_mask_feather: int = 0,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> tuple[dict[str, Any]]:
|
||||
"""Delegate ordinary or regional sampling to the routing service."""
|
||||
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Attention Coupling requires latent_image.")
|
||||
output = cls.sampling_service_class().sample(
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
model=model,
|
||||
seed=seed,
|
||||
steps=steps,
|
||||
|
||||
@@ -17,6 +17,7 @@ from .ksampler_schema import (
|
||||
attention_coupling_ksampler_inputs,
|
||||
contextual_diffusion_inputs,
|
||||
)
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -75,6 +76,7 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
|
||||
optional=True,
|
||||
tooltip=tooltips.CONTEXTUAL_DIFFUSION_SEGS,
|
||||
),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
@@ -98,11 +100,11 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: object,
|
||||
negative: object,
|
||||
region_masks: object,
|
||||
regional_prompt_weight: float,
|
||||
region_mask_feather: int,
|
||||
latent_image: dict[str, Any],
|
||||
negative: object | None = None,
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = 1.0,
|
||||
region_mask_feather: int = 0,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_context_size: int = 96,
|
||||
@@ -112,9 +114,22 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
|
||||
global_steps: int = 1,
|
||||
global_decay: float = 0.5,
|
||||
segs: object | None = None,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> tuple[dict[str, Any], object]:
|
||||
"""Delegate the complete request to the combined application service."""
|
||||
|
||||
if latent_image is None:
|
||||
raise TypeError(
|
||||
"KSampler Contextual Attention Coupling requires latent_image."
|
||||
)
|
||||
if region_masks is None:
|
||||
raise TypeError(
|
||||
"KSampler Contextual Attention Coupling requires region_masks."
|
||||
)
|
||||
result = cls.sampling_service_class().sample(
|
||||
model=model,
|
||||
seed=seed,
|
||||
@@ -137,5 +152,12 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
|
||||
global_steps=global_steps,
|
||||
global_decay=global_decay,
|
||||
segs=segs,
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
)
|
||||
return result.latent, result.contexts
|
||||
|
||||
@@ -18,6 +18,7 @@ from .ksampler_schema import (
|
||||
ksampler_inputs,
|
||||
optional_regional_sampling_inputs,
|
||||
)
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -67,6 +68,7 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
|
||||
_comfy_io,
|
||||
segs_tooltip=tooltips.CONTEXTUAL_DIFFUSION_SEGS,
|
||||
),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
@@ -90,8 +92,8 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
latent_image: dict[str, Any],
|
||||
negative: Any | None = None,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_context_size: int = 96,
|
||||
@@ -104,9 +106,16 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = 0.5,
|
||||
region_mask_feather: int = 0,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> tuple[dict[str, Any], object]:
|
||||
"""Delegate Contextual Diffusion sampling to its application service."""
|
||||
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Contextual Diffusion requires latent_image.")
|
||||
result = cls.service_class().sample(
|
||||
model=model,
|
||||
seed=seed,
|
||||
@@ -129,5 +138,12 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
|
||||
region_masks=region_masks,
|
||||
regional_prompt_weight=regional_prompt_weight,
|
||||
region_mask_feather=region_mask_feather,
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
)
|
||||
return result.latent, result.contexts
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..nodes import tooltips
|
||||
from ..services.ksampler_sampling_service import KSamplerSamplingService
|
||||
from ..services.regional_conditioning_service import RegionalConditioningService
|
||||
from .ksampler_schema import regional_ksampler_inputs
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -51,7 +52,10 @@ class KSamplerPromptByRegionV3(_ComfyNodeBase):
|
||||
"mask-bound regional prompts."
|
||||
),
|
||||
search_aliases=["ksampler", "regional prompt", "masked prompt"],
|
||||
inputs=regional_ksampler_inputs(_comfy_io),
|
||||
inputs=[
|
||||
*regional_ksampler_inputs(_comfy_io),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
"latent",
|
||||
@@ -70,15 +74,24 @@ class KSamplerPromptByRegionV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: object,
|
||||
negative: object,
|
||||
region_masks: object,
|
||||
regional_prompt_weight: float,
|
||||
region_mask_feather: int,
|
||||
latent_image: dict[str, Any],
|
||||
denoise: float,
|
||||
negative: object | None = None,
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = 0.5,
|
||||
region_mask_feather: int = 0,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> tuple[dict[str, Any]]:
|
||||
"""Assemble regional conditioning and sample the full latent."""
|
||||
|
||||
if region_masks is None:
|
||||
raise TypeError("KSampler Prompt by Region requires region_masks.")
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Prompt by Region requires latent_image.")
|
||||
assembled_positive, assembled_negative = (
|
||||
cls.conditioning_service_class().assemble(
|
||||
positive=positive,
|
||||
@@ -89,6 +102,13 @@ class KSamplerPromptByRegionV3(_ComfyNodeBase):
|
||||
)
|
||||
)
|
||||
output = cls.sampling_service_class().sample(
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
model=model,
|
||||
seed=seed,
|
||||
steps=steps,
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..nodes import tooltips
|
||||
from ..services.regional_conditioning_service import RegionalConditioningService
|
||||
from ..services.tiled_diffusion_sampling_service import TiledDiffusionSamplingService
|
||||
from .ksampler_schema import regional_ksampler_inputs, tiled_diffusion_inputs
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -60,6 +61,7 @@ class KSamplerPromptByTiledRegionV3(_ComfyNodeBase):
|
||||
inputs=[
|
||||
*regional_ksampler_inputs(_comfy_io),
|
||||
*tiled_diffusion_inputs(_comfy_io),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
@@ -79,20 +81,29 @@ class KSamplerPromptByTiledRegionV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: object,
|
||||
negative: object,
|
||||
region_masks: object,
|
||||
regional_prompt_weight: float,
|
||||
region_mask_feather: int,
|
||||
latent_image: dict[str, Any],
|
||||
denoise: float,
|
||||
diffusion_mode: str,
|
||||
latent_tile_width: int,
|
||||
latent_tile_height: int,
|
||||
latent_tile_overlap: int,
|
||||
latent_tile_batch_size: int,
|
||||
negative: object | None = None,
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = 0.5,
|
||||
region_mask_feather: int = 0,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_tile_width: int = 128,
|
||||
latent_tile_height: int = 128,
|
||||
latent_tile_overlap: int = 16,
|
||||
latent_tile_batch_size: int = 4,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> tuple[dict[str, Any]]:
|
||||
"""Assemble regional conditioning and sample overlapping latent tiles."""
|
||||
|
||||
if region_masks is None:
|
||||
raise TypeError("KSampler Prompt by Tiled Region requires region_masks.")
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Prompt by Tiled Region requires latent_image.")
|
||||
assembled_positive, assembled_negative = (
|
||||
cls.conditioning_service_class().assemble(
|
||||
positive=positive,
|
||||
@@ -103,6 +114,13 @@ class KSamplerPromptByTiledRegionV3(_ComfyNodeBase):
|
||||
)
|
||||
)
|
||||
output = cls.sampling_service_class().sample(
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
diffusion_mode=diffusion_mode,
|
||||
model=model,
|
||||
seed=seed,
|
||||
|
||||
@@ -64,7 +64,11 @@ def ksampler_inputs(
|
||||
tooltip=tooltips.SCHEDULER,
|
||||
),
|
||||
conditioning.Input("positive", tooltip=tooltips.POSITIVE_CONDITIONING),
|
||||
conditioning.Input("negative", tooltip=tooltips.NEGATIVE_CONDITIONING),
|
||||
conditioning.Input(
|
||||
"negative",
|
||||
optional=True,
|
||||
tooltip=tooltips.NEGATIVE_CONDITIONING,
|
||||
),
|
||||
comfy_io.Latent.Input("latent_image", tooltip=tooltips.LATENT_IMAGE),
|
||||
comfy_io.Float.Input(
|
||||
"denoise",
|
||||
@@ -266,8 +270,10 @@ def attention_coupling_ksampler_inputs(
|
||||
comfy_io.MultiType.Input(
|
||||
"negative",
|
||||
[comfy_io.Conditioning, conditioning_batch],
|
||||
optional=True,
|
||||
tooltip=(
|
||||
"Global-first negative conditioning aligned to the same masks; "
|
||||
"Optional global-first negative conditioning aligned to the same "
|
||||
"masks; leave disconnected for positive-only sampling. When used, "
|
||||
"its global model hooks must match the positive global entry. "
|
||||
"Regional LoRA hooks retain their negative-branch ownership and "
|
||||
"independent schedules."
|
||||
@@ -327,9 +333,11 @@ def regional_conditioning_inputs(comfy_io: Any) -> list[Any]:
|
||||
comfy_io.MultiType.Input(
|
||||
"negative",
|
||||
[comfy_io.Conditioning, conditioning_batch],
|
||||
optional=True,
|
||||
tooltip=(
|
||||
"Negative conditioning whose first batch entry is global and "
|
||||
"later entries pair with masks in order."
|
||||
"Optional negative conditioning whose first batch entry is global "
|
||||
"and later entries pair with masks in order; leave disconnected "
|
||||
"for positive-only sampling."
|
||||
),
|
||||
),
|
||||
comfy_io.Mask.Input(
|
||||
|
||||
@@ -18,6 +18,7 @@ from .ksampler_schema import (
|
||||
attention_coupling_ksampler_inputs,
|
||||
tiled_diffusion_inputs,
|
||||
)
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -78,6 +79,7 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
|
||||
region_masks_optional=True,
|
||||
),
|
||||
*tiled_diffusion_inputs(_comfy_io),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
@@ -97,14 +99,19 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: object,
|
||||
negative: object,
|
||||
latent_image: dict[str, Any],
|
||||
negative: object | None = None,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_tile_width: int = 128,
|
||||
latent_tile_height: int = 128,
|
||||
latent_tile_overlap: int = 16,
|
||||
latent_tile_batch_size: int = 4,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = (
|
||||
ATTENTION_COUPLING_REGIONAL_PROMPT_WEIGHT_DEFAULT
|
||||
@@ -113,7 +120,16 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
|
||||
) -> tuple[dict[str, Any]]:
|
||||
"""Delegate ordinary or regional tiled sampling to the routing service."""
|
||||
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Tiled Attention Coupling requires latent_image.")
|
||||
output = cls.sampling_service_class().sample(
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
diffusion_mode=diffusion_mode,
|
||||
model=model,
|
||||
seed=seed,
|
||||
|
||||
@@ -16,6 +16,7 @@ from .ksampler_schema import (
|
||||
optional_regional_sampling_inputs,
|
||||
tiled_diffusion_inputs,
|
||||
)
|
||||
from .sampler_options_schema import inversion_from_controls, noise_inversion_inputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -65,6 +66,7 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
|
||||
"boundaries while preserving the configured overlap."
|
||||
),
|
||||
),
|
||||
*noise_inversion_inputs(_comfy_io, convenience=True),
|
||||
],
|
||||
outputs=[
|
||||
_comfy_io.Latent.Output(
|
||||
@@ -84,8 +86,8 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
latent_image: dict[str, Any],
|
||||
negative: Any | None = None,
|
||||
latent_image: dict[str, Any] | None = None,
|
||||
denoise: float = 1.0,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_tile_width: int = 128,
|
||||
@@ -96,9 +98,16 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
|
||||
region_masks: object | None = None,
|
||||
regional_prompt_weight: float = 0.5,
|
||||
region_mask_feather: int = 0,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> tuple[dict[str, Any]]:
|
||||
"""Delegate tiled diffusion sampling to its application service."""
|
||||
|
||||
if latent_image is None:
|
||||
raise TypeError("KSampler Tiled Diffusion requires latent_image.")
|
||||
output = cls.service_class().sample(
|
||||
diffusion_mode=diffusion_mode,
|
||||
model=model,
|
||||
@@ -120,5 +129,12 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
|
||||
region_masks=region_masks,
|
||||
regional_prompt_weight=regional_prompt_weight,
|
||||
region_mask_feather=region_mask_feather,
|
||||
noise_inversion=inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
),
|
||||
)
|
||||
return (output,)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Expose shared inversion controls on maintained implementation-backed samplers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..nodes.detailer_input_adapters import (
|
||||
float_input,
|
||||
int_input,
|
||||
str_input,
|
||||
)
|
||||
from .legacy_node_adapter import LegacyNodeV3Adapter
|
||||
from .sampler_options_schema import (
|
||||
COMFY_IO,
|
||||
inversion_from_controls,
|
||||
noise_inversion_inputs,
|
||||
)
|
||||
|
||||
|
||||
class LegacyInversionNodeV3Adapter(LegacyNodeV3Adapter):
|
||||
"""Normalize direct or list-mode widgets into the shared inversion domain value."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Append the shared five inversion controls after the sampler inputs."""
|
||||
schema = super().define_schema()
|
||||
schema.inputs.extend(noise_inversion_inputs(COMFY_IO, convenience=True))
|
||||
return schema
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs: object) -> Any:
|
||||
"""Narrow inversion widgets before delegating normal implementation inputs."""
|
||||
values = dict(kwargs)
|
||||
list_mode = bool(getattr(cls.LEGACY_NODE_CLASS, "INPUT_IS_LIST", False))
|
||||
operation = cls.DISPLAY_NAME
|
||||
inversion = inversion_from_controls(
|
||||
inversion_method=str_input(
|
||||
values.pop("inversion_method", "euler"),
|
||||
"inversion_method",
|
||||
list_mode,
|
||||
operation,
|
||||
),
|
||||
inversion_resolution_scale=float_input(
|
||||
values.pop("inversion_resolution_scale", 0.5),
|
||||
"inversion_resolution_scale",
|
||||
list_mode,
|
||||
operation,
|
||||
),
|
||||
inversion_steps=int_input(
|
||||
values.pop("inversion_steps", 2),
|
||||
"inversion_steps",
|
||||
list_mode,
|
||||
operation,
|
||||
),
|
||||
inversion_switch_fraction=float_input(
|
||||
values.pop("inversion_switch_fraction", 0.75),
|
||||
"inversion_switch_fraction",
|
||||
list_mode,
|
||||
operation,
|
||||
),
|
||||
inversion_finishing_steps=int_input(
|
||||
values.pop("inversion_finishing_steps", 1),
|
||||
"inversion_finishing_steps",
|
||||
list_mode,
|
||||
operation,
|
||||
),
|
||||
)
|
||||
values["noise_inversion"] = inversion
|
||||
return super().execute(**values)
|
||||
@@ -0,0 +1,275 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Translate maintained implementation contracts into native Comfy v3 schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class _ComfyNodeBase:
|
||||
"""Type-checking base for Comfy v3 nodes."""
|
||||
|
||||
hidden: ClassVar[Any]
|
||||
RETURN_TYPES: ClassVar[list[str]]
|
||||
RETURN_NAMES: ClassVar[list[str]]
|
||||
|
||||
else:
|
||||
_ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode
|
||||
|
||||
_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io
|
||||
|
||||
_HIDDEN_INPUTS = {
|
||||
"PROMPT": "prompt",
|
||||
"DYNPROMPT": "dynprompt",
|
||||
"EXTRA_PNGINFO": "extra_pnginfo",
|
||||
"UNIQUE_ID": "unique_id",
|
||||
}
|
||||
|
||||
|
||||
class LegacyNodeV3Adapter(_ComfyNodeBase):
|
||||
"""Build a v3 schema and execution bridge for a legacy implementation class."""
|
||||
|
||||
LEGACY_NODE_CLASS: ClassVar[type[Any]]
|
||||
NODE_ID: ClassVar[str]
|
||||
DISPLAY_NAME: ClassVar[str]
|
||||
ENABLE_EXPAND: ClassVar[bool] = False
|
||||
WORKFLOW_INPUT_ORDER: ClassVar[tuple[str, ...] | None] = None
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Declare a v3 schema from the implementation class contract."""
|
||||
|
||||
legacy = cls.LEGACY_NODE_CLASS
|
||||
return _comfy_io.Schema(
|
||||
node_id=cls.NODE_ID,
|
||||
display_name=cls.DISPLAY_NAME,
|
||||
category=str(getattr(legacy, "CATEGORY", "SimpleSyrup")),
|
||||
description=str(getattr(legacy, "DESCRIPTION", "")),
|
||||
search_aliases=list(getattr(legacy, "SEARCH_ALIASES", [])),
|
||||
inputs=_v3_inputs(
|
||||
legacy.INPUT_TYPES(),
|
||||
workflow_order=cls.WORKFLOW_INPUT_ORDER,
|
||||
),
|
||||
outputs=_v3_outputs(legacy),
|
||||
hidden=_v3_hidden_inputs(legacy.INPUT_TYPES()),
|
||||
is_input_list=bool(getattr(legacy, "INPUT_IS_LIST", False)),
|
||||
is_output_node=bool(getattr(legacy, "OUTPUT_NODE", False)),
|
||||
enable_expand=cls.ENABLE_EXPAND,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs: object) -> Any:
|
||||
"""Run the wrapped implementation with v3-provided inputs."""
|
||||
|
||||
values = dict(kwargs)
|
||||
for name, hidden_attr in _legacy_hidden_inputs(
|
||||
cls.LEGACY_NODE_CLASS.INPUT_TYPES()
|
||||
).items():
|
||||
if name not in values:
|
||||
values[name] = getattr(cls.hidden, hidden_attr)
|
||||
|
||||
function_name = str(cls.LEGACY_NODE_CLASS.FUNCTION)
|
||||
implementation = cls.LEGACY_NODE_CLASS()
|
||||
function = getattr(implementation, function_name)
|
||||
return function(**values)
|
||||
|
||||
|
||||
def _v3_inputs(
|
||||
input_types: Mapping[str, Mapping[str, object]],
|
||||
*,
|
||||
workflow_order: tuple[str, ...] | None = None,
|
||||
) -> list[Any]:
|
||||
"""Return v3 inputs while preserving any explicit persisted socket order."""
|
||||
|
||||
declarations: dict[str, tuple[object, bool]] = {}
|
||||
for section_name, optional in (("required", False), ("optional", True)):
|
||||
section = input_types.get(section_name, {})
|
||||
for name, declaration in section.items():
|
||||
if name in declarations:
|
||||
raise ValueError(f"legacy input {name} is declared more than once.")
|
||||
declarations[name] = (declaration, optional)
|
||||
order = tuple(declarations) if workflow_order is None else workflow_order
|
||||
if len(order) != len(set(order)) or set(order) != set(declarations):
|
||||
raise ValueError("legacy workflow input order must name every input once.")
|
||||
return [
|
||||
_v3_input(name, declarations[name][0], optional=declarations[name][1])
|
||||
for name in order
|
||||
]
|
||||
|
||||
|
||||
def _v3_input(name: str, declaration: object, *, optional: bool) -> Any:
|
||||
"""Return one v3 input declaration from a legacy field declaration."""
|
||||
|
||||
if not isinstance(declaration, tuple) or not declaration:
|
||||
raise TypeError(f"legacy input {name} declaration must be a tuple.")
|
||||
|
||||
io_declaration = declaration[0]
|
||||
options = _input_options(declaration)
|
||||
tooltip = _string_option(options, "tooltip")
|
||||
advanced = _bool_option(options, "advanced")
|
||||
raw_link = _bool_option(options, "rawLink") or _bool_option(options, "raw_link")
|
||||
force_input = _bool_option(options, "forceInput") or _bool_option(
|
||||
options, "force_input"
|
||||
)
|
||||
|
||||
if isinstance(io_declaration, (list, tuple)):
|
||||
return _comfy_io.Combo.Input(
|
||||
name,
|
||||
options=list(io_declaration),
|
||||
optional=optional,
|
||||
default=options.get("default"),
|
||||
control_after_generate=options.get("control_after_generate"),
|
||||
tooltip=tooltip,
|
||||
raw_link=raw_link,
|
||||
advanced=advanced,
|
||||
)
|
||||
|
||||
if not isinstance(io_declaration, str):
|
||||
raise TypeError(f"legacy input {name} type must be a string or options list.")
|
||||
|
||||
input_type = io_declaration
|
||||
input_class = _io_class(input_type)
|
||||
common_options = {
|
||||
"optional": optional,
|
||||
"tooltip": tooltip,
|
||||
"raw_link": raw_link,
|
||||
"advanced": advanced,
|
||||
}
|
||||
|
||||
if input_type == "INT":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
min=options.get("min"),
|
||||
max=options.get("max"),
|
||||
step=options.get("step"),
|
||||
control_after_generate=options.get("control_after_generate"),
|
||||
**common_options,
|
||||
)
|
||||
if input_type == "FLOAT":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
min=options.get("min"),
|
||||
max=options.get("max"),
|
||||
step=options.get("step"),
|
||||
round=options.get("round"),
|
||||
**common_options,
|
||||
)
|
||||
if input_type == "STRING":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
multiline=bool(options.get("multiline", False)),
|
||||
force_input=force_input,
|
||||
**common_options,
|
||||
)
|
||||
if input_type == "BOOLEAN":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
label_on=options.get("label_on"),
|
||||
label_off=options.get("label_off"),
|
||||
**common_options,
|
||||
)
|
||||
|
||||
return input_class.Input(name, **common_options)
|
||||
|
||||
|
||||
def _v3_outputs(legacy: type[Any]) -> list[Any]:
|
||||
"""Return v3 output declarations from legacy return metadata."""
|
||||
|
||||
return_types = tuple(getattr(legacy, "RETURN_TYPES", ()))
|
||||
return_names = getattr(legacy, "RETURN_NAMES", None)
|
||||
output_tooltips = tuple(getattr(legacy, "OUTPUT_TOOLTIPS", ()))
|
||||
output_is_list = tuple(
|
||||
getattr(legacy, "OUTPUT_IS_LIST", (False,) * len(return_types))
|
||||
)
|
||||
outputs: list[Any] = []
|
||||
for index, io_type in enumerate(return_types):
|
||||
output_name = None
|
||||
if isinstance(return_names, tuple) and index < len(return_names):
|
||||
output_name = str(return_names[index])
|
||||
tooltip = None
|
||||
if index < len(output_tooltips):
|
||||
tooltip = str(output_tooltips[index])
|
||||
is_output_list = index < len(output_is_list) and bool(output_is_list[index])
|
||||
outputs.append(
|
||||
_io_class(str(io_type)).Output(
|
||||
output_name,
|
||||
tooltip=tooltip,
|
||||
is_output_list=is_output_list,
|
||||
)
|
||||
)
|
||||
return outputs
|
||||
|
||||
|
||||
def _v3_hidden_inputs(input_types: Mapping[str, Mapping[str, object]]) -> list[Any]:
|
||||
"""Return v3 hidden declarations requested by legacy hidden inputs."""
|
||||
|
||||
hidden_values = set(_legacy_hidden_inputs(input_types).values())
|
||||
return [getattr(_comfy_io.Hidden, value) for value in sorted(hidden_values)]
|
||||
|
||||
|
||||
def _legacy_hidden_inputs(
|
||||
input_types: Mapping[str, Mapping[str, object]],
|
||||
) -> dict[str, str]:
|
||||
"""Return legacy hidden input names mapped to v3 hidden holder attributes."""
|
||||
|
||||
hidden_inputs: dict[str, str] = {}
|
||||
for name, sentinel in input_types.get("hidden", {}).items():
|
||||
if isinstance(sentinel, str) and sentinel in _HIDDEN_INPUTS:
|
||||
hidden_inputs[name] = _HIDDEN_INPUTS[sentinel]
|
||||
return hidden_inputs
|
||||
|
||||
|
||||
def _io_class(io_type: str) -> Any:
|
||||
"""Return the v3 IO class for a legacy Comfy type string."""
|
||||
|
||||
known_types = {
|
||||
"BOOLEAN": _comfy_io.Boolean,
|
||||
"INT": _comfy_io.Int,
|
||||
"FLOAT": _comfy_io.Float,
|
||||
"STRING": _comfy_io.String,
|
||||
"IMAGE": _comfy_io.Image,
|
||||
"MASK": _comfy_io.Mask,
|
||||
"LATENT": _comfy_io.Latent,
|
||||
"MODEL": _comfy_io.Model,
|
||||
"CLIP": _comfy_io.Clip,
|
||||
"VAE": _comfy_io.Vae,
|
||||
"CONDITIONING": _comfy_io.Conditioning,
|
||||
"SEGS": _comfy_io.SEGS,
|
||||
}
|
||||
return known_types.get(io_type, _comfy_io.Custom(io_type))
|
||||
|
||||
|
||||
def _input_options(declaration: tuple[object, ...]) -> dict[str, object]:
|
||||
"""Return an input options dictionary from a legacy declaration."""
|
||||
|
||||
if len(declaration) < 2 or not isinstance(declaration[1], dict):
|
||||
return {}
|
||||
return dict(declaration[1])
|
||||
|
||||
|
||||
def _string_option(options: Mapping[str, object], name: str) -> str | None:
|
||||
"""Return a string option when present."""
|
||||
|
||||
value = options.get(name)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _bool_option(options: Mapping[str, object], name: str) -> bool | None:
|
||||
"""Return a boolean option when present."""
|
||||
|
||||
value = options.get(name)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return None
|
||||
@@ -6,10 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from ..nodes.conditioning_batch_pack import (
|
||||
ConditioningBatchAppend,
|
||||
ConditioningBatchStart,
|
||||
@@ -40,71 +36,14 @@ from ..nodes.segs_from_sam_output import SEGSFromSAMOutput
|
||||
from ..nodes.simple_load_anima import SimpleLoadAnima
|
||||
from ..nodes.simple_preview_segs import SimplePreviewSEGS
|
||||
from ..nodes.vitmatte_model_loader import ViTMatteModelLoader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class _ComfyNodeBase:
|
||||
"""Type-checking base for Comfy v3 nodes."""
|
||||
|
||||
hidden: ClassVar[Any]
|
||||
RETURN_TYPES: ClassVar[list[str]]
|
||||
RETURN_NAMES: ClassVar[list[str]]
|
||||
|
||||
else:
|
||||
_ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode
|
||||
|
||||
_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io
|
||||
|
||||
_HIDDEN_INPUTS = {
|
||||
"PROMPT": "prompt",
|
||||
"DYNPROMPT": "dynprompt",
|
||||
"EXTRA_PNGINFO": "extra_pnginfo",
|
||||
"UNIQUE_ID": "unique_id",
|
||||
}
|
||||
|
||||
|
||||
class LegacyNodeV3Adapter(_ComfyNodeBase):
|
||||
"""Build a v3 schema and execution bridge for a legacy implementation class."""
|
||||
|
||||
LEGACY_NODE_CLASS: ClassVar[type[Any]]
|
||||
NODE_ID: ClassVar[str]
|
||||
DISPLAY_NAME: ClassVar[str]
|
||||
ENABLE_EXPAND: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Declare a v3 schema from the implementation class contract."""
|
||||
|
||||
legacy = cls.LEGACY_NODE_CLASS
|
||||
return _comfy_io.Schema(
|
||||
node_id=cls.NODE_ID,
|
||||
display_name=cls.DISPLAY_NAME,
|
||||
category=str(getattr(legacy, "CATEGORY", "SimpleSyrup")),
|
||||
description=str(getattr(legacy, "DESCRIPTION", "")),
|
||||
search_aliases=list(getattr(legacy, "SEARCH_ALIASES", [])),
|
||||
inputs=_v3_inputs(legacy.INPUT_TYPES()),
|
||||
outputs=_v3_outputs(legacy),
|
||||
hidden=_v3_hidden_inputs(legacy.INPUT_TYPES()),
|
||||
is_input_list=bool(getattr(legacy, "INPUT_IS_LIST", False)),
|
||||
is_output_node=bool(getattr(legacy, "OUTPUT_NODE", False)),
|
||||
enable_expand=cls.ENABLE_EXPAND,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, **kwargs: object) -> Any:
|
||||
"""Run the wrapped implementation with v3-provided inputs."""
|
||||
|
||||
values = dict(kwargs)
|
||||
for name, hidden_attr in _legacy_hidden_inputs(
|
||||
cls.LEGACY_NODE_CLASS.INPUT_TYPES()
|
||||
).items():
|
||||
if name not in values:
|
||||
values[name] = getattr(cls.hidden, hidden_attr)
|
||||
|
||||
function_name = str(cls.LEGACY_NODE_CLASS.FUNCTION)
|
||||
implementation = cls.LEGACY_NODE_CLASS()
|
||||
function = getattr(implementation, function_name)
|
||||
return function(**values)
|
||||
from .legacy_inversion_node_adapter import LegacyInversionNodeV3Adapter
|
||||
from .legacy_node_adapter import LegacyNodeV3Adapter
|
||||
from .legacy_workflow_input_order import (
|
||||
DETAIL_SEGS_AS_REGIONS_INPUT_ORDER,
|
||||
DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER,
|
||||
DETAIL_SEGS_BY_SCALE_FACTOR_TILED_INPUT_ORDER,
|
||||
KSAMPLER_EXTRAS_INPUT_ORDER,
|
||||
)
|
||||
|
||||
|
||||
class ConditioningBatchStartV3(LegacyNodeV3Adapter):
|
||||
@@ -145,6 +84,7 @@ class KSamplerExtrasV3(LegacyNodeV3Adapter):
|
||||
LEGACY_NODE_CLASS = KSamplerExtras
|
||||
NODE_ID = "SimpleSyrup.KSamplerExtras"
|
||||
DISPLAY_NAME = "KSampler (Extras)"
|
||||
WORKFLOW_INPUT_ORDER = KSAMPLER_EXTRAS_INPUT_ORDER
|
||||
|
||||
|
||||
class LayerStyleSAMModelsAdapterV3(LegacyNodeV3Adapter):
|
||||
@@ -213,12 +153,13 @@ class ResizeImageToTargetV3(LegacyNodeV3Adapter):
|
||||
DISPLAY_NAME = "Resize Image to Target"
|
||||
|
||||
|
||||
class DetailSEGSAsRegionsV3(LegacyNodeV3Adapter):
|
||||
class DetailSEGSAsRegionsV3(LegacyInversionNodeV3Adapter):
|
||||
"""Expose Detail SEGS as Regions through Comfy v3 only."""
|
||||
|
||||
LEGACY_NODE_CLASS = DetailSEGSAsRegions
|
||||
NODE_ID = "SimpleSyrup.DetailSEGSAsRegions"
|
||||
DISPLAY_NAME = "Detail SEGS as Regions"
|
||||
WORKFLOW_INPUT_ORDER = DETAIL_SEGS_AS_REGIONS_INPUT_ORDER
|
||||
|
||||
|
||||
class DetailSEGSByScaleFactorV3(LegacyNodeV3Adapter):
|
||||
@@ -227,14 +168,16 @@ class DetailSEGSByScaleFactorV3(LegacyNodeV3Adapter):
|
||||
LEGACY_NODE_CLASS = DetailSEGSByScaleFactor
|
||||
NODE_ID = "SimpleSyrup.DetailSEGSByScaleFactor"
|
||||
DISPLAY_NAME = "Detail SEGS by Scale Factor"
|
||||
WORKFLOW_INPUT_ORDER = DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER
|
||||
|
||||
|
||||
class DetailSEGSByScaleFactorTiledDiffusionV3(LegacyNodeV3Adapter):
|
||||
class DetailSEGSByScaleFactorTiledDiffusionV3(LegacyInversionNodeV3Adapter):
|
||||
"""Expose Detail SEGS by Scale Factor with Tiled Diffusion through Comfy v3."""
|
||||
|
||||
LEGACY_NODE_CLASS = DetailSEGSByScaleFactorTiledDiffusion
|
||||
NODE_ID = "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion"
|
||||
DISPLAY_NAME = "Detail SEGS by Scale Factor w/ Tiled Diffusion"
|
||||
WORKFLOW_INPUT_ORDER = DETAIL_SEGS_BY_SCALE_FACTOR_TILED_INPUT_ORDER
|
||||
|
||||
|
||||
class SAMModelLoaderV3(LegacyNodeV3Adapter):
|
||||
@@ -309,189 +252,6 @@ class ViTMatteModelLoaderV3(LegacyNodeV3Adapter):
|
||||
DISPLAY_NAME = "ViTMatte Model Loader"
|
||||
|
||||
|
||||
def _v3_inputs(input_types: Mapping[str, Mapping[str, object]]) -> list[Any]:
|
||||
"""Return v3 input declarations from legacy required and optional inputs."""
|
||||
|
||||
inputs: list[Any] = []
|
||||
for section_name, optional in (("required", False), ("optional", True)):
|
||||
section = input_types.get(section_name, {})
|
||||
for name, declaration in section.items():
|
||||
inputs.append(_v3_input(name, declaration, optional=optional))
|
||||
return inputs
|
||||
|
||||
|
||||
def _v3_input(name: str, declaration: object, *, optional: bool) -> Any:
|
||||
"""Return one v3 input declaration from a legacy field declaration."""
|
||||
|
||||
if not isinstance(declaration, tuple) or not declaration:
|
||||
raise TypeError(f"legacy input {name} declaration must be a tuple.")
|
||||
|
||||
io_declaration = declaration[0]
|
||||
options = _input_options(declaration)
|
||||
tooltip = _string_option(options, "tooltip")
|
||||
advanced = _bool_option(options, "advanced")
|
||||
raw_link = _bool_option(options, "rawLink") or _bool_option(options, "raw_link")
|
||||
force_input = _bool_option(options, "forceInput") or _bool_option(
|
||||
options, "force_input"
|
||||
)
|
||||
|
||||
if isinstance(io_declaration, (list, tuple)):
|
||||
return _comfy_io.Combo.Input(
|
||||
name,
|
||||
options=list(io_declaration),
|
||||
optional=optional,
|
||||
default=options.get("default"),
|
||||
control_after_generate=options.get("control_after_generate"),
|
||||
tooltip=tooltip,
|
||||
raw_link=raw_link,
|
||||
advanced=advanced,
|
||||
)
|
||||
|
||||
if not isinstance(io_declaration, str):
|
||||
raise TypeError(f"legacy input {name} type must be a string or options list.")
|
||||
|
||||
input_type = io_declaration
|
||||
input_class = _io_class(input_type)
|
||||
common_options = {
|
||||
"optional": optional,
|
||||
"tooltip": tooltip,
|
||||
"raw_link": raw_link,
|
||||
"advanced": advanced,
|
||||
}
|
||||
|
||||
if input_type == "INT":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
min=options.get("min"),
|
||||
max=options.get("max"),
|
||||
step=options.get("step"),
|
||||
control_after_generate=options.get("control_after_generate"),
|
||||
**common_options,
|
||||
)
|
||||
if input_type == "FLOAT":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
min=options.get("min"),
|
||||
max=options.get("max"),
|
||||
step=options.get("step"),
|
||||
round=options.get("round"),
|
||||
**common_options,
|
||||
)
|
||||
if input_type == "STRING":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
multiline=bool(options.get("multiline", False)),
|
||||
force_input=force_input,
|
||||
**common_options,
|
||||
)
|
||||
if input_type == "BOOLEAN":
|
||||
return input_class.Input(
|
||||
name,
|
||||
default=options.get("default"),
|
||||
label_on=options.get("label_on"),
|
||||
label_off=options.get("label_off"),
|
||||
**common_options,
|
||||
)
|
||||
|
||||
return input_class.Input(name, **common_options)
|
||||
|
||||
|
||||
def _v3_outputs(legacy: type[Any]) -> list[Any]:
|
||||
"""Return v3 output declarations from legacy return metadata."""
|
||||
|
||||
return_types = tuple(getattr(legacy, "RETURN_TYPES", ()))
|
||||
return_names = getattr(legacy, "RETURN_NAMES", None)
|
||||
output_tooltips = tuple(getattr(legacy, "OUTPUT_TOOLTIPS", ()))
|
||||
output_is_list = tuple(
|
||||
getattr(legacy, "OUTPUT_IS_LIST", (False,) * len(return_types))
|
||||
)
|
||||
outputs: list[Any] = []
|
||||
for index, io_type in enumerate(return_types):
|
||||
output_name = None
|
||||
if isinstance(return_names, tuple) and index < len(return_names):
|
||||
output_name = str(return_names[index])
|
||||
tooltip = None
|
||||
if index < len(output_tooltips):
|
||||
tooltip = str(output_tooltips[index])
|
||||
is_output_list = index < len(output_is_list) and bool(output_is_list[index])
|
||||
outputs.append(
|
||||
_io_class(str(io_type)).Output(
|
||||
output_name,
|
||||
tooltip=tooltip,
|
||||
is_output_list=is_output_list,
|
||||
)
|
||||
)
|
||||
return outputs
|
||||
|
||||
|
||||
def _v3_hidden_inputs(input_types: Mapping[str, Mapping[str, object]]) -> list[Any]:
|
||||
"""Return v3 hidden declarations requested by legacy hidden inputs."""
|
||||
|
||||
hidden_values = set(_legacy_hidden_inputs(input_types).values())
|
||||
return [getattr(_comfy_io.Hidden, value) for value in sorted(hidden_values)]
|
||||
|
||||
|
||||
def _legacy_hidden_inputs(
|
||||
input_types: Mapping[str, Mapping[str, object]],
|
||||
) -> dict[str, str]:
|
||||
"""Return legacy hidden input names mapped to v3 hidden holder attributes."""
|
||||
|
||||
hidden_inputs: dict[str, str] = {}
|
||||
for name, sentinel in input_types.get("hidden", {}).items():
|
||||
if isinstance(sentinel, str) and sentinel in _HIDDEN_INPUTS:
|
||||
hidden_inputs[name] = _HIDDEN_INPUTS[sentinel]
|
||||
return hidden_inputs
|
||||
|
||||
|
||||
def _io_class(io_type: str) -> Any:
|
||||
"""Return the v3 IO class for a legacy Comfy type string."""
|
||||
|
||||
known_types = {
|
||||
"BOOLEAN": _comfy_io.Boolean,
|
||||
"INT": _comfy_io.Int,
|
||||
"FLOAT": _comfy_io.Float,
|
||||
"STRING": _comfy_io.String,
|
||||
"IMAGE": _comfy_io.Image,
|
||||
"MASK": _comfy_io.Mask,
|
||||
"LATENT": _comfy_io.Latent,
|
||||
"MODEL": _comfy_io.Model,
|
||||
"CLIP": _comfy_io.Clip,
|
||||
"VAE": _comfy_io.Vae,
|
||||
"CONDITIONING": _comfy_io.Conditioning,
|
||||
"SEGS": _comfy_io.SEGS,
|
||||
}
|
||||
return known_types.get(io_type, _comfy_io.Custom(io_type))
|
||||
|
||||
|
||||
def _input_options(declaration: tuple[object, ...]) -> dict[str, object]:
|
||||
"""Return an input options dictionary from a legacy declaration."""
|
||||
|
||||
if len(declaration) < 2 or not isinstance(declaration[1], dict):
|
||||
return {}
|
||||
return dict(declaration[1])
|
||||
|
||||
|
||||
def _string_option(options: Mapping[str, object], name: str) -> str | None:
|
||||
"""Return a string option when present."""
|
||||
|
||||
value = options.get(name)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _bool_option(options: Mapping[str, object], name: str) -> bool | None:
|
||||
"""Return a boolean option when present."""
|
||||
|
||||
value = options.get(name)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConditioningBatchAppendV3",
|
||||
"ConditioningBatchStartV3",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Retain persisted socket order for legacy-backed Comfy v3 nodes."""
|
||||
|
||||
KSAMPLER_EXTRAS_INPUT_ORDER = (
|
||||
"model",
|
||||
"seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"positive",
|
||||
"negative",
|
||||
"latent_image",
|
||||
"denoise",
|
||||
)
|
||||
|
||||
DETAIL_SEGS_AS_REGIONS_INPUT_ORDER = (
|
||||
"image",
|
||||
"model",
|
||||
"vae",
|
||||
"negative",
|
||||
"positive",
|
||||
"segs",
|
||||
"region_positive",
|
||||
"global_prompt_weight",
|
||||
"scale_factor",
|
||||
"upscale_method",
|
||||
"seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"denoise",
|
||||
"feather",
|
||||
"noise_mask",
|
||||
"noise_mask_feather",
|
||||
"tiled_encode",
|
||||
"tiled_decode",
|
||||
)
|
||||
|
||||
DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER = (
|
||||
"image",
|
||||
"segs",
|
||||
"model",
|
||||
"vae",
|
||||
"positive",
|
||||
"negative",
|
||||
"scale_factor",
|
||||
"upscale_method",
|
||||
"clamp_size",
|
||||
"seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"denoise",
|
||||
"feather",
|
||||
"noise_mask",
|
||||
"noise_mask_feather",
|
||||
"tiled_encode",
|
||||
"tiled_decode",
|
||||
)
|
||||
|
||||
DETAIL_SEGS_BY_SCALE_FACTOR_TILED_INPUT_ORDER = (
|
||||
*DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER,
|
||||
"diffusion_mode",
|
||||
"latent_tile_width",
|
||||
"latent_tile_height",
|
||||
"latent_tile_overlap",
|
||||
"latent_tile_batch_size",
|
||||
)
|
||||
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
||||
import torch
|
||||
|
||||
from ..domain.segs import SORT_ORDER_OPTIONS, NativeSegs
|
||||
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
|
||||
from ..services.mask_to_segs_service import MaskToSEGSService
|
||||
from ..services.segs_output_service import (
|
||||
CombinedSegsResult,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Expose inversion resolution and integration controls as sampler options."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..domain.sampler_options import SamplerOptions, append_sampler_capability
|
||||
from .sampler_options_schema import (
|
||||
COMFY_IO,
|
||||
OptionsNodeBase,
|
||||
inversion_from_controls,
|
||||
noise_inversion_inputs,
|
||||
options_input,
|
||||
options_output,
|
||||
)
|
||||
|
||||
|
||||
class NoiseInversionOptionsV3(OptionsNodeBase):
|
||||
"""Add source-derived starting noise to an immutable sampler options chain."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Declare the accepted recipe with independently editable controls."""
|
||||
return COMFY_IO.Schema(
|
||||
node_id="SimpleSyrup.NoiseInversionOptions",
|
||||
display_name="Noise Inversion Options",
|
||||
category="SimpleSyrup/Sampling/Options",
|
||||
description=(
|
||||
"Derives starting noise from an input image before sampling; "
|
||||
"control inversion quality and cost independently."
|
||||
),
|
||||
inputs=[options_input(COMFY_IO), *noise_inversion_inputs(COMFY_IO)],
|
||||
outputs=[options_output(COMFY_IO)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
options: SamplerOptions | None = None,
|
||||
) -> tuple[SamplerOptions]:
|
||||
"""Append inversion or pass through at zero steps without preparing a model."""
|
||||
inversion = inversion_from_controls(
|
||||
inversion_method=inversion_method,
|
||||
inversion_resolution_scale=inversion_resolution_scale,
|
||||
inversion_steps=inversion_steps,
|
||||
inversion_switch_fraction=inversion_switch_fraction,
|
||||
inversion_finishing_steps=inversion_finishing_steps,
|
||||
)
|
||||
return (append_sampler_capability(options, inversion),)
|
||||
@@ -0,0 +1,134 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Declare bypass-compatible sampler options sockets and inversion controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
|
||||
from ..domain.noise_inversion import (
|
||||
INVERSION_METHODS,
|
||||
InversionMethod,
|
||||
NoiseInversionOptions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class OptionsNodeBase:
|
||||
"""Describe Comfy's host-facing node metadata for strict type checking."""
|
||||
|
||||
RETURN_TYPES: ClassVar[list[str]]
|
||||
RETURN_NAMES: ClassVar[list[str]]
|
||||
|
||||
else:
|
||||
OptionsNodeBase = import_module("comfy_api.latest").io.ComfyNode
|
||||
|
||||
COMFY_IO: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io
|
||||
OPTIONS_TYPE = "SIMPLE_SYRUP_SAMPLER_OPTIONS"
|
||||
|
||||
|
||||
def options_input(comfy_io: Any) -> Any:
|
||||
"""Allow any capability to start a chain or consume a preceding capability."""
|
||||
return comfy_io.Custom(OPTIONS_TYPE).Input(
|
||||
"options",
|
||||
optional=True,
|
||||
tooltip=(
|
||||
"Optional preceding sampler options; bypass this node "
|
||||
"to omit its contribution."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def options_output(comfy_io: Any) -> Any:
|
||||
"""Match the input type so Comfy can bypass capability nodes natively."""
|
||||
return comfy_io.Custom(OPTIONS_TYPE).Output(
|
||||
"options",
|
||||
tooltip="Combined sampler options; connect another options node or KSampler.",
|
||||
)
|
||||
|
||||
|
||||
def noise_inversion_inputs(comfy_io: Any, *, convenience: bool = False) -> list[Any]:
|
||||
"""Default to the accepted recipe and use zero steps to disable inversion."""
|
||||
return [
|
||||
comfy_io.Combo.Input(
|
||||
"inversion_method",
|
||||
options=list(INVERSION_METHODS),
|
||||
default="euler",
|
||||
optional=convenience,
|
||||
tooltip=(
|
||||
"Applies to both inversion stages; Euler uses one evaluation per step, "
|
||||
"Heun uses two for greater accuracy."
|
||||
),
|
||||
),
|
||||
comfy_io.Float.Input(
|
||||
"inversion_resolution_scale",
|
||||
default=0.5,
|
||||
min=0.01,
|
||||
max=1.0,
|
||||
step=0.05,
|
||||
optional=convenience,
|
||||
tooltip=(
|
||||
"Scales inversion width and height; "
|
||||
"0.5 uses half-sized dimensions for lower cost."
|
||||
),
|
||||
),
|
||||
comfy_io.Int.Input(
|
||||
"inversion_steps",
|
||||
default=2,
|
||||
min=0,
|
||||
max=64,
|
||||
optional=convenience,
|
||||
tooltip=(
|
||||
"Steps at the selected inversion resolution; 0 disables all inversion, "
|
||||
"including finishing. More steps cost more model evaluations."
|
||||
),
|
||||
),
|
||||
comfy_io.Float.Input(
|
||||
"inversion_switch_fraction",
|
||||
default=0.75,
|
||||
min=0.01,
|
||||
max=1.0,
|
||||
step=0.05,
|
||||
optional=convenience,
|
||||
tooltip=(
|
||||
"Noise-level fraction reached before the full-resolution finish; "
|
||||
"0.75 means 75%."
|
||||
),
|
||||
),
|
||||
comfy_io.Int.Input(
|
||||
"inversion_finishing_steps",
|
||||
default=1,
|
||||
min=0,
|
||||
max=64,
|
||||
optional=convenience,
|
||||
tooltip=(
|
||||
"Full-resolution inversion steps after a reduced stage; "
|
||||
"0 finishes entirely at reduced size."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def inversion_from_controls(
|
||||
*,
|
||||
inversion_method: str = "euler",
|
||||
inversion_resolution_scale: float = 0.5,
|
||||
inversion_steps: int = 2,
|
||||
inversion_switch_fraction: float = 0.75,
|
||||
inversion_finishing_steps: int = 1,
|
||||
) -> NoiseInversionOptions | None:
|
||||
"""Disable all stages at zero steps or construct a shared-method recipe."""
|
||||
if type(inversion_steps) is not int or not 0 <= inversion_steps <= 64:
|
||||
raise ValueError("Inversion steps must be an integer between 0 and 64.")
|
||||
if inversion_steps == 0:
|
||||
return None
|
||||
return NoiseInversionOptions(
|
||||
method=cast(InversionMethod, inversion_method),
|
||||
resolution_scale=inversion_resolution_scale,
|
||||
steps=inversion_steps,
|
||||
switch_fraction=inversion_switch_fraction,
|
||||
finishing_steps=inversion_finishing_steps,
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from ..runtime.prompt_control_schedule_encode_graph import (
|
||||
from ..services.prompt_control_schedule_encode_graph import (
|
||||
PromptControlScheduleEncodeGraphBuilder,
|
||||
)
|
||||
|
||||
@@ -94,10 +94,12 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase):
|
||||
"negative_prompt",
|
||||
multiline=False,
|
||||
default="",
|
||||
optional=True,
|
||||
tooltip=(
|
||||
"Negative Prompt-Control text; [SEP] or [SEP|name] creates "
|
||||
"ordered conditioning entries, and global text fills "
|
||||
"missing negative regions."
|
||||
"Optional negative Prompt-Control text; [SEP] or [SEP|name] "
|
||||
"creates ordered conditioning entries, and global text fills "
|
||||
"missing negative regions. Leave disconnected to encode an "
|
||||
"empty negative prompt."
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -126,7 +128,7 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase):
|
||||
model: Any,
|
||||
clip: Any,
|
||||
positive_prompt: str,
|
||||
negative_prompt: str,
|
||||
negative_prompt: str = "",
|
||||
encode_style: str = "",
|
||||
) -> Any:
|
||||
"""Build lazy Prompt-Control graph expansion for prompts."""
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Configure the single local tiling authority for a sampler options chain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..domain.sampler_options import (
|
||||
SamplerOptions,
|
||||
TilingOptions,
|
||||
append_sampler_capability,
|
||||
)
|
||||
from .ksampler_schema import tiled_diffusion_inputs
|
||||
from .sampler_options_schema import (
|
||||
COMFY_IO,
|
||||
OptionsNodeBase,
|
||||
options_input,
|
||||
options_output,
|
||||
)
|
||||
|
||||
|
||||
class TilingOptionsV3(OptionsNodeBase):
|
||||
"""Add bounded local tiles, blend policy and mask-dependent denoising."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Any:
|
||||
"""Share tiled controls and expose mask-dependent denoising."""
|
||||
return COMFY_IO.Schema(
|
||||
node_id="SimpleSyrup.TilingOptions",
|
||||
display_name="Tiling Options",
|
||||
category="SimpleSyrup/Sampling/Options",
|
||||
description=(
|
||||
"Samples bounded local tiles; ignored when "
|
||||
"Contextual Diffusion Options is connected."
|
||||
),
|
||||
inputs=[
|
||||
options_input(COMFY_IO),
|
||||
*tiled_diffusion_inputs(COMFY_IO),
|
||||
COMFY_IO.Boolean.Input(
|
||||
"differential_diffusion",
|
||||
default=False,
|
||||
tooltip=(
|
||||
"Uses the noise mask to vary denoising strength spatially; "
|
||||
"preserves existing model mask behavior."
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[options_output(COMFY_IO)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
diffusion_mode: str = "multidiffusion",
|
||||
latent_tile_width: int = 128,
|
||||
latent_tile_height: int = 128,
|
||||
latent_tile_overlap: int = 16,
|
||||
latent_tile_batch_size: int = 4,
|
||||
differential_diffusion: bool = False,
|
||||
options: SamplerOptions | None = None,
|
||||
) -> tuple[SamplerOptions]:
|
||||
"""Append validated tiling without changing the incoming chain."""
|
||||
return (
|
||||
append_sampler_capability(
|
||||
options,
|
||||
TilingOptions(
|
||||
diffusion_mode=diffusion_mode,
|
||||
width=latent_tile_width,
|
||||
height=latent_tile_height,
|
||||
overlap=latent_tile_overlap,
|
||||
batch_size=latent_tile_batch_size,
|
||||
differential_diffusion=differential_diffusion,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from uuid import UUID
|
||||
import torch
|
||||
from comfy import sampler_helpers, samplers
|
||||
|
||||
from ..domain.attention_coupling_preparation import AttentionCouplingPreparation
|
||||
from ..domain.conditioning_schedule import ConditioningScheduleRange
|
||||
from ..domain.processed_regional_attention import (
|
||||
ProcessedRegionalAttentionBranch,
|
||||
@@ -23,9 +24,6 @@ from ..domain.processed_regional_attention import (
|
||||
from ..domain.raw_regional_attention import (
|
||||
RawRegionalAttentionBranch,
|
||||
)
|
||||
from ..services.attention_coupling_preparation_service import (
|
||||
AttentionCouplingPreparation,
|
||||
)
|
||||
from .attention_coupling.context_validation import RegionalContextValidator
|
||||
from .ppm_negpip_interop import PpmNegpipInterop
|
||||
|
||||
|
||||
@@ -16,15 +16,24 @@ from ..domain.contextual_diffusion import (
|
||||
ContextualDiffusionControls,
|
||||
ContextualDiffusionPlan,
|
||||
)
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.regional_features import (
|
||||
EMPTY_REGIONAL_CAPABILITY_ADMISSION,
|
||||
RegionalCapabilityAdmission,
|
||||
)
|
||||
from ..domain.sampler_options import TilingOptions
|
||||
from ..domain.segs import NativeSegs
|
||||
from ..shared.logging import get_logger
|
||||
from . import sampling_samplers, sampling_schedulers
|
||||
from . import sampling_noise, sampling_samplers, sampling_schedulers
|
||||
from .contextual_model_wrapper import ContextualDiffusionModelWrapper
|
||||
from .differential_diffusion import (
|
||||
differential_diffusion_mutation,
|
||||
has_denoise_mask_function,
|
||||
)
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
from .inversion_model_factory import InversionModelFactory
|
||||
from .model_patcher_mutations import ModelUnetWrapperMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
|
||||
from .sampling_model_types import ModelFunctionWrapper
|
||||
from .tiled_sampling_validation import (
|
||||
Latent,
|
||||
@@ -57,14 +66,18 @@ def sample_contextual_diffusion(
|
||||
capability_admission: RegionalCapabilityAdmission = (
|
||||
EMPTY_REGIONAL_CAPABILITY_ADMISSION
|
||||
),
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
inversion_segs: NativeSegs | None = None,
|
||||
inversion_region_masks: torch.Tensor | None = None,
|
||||
differential_diffusion: bool = False,
|
||||
) -> Latent:
|
||||
"""Sample one latent through global context and one tiled prediction plan."""
|
||||
|
||||
validate_sampling_controls(
|
||||
steps=steps,
|
||||
denoise=denoise,
|
||||
latent_tile_width=controls.latent_context_size,
|
||||
latent_tile_height=controls.latent_context_size,
|
||||
latent_tile_width=controls.tile_width,
|
||||
latent_tile_height=controls.tile_height,
|
||||
latent_tile_batch_size=controls.latent_context_batch_size,
|
||||
)
|
||||
controls.validate()
|
||||
@@ -89,8 +102,8 @@ def sample_contextual_diffusion(
|
||||
steps=steps,
|
||||
denoise=denoise,
|
||||
view=sampling_schedulers.SchedulerView(
|
||||
latent_width=controls.latent_context_size,
|
||||
latent_height=controls.latent_context_size,
|
||||
latent_width=controls.tile_width,
|
||||
latent_height=controls.tile_height,
|
||||
),
|
||||
).to(model.load_device)
|
||||
latent_samples = validate_latent_samples(latent_image, sampler_label=SAMPLER_LABEL)
|
||||
@@ -113,23 +126,55 @@ def sample_contextual_diffusion(
|
||||
controls=controls,
|
||||
sigmas=sigmas,
|
||||
diffusion_mode=diffusion_mode,
|
||||
differential_diffusion=differential_diffusion,
|
||||
)
|
||||
batch_inds = latent_image.get("batch_index")
|
||||
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
|
||||
inversion_factory = (
|
||||
InversionModelFactory(
|
||||
model=model,
|
||||
canvas_width=plan.latent_width,
|
||||
canvas_height=plan.latent_height,
|
||||
tiling=TilingOptions(
|
||||
diffusion_mode=diffusion_mode,
|
||||
width=controls.tile_width,
|
||||
height=controls.tile_height,
|
||||
overlap=controls.latent_context_overlap,
|
||||
batch_size=controls.latent_context_batch_size,
|
||||
differential_diffusion=differential_diffusion,
|
||||
),
|
||||
context=controls,
|
||||
forward_sigmas=sigmas,
|
||||
segs=inversion_segs,
|
||||
region_masks=inversion_region_masks,
|
||||
)
|
||||
if noise_inversion is not None
|
||||
else None
|
||||
)
|
||||
noise = sampling_noise.prepare_sampling_noise(
|
||||
comfy_sample=comfy_sample,
|
||||
sampler_name=sampler_name,
|
||||
samples=latent_samples,
|
||||
seed=seed,
|
||||
batch_indices=batch_inds,
|
||||
model=sampling_model,
|
||||
)
|
||||
callback = _latent_preview().prepare_callback(sampling_model, steps)
|
||||
samples = comfy_sample.sample_custom(
|
||||
sampling_model,
|
||||
noise,
|
||||
cfg,
|
||||
sampler,
|
||||
sigmas,
|
||||
positive,
|
||||
negative,
|
||||
latent_samples,
|
||||
samples = sample_with_optional_negative(
|
||||
comfy_sample=comfy_sample,
|
||||
model=sampling_model,
|
||||
noise=noise,
|
||||
cfg=cfg,
|
||||
sampler=sampler,
|
||||
sigmas=sigmas,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_samples,
|
||||
noise_mask=latent_image.get("noise_mask"),
|
||||
callback=callback,
|
||||
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
|
||||
seed=seed,
|
||||
noise_inversion=noise_inversion,
|
||||
inversion_model_factory=inversion_factory,
|
||||
)
|
||||
|
||||
LOGGER.info(
|
||||
@@ -169,10 +214,12 @@ def clone_model_with_contextual_diffusion(
|
||||
controls: ContextualDiffusionControls,
|
||||
sigmas: torch.Tensor,
|
||||
diffusion_mode: str,
|
||||
differential_diffusion: bool = False,
|
||||
existing_wrapper: ModelFunctionWrapper | None = None,
|
||||
) -> Any:
|
||||
"""Derive a model with one pre-CFG contextual prediction wrapper."""
|
||||
|
||||
old_wrapper = model.model_options.get("model_function_wrapper")
|
||||
old_wrapper = existing_wrapper or model.model_options.get("model_function_wrapper")
|
||||
if old_wrapper is not None and not callable(old_wrapper):
|
||||
raise ValueError("Existing model_function_wrapper is not callable.")
|
||||
wrapper = ContextualDiffusionModelWrapper(
|
||||
@@ -182,9 +229,13 @@ def clone_model_with_contextual_diffusion(
|
||||
diffusion_mode=diffusion_mode,
|
||||
existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper),
|
||||
)
|
||||
mutations: list[ModelMutation] = []
|
||||
if differential_diffusion and not has_denoise_mask_function(model):
|
||||
mutations.append(differential_diffusion_mutation())
|
||||
mutations.append(ModelUnetWrapperMutation(wrapper))
|
||||
return PATCHER_LIFECYCLE.derive_model(
|
||||
model,
|
||||
(ModelUnetWrapperMutation(wrapper),),
|
||||
mutations,
|
||||
operation="SimpleSyrup contextual diffusion",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Convert and align image assets used by detail sampling previews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..domain.segs import CropRegion
|
||||
|
||||
CropBox = tuple[int, int, int, int]
|
||||
|
||||
|
||||
def image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image:
|
||||
"""Convert a single-image BHWC tensor to an RGB PIL image."""
|
||||
|
||||
if image.ndim != 4:
|
||||
raise ValueError("detail preview image must be a BHWC tensor.")
|
||||
if int(image.shape[0]) != 1:
|
||||
raise ValueError("detail preview image must contain exactly one image.")
|
||||
if int(image.shape[-1]) < 1:
|
||||
raise ValueError("detail preview image must contain at least one channel.")
|
||||
array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy()
|
||||
if array.shape[-1] == 1:
|
||||
array = np.repeat(array, 3, axis=-1)
|
||||
elif array.shape[-1] >= 3:
|
||||
array = array[..., :3]
|
||||
else:
|
||||
array = np.repeat(array[..., :1], 3, axis=-1)
|
||||
return Image.fromarray((array * 255.0).round().astype(np.uint8))
|
||||
|
||||
|
||||
def normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor:
|
||||
"""Normalize an HW or single-item BHW mask tensor to HW float."""
|
||||
|
||||
working = mask.detach().float()
|
||||
if working.ndim == 3 and int(working.shape[0]) == 1:
|
||||
working = working[0]
|
||||
if working.ndim != 2:
|
||||
raise ValueError("detail preview work mask must be an HW tensor.")
|
||||
return working
|
||||
|
||||
|
||||
def detail_alpha_mask(
|
||||
mask: torch.Tensor,
|
||||
*,
|
||||
source_size: tuple[int, int],
|
||||
preview_size: tuple[int, int],
|
||||
sampled_box: CropBox,
|
||||
target_size: tuple[int, int],
|
||||
) -> Image.Image:
|
||||
"""Return an alpha mask aligned to the sampled preview paste box."""
|
||||
|
||||
working = normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0)
|
||||
mask_image = Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8))
|
||||
if mask_image.size == source_size:
|
||||
preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR)
|
||||
return preview_mask.crop(sampled_box).resize(
|
||||
target_size,
|
||||
Image.Resampling.BILINEAR,
|
||||
)
|
||||
return mask_image.resize(target_size, Image.Resampling.BILINEAR)
|
||||
|
||||
|
||||
def validate_crop_region(
|
||||
crop_region: CropRegion,
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
) -> None:
|
||||
"""Reject crop regions that cannot be mapped into the source image."""
|
||||
|
||||
if crop_region.left < 0 or crop_region.top < 0:
|
||||
raise ValueError("crop_region left and top must be non-negative.")
|
||||
if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top:
|
||||
raise ValueError("crop_region right/bottom must be greater than left/top.")
|
||||
if crop_region.right > source_width or crop_region.bottom > source_height:
|
||||
raise ValueError("crop_region must fit within the source image.")
|
||||
@@ -15,6 +15,12 @@ import torch
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from ..domain.segs import CropRegion
|
||||
from .detail_preview_images import (
|
||||
detail_alpha_mask,
|
||||
image_tensor_to_rgb_pil,
|
||||
normalize_mask_tensor,
|
||||
validate_crop_region,
|
||||
)
|
||||
|
||||
DETAIL_PREVIEW_WASH_OPACITY = 0.55
|
||||
DETAIL_PREVIEW_OUTLINE_RGB = (255, 0, 0)
|
||||
@@ -126,7 +132,7 @@ class DetailPreviewCompositor:
|
||||
) -> DetailPreviewCompositor:
|
||||
"""Create a compositor with detailer background work precomputed."""
|
||||
|
||||
source_image = _image_tensor_to_rgb_pil(context.image)
|
||||
source_image = image_tensor_to_rgb_pil(context.image)
|
||||
sampled_region = context.sampled_region or context.work_region
|
||||
geometry = build_detail_preview_geometry(
|
||||
source_width=source_image.width,
|
||||
@@ -147,7 +153,7 @@ class DetailPreviewCompositor:
|
||||
)
|
||||
left, top, right, bottom = geometry.crop_box
|
||||
crop_size = (max(1, right - left), max(1, bottom - top))
|
||||
detail_alpha_mask = _detail_alpha_mask(
|
||||
detail_alpha = detail_alpha_mask(
|
||||
context.work_mask,
|
||||
source_size=(source_image.width, source_image.height),
|
||||
preview_size=geometry.preview_size,
|
||||
@@ -157,7 +163,7 @@ class DetailPreviewCompositor:
|
||||
return cls(
|
||||
geometry=geometry,
|
||||
washed_background=washed_background,
|
||||
detail_alpha_mask=detail_alpha_mask,
|
||||
detail_alpha_mask=detail_alpha,
|
||||
)
|
||||
|
||||
def compose(self, crop_preview: Image.Image) -> Image.Image:
|
||||
@@ -211,9 +217,9 @@ def build_detail_preview_geometry(
|
||||
source_height,
|
||||
max_preview_resolution,
|
||||
)
|
||||
_validate_crop_region(crop_region, source_width, source_height)
|
||||
validate_crop_region(crop_region, source_width, source_height)
|
||||
resolved_outline_region = outline_region or crop_region
|
||||
_validate_crop_region(resolved_outline_region, source_width, source_height)
|
||||
validate_crop_region(resolved_outline_region, source_width, source_height)
|
||||
|
||||
preview_width, preview_height = preview_size
|
||||
scale_x = float(preview_width) / float(source_width)
|
||||
@@ -238,7 +244,7 @@ def build_detail_preview_geometry(
|
||||
def work_region_from_mask(mask: torch.Tensor) -> CropRegion:
|
||||
"""Return the tight work region around a non-empty HW or single-item BHW mask."""
|
||||
|
||||
working = _normalize_mask_tensor(mask)
|
||||
working = normalize_mask_tensor(mask)
|
||||
coordinates = torch.nonzero(working > 0, as_tuple=False)
|
||||
if coordinates.numel() == 0:
|
||||
raise ValueError("detail preview work mask must contain at least one pixel.")
|
||||
@@ -367,83 +373,6 @@ def _source_outline_box(
|
||||
)
|
||||
|
||||
|
||||
def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image:
|
||||
"""Convert a single-image BHWC tensor to an RGB PIL image."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
if image.ndim != 4:
|
||||
raise ValueError("detail preview image must be a BHWC tensor.")
|
||||
if int(image.shape[0]) != 1:
|
||||
raise ValueError("detail preview image must contain exactly one image.")
|
||||
if int(image.shape[-1]) < 1:
|
||||
raise ValueError("detail preview image must contain at least one channel.")
|
||||
|
||||
array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy()
|
||||
if array.shape[-1] == 1:
|
||||
array = np.repeat(array, 3, axis=-1)
|
||||
elif array.shape[-1] >= 3:
|
||||
array = array[..., :3]
|
||||
else:
|
||||
array = np.repeat(array[..., :1], 3, axis=-1)
|
||||
return Image.fromarray((array * 255.0).round().astype(np.uint8))
|
||||
|
||||
|
||||
def _mask_tensor_to_l_pil(mask: torch.Tensor) -> Image.Image:
|
||||
"""Convert an HW or single-item BHW mask tensor to a grayscale alpha image."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
working = _normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0)
|
||||
return Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8))
|
||||
|
||||
|
||||
def _normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor:
|
||||
"""Normalize an HW or single-item BHW mask tensor to HW float."""
|
||||
|
||||
working = mask.detach().float()
|
||||
if working.ndim == 3 and int(working.shape[0]) == 1:
|
||||
working = working[0]
|
||||
if working.ndim != 2:
|
||||
raise ValueError("detail preview work mask must be an HW tensor.")
|
||||
return working
|
||||
|
||||
|
||||
def _detail_alpha_mask(
|
||||
mask: torch.Tensor,
|
||||
*,
|
||||
source_size: tuple[int, int],
|
||||
preview_size: tuple[int, int],
|
||||
sampled_box: CropBox,
|
||||
target_size: tuple[int, int],
|
||||
) -> Image.Image:
|
||||
"""Return an alpha mask aligned to the sampled preview paste box."""
|
||||
|
||||
mask_image = _mask_tensor_to_l_pil(mask)
|
||||
if mask_image.size == source_size:
|
||||
preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR)
|
||||
return preview_mask.crop(sampled_box).resize(
|
||||
target_size,
|
||||
Image.Resampling.BILINEAR,
|
||||
)
|
||||
return mask_image.resize(target_size, Image.Resampling.BILINEAR)
|
||||
|
||||
|
||||
def _validate_crop_region(
|
||||
crop_region: CropRegion,
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
) -> None:
|
||||
"""Reject crop regions that cannot be mapped into the source image."""
|
||||
|
||||
if crop_region.left < 0 or crop_region.top < 0:
|
||||
raise ValueError("crop_region left and top must be non-negative.")
|
||||
if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top:
|
||||
raise ValueError("crop_region right/bottom must be greater than left/top.")
|
||||
if crop_region.right > source_width or crop_region.bottom > source_height:
|
||||
raise ValueError("crop_region must fit within the source image.")
|
||||
|
||||
|
||||
def _validate_positive_int(name: str, value: int) -> None:
|
||||
"""Reject non-positive integer values."""
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ from typing import Any, TypeAlias, cast
|
||||
|
||||
import torch
|
||||
|
||||
from . import sampling_samplers, sampling_schedulers
|
||||
from . import sampling_noise, sampling_samplers, sampling_schedulers
|
||||
from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback
|
||||
from .differential_diffusion import clone_with_differential_diffusion
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
|
||||
Latent: TypeAlias = dict[str, Any]
|
||||
|
||||
@@ -80,21 +81,29 @@ class DetailSampler:
|
||||
batch_inds = (
|
||||
latent_image["batch_index"] if "batch_index" in latent_image else None
|
||||
)
|
||||
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
|
||||
noise = sampling_noise.prepare_sampling_noise(
|
||||
comfy_sample=comfy_sample,
|
||||
sampler_name=sampler_name,
|
||||
samples=latent_samples,
|
||||
seed=seed,
|
||||
batch_indices=batch_inds,
|
||||
model=model,
|
||||
)
|
||||
noise_mask = latent_image.get("noise_mask", None)
|
||||
if preview_context is None:
|
||||
callback = _latent_preview().prepare_callback(model, steps)
|
||||
else:
|
||||
callback = prepare_detail_preview_callback(model, steps, preview_context)
|
||||
samples = comfy_sample.sample_custom(
|
||||
model,
|
||||
noise,
|
||||
cfg,
|
||||
sampler,
|
||||
sigmas,
|
||||
positive,
|
||||
negative,
|
||||
latent_samples,
|
||||
samples = sample_with_optional_negative(
|
||||
comfy_sample=comfy_sample,
|
||||
model=model,
|
||||
noise=noise,
|
||||
cfg=cfg,
|
||||
sampler=sampler,
|
||||
sigmas=sigmas,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_samples,
|
||||
noise_mask=noise_mask,
|
||||
callback=callback,
|
||||
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
|
||||
|
||||
@@ -13,7 +13,7 @@ import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..domain.segs import Segment
|
||||
from ..masking.segs_mask_ops import crop_image, crop_mask, resize_mask
|
||||
from ..domain.segs_mask_ops import crop_image, crop_mask, resize_mask
|
||||
from ..shared.tensor_validation import validate_image_tensor
|
||||
|
||||
SEG_IMAGE_MODES = ("transparent mask", "black mask", "full crop")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Select ComfyUI's CFG or positive-only guider for latent sampling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..shared.logging import get_logger
|
||||
from .noise_inversion import InversionModelFactory, invert_sampling_noise
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
def sample_with_optional_negative(
|
||||
*,
|
||||
comfy_sample: Any,
|
||||
model: Any,
|
||||
noise: torch.Tensor,
|
||||
cfg: float,
|
||||
sampler: Any,
|
||||
sigmas: torch.Tensor,
|
||||
positive: Any,
|
||||
negative: Any | None,
|
||||
latent_image: torch.Tensor,
|
||||
noise_mask: Any = None,
|
||||
callback: Any = None,
|
||||
disable_pbar: bool = False,
|
||||
seed: int | None = None,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
inversion_model_factory: InversionModelFactory | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Prepare optional source-derived noise and select the actual Comfy guider."""
|
||||
|
||||
if noise_inversion is not None:
|
||||
inversion = invert_sampling_noise(
|
||||
model=model,
|
||||
latent=latent_image,
|
||||
forward_sigmas=sigmas,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
cfg=cfg,
|
||||
seed=seed,
|
||||
options=noise_inversion,
|
||||
model_factory=inversion_model_factory,
|
||||
noise_mask=noise_mask,
|
||||
)
|
||||
noise = inversion.noise.to(noise)
|
||||
|
||||
if negative is not None:
|
||||
return cast(
|
||||
torch.Tensor,
|
||||
comfy_sample.sample_custom(
|
||||
model,
|
||||
noise,
|
||||
cfg,
|
||||
sampler,
|
||||
sigmas,
|
||||
positive,
|
||||
negative,
|
||||
latent_image,
|
||||
noise_mask=noise_mask,
|
||||
callback=callback,
|
||||
disable_pbar=disable_pbar,
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
|
||||
comfy_samplers = import_module("comfy.samplers")
|
||||
model_management = import_module("comfy.model_management")
|
||||
guider = comfy_samplers.CFGGuider(model)
|
||||
guider.inner_set_conds({"positive": positive})
|
||||
samples = guider.sample(
|
||||
noise,
|
||||
latent_image,
|
||||
sampler,
|
||||
sigmas,
|
||||
denoise_mask=noise_mask,
|
||||
callback=callback,
|
||||
disable_pbar=disable_pbar,
|
||||
seed=seed,
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Positive-only ComfyUI guider selected",
|
||||
extra={
|
||||
"operation": "sample_with_optional_negative",
|
||||
"guidance_mode": "positive_only",
|
||||
},
|
||||
)
|
||||
return cast(
|
||||
torch.Tensor,
|
||||
samples.to(
|
||||
device=model_management.intermediate_device(),
|
||||
dtype=model_management.intermediate_dtype(),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Prepare each inversion resolution from the original, spatially unwrapped MODEL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as functional
|
||||
|
||||
from ..domain.contextual_diffusion import (
|
||||
ContextualDiffusionControls,
|
||||
build_contextual_diffusion_plan,
|
||||
)
|
||||
from ..domain.regional_tiled_diffusion import (
|
||||
build_region_constrained_tiled_diffusion_plan,
|
||||
)
|
||||
from ..domain.sampler_options import TilingOptions
|
||||
from ..domain.segs import NativeSegs
|
||||
from ..domain.segs_tiled_diffusion import build_segs_guided_tiled_diffusion_plan
|
||||
from ..domain.tiled_diffusion import TiledDiffusionPlan, build_tiled_diffusion_plan
|
||||
from .inversion_spatial_context import InversionSpatialContextWrapper
|
||||
from .model_patcher_mutations import ModelUnetWrapperMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE
|
||||
from .sampling_model_types import ModelFunctionWrapper
|
||||
|
||||
|
||||
class InversionModelFactory:
|
||||
"""Own stage planning while sharing the forward spatial wrapper authorities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: Any,
|
||||
canvas_width: int,
|
||||
canvas_height: int,
|
||||
tiling: TilingOptions | None = None,
|
||||
context: ContextualDiffusionControls | None = None,
|
||||
forward_sigmas: torch.Tensor | None = None,
|
||||
segs: NativeSegs | None = None,
|
||||
region_masks: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
"""Retain canonical inputs, not an already wrapped full-resolution model."""
|
||||
if context is not None and (tiling is None or forward_sigmas is None):
|
||||
raise ValueError(
|
||||
"Contextual inversion requires tile controls and forward sigmas."
|
||||
)
|
||||
self._model = model
|
||||
self._width = canvas_width
|
||||
self._height = canvas_height
|
||||
self._tiling = tiling
|
||||
self._context = context
|
||||
self._sigmas = forward_sigmas
|
||||
self._segs = segs
|
||||
self._masks = region_masks
|
||||
|
||||
def __call__(self, latent: torch.Tensor) -> Any:
|
||||
"""Replan one resolution with canonical regional-mask coordinates."""
|
||||
from .contextual_diffusion_sampling import clone_model_with_contextual_diffusion
|
||||
from .mixture_of_diffusers_sampling import clone_model_with_mixture_of_diffusers
|
||||
from .multidiffusion_sampling import clone_model_with_multidiffusion
|
||||
|
||||
width, height = int(latent.shape[-1]), int(latent.shape[-2])
|
||||
old_wrapper = self._model.model_options.get("model_function_wrapper")
|
||||
if old_wrapper is not None and not callable(old_wrapper):
|
||||
raise TypeError("Existing model_function_wrapper must be callable.")
|
||||
wrapper = cast(ModelFunctionWrapper | None, old_wrapper)
|
||||
if wrapper is not None and (width, height) != (self._width, self._height):
|
||||
wrapper = InversionSpatialContextWrapper(
|
||||
wrapper,
|
||||
canvas_width=self._width,
|
||||
canvas_height=self._height,
|
||||
stage_width=width,
|
||||
stage_height=height,
|
||||
)
|
||||
masks = self._stage_masks(height, width)
|
||||
if self._context is not None:
|
||||
assert self._tiling is not None and self._sigmas is not None
|
||||
plan = build_contextual_diffusion_plan(
|
||||
latent_width=width,
|
||||
latent_height=height,
|
||||
controls=self._context,
|
||||
segs=self._segs,
|
||||
region_masks=masks,
|
||||
segs_canvas=(self._height, self._width),
|
||||
)
|
||||
return clone_model_with_contextual_diffusion(
|
||||
self._model,
|
||||
plan=plan,
|
||||
controls=self._context,
|
||||
sigmas=self._sigmas,
|
||||
diffusion_mode=self._tiling.diffusion_mode,
|
||||
differential_diffusion=self._tiling.differential_diffusion,
|
||||
existing_wrapper=wrapper,
|
||||
)
|
||||
if self._tiling is not None:
|
||||
tile_plan = self._tile_plan(width, height, masks)
|
||||
clone = (
|
||||
clone_model_with_multidiffusion
|
||||
if self._tiling.diffusion_mode == "multidiffusion"
|
||||
else clone_model_with_mixture_of_diffusers
|
||||
)
|
||||
derived, _ = clone(
|
||||
self._model,
|
||||
latent_width=width,
|
||||
latent_height=height,
|
||||
tile_width=self._tiling.width,
|
||||
tile_height=self._tiling.height,
|
||||
overlap=self._tiling.overlap,
|
||||
tile_batch_size=self._tiling.batch_size,
|
||||
differential_diffusion=self._tiling.differential_diffusion,
|
||||
tiled_plan=tile_plan,
|
||||
existing_wrapper=wrapper,
|
||||
)
|
||||
return derived
|
||||
if wrapper is old_wrapper:
|
||||
return self._model
|
||||
assert wrapper is not None
|
||||
return PATCHER_LIFECYCLE.derive_model(
|
||||
self._model,
|
||||
(ModelUnetWrapperMutation(wrapper),),
|
||||
operation="SimpleSyrup inversion spatial context",
|
||||
)
|
||||
|
||||
def _stage_masks(self, height: int, width: int) -> torch.Tensor | None:
|
||||
"""Resize planning masks once; attention masks keep their canonical bank."""
|
||||
if self._masks is None:
|
||||
return None
|
||||
if tuple(self._masks.shape[-2:]) == (height, width):
|
||||
return self._masks
|
||||
return functional.interpolate(
|
||||
self._masks.unsqueeze(1).float(), size=(height, width), mode="nearest"
|
||||
).squeeze(1)
|
||||
|
||||
def _tile_plan(
|
||||
self, width: int, height: int, masks: torch.Tensor | None
|
||||
) -> TiledDiffusionPlan:
|
||||
"""Share regular, SEGS and regional ownership with forward sampling."""
|
||||
assert self._tiling is not None
|
||||
geometry = {
|
||||
"latent_width": width,
|
||||
"latent_height": height,
|
||||
"tile_width": self._tiling.width,
|
||||
"tile_height": self._tiling.height,
|
||||
"overlap": self._tiling.overlap,
|
||||
"tile_batch_size": self._tiling.batch_size,
|
||||
}
|
||||
if masks is not None:
|
||||
return build_region_constrained_tiled_diffusion_plan(
|
||||
region_masks=masks,
|
||||
segs=self._segs,
|
||||
segs_canvas=(self._height, self._width),
|
||||
**geometry,
|
||||
)
|
||||
if self._segs is not None:
|
||||
return build_segs_guided_tiled_diffusion_plan(
|
||||
segs=self._segs,
|
||||
segs_canvas=(self._height, self._width),
|
||||
**geometry,
|
||||
)
|
||||
return build_tiled_diffusion_plan(**geometry)
|
||||
@@ -0,0 +1,128 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Project reduced inversion views into the original regional-attention canvas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.spatial_views import SpatialBatchLayout, SpatialView, SpatialViewKind
|
||||
from .sampling_model_types import ApplyModel, ModelFunctionWrapper
|
||||
from .spatial_model_arguments import (
|
||||
SIMPLE_SYRUP_TRANSFORMER_NAMESPACE,
|
||||
SPATIAL_BATCH_LAYOUT_KEY,
|
||||
)
|
||||
|
||||
|
||||
def rebase_inversion_layout(
|
||||
layout: SpatialBatchLayout, *, canvas_width: int, canvas_height: int
|
||||
) -> SpatialBatchLayout:
|
||||
"""Keep actual model dimensions while mapping source rectangles to full masks."""
|
||||
scale_x = canvas_width / layout.canvas_width
|
||||
scale_y = canvas_height / layout.canvas_height
|
||||
views: list[SpatialView] = []
|
||||
for view in layout.views:
|
||||
left = round(view.source_x * scale_x)
|
||||
top = round(view.source_y * scale_y)
|
||||
right = round(view.source_right * scale_x)
|
||||
bottom = round(view.source_bottom * scale_y)
|
||||
views.append(
|
||||
SpatialView(
|
||||
kind=(
|
||||
SpatialViewKind.CONTEXTUAL_GLOBAL
|
||||
if view.kind is SpatialViewKind.FULL
|
||||
else view.kind
|
||||
),
|
||||
source_x=left,
|
||||
source_y=top,
|
||||
source_width=right - left,
|
||||
source_height=bottom - top,
|
||||
model_width=view.model_width,
|
||||
model_height=view.model_height,
|
||||
)
|
||||
)
|
||||
return SpatialBatchLayout(
|
||||
canvas_width, canvas_height, tuple(views), layout.input_batch_size
|
||||
)
|
||||
|
||||
|
||||
class InversionSpatialContextWrapper:
|
||||
"""Preserve regional mask coordinates without resizing conditioning twice."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
existing_wrapper: ModelFunctionWrapper,
|
||||
*,
|
||||
canvas_width: int,
|
||||
canvas_height: int,
|
||||
stage_width: int,
|
||||
stage_height: int,
|
||||
) -> None:
|
||||
"""Bind one stage's geometry to the original attention-mask canvas."""
|
||||
self._existing_wrapper = existing_wrapper
|
||||
self._canvas_width = canvas_width
|
||||
self._canvas_height = canvas_height
|
||||
self._stage_width = stage_width
|
||||
self._stage_height = stage_height
|
||||
|
||||
def __call__(self, apply_model: ApplyModel, args: dict[str, Any]) -> torch.Tensor:
|
||||
"""Replace only layout metadata, preserving expanded CFG batch metadata."""
|
||||
x = args.get("input")
|
||||
c = args.get("c", {})
|
||||
if not isinstance(x, torch.Tensor) or not isinstance(c, dict):
|
||||
raise TypeError(
|
||||
"Inversion spatial context requires tensor input and conditioning."
|
||||
)
|
||||
transformer_options = c.get("transformer_options", {})
|
||||
if not isinstance(transformer_options, dict):
|
||||
raise TypeError("Inversion transformer_options must be a dictionary.")
|
||||
namespace = transformer_options.get(SIMPLE_SYRUP_TRANSFORMER_NAMESPACE, {})
|
||||
if not isinstance(namespace, dict):
|
||||
raise TypeError(
|
||||
"Inversion SimpleSyrup transformer namespace must be a dictionary."
|
||||
)
|
||||
layout = namespace.get(SPATIAL_BATCH_LAYOUT_KEY)
|
||||
if layout is None:
|
||||
if tuple(x.shape[-2:]) != (self._stage_height, self._stage_width):
|
||||
raise ValueError(
|
||||
"Reduced inversion calls require explicit spatial layout."
|
||||
)
|
||||
layout = SpatialBatchLayout(
|
||||
self._stage_width,
|
||||
self._stage_height,
|
||||
(
|
||||
SpatialView(
|
||||
SpatialViewKind.FULL,
|
||||
0,
|
||||
0,
|
||||
self._stage_width,
|
||||
self._stage_height,
|
||||
self._stage_width,
|
||||
self._stage_height,
|
||||
),
|
||||
),
|
||||
int(x.shape[0]),
|
||||
)
|
||||
if not isinstance(layout, SpatialBatchLayout):
|
||||
raise TypeError("Inversion spatial layout has an invalid type.")
|
||||
if (layout.canvas_width, layout.canvas_height) != (
|
||||
self._stage_width,
|
||||
self._stage_height,
|
||||
) or layout.expanded_batch_size != int(x.shape[0]):
|
||||
raise ValueError(
|
||||
"Inversion layout must describe the current stage and model batch."
|
||||
)
|
||||
rebased = rebase_inversion_layout(
|
||||
layout, canvas_width=self._canvas_width, canvas_height=self._canvas_height
|
||||
)
|
||||
projected_namespace = {**namespace, SPATIAL_BATCH_LAYOUT_KEY: rebased}
|
||||
projected_options = {
|
||||
**transformer_options,
|
||||
SIMPLE_SYRUP_TRANSFORMER_NAMESPACE: projected_namespace,
|
||||
}
|
||||
projected_args = {**args, "c": {**c, "transformer_options": projected_options}}
|
||||
return self._existing_wrapper(apply_model, projected_args)
|
||||
@@ -16,21 +16,26 @@ from typing import Any, cast
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.regional_features import (
|
||||
EMPTY_REGIONAL_CAPABILITY_ADMISSION,
|
||||
RegionalCapabilityAdmission,
|
||||
)
|
||||
from ..domain.sampler_options import TilingOptions
|
||||
from ..domain.segs import NativeSegs
|
||||
from ..domain.tiled_diffusion import (
|
||||
TiledDiffusionPlan,
|
||||
build_tiled_diffusion_plan,
|
||||
)
|
||||
from ..shared.logging import get_logger
|
||||
from . import sampling_samplers, sampling_schedulers
|
||||
from . import sampling_noise, sampling_samplers, sampling_schedulers
|
||||
from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback
|
||||
from .differential_diffusion import (
|
||||
differential_diffusion_mutation,
|
||||
has_denoise_mask_function,
|
||||
)
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
from .inversion_model_factory import InversionModelFactory
|
||||
from .model_patcher_mutations import ModelUnetWrapperMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
|
||||
from .sampling_model_types import (
|
||||
@@ -72,6 +77,9 @@ def sample_mixture_of_diffusers(
|
||||
EMPTY_REGIONAL_CAPABILITY_ADMISSION
|
||||
),
|
||||
tiled_plan: TiledDiffusionPlan | None = None,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
inversion_segs: NativeSegs | None = None,
|
||||
inversion_region_masks: torch.Tensor | None = None,
|
||||
) -> Latent:
|
||||
"""Sample a latent with a cloned model patched for Mixture of Diffusers."""
|
||||
|
||||
@@ -134,22 +142,50 @@ def sample_mixture_of_diffusers(
|
||||
)
|
||||
|
||||
batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None
|
||||
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
|
||||
noise = sampling_noise.prepare_sampling_noise(
|
||||
comfy_sample=comfy_sample,
|
||||
sampler_name=sampler_name,
|
||||
samples=latent_samples,
|
||||
seed=seed,
|
||||
batch_indices=batch_inds,
|
||||
model=sampling_model,
|
||||
)
|
||||
noise_mask = latent_image.get("noise_mask", None)
|
||||
callback = _sampling_callback(sampling_model, steps, preview_context)
|
||||
samples = comfy_sample.sample_custom(
|
||||
sampling_model,
|
||||
noise,
|
||||
cfg,
|
||||
sampler,
|
||||
sigmas,
|
||||
positive,
|
||||
negative,
|
||||
latent_samples,
|
||||
samples = sample_with_optional_negative(
|
||||
comfy_sample=comfy_sample,
|
||||
model=sampling_model,
|
||||
noise=noise,
|
||||
cfg=cfg,
|
||||
sampler=sampler,
|
||||
sigmas=sigmas,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_samples,
|
||||
noise_mask=noise_mask,
|
||||
callback=callback,
|
||||
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
|
||||
seed=seed,
|
||||
noise_inversion=noise_inversion,
|
||||
inversion_model_factory=(
|
||||
InversionModelFactory(
|
||||
model=model,
|
||||
canvas_width=latent_width,
|
||||
canvas_height=latent_height,
|
||||
tiling=TilingOptions(
|
||||
diffusion_mode="mixture_of_diffusers",
|
||||
width=latent_tile_width,
|
||||
height=latent_tile_height,
|
||||
overlap=latent_tile_overlap,
|
||||
batch_size=latent_tile_batch_size,
|
||||
differential_diffusion=differential_diffusion,
|
||||
),
|
||||
segs=inversion_segs,
|
||||
region_masks=inversion_region_masks,
|
||||
)
|
||||
if noise_inversion is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
LOGGER.info(
|
||||
@@ -188,6 +224,7 @@ def clone_model_with_mixture_of_diffusers(
|
||||
tile_batch_size: int,
|
||||
differential_diffusion: bool = False,
|
||||
tiled_plan: TiledDiffusionPlan | None = None,
|
||||
existing_wrapper: ModelFunctionWrapper | None = None,
|
||||
) -> tuple[Any, TiledDiffusionPlan]:
|
||||
"""Return a derived model patched with a pre-CFG Mixture wrapper."""
|
||||
|
||||
@@ -200,7 +237,7 @@ def clone_model_with_mixture_of_diffusers(
|
||||
tile_batch_size=tile_batch_size,
|
||||
)
|
||||
_validate_supplied_plan(plan, latent_width, latent_height)
|
||||
old_wrapper = model.model_options.get("model_function_wrapper")
|
||||
old_wrapper = existing_wrapper or model.model_options.get("model_function_wrapper")
|
||||
if old_wrapper is not None and not callable(old_wrapper):
|
||||
raise ValueError("Existing model_function_wrapper is not callable.")
|
||||
|
||||
|
||||
@@ -16,21 +16,26 @@ from typing import Any, cast
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.regional_features import (
|
||||
EMPTY_REGIONAL_CAPABILITY_ADMISSION,
|
||||
RegionalCapabilityAdmission,
|
||||
)
|
||||
from ..domain.sampler_options import TilingOptions
|
||||
from ..domain.segs import NativeSegs
|
||||
from ..domain.tiled_diffusion import (
|
||||
TiledDiffusionPlan,
|
||||
build_tiled_diffusion_plan,
|
||||
)
|
||||
from ..shared.logging import get_logger
|
||||
from . import sampling_samplers, sampling_schedulers
|
||||
from . import sampling_noise, sampling_samplers, sampling_schedulers
|
||||
from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback
|
||||
from .differential_diffusion import (
|
||||
differential_diffusion_mutation,
|
||||
has_denoise_mask_function,
|
||||
)
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
from .inversion_model_factory import InversionModelFactory
|
||||
from .model_patcher_mutations import ModelUnetWrapperMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
|
||||
from .sampling_model_types import (
|
||||
@@ -73,6 +78,9 @@ def sample_multidiffusion(
|
||||
EMPTY_REGIONAL_CAPABILITY_ADMISSION
|
||||
),
|
||||
tiled_plan: TiledDiffusionPlan | None = None,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
inversion_segs: NativeSegs | None = None,
|
||||
inversion_region_masks: torch.Tensor | None = None,
|
||||
) -> Latent:
|
||||
"""Sample a latent with a cloned model patched for MultiDiffusion."""
|
||||
|
||||
@@ -136,22 +144,49 @@ def sample_multidiffusion(
|
||||
)
|
||||
|
||||
batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None
|
||||
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
|
||||
noise = sampling_noise.prepare_sampling_noise(
|
||||
comfy_sample=comfy_sample,
|
||||
sampler_name=sampler_name,
|
||||
samples=latent_samples,
|
||||
seed=seed,
|
||||
batch_indices=batch_inds,
|
||||
model=sampling_model,
|
||||
)
|
||||
noise_mask = latent_image.get("noise_mask", None)
|
||||
callback = _sampling_callback(sampling_model, steps, preview_context)
|
||||
samples = comfy_sample.sample_custom(
|
||||
sampling_model,
|
||||
noise,
|
||||
cfg,
|
||||
sampler,
|
||||
sigmas,
|
||||
positive,
|
||||
negative,
|
||||
latent_samples,
|
||||
samples = sample_with_optional_negative(
|
||||
comfy_sample=comfy_sample,
|
||||
model=sampling_model,
|
||||
noise=noise,
|
||||
cfg=cfg,
|
||||
sampler=sampler,
|
||||
sigmas=sigmas,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_samples,
|
||||
noise_mask=noise_mask,
|
||||
callback=callback,
|
||||
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
|
||||
seed=seed,
|
||||
noise_inversion=noise_inversion,
|
||||
inversion_model_factory=(
|
||||
InversionModelFactory(
|
||||
model=model,
|
||||
canvas_width=latent_width,
|
||||
canvas_height=latent_height,
|
||||
tiling=TilingOptions(
|
||||
width=latent_tile_width,
|
||||
height=latent_tile_height,
|
||||
overlap=latent_tile_overlap,
|
||||
batch_size=latent_tile_batch_size,
|
||||
differential_diffusion=differential_diffusion,
|
||||
),
|
||||
segs=inversion_segs,
|
||||
region_masks=inversion_region_masks,
|
||||
)
|
||||
if noise_inversion is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
LOGGER.info(
|
||||
@@ -191,6 +226,7 @@ def clone_model_with_multidiffusion(
|
||||
tile_batch_size: int,
|
||||
differential_diffusion: bool = False,
|
||||
tiled_plan: TiledDiffusionPlan | None = None,
|
||||
existing_wrapper: ModelFunctionWrapper | None = None,
|
||||
) -> tuple[Any, TiledDiffusionPlan]:
|
||||
"""Return a derived model patched with a pre-CFG MultiDiffusion wrapper."""
|
||||
|
||||
@@ -203,7 +239,7 @@ def clone_model_with_multidiffusion(
|
||||
tile_batch_size=tile_batch_size,
|
||||
)
|
||||
_validate_supplied_plan(plan, latent_width, latent_height)
|
||||
old_wrapper = model.model_options.get("model_function_wrapper")
|
||||
old_wrapper = existing_wrapper or model.model_options.get("model_function_wrapper")
|
||||
if old_wrapper is not None and not callable(old_wrapper):
|
||||
raise ValueError("Existing model_function_wrapper is not callable.")
|
||||
|
||||
|
||||
@@ -185,9 +185,16 @@ def krea2_diffusion_negpip_wrapper(
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> object:
|
||||
"""Move a processed Krea sign mask into call-local transformer options."""
|
||||
"""Inject call-local signs at either supported Comfy Krea argument boundary."""
|
||||
|
||||
positional_options = args[5] if len(args) > 5 else None
|
||||
options_index = (
|
||||
5
|
||||
if len(args) > 5
|
||||
else 4
|
||||
if len(args) == 5 and isinstance(args[4], dict)
|
||||
else None
|
||||
)
|
||||
positional_options = args[options_index] if options_index is not None else None
|
||||
transformer_options = (
|
||||
positional_options
|
||||
if positional_options is not None
|
||||
@@ -201,9 +208,9 @@ def krea2_diffusion_negpip_wrapper(
|
||||
if not isinstance(multiplier, torch.Tensor):
|
||||
raise TypeError("Krea NegPiP processed mask must be a tensor.")
|
||||
prepared[TRANSFORMER_MASK_KEY] = multiplier
|
||||
if len(args) > 5:
|
||||
if options_index is not None:
|
||||
prepared_args = list(args)
|
||||
prepared_args[5] = prepared
|
||||
prepared_args[options_index] = prepared
|
||||
return executor(*prepared_args, **kwargs)
|
||||
kwargs["transformer_options"] = prepared
|
||||
return executor(*args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Supply model-local NegPiP attention hooks for Comfy's earlier Krea boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from inspect import signature
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
from comfy.ldm.flux.math import apply_rope
|
||||
from comfy.ldm.krea2.model import Attention
|
||||
from comfy.ldm.modules.attention import optimized_attention_masked
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
from einops import rearrange
|
||||
|
||||
from ..model_patcher_mutations import ModelCallableObjectPatchMutation
|
||||
from .krea2 import TRANSFORMER_MASK_KEY
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def krea2_host_mutations(
|
||||
model: ModelPatcher,
|
||||
) -> tuple[ModelCallableObjectPatchMutation, ...]:
|
||||
"""Adapt only the known five-argument host; retain native reference-capable Krea."""
|
||||
diffusion = model.get_model_object("diffusion_model")
|
||||
parameters = tuple(signature(diffusion._forward).parameters)
|
||||
if "ref_latents" in parameters:
|
||||
return ()
|
||||
if parameters != (
|
||||
"x",
|
||||
"timesteps",
|
||||
"context",
|
||||
"attention_mask",
|
||||
"transformer_options",
|
||||
"kwargs",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Krea NegPiP does not support model signature {parameters!r}."
|
||||
)
|
||||
mutations: list[ModelCallableObjectPatchMutation] = []
|
||||
for index, block in enumerate(diffusion.blocks):
|
||||
attention = block.attn
|
||||
if not isinstance(attention, Attention):
|
||||
raise TypeError(f"Krea NegPiP requires host Attention at block {index}.")
|
||||
mutations.append(
|
||||
ModelCallableObjectPatchMutation(
|
||||
f"diffusion_model.blocks.{index}.attn.forward",
|
||||
Krea2HostAttention(attention, index, len(diffusion.blocks)),
|
||||
)
|
||||
)
|
||||
if not mutations:
|
||||
raise ValueError("Krea NegPiP requires at least one joint attention block.")
|
||||
LOGGER.info(
|
||||
"Krea NegPiP installed model-local host attention hooks",
|
||||
extra={"blocks": len(mutations)},
|
||||
)
|
||||
return tuple(mutations)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Krea2HostAttention:
|
||||
"""Preserve host attention math while exposing its missing pre-RoPE patch point.
|
||||
|
||||
Comfy 0.28's public model boundary lacks attention callbacks. Object patches
|
||||
scope this adapter to the derived MODEL and Comfy restores them on unload.
|
||||
Text-fusion attention stays untouched; only joint text/image blocks use it.
|
||||
"""
|
||||
|
||||
attention: Any # Comfy Attention exposes dynamically constructed linear modules.
|
||||
block_index: int
|
||||
total_blocks: int
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
freqs: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
transformer_options: dict[str, Any] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Run host QKV projections and attention with local patch metadata."""
|
||||
options = {} if transformer_options is None else transformer_options.copy()
|
||||
multiplier = options.get(TRANSFORMER_MASK_KEY)
|
||||
if multiplier is None:
|
||||
return cast(
|
||||
torch.Tensor,
|
||||
type(self.attention).forward(
|
||||
self.attention,
|
||||
x,
|
||||
freqs,
|
||||
mask,
|
||||
transformer_options=options,
|
||||
),
|
||||
)
|
||||
if not isinstance(multiplier, torch.Tensor) or multiplier.ndim != 3:
|
||||
raise ValueError(
|
||||
"Krea NegPiP host attention requires a processed token sign mask."
|
||||
)
|
||||
options.update(
|
||||
block_index=self.block_index,
|
||||
total_blocks=self.total_blocks,
|
||||
block_type="single",
|
||||
img_slice=[multiplier.shape[1], x.shape[1]],
|
||||
)
|
||||
attention = self.attention
|
||||
q, k, v, gate = (
|
||||
attention.wq(x),
|
||||
attention.wk(x),
|
||||
attention.wv(x),
|
||||
attention.gate(x),
|
||||
)
|
||||
q = rearrange(q, "B L (H D) -> B H L D", H=attention.heads)
|
||||
k = rearrange(k, "B L (H D) -> B H L D", H=attention.kvheads)
|
||||
v = rearrange(v, "B L (H D) -> B H L D", H=attention.kvheads)
|
||||
q, k = attention.qknorm(q, k)
|
||||
for patch in options.get("patches", {}).get("attn1_patch", []):
|
||||
result = patch(
|
||||
q, k, v, pe=freqs, attn_mask=mask, extra_options=options.copy()
|
||||
)
|
||||
q, k, v = result.get("q", q), result.get("k", k), result.get("v", v)
|
||||
freqs, mask = result.get("pe", freqs), result.get("attn_mask", mask)
|
||||
if freqs is not None:
|
||||
q, k = apply_rope(q, k, freqs)
|
||||
if attention.kvheads != attention.heads:
|
||||
repeats = attention.heads // attention.kvheads
|
||||
k = k.repeat_interleave(repeats, dim=1)
|
||||
v = v.repeat_interleave(repeats, dim=1)
|
||||
out = optimized_attention_masked(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
attention.heads,
|
||||
mask=mask,
|
||||
skip_reshape=True,
|
||||
transformer_options=options,
|
||||
)
|
||||
for patch in options.get("patches", {}).get("attn1_output_patch", []):
|
||||
out = patch(out, options.copy())
|
||||
return cast(torch.Tensor, attention.wo(out * torch.nn.functional.sigmoid(gate)))
|
||||
@@ -0,0 +1,325 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Derive source-dependent sampling noise with measured, uncached inversion stages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.inversion_solver import (
|
||||
InversionSolverEvidence,
|
||||
integrate_inversion,
|
||||
lift_inversion_displacement,
|
||||
)
|
||||
from ..domain.noise_inversion import InversionMethod, NoiseInversionOptions
|
||||
from ..shared.logging import get_logger
|
||||
from .spatial_tensor_projection import resize_spatial_tensor
|
||||
from .tiled_sampling_validation import validate_tensor_shape
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
INVERSION_START_SIGMA = 0.0001
|
||||
InversionModelFactory: TypeAlias = Callable[[torch.Tensor], Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InversionStageMeasurement:
|
||||
"""Describe actual work and elapsed time for one inversion stage."""
|
||||
|
||||
name: str
|
||||
seconds: float
|
||||
latent_shape: tuple[int, ...]
|
||||
steps: int
|
||||
evaluations: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NoiseInversionResult:
|
||||
"""Return inferred noise with paid inversion cost and reconstruction evidence."""
|
||||
|
||||
noise: torch.Tensor
|
||||
seconds: float
|
||||
stages: tuple[InversionStageMeasurement, ...]
|
||||
reconstruction_max_error: float
|
||||
|
||||
|
||||
def _clock(device: torch.device) -> float:
|
||||
"""Measure completed CUDA work rather than asynchronous kernel submission."""
|
||||
if device.type == "cuda":
|
||||
torch.cuda.synchronize(device)
|
||||
return time.perf_counter()
|
||||
|
||||
|
||||
def _source_in_model_space(model: Any, latent: torch.Tensor) -> torch.Tensor:
|
||||
"""Narrow host latent processing before arithmetic or model execution."""
|
||||
source = model.model.process_latent_in(latent)
|
||||
if not isinstance(source, torch.Tensor) or source.shape != latent.shape:
|
||||
raise ValueError(
|
||||
"Model latent processing must preserve the inversion source shape."
|
||||
)
|
||||
if not source.is_floating_point() or not bool(torch.isfinite(source).all()):
|
||||
raise ValueError(
|
||||
"Model latent processing must produce finite floating-point values."
|
||||
)
|
||||
return source.detach().float().cpu()
|
||||
|
||||
|
||||
def validate_inversion_target(model: Any, sigmas: torch.Tensor) -> float:
|
||||
"""Reject unsupported scaling and singular targets before model execution."""
|
||||
sampling_types = import_module("comfy.model_sampling")
|
||||
sampling = model.get_model_object("model_sampling")
|
||||
if not isinstance(
|
||||
sampling, (sampling_types.CONST, sampling_types.EPS)
|
||||
) or isinstance(
|
||||
sampling, (sampling_types.IMG_TO_IMG, sampling_types.IMG_TO_IMG_FLOW)
|
||||
):
|
||||
raise ValueError(
|
||||
"Noise inversion requires a flow or EPS-compatible image model."
|
||||
)
|
||||
if sigmas.ndim != 1 or len(sigmas) < 2 or not bool(torch.isfinite(sigmas).all()):
|
||||
raise ValueError("Noise inversion requires a finite forward sampling schedule.")
|
||||
target = float(sigmas[0])
|
||||
if target <= INVERSION_START_SIGMA:
|
||||
raise ValueError(
|
||||
"Noise inversion requires a positive partial-denoise start sigma."
|
||||
)
|
||||
if isinstance(sampling, sampling_types.CONST) and target >= 0.9999:
|
||||
raise ValueError(
|
||||
"Flow noise inversion requires denoise below the full-noise endpoint."
|
||||
)
|
||||
if isinstance(sampling, sampling_types.EPS):
|
||||
maximum = float(sampling.sigma_max)
|
||||
if target > maximum or math.isclose(target, maximum, rel_tol=1e-5):
|
||||
raise ValueError(
|
||||
"Noise inversion requires partial denoise, below the model's "
|
||||
"maximum sigma."
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def invert_sampling_noise(
|
||||
*,
|
||||
model: Any,
|
||||
latent: torch.Tensor,
|
||||
forward_sigmas: torch.Tensor,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
cfg: float,
|
||||
seed: int | None,
|
||||
options: NoiseInversionOptions,
|
||||
model_factory: InversionModelFactory | None = None,
|
||||
noise_mask: Any = None,
|
||||
) -> NoiseInversionResult:
|
||||
"""Invert a source latent through Comfy's actual CFG or positive-only guider.
|
||||
|
||||
A spatial sampler supplies a factory that replans each resolution from its
|
||||
unwrapped prepared model. The final noise recreates the inferred endpoint
|
||||
under the model's own affine noise scaling; no inference cache is used.
|
||||
"""
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
|
||||
if not isinstance(options, NoiseInversionOptions):
|
||||
raise TypeError("Noise inversion requires validated NoiseInversionOptions.")
|
||||
validate_tensor_shape(latent, sampler_label="Noise Inversion")
|
||||
if not latent.is_floating_point() or not bool(torch.isfinite(latent).all()):
|
||||
raise ValueError(
|
||||
"Noise inversion source must contain finite floating-point values."
|
||||
)
|
||||
target = validate_inversion_target(model, forward_sigmas)
|
||||
coarse_target = target * options.coarse_target_fraction
|
||||
if coarse_target <= INVERSION_START_SIGMA:
|
||||
raise ValueError(
|
||||
"Noise inversion transition must exceed the initial inversion sigma."
|
||||
)
|
||||
device = torch.device(model.load_device)
|
||||
started = _clock(device)
|
||||
sampling = model.get_model_object("model_sampling")
|
||||
source = _source_in_model_space(model, latent)
|
||||
phases: list[InversionStageMeasurement] = []
|
||||
comfy_sample = import_module("comfy.sample")
|
||||
comfy_samplers = import_module("comfy.samplers")
|
||||
|
||||
def stage(
|
||||
stage_latent: torch.Tensor,
|
||||
begin: float,
|
||||
end: float,
|
||||
count: int,
|
||||
initial: torch.Tensor | None,
|
||||
name: str,
|
||||
method: InversionMethod,
|
||||
) -> torch.Tensor:
|
||||
"""Capture the model-space endpoint before Comfy converts output latents."""
|
||||
stage_started = _clock(device)
|
||||
stage_model = (
|
||||
model_factory(stage_latent) if model_factory is not None else model
|
||||
)
|
||||
schedule = torch.linspace(begin, end, count + 1, device=device)
|
||||
evidence = InversionSolverEvidence()
|
||||
endpoints: list[torch.Tensor] = []
|
||||
|
||||
def invert(
|
||||
model_fn: Any,
|
||||
state: torch.Tensor,
|
||||
sigmas: torch.Tensor,
|
||||
extra_args: dict[str, Any],
|
||||
callback: Any,
|
||||
disable: bool,
|
||||
) -> torch.Tensor:
|
||||
"""Use Comfy's denoised predictions as the inversion velocity field."""
|
||||
if initial is not None:
|
||||
state = initial.to(state)
|
||||
|
||||
def evaluate(
|
||||
x: torch.Tensor, sigma: torch.Tensor, index: int
|
||||
) -> torch.Tensor:
|
||||
"""Narrow the dynamic Comfy model result before numeric integration."""
|
||||
prediction = model_fn(
|
||||
x, sigma * x.new_ones((x.shape[0],)), **extra_args
|
||||
)
|
||||
if not isinstance(prediction, torch.Tensor):
|
||||
raise TypeError(
|
||||
"Noise inversion model must return tensor predictions."
|
||||
)
|
||||
return (x - prediction) / sigma
|
||||
|
||||
endpoint = integrate_inversion(
|
||||
state, sigmas, evaluate, method=method, evidence=evidence
|
||||
)
|
||||
endpoints.append(endpoint.detach().float().cpu())
|
||||
return endpoint
|
||||
|
||||
sample_with_optional_negative(
|
||||
comfy_sample=comfy_sample,
|
||||
model=stage_model,
|
||||
noise=torch.zeros_like(stage_latent),
|
||||
cfg=cfg,
|
||||
sampler=comfy_samplers.KSAMPLER(invert),
|
||||
sigmas=schedule,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=stage_latent,
|
||||
noise_mask=noise_mask,
|
||||
seed=seed,
|
||||
disable_pbar=True,
|
||||
)
|
||||
if len(endpoints) != 1:
|
||||
raise RuntimeError(
|
||||
"Noise inversion must produce exactly one endpoint per stage."
|
||||
)
|
||||
phases.append(
|
||||
InversionStageMeasurement(
|
||||
name,
|
||||
_clock(device) - stage_started,
|
||||
tuple(stage_latent.shape),
|
||||
count,
|
||||
evidence.evaluations,
|
||||
)
|
||||
)
|
||||
return endpoints[0]
|
||||
|
||||
if options.resolution_scale == 1:
|
||||
endpoint = stage(
|
||||
latent,
|
||||
INVERSION_START_SIGMA,
|
||||
target,
|
||||
options.steps,
|
||||
None,
|
||||
"full",
|
||||
options.method,
|
||||
)
|
||||
else:
|
||||
height, width = options.coarse_shape(
|
||||
int(latent.shape[-2]), int(latent.shape[-1])
|
||||
)
|
||||
coarse = resize_spatial_tensor(latent, height=height, width=width, mode="area")
|
||||
coarse_source = _source_in_model_space(model, coarse)
|
||||
coarse_endpoint = stage(
|
||||
coarse,
|
||||
INVERSION_START_SIGMA,
|
||||
coarse_target,
|
||||
options.steps,
|
||||
None,
|
||||
"coarse",
|
||||
options.method,
|
||||
)
|
||||
endpoint = lift_inversion_displacement(
|
||||
source,
|
||||
coarse_source,
|
||||
coarse_endpoint,
|
||||
resize=lambda x, h, w: resize_spatial_tensor(
|
||||
x, height=h, width=w, mode="bilinear"
|
||||
),
|
||||
)
|
||||
if options.finishing_steps:
|
||||
endpoint = stage(
|
||||
latent,
|
||||
coarse_target,
|
||||
target,
|
||||
options.finishing_steps,
|
||||
endpoint,
|
||||
"full_finish",
|
||||
options.method,
|
||||
)
|
||||
|
||||
sigma = torch.tensor(target)
|
||||
zero = torch.zeros_like(source)
|
||||
base = sampling.noise_scaling(sigma, zero.clone(), source, max_denoise=False)
|
||||
amplitude = sampling.noise_scaling(
|
||||
sigma, torch.ones_like(source), zero, max_denoise=False
|
||||
)
|
||||
if not isinstance(base, torch.Tensor) or not isinstance(amplitude, torch.Tensor):
|
||||
raise TypeError("Model noise scaling must return tensors.")
|
||||
if not bool(torch.isfinite(amplitude).all()) or bool(torch.any(amplitude == 0)):
|
||||
raise ValueError("Model noise scaling is not invertible at the target sigma.")
|
||||
noise = (endpoint - base) / amplitude
|
||||
if not bool(torch.isfinite(noise).all()):
|
||||
raise FloatingPointError("Noise inversion produced non-finite sampling noise.")
|
||||
reconstructed = sampling.noise_scaling(
|
||||
sigma, noise.clone(), source, max_denoise=False
|
||||
)
|
||||
if (
|
||||
not isinstance(reconstructed, torch.Tensor)
|
||||
or reconstructed.shape != endpoint.shape
|
||||
):
|
||||
raise ValueError(
|
||||
"Model noise scaling must preserve the inversion endpoint shape."
|
||||
)
|
||||
if not torch.allclose(reconstructed, endpoint, atol=1e-5, rtol=1e-5):
|
||||
raise ValueError(
|
||||
"Model noise scaling cannot reconstruct the inversion endpoint."
|
||||
)
|
||||
error = float((reconstructed - endpoint).abs().max())
|
||||
elapsed = _clock(device) - started
|
||||
LOGGER.info(
|
||||
"Noise inversion completed in %.3f seconds",
|
||||
elapsed,
|
||||
extra={
|
||||
"operation": "noise_inversion",
|
||||
"method": options.method,
|
||||
"resolution_scale": options.resolution_scale,
|
||||
"steps": options.steps,
|
||||
"finishing_steps": options.finishing_steps,
|
||||
"inversion_seconds": elapsed,
|
||||
"inversion_stages": [
|
||||
{
|
||||
"name": phase.name,
|
||||
"seconds": phase.seconds,
|
||||
"latent_shape": phase.latent_shape,
|
||||
"steps": phase.steps,
|
||||
"evaluations": phase.evaluations,
|
||||
}
|
||||
for phase in phases
|
||||
],
|
||||
"evaluations": sum(phase.evaluations for phase in phases),
|
||||
"endpoint_reconstruction_max_error": error,
|
||||
},
|
||||
)
|
||||
return NoiseInversionResult(noise, elapsed, tuple(phases), error)
|
||||
@@ -0,0 +1,75 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Adapt shared detail sampling to regional MultiDiffusion runtime calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.regional_detailing import LatentRegion
|
||||
from . import regional_multidiffusion_sampling
|
||||
from .detail_previews import DetailPreviewContext
|
||||
from .detail_sampling import DetailSampler, Latent
|
||||
|
||||
|
||||
class RegionalDetailSampler:
|
||||
"""Adapt shared detail sampling helpers to regional MultiDiffusion."""
|
||||
|
||||
def __init__(self, detail_sampler: DetailSampler | None = None) -> None:
|
||||
"""Create the runtime adapter with injectable encode/decode behavior."""
|
||||
|
||||
self._detail_sampler = detail_sampler or DetailSampler()
|
||||
|
||||
def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent:
|
||||
"""Encode pixels into a latent dictionary."""
|
||||
|
||||
return self._detail_sampler.encode(vae, pixels, tiled)
|
||||
|
||||
def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor:
|
||||
"""Decode latent samples into pixels."""
|
||||
|
||||
return self._detail_sampler.decode(vae, latent, tiled)
|
||||
|
||||
def sample_regions(
|
||||
self,
|
||||
*,
|
||||
model: Any,
|
||||
seed: int,
|
||||
steps: int,
|
||||
cfg: float,
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
positive: Any,
|
||||
negative: Any,
|
||||
latent_image: Latent,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
denoise: float,
|
||||
global_prompt_weight: float,
|
||||
preview_context: DetailPreviewContext | None = None,
|
||||
differential_diffusion: bool = False,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
) -> Latent:
|
||||
"""Sample one full latent with regional MultiDiffusion."""
|
||||
|
||||
return regional_multidiffusion_sampling.sample_regional_multidiffusion(
|
||||
model=model,
|
||||
seed=seed,
|
||||
steps=steps,
|
||||
cfg=cfg,
|
||||
sampler_name=sampler_name,
|
||||
scheduler=scheduler,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_image,
|
||||
regions=regions,
|
||||
denoise=denoise,
|
||||
global_prompt_weight=global_prompt_weight,
|
||||
preview_context=preview_context,
|
||||
differential_diffusion=differential_diffusion,
|
||||
noise_inversion=noise_inversion,
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Rebuild regional prediction ownership from canonical inputs for inversion stages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.regional_detailing import LatentRegion
|
||||
from ..domain.regional_inversion_geometry import project_inversion_regions
|
||||
from .inversion_model_factory import InversionModelFactory
|
||||
|
||||
|
||||
class RegionalInversionModelFactory:
|
||||
"""Retain one original MODEL and the full-resolution region bank."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: Any,
|
||||
canvas_width: int,
|
||||
canvas_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
global_prompt_weight: float,
|
||||
differential_diffusion: bool,
|
||||
) -> None:
|
||||
"""Keep regional conditioning unchanged across source-sized inversion views."""
|
||||
self._base = InversionModelFactory(
|
||||
model=model,
|
||||
canvas_width=canvas_width,
|
||||
canvas_height=canvas_height,
|
||||
)
|
||||
self._width = canvas_width
|
||||
self._height = canvas_height
|
||||
self._regions = regions
|
||||
self._weight = global_prompt_weight
|
||||
self._differential = differential_diffusion
|
||||
|
||||
def __call__(self, latent: torch.Tensor) -> Any:
|
||||
"""Use the authoritative regional calc-cond-batch wrapper at each stage size."""
|
||||
from .regional_multidiffusion_sampling import (
|
||||
clone_model_with_regional_multidiffusion,
|
||||
)
|
||||
|
||||
height, width = int(latent.shape[-2]), int(latent.shape[-1])
|
||||
regions = project_inversion_regions(
|
||||
self._regions,
|
||||
source_width=self._width,
|
||||
source_height=self._height,
|
||||
target_width=width,
|
||||
target_height=height,
|
||||
)
|
||||
derived, _ = clone_model_with_regional_multidiffusion(
|
||||
self._base(latent),
|
||||
latent_width=width,
|
||||
latent_height=height,
|
||||
latent_ndim=latent.ndim,
|
||||
regions=regions,
|
||||
global_prompt_weight=self._weight,
|
||||
differential_diffusion=self._differential,
|
||||
)
|
||||
return derived
|
||||
@@ -0,0 +1,293 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Blend regional predictions inside ComfyUI calc-cond-batch execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.regional_detailing import LatentRegion
|
||||
from .tiled_sampling_validation import validate_tensor_shape
|
||||
|
||||
SAMPLER_LABEL = "Regional MultiDiffusion"
|
||||
CalcCondBatchFunction: TypeAlias = Callable[[dict[str, Any]], list[torch.Tensor]]
|
||||
|
||||
|
||||
class RegionalMultiDiffusionCalcCondBatch:
|
||||
"""Blend regional condition predictions before CFG is applied."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
global_prompt_weight: float,
|
||||
) -> None:
|
||||
"""Create a calc-cond-batch wrapper for one latent sampling shape."""
|
||||
|
||||
self._latent_width = latent_width
|
||||
self._latent_height = latent_height
|
||||
self._regions = regions
|
||||
self._existing_calc_cond_batch = existing_calc_cond_batch
|
||||
self._global_prompt_weight = global_prompt_weight
|
||||
|
||||
def __call__(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Return fallback predictions blended with regional predictions."""
|
||||
|
||||
x = args["input"]
|
||||
if not isinstance(x, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion model input must be a tensor.")
|
||||
validate_tensor_shape(x, sampler_label=SAMPLER_LABEL)
|
||||
if x.shape[-2:] != (self._latent_height, self._latent_width):
|
||||
return self._call_original(args)
|
||||
if not self._regions:
|
||||
return self._call_original(args)
|
||||
|
||||
timestep = args["sigma"]
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion sigma must be a tensor.")
|
||||
conds = args["conds"]
|
||||
if not isinstance(conds, list) or not conds:
|
||||
raise ValueError("Regional MultiDiffusion conds must be a non-empty list.")
|
||||
|
||||
fallback = self._call_original(args)
|
||||
regional_buffers = [torch.zeros_like(output) for output in fallback]
|
||||
regional_weights = [_new_spatial_weight(x) for _output in fallback]
|
||||
input_batch_size = int(x.shape[0])
|
||||
|
||||
for region in self._regions:
|
||||
region_slice = _region_slicer(region, x.ndim)
|
||||
region_x = x[region_slice]
|
||||
region_conds = [
|
||||
_prepare_region_conditioning(region.positive, args=args, x=region_x),
|
||||
*conds[1:],
|
||||
]
|
||||
region_args = args.copy()
|
||||
region_args["conds"] = region_conds
|
||||
region_args["input"] = region_x
|
||||
region_args["sigma"] = timestep
|
||||
region_outputs = self._call_original(region_args)
|
||||
self._accumulate_region_outputs(
|
||||
outputs=region_outputs,
|
||||
buffers=regional_buffers,
|
||||
weights=regional_weights,
|
||||
region=region,
|
||||
input_batch_size=input_batch_size,
|
||||
)
|
||||
|
||||
return [
|
||||
_blend_prediction(
|
||||
fallback_output,
|
||||
region_output,
|
||||
region_weight,
|
||||
global_prompt_weight=self._global_prompt_weight,
|
||||
)
|
||||
for fallback_output, region_output, region_weight in zip(
|
||||
fallback,
|
||||
regional_buffers,
|
||||
regional_weights,
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
|
||||
def _call_original(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Call the previous calc-cond-batch hook or ComfyUI default."""
|
||||
|
||||
clean_args = args.copy()
|
||||
clean_options = _clean_model_options(
|
||||
cast(dict[str, Any], clean_args["model_options"]),
|
||||
self._existing_calc_cond_batch,
|
||||
)
|
||||
clean_args["model_options"] = clean_options
|
||||
if self._existing_calc_cond_batch is not None:
|
||||
return self._existing_calc_cond_batch(clean_args)
|
||||
comfy_samplers = _comfy_samplers()
|
||||
return cast(
|
||||
list[torch.Tensor],
|
||||
comfy_samplers.calc_cond_batch(
|
||||
clean_args["model"],
|
||||
clean_args["conds"],
|
||||
clean_args["input"],
|
||||
clean_args["sigma"],
|
||||
clean_options,
|
||||
),
|
||||
)
|
||||
|
||||
def _accumulate_region_outputs(
|
||||
self,
|
||||
*,
|
||||
outputs: list[torch.Tensor],
|
||||
buffers: list[torch.Tensor],
|
||||
weights: list[torch.Tensor],
|
||||
region: LatentRegion,
|
||||
input_batch_size: int,
|
||||
) -> None:
|
||||
"""Accumulate one region prediction into full-latent buffers."""
|
||||
|
||||
for output_index, output in enumerate(outputs[: len(buffers)]):
|
||||
region_slice = _region_slicer(region, output.ndim)
|
||||
box = region.latent_box
|
||||
mask_slice = (
|
||||
region.latent_mask[
|
||||
box.y : box.y + box.height,
|
||||
box.x : box.x + box.width,
|
||||
]
|
||||
.reshape((1,) * (output.ndim - 2) + (box.height, box.width))
|
||||
.to(device=output.device, dtype=torch.float32)
|
||||
)
|
||||
buffers[output_index][region_slice] += output[
|
||||
:input_batch_size
|
||||
] * mask_slice.to(dtype=output.dtype)
|
||||
weights[output_index][region_slice] += mask_slice
|
||||
|
||||
|
||||
def validate_regions(
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
) -> None:
|
||||
"""Reject regions incompatible with the current latent shape."""
|
||||
|
||||
for region in regions:
|
||||
box = region.latent_box
|
||||
if region.latent_mask.shape != (latent_height, latent_width):
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent_mask must match the full latent height and width."
|
||||
)
|
||||
if box.x < 0 or box.y < 0 or box.width < 1 or box.height < 1:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"has an invalid latent box."
|
||||
)
|
||||
if box.x + box.width > latent_width or box.y + box.height > latent_height:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent box must fit inside the latent."
|
||||
)
|
||||
|
||||
|
||||
def _region_slicer(region: LatentRegion, tensor_ndim: int) -> tuple[slice, ...]:
|
||||
"""Return a slicer that crops a tensor to a latent region box."""
|
||||
|
||||
box = region.latent_box
|
||||
return (
|
||||
(slice(None),) * (tensor_ndim - 2)
|
||||
+ (slice(box.y, box.y + box.height),)
|
||||
+ (slice(box.x, box.x + box.width),)
|
||||
)
|
||||
|
||||
|
||||
def _new_spatial_weight(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Create a full-latent spatial weight buffer."""
|
||||
|
||||
return torch.zeros(
|
||||
(1,) * (x.ndim - 2) + (int(x.shape[-2]), int(x.shape[-1])),
|
||||
device=x.device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def _blend_prediction(
|
||||
fallback: torch.Tensor,
|
||||
regional: torch.Tensor,
|
||||
weight: torch.Tensor,
|
||||
*,
|
||||
global_prompt_weight: float,
|
||||
) -> torch.Tensor:
|
||||
"""Blend normalized regional predictions with global fallback predictions."""
|
||||
|
||||
has_region = weight > 0
|
||||
normalized = torch.where(
|
||||
has_region,
|
||||
regional / torch.clamp(weight, min=1.0e-37).to(dtype=regional.dtype),
|
||||
regional,
|
||||
)
|
||||
coverage = torch.clamp(weight, 0.0, 1.0).to(dtype=fallback.dtype)
|
||||
regional_alpha = coverage * (1.0 - global_prompt_weight)
|
||||
blended = fallback * (1.0 - regional_alpha) + normalized * regional_alpha
|
||||
return torch.where(has_region, blended, fallback)
|
||||
|
||||
|
||||
def _prepare_region_conditioning(
|
||||
conditioning: object,
|
||||
*,
|
||||
args: dict[str, Any],
|
||||
x: torch.Tensor,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert raw Comfy CONDITIONING into sampler-ready dictionaries."""
|
||||
|
||||
if _is_processed_conditioning(conditioning):
|
||||
return cast(list[dict[str, Any]], conditioning)
|
||||
if not isinstance(conditioning, list):
|
||||
raise TypeError("Regional MultiDiffusion region_positive must be CONDITIONING.")
|
||||
sampler_helpers = _comfy_sampler_helpers()
|
||||
comfy_samplers = _comfy_samplers()
|
||||
model = args["model"]
|
||||
converted = cast(list[dict[str, Any]], sampler_helpers.convert_cond(conditioning))
|
||||
comfy_samplers.resolve_areas_and_cond_masks_multidim(
|
||||
converted,
|
||||
tuple(int(dim) for dim in x.shape[2:]),
|
||||
x.device,
|
||||
)
|
||||
comfy_samplers.calculate_start_end_timesteps(model, converted)
|
||||
if hasattr(model, "extra_conds"):
|
||||
converted = cast(
|
||||
list[dict[str, Any]],
|
||||
comfy_samplers.encode_model_conds(
|
||||
model.extra_conds,
|
||||
converted,
|
||||
x,
|
||||
x.device,
|
||||
"positive",
|
||||
),
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
def _is_processed_conditioning(conditioning: object) -> bool:
|
||||
"""Return whether a conditioning value is already sampler-ready."""
|
||||
|
||||
if not isinstance(conditioning, list):
|
||||
return False
|
||||
if not conditioning:
|
||||
return True
|
||||
return all(
|
||||
isinstance(item, dict) and "model_conds" in item for item in conditioning
|
||||
)
|
||||
|
||||
|
||||
def _clean_model_options(
|
||||
model_options: dict[str, Any],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return model options that cannot recurse into this wrapper."""
|
||||
|
||||
clean_options = model_options.copy()
|
||||
if existing_calc_cond_batch is None:
|
||||
clean_options.pop("sampler_calc_cond_batch_function", None)
|
||||
else:
|
||||
clean_options["sampler_calc_cond_batch_function"] = existing_calc_cond_batch
|
||||
return clean_options
|
||||
|
||||
|
||||
def _comfy_samplers() -> ModuleType:
|
||||
"""Import Comfy sampler helpers lazily."""
|
||||
|
||||
return import_module("comfy.samplers")
|
||||
|
||||
|
||||
def _comfy_sampler_helpers() -> ModuleType:
|
||||
"""Import Comfy sampler conditioning helpers lazily."""
|
||||
|
||||
return import_module("comfy.sampler_helpers")
|
||||
@@ -10,25 +10,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
import torch
|
||||
from typing import Any, cast
|
||||
|
||||
from ..domain.noise_inversion import NoiseInversionOptions
|
||||
from ..domain.regional_detailing import LatentRegion
|
||||
from ..domain.regional_features import EMPTY_REGIONAL_CAPABILITY_ADMISSION
|
||||
from ..shared.logging import get_logger
|
||||
from . import sampling_samplers, sampling_schedulers
|
||||
from . import sampling_noise, sampling_samplers, sampling_schedulers
|
||||
from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback
|
||||
from .differential_diffusion import (
|
||||
differential_diffusion_mutation,
|
||||
has_denoise_mask_function,
|
||||
)
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
from .model_patcher_mutations import ModelCalcCondBatchMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
|
||||
from .regional_inversion_model_factory import RegionalInversionModelFactory
|
||||
from .regional_multidiffusion_prediction import (
|
||||
CalcCondBatchFunction,
|
||||
RegionalMultiDiffusionCalcCondBatch,
|
||||
validate_regions,
|
||||
)
|
||||
from .tiled_sampling_validation import (
|
||||
Latent,
|
||||
reject_unsupported_conditioning,
|
||||
@@ -39,7 +44,6 @@ from .tiled_sampling_validation import (
|
||||
LOGGER = get_logger(__name__)
|
||||
SAMPLER_LABEL = "Regional MultiDiffusion"
|
||||
UNIPC_SAMPLERS = frozenset({"uni_pc", "uni_pc_bh2"})
|
||||
CalcCondBatchFunction: TypeAlias = Callable[[dict[str, Any]], list[torch.Tensor]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -70,6 +74,7 @@ def sample_regional_multidiffusion(
|
||||
global_prompt_weight: float,
|
||||
preview_context: DetailPreviewContext | None = None,
|
||||
differential_diffusion: bool = False,
|
||||
noise_inversion: NoiseInversionOptions | None = None,
|
||||
) -> Latent:
|
||||
"""Sample a latent with regional MultiDiffusion prompt blending."""
|
||||
|
||||
@@ -133,22 +138,43 @@ def sample_regional_multidiffusion(
|
||||
)
|
||||
|
||||
batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None
|
||||
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
|
||||
noise = sampling_noise.prepare_sampling_noise(
|
||||
comfy_sample=comfy_sample,
|
||||
sampler_name=sampler_name,
|
||||
samples=latent_samples,
|
||||
seed=seed,
|
||||
batch_indices=batch_inds,
|
||||
model=sampling_model,
|
||||
)
|
||||
noise_mask = latent_image.get("noise_mask", None)
|
||||
callback = _sampling_callback(sampling_model, steps, preview_context)
|
||||
samples = comfy_sample.sample_custom(
|
||||
sampling_model,
|
||||
noise,
|
||||
cfg,
|
||||
sampler,
|
||||
sigmas,
|
||||
positive,
|
||||
negative,
|
||||
latent_samples,
|
||||
samples = sample_with_optional_negative(
|
||||
comfy_sample=comfy_sample,
|
||||
model=sampling_model,
|
||||
noise=noise,
|
||||
cfg=cfg,
|
||||
sampler=sampler,
|
||||
sigmas=sigmas,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
latent_image=latent_samples,
|
||||
noise_mask=noise_mask,
|
||||
callback=callback,
|
||||
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
|
||||
seed=seed,
|
||||
noise_inversion=noise_inversion,
|
||||
inversion_model_factory=(
|
||||
RegionalInversionModelFactory(
|
||||
model=model,
|
||||
canvas_width=latent_width,
|
||||
canvas_height=latent_height,
|
||||
regions=regions,
|
||||
global_prompt_weight=global_prompt_weight,
|
||||
differential_diffusion=differential_diffusion,
|
||||
)
|
||||
if noise_inversion is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
LOGGER.info(
|
||||
@@ -193,7 +219,7 @@ def clone_model_with_regional_multidiffusion(
|
||||
denoise=1.0,
|
||||
global_prompt_weight=global_prompt_weight,
|
||||
)
|
||||
_validate_regions(
|
||||
validate_regions(
|
||||
latent_width=latent_width,
|
||||
latent_height=latent_height,
|
||||
regions=regions,
|
||||
@@ -234,220 +260,6 @@ def clone_model_with_regional_multidiffusion(
|
||||
return derived_model, summary
|
||||
|
||||
|
||||
class RegionalMultiDiffusionCalcCondBatch:
|
||||
"""Blend regional condition predictions before CFG is applied."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
global_prompt_weight: float,
|
||||
) -> None:
|
||||
"""Create a calc-cond-batch wrapper for one latent sampling shape."""
|
||||
|
||||
self._latent_width = latent_width
|
||||
self._latent_height = latent_height
|
||||
self._regions = regions
|
||||
self._existing_calc_cond_batch = existing_calc_cond_batch
|
||||
self._global_prompt_weight = global_prompt_weight
|
||||
|
||||
def __call__(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Return fallback predictions blended with regional predictions."""
|
||||
|
||||
x = args["input"]
|
||||
if not isinstance(x, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion model input must be a tensor.")
|
||||
validate_tensor_shape(x, sampler_label=SAMPLER_LABEL)
|
||||
if x.shape[-2:] != (self._latent_height, self._latent_width):
|
||||
return self._call_original(args)
|
||||
if not self._regions:
|
||||
return self._call_original(args)
|
||||
|
||||
timestep = args["sigma"]
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion sigma must be a tensor.")
|
||||
conds = args["conds"]
|
||||
if not isinstance(conds, list) or not conds:
|
||||
raise ValueError("Regional MultiDiffusion conds must be a non-empty list.")
|
||||
|
||||
fallback = self._call_original(args)
|
||||
regional_buffers = [torch.zeros_like(output) for output in fallback]
|
||||
regional_weights = [_new_spatial_weight(x) for _output in fallback]
|
||||
input_batch_size = int(x.shape[0])
|
||||
|
||||
for region in self._regions:
|
||||
region_slice = _region_slicer(region, x.ndim)
|
||||
region_x = x[region_slice]
|
||||
region_conds = [
|
||||
_prepare_region_conditioning(
|
||||
region.positive,
|
||||
args=args,
|
||||
x=region_x,
|
||||
),
|
||||
*conds[1:],
|
||||
]
|
||||
region_args = args.copy()
|
||||
region_args["conds"] = region_conds
|
||||
region_args["input"] = region_x
|
||||
region_args["sigma"] = timestep
|
||||
region_outputs = self._call_original(region_args)
|
||||
self._accumulate_region_outputs(
|
||||
outputs=region_outputs,
|
||||
buffers=regional_buffers,
|
||||
weights=regional_weights,
|
||||
region=region,
|
||||
input_batch_size=input_batch_size,
|
||||
)
|
||||
|
||||
return [
|
||||
_blend_prediction(
|
||||
fallback_output,
|
||||
region_output,
|
||||
region_weight,
|
||||
global_prompt_weight=self._global_prompt_weight,
|
||||
)
|
||||
for fallback_output, region_output, region_weight in zip(
|
||||
fallback,
|
||||
regional_buffers,
|
||||
regional_weights,
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
|
||||
def _call_original(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Call the previous calc-cond-batch hook or ComfyUI default."""
|
||||
|
||||
clean_args = args.copy()
|
||||
clean_options = _clean_model_options(
|
||||
cast(dict[str, Any], clean_args["model_options"]),
|
||||
self._existing_calc_cond_batch,
|
||||
)
|
||||
clean_args["model_options"] = clean_options
|
||||
if self._existing_calc_cond_batch is not None:
|
||||
return self._existing_calc_cond_batch(clean_args)
|
||||
comfy_samplers = _comfy_samplers()
|
||||
return cast(
|
||||
list[torch.Tensor],
|
||||
comfy_samplers.calc_cond_batch(
|
||||
clean_args["model"],
|
||||
clean_args["conds"],
|
||||
clean_args["input"],
|
||||
clean_args["sigma"],
|
||||
clean_options,
|
||||
),
|
||||
)
|
||||
|
||||
def _accumulate_region_outputs(
|
||||
self,
|
||||
*,
|
||||
outputs: list[torch.Tensor],
|
||||
buffers: list[torch.Tensor],
|
||||
weights: list[torch.Tensor],
|
||||
region: LatentRegion,
|
||||
input_batch_size: int,
|
||||
) -> None:
|
||||
"""Accumulate one region prediction into full-latent buffers."""
|
||||
|
||||
for output_index, output in enumerate(outputs[: len(buffers)]):
|
||||
region_slice = _region_slicer(region, output.ndim)
|
||||
box = region.latent_box
|
||||
mask_slice = (
|
||||
region.latent_mask[
|
||||
box.y : box.y + box.height,
|
||||
box.x : box.x + box.width,
|
||||
]
|
||||
.reshape((1,) * (output.ndim - 2) + (box.height, box.width))
|
||||
.to(device=output.device, dtype=torch.float32)
|
||||
)
|
||||
buffers[output_index][region_slice] += output[
|
||||
:input_batch_size
|
||||
] * mask_slice.to(dtype=output.dtype)
|
||||
weights[output_index][region_slice] += mask_slice
|
||||
|
||||
|
||||
def _validate_regions(
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
) -> None:
|
||||
"""Reject regions incompatible with the current latent shape."""
|
||||
|
||||
for region in regions:
|
||||
_validate_region(region, latent_width=latent_width, latent_height=latent_height)
|
||||
|
||||
|
||||
def _validate_region(
|
||||
region: LatentRegion,
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
) -> None:
|
||||
"""Reject regions incompatible with the current latent shape."""
|
||||
|
||||
box = region.latent_box
|
||||
if region.latent_mask.shape != (latent_height, latent_width):
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent_mask must match the full latent height and width."
|
||||
)
|
||||
if box.x < 0 or box.y < 0 or box.width < 1 or box.height < 1:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"has an invalid latent box."
|
||||
)
|
||||
if box.x + box.width > latent_width or box.y + box.height > latent_height:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent box must fit inside the latent."
|
||||
)
|
||||
|
||||
|
||||
def _region_slicer(region: LatentRegion, tensor_ndim: int) -> tuple[slice, ...]:
|
||||
"""Return a slicer that crops a tensor to a latent region box."""
|
||||
|
||||
box = region.latent_box
|
||||
return (
|
||||
(slice(None),) * (tensor_ndim - 2)
|
||||
+ (slice(box.y, box.y + box.height),)
|
||||
+ (slice(box.x, box.x + box.width),)
|
||||
)
|
||||
|
||||
|
||||
def _new_spatial_weight(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Create a full-latent spatial weight buffer."""
|
||||
|
||||
return torch.zeros(
|
||||
(1,) * (x.ndim - 2) + (int(x.shape[-2]), int(x.shape[-1])),
|
||||
device=x.device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def _blend_prediction(
|
||||
fallback: torch.Tensor,
|
||||
regional: torch.Tensor,
|
||||
weight: torch.Tensor,
|
||||
*,
|
||||
global_prompt_weight: float,
|
||||
) -> torch.Tensor:
|
||||
"""Blend normalized regional predictions with global fallback predictions."""
|
||||
|
||||
has_region = weight > 0
|
||||
normalized = torch.where(
|
||||
has_region,
|
||||
regional / torch.clamp(weight, min=1.0e-37).to(dtype=regional.dtype),
|
||||
regional,
|
||||
)
|
||||
coverage = torch.clamp(weight, 0.0, 1.0).to(dtype=fallback.dtype)
|
||||
regional_alpha = coverage * (1.0 - global_prompt_weight)
|
||||
blended = fallback * (1.0 - regional_alpha) + normalized * regional_alpha
|
||||
return torch.where(has_region, blended, fallback)
|
||||
|
||||
|
||||
def _validate_sampling_controls(
|
||||
*,
|
||||
steps: int,
|
||||
@@ -470,69 +282,6 @@ def _validate_global_prompt_weight(global_prompt_weight: float) -> None:
|
||||
raise ValueError("global_prompt_weight must be between 0.0 and 1.0.")
|
||||
|
||||
|
||||
def _prepare_region_conditioning(
|
||||
conditioning: object,
|
||||
*,
|
||||
args: dict[str, Any],
|
||||
x: torch.Tensor,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert raw Comfy CONDITIONING into sampler-ready condition dictionaries."""
|
||||
|
||||
if _is_processed_conditioning(conditioning):
|
||||
return cast(list[dict[str, Any]], conditioning)
|
||||
if not isinstance(conditioning, list):
|
||||
raise TypeError("Regional MultiDiffusion region_positive must be CONDITIONING.")
|
||||
|
||||
sampler_helpers = _comfy_sampler_helpers()
|
||||
comfy_samplers = _comfy_samplers()
|
||||
model = args["model"]
|
||||
converted = cast(list[dict[str, Any]], sampler_helpers.convert_cond(conditioning))
|
||||
comfy_samplers.resolve_areas_and_cond_masks_multidim(
|
||||
converted,
|
||||
tuple(int(dim) for dim in x.shape[2:]),
|
||||
x.device,
|
||||
)
|
||||
comfy_samplers.calculate_start_end_timesteps(model, converted)
|
||||
if hasattr(model, "extra_conds"):
|
||||
converted = cast(
|
||||
list[dict[str, Any]],
|
||||
comfy_samplers.encode_model_conds(
|
||||
model.extra_conds,
|
||||
converted,
|
||||
x,
|
||||
x.device,
|
||||
"positive",
|
||||
),
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
def _is_processed_conditioning(conditioning: object) -> bool:
|
||||
"""Return whether a conditioning value is already sampler-ready."""
|
||||
|
||||
if not isinstance(conditioning, list):
|
||||
return False
|
||||
if not conditioning:
|
||||
return True
|
||||
return all(
|
||||
isinstance(item, dict) and "model_conds" in item for item in conditioning
|
||||
)
|
||||
|
||||
|
||||
def _clean_model_options(
|
||||
model_options: dict[str, Any],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return model options that cannot recurse into this wrapper."""
|
||||
|
||||
clean_options = model_options.copy()
|
||||
if existing_calc_cond_batch is None:
|
||||
clean_options.pop("sampler_calc_cond_batch_function", None)
|
||||
else:
|
||||
clean_options["sampler_calc_cond_batch_function"] = existing_calc_cond_batch
|
||||
return clean_options
|
||||
|
||||
|
||||
def _reject_unipc_sampler(sampler_name: str) -> None:
|
||||
"""Reject UniPC samplers because MultiDiffusion is incompatible with them."""
|
||||
|
||||
@@ -566,18 +315,6 @@ def _comfy_utils() -> ModuleType:
|
||||
return import_module("comfy.utils")
|
||||
|
||||
|
||||
def _comfy_samplers() -> ModuleType:
|
||||
"""Import ComfyUI sampler helpers lazily."""
|
||||
|
||||
return import_module("comfy.samplers")
|
||||
|
||||
|
||||
def _comfy_sampler_helpers() -> ModuleType:
|
||||
"""Import ComfyUI conditioning conversion helpers lazily."""
|
||||
|
||||
return import_module("comfy.sampler_helpers")
|
||||
|
||||
|
||||
def _latent_preview() -> ModuleType:
|
||||
"""Import ComfyUI preview helpers lazily."""
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Registry values follow RES4LYF beta/rk_coefficients_beta.py at
|
||||
# 3d1d69da69ee47f7647d59e1bd0967e472fccc41.
|
||||
|
||||
"""Expose pinned RES4LYF sampler names without loading its solver at import."""
|
||||
|
||||
RES4LYF_SAMPLER_NAMES: tuple[str, ...] = (
|
||||
"multistep/res_2m",
|
||||
"multistep/res_3m",
|
||||
"multistep/dpmpp_2m",
|
||||
"multistep/dpmpp_3m",
|
||||
"multistep/abnorsett_2m",
|
||||
"multistep/abnorsett_3m",
|
||||
"multistep/abnorsett_4m",
|
||||
"multistep/deis_2m",
|
||||
"multistep/deis_3m",
|
||||
"multistep/deis_4m",
|
||||
"exponential/res_2s_rkmk2e",
|
||||
"exponential/res_2s",
|
||||
"exponential/res_2s_stable",
|
||||
"exponential/res_3s",
|
||||
"exponential/res_3s_non-monotonic",
|
||||
"exponential/res_3s_alt",
|
||||
"exponential/res_3s_cox_matthews",
|
||||
"exponential/res_3s_lie",
|
||||
"exponential/res_3s_sunstar",
|
||||
"exponential/res_3s_strehmel_weiner",
|
||||
"exponential/res_4s_krogstad",
|
||||
"exponential/res_4s_krogstad_alt",
|
||||
"exponential/res_4s_strehmel_weiner",
|
||||
"exponential/res_4s_strehmel_weiner_alt",
|
||||
"exponential/res_4s_cox_matthews",
|
||||
"exponential/res_4s_cfree4",
|
||||
"exponential/res_4s_friedli",
|
||||
"exponential/res_4s_minchev",
|
||||
"exponential/res_4s_munthe-kaas",
|
||||
"exponential/res_5s",
|
||||
"exponential/res_5s_hochbruck-ostermann",
|
||||
"exponential/res_6s",
|
||||
"exponential/res_8s",
|
||||
"exponential/res_8s_alt",
|
||||
"exponential/res_10s",
|
||||
"exponential/res_15s",
|
||||
"exponential/res_16s",
|
||||
"exponential/etdrk2_2s",
|
||||
"exponential/etdrk3_a_3s",
|
||||
"exponential/etdrk3_b_3s",
|
||||
"exponential/etdrk4_4s",
|
||||
"exponential/etdrk4_4s_alt",
|
||||
"exponential/dpmpp_2s",
|
||||
"exponential/dpmpp_sde_2s",
|
||||
"exponential/dpmpp_3s",
|
||||
"exponential/lawson2a_2s",
|
||||
"exponential/lawson2b_2s",
|
||||
"exponential/lawson4_4s",
|
||||
"exponential/lawson41-gen_4s",
|
||||
"exponential/lawson41-gen-mod_4s",
|
||||
"exponential/ddim",
|
||||
"hybrid/pec423_2h2s",
|
||||
"hybrid/pec433_2h3s",
|
||||
"hybrid/abnorsett2_1h2s",
|
||||
"hybrid/abnorsett3_2h2s",
|
||||
"hybrid/abnorsett4_3h2s",
|
||||
"hybrid/lawson42-gen-mod_1h4s",
|
||||
"hybrid/lawson43-gen-mod_2h4s",
|
||||
"hybrid/lawson44-gen-mod_3h4s",
|
||||
"hybrid/lawson45-gen-mod_4h4s",
|
||||
"linear/ralston_2s",
|
||||
"linear/ralston_3s",
|
||||
"linear/ralston_4s",
|
||||
"linear/midpoint_2s",
|
||||
"linear/heun_2s",
|
||||
"linear/heun_3s",
|
||||
"linear/houwen-wray_3s",
|
||||
"linear/kutta_3s",
|
||||
"linear/ssprk3_3s",
|
||||
"linear/ssprk4_4s",
|
||||
"linear/rk38_4s",
|
||||
"linear/rk4_4s",
|
||||
"linear/rk5_7s",
|
||||
"linear/rk6_7s",
|
||||
"linear/bogacki-shampine_4s",
|
||||
"linear/bogacki-shampine_7s",
|
||||
"linear/dormand-prince_6s",
|
||||
"linear/dormand-prince_13s",
|
||||
"linear/tsi_7s",
|
||||
"linear/euler",
|
||||
"diag_implicit/irk_exp_diag_2s",
|
||||
"diag_implicit/kraaijevanger_spijker_2s",
|
||||
"diag_implicit/qin_zhang_2s",
|
||||
"diag_implicit/pareschi_russo_2s",
|
||||
"diag_implicit/pareschi_russo_alt_2s",
|
||||
"diag_implicit/crouzeix_2s",
|
||||
"diag_implicit/crouzeix_3s",
|
||||
"diag_implicit/crouzeix_3s_alt",
|
||||
"fully_implicit/gauss-legendre_2s",
|
||||
"fully_implicit/gauss-legendre_3s",
|
||||
"fully_implicit/gauss-legendre_4s",
|
||||
"fully_implicit/gauss-legendre_4s_alternating_a",
|
||||
"fully_implicit/gauss-legendre_4s_ascending_a",
|
||||
"fully_implicit/gauss-legendre_4s_alt",
|
||||
"fully_implicit/gauss-legendre_5s",
|
||||
"fully_implicit/gauss-legendre_5s_ascending",
|
||||
"fully_implicit/radau_ia_2s",
|
||||
"fully_implicit/radau_ia_3s",
|
||||
"fully_implicit/radau_iia_2s",
|
||||
"fully_implicit/radau_iia_3s",
|
||||
"fully_implicit/radau_iia_3s_alt",
|
||||
"fully_implicit/radau_iia_5s",
|
||||
"fully_implicit/radau_iia_7s",
|
||||
"fully_implicit/radau_iia_9s",
|
||||
"fully_implicit/radau_iia_11s",
|
||||
"fully_implicit/lobatto_iiia_2s",
|
||||
"fully_implicit/lobatto_iiia_3s",
|
||||
"fully_implicit/lobatto_iiia_4s",
|
||||
"fully_implicit/lobatto_iiib_2s",
|
||||
"fully_implicit/lobatto_iiib_3s",
|
||||
"fully_implicit/lobatto_iiib_4s",
|
||||
"fully_implicit/lobatto_iiic_2s",
|
||||
"fully_implicit/lobatto_iiic_3s",
|
||||
"fully_implicit/lobatto_iiic_4s",
|
||||
"fully_implicit/lobatto_iiic_star_2s",
|
||||
"fully_implicit/lobatto_iiic_star_3s",
|
||||
"fully_implicit/lobatto_iiid_2s",
|
||||
"fully_implicit/lobatto_iiid_3s",
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Resolve pinned RES4LYF solver methods within ComfyUI's sampler boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
|
||||
from .res4lyf_sampler_names import RES4LYF_SAMPLER_NAMES
|
||||
from .sampling_samplers import SamplerObject
|
||||
|
||||
|
||||
def resolve_res4lyf_sampler(sampler_name: str) -> SamplerObject:
|
||||
"""Bind an upstream method name to the pinned RES4LYF solver."""
|
||||
|
||||
if sampler_name not in RES4LYF_SAMPLER_NAMES:
|
||||
raise ValueError(f"Unsupported RES4LYF sampler '{sampler_name}'.")
|
||||
|
||||
method = sampler_name.rsplit("/", 1)[-1]
|
||||
implicit = sampler_name.startswith(("fully_implicit/", "diag_implicit/"))
|
||||
options = {
|
||||
"rk_type": "euler" if implicit else method,
|
||||
"implicit_sampler_name": method if implicit else "use_explicit",
|
||||
"implicit_type": "bongmath",
|
||||
"implicit_type_substeps": "bongmath",
|
||||
"bongmath": sampler_name != "linear/rk5_7s",
|
||||
}
|
||||
comfy_samplers = import_module("comfy.samplers")
|
||||
return cast(
|
||||
SamplerObject,
|
||||
comfy_samplers.KSAMPLER(_sample_res4lyf, extra_options=options),
|
||||
)
|
||||
|
||||
|
||||
def _sample_res4lyf(
|
||||
model: Any,
|
||||
x: torch.Tensor,
|
||||
sigmas: torch.Tensor,
|
||||
*,
|
||||
extra_args: dict[str, Any],
|
||||
callback: Any,
|
||||
disable: bool,
|
||||
rk_type: str,
|
||||
implicit_sampler_name: str,
|
||||
implicit_type: str,
|
||||
implicit_type_substeps: str,
|
||||
bongmath: bool,
|
||||
) -> torch.Tensor:
|
||||
"""Pass ComfyUI's seed to the same SDE stream used by RES4LYF's node."""
|
||||
|
||||
seed = extra_args.get("seed")
|
||||
if not isinstance(seed, int):
|
||||
raise ValueError("RES4LYF sampler requires an integer sampling seed.")
|
||||
solver = import_module(
|
||||
"simple_syrup.third_party.res4lyf_runtime.beta.rk_sampler_beta"
|
||||
)
|
||||
samples = cast(
|
||||
torch.Tensor,
|
||||
solver.sample_rk_beta(
|
||||
model,
|
||||
x,
|
||||
sigmas,
|
||||
extra_args=extra_args,
|
||||
callback=callback,
|
||||
disable=disable,
|
||||
rk_type=rk_type,
|
||||
implicit_sampler_name=implicit_sampler_name,
|
||||
implicit_type=implicit_type,
|
||||
implicit_type_substeps=implicit_type_substeps,
|
||||
BONGMATH=bongmath,
|
||||
noise_seed=seed + 1,
|
||||
),
|
||||
)
|
||||
if not torch.isfinite(samples).all():
|
||||
raise ValueError(
|
||||
f"RES4LYF sampler '{rk_type}' produced non-finite latent values. "
|
||||
"Try another sampler or scheduler for this model."
|
||||
)
|
||||
return samples
|
||||
@@ -0,0 +1,68 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Own initial latent noise generation for core and RES4LYF samplers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
import torch
|
||||
|
||||
from .res4lyf_sampler_names import RES4LYF_SAMPLER_NAMES
|
||||
|
||||
|
||||
class SamplingModel(Protocol):
|
||||
"""Expose the model sampling object used by RES4LYF noise generation."""
|
||||
|
||||
def get_model_object(self, name: str) -> object:
|
||||
"""Return a named ComfyUI model object."""
|
||||
|
||||
|
||||
class ModelSamplingBounds(Protocol):
|
||||
"""Expose the sigma limits used by RES4LYF's noise generator."""
|
||||
|
||||
sigma_max: float | torch.Tensor
|
||||
sigma_min: float | torch.Tensor
|
||||
|
||||
|
||||
def prepare_sampling_noise(
|
||||
*,
|
||||
comfy_sample: Any,
|
||||
sampler_name: str,
|
||||
samples: torch.Tensor,
|
||||
seed: int,
|
||||
batch_indices: Any,
|
||||
model: SamplingModel,
|
||||
) -> torch.Tensor:
|
||||
"""Generate RES4LYF's default noise or preserve ComfyUI's core path."""
|
||||
|
||||
if sampler_name not in RES4LYF_SAMPLER_NAMES:
|
||||
return cast(
|
||||
torch.Tensor, comfy_sample.prepare_noise(samples, seed, batch_indices)
|
||||
)
|
||||
|
||||
model_sampling = cast(ModelSamplingBounds, model.get_model_object("model_sampling"))
|
||||
sigma_max = model_sampling.sigma_max
|
||||
sigma_min = model_sampling.sigma_min
|
||||
noise_classes = import_module(
|
||||
"simple_syrup.third_party.res4lyf_runtime.beta.noise_classes"
|
||||
)
|
||||
latents = import_module("simple_syrup.third_party.res4lyf_runtime.latents")
|
||||
reference = samples.to(torch.float32)
|
||||
generator = noise_classes.NOISE_GENERATOR_CLASSES_SIMPLE["gaussian"](
|
||||
x=reference.to(torch.float64),
|
||||
seed=seed,
|
||||
sigma_max=sigma_max,
|
||||
sigma_min=sigma_min,
|
||||
)
|
||||
noise = cast(torch.Tensor, generator(sigma=sigma_max, sigma_next=sigma_min))
|
||||
if noise.std() > 0:
|
||||
noise = cast(
|
||||
torch.Tensor,
|
||||
latents.normalize_zscore(noise, channelwise=True, inplace=True),
|
||||
)
|
||||
noise = noise - noise.mean()
|
||||
return noise.to(reference.dtype)
|
||||
@@ -0,0 +1,283 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Own reference noise-level tables used by local AYS and GITS schedules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
AYS_NOISE_LEVELS: dict[str, tuple[float, ...]] = {
|
||||
"SD1": (
|
||||
14.6146412293,
|
||||
6.4745760956,
|
||||
3.8636745985,
|
||||
2.6946151520,
|
||||
1.8841921177,
|
||||
1.3943805092,
|
||||
0.9642583904,
|
||||
0.6523686016,
|
||||
0.3977456272,
|
||||
0.1515232662,
|
||||
0.0291671582,
|
||||
),
|
||||
"SDXL": (
|
||||
14.6146412293,
|
||||
6.3184485287,
|
||||
3.7681790315,
|
||||
2.1811480769,
|
||||
1.3405244945,
|
||||
0.8620721141,
|
||||
0.5550693289,
|
||||
0.3798540708,
|
||||
0.2332364134,
|
||||
0.1114188177,
|
||||
0.0291671582,
|
||||
),
|
||||
}
|
||||
|
||||
GITS_DEFAULT_NOISE_LEVELS: tuple[tuple[float, ...], ...] = (
|
||||
(14.61464119, 0.803307, 0.02916753),
|
||||
(14.61464119, 1.56271636, 0.52423614, 0.02916753),
|
||||
(14.61464119, 2.36326075, 0.92192322, 0.36617002, 0.02916753),
|
||||
(14.61464119, 2.84484982, 1.24153244, 0.59516323, 0.25053367, 0.02916753),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.05039096,
|
||||
0.95350921,
|
||||
0.45573691,
|
||||
0.17026083,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.45070267,
|
||||
1.24153244,
|
||||
0.64427125,
|
||||
0.29807833,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.45070267,
|
||||
1.36964464,
|
||||
0.803307,
|
||||
0.45573691,
|
||||
0.25053367,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.84484982,
|
||||
1.61558151,
|
||||
0.95350921,
|
||||
0.59516323,
|
||||
0.36617002,
|
||||
0.19894916,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.84484982,
|
||||
1.67050016,
|
||||
1.08895338,
|
||||
0.74807048,
|
||||
0.50118381,
|
||||
0.32104823,
|
||||
0.19894916,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.95596409,
|
||||
1.84880662,
|
||||
1.24153244,
|
||||
0.83188516,
|
||||
0.59516323,
|
||||
0.41087446,
|
||||
0.27464288,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
3.07277966,
|
||||
1.98035145,
|
||||
1.36964464,
|
||||
0.95350921,
|
||||
0.69515091,
|
||||
0.50118381,
|
||||
0.36617002,
|
||||
0.25053367,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
6.77309084,
|
||||
3.46139455,
|
||||
2.36326075,
|
||||
1.56271636,
|
||||
1.08895338,
|
||||
0.803307,
|
||||
0.59516323,
|
||||
0.45573691,
|
||||
0.34370604,
|
||||
0.25053367,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
6.77309084,
|
||||
3.46139455,
|
||||
2.45070267,
|
||||
1.61558151,
|
||||
1.162866,
|
||||
0.86115354,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.38853383,
|
||||
0.29807833,
|
||||
0.22545385,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.12350607,
|
||||
1.51179266,
|
||||
1.08895338,
|
||||
0.83188516,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.38853383,
|
||||
0.29807833,
|
||||
0.22545385,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.12350607,
|
||||
1.51179266,
|
||||
1.08895338,
|
||||
0.83188516,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.41087446,
|
||||
0.32104823,
|
||||
0.25053367,
|
||||
0.19894916,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.12350607,
|
||||
1.51179266,
|
||||
1.08895338,
|
||||
0.83188516,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.41087446,
|
||||
0.34370604,
|
||||
0.27464288,
|
||||
0.22545385,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.19988537,
|
||||
1.61558151,
|
||||
1.20157266,
|
||||
0.92192322,
|
||||
0.72133851,
|
||||
0.57119018,
|
||||
0.45573691,
|
||||
0.36617002,
|
||||
0.29807833,
|
||||
0.25053367,
|
||||
0.19894916,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.19988537,
|
||||
1.61558151,
|
||||
1.24153244,
|
||||
0.95350921,
|
||||
0.74807048,
|
||||
0.59516323,
|
||||
0.4783645,
|
||||
0.38853383,
|
||||
0.32104823,
|
||||
0.27464288,
|
||||
0.22545385,
|
||||
0.19894916,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.19988537,
|
||||
1.61558151,
|
||||
1.24153244,
|
||||
0.95350921,
|
||||
0.74807048,
|
||||
0.59516323,
|
||||
0.50118381,
|
||||
0.41087446,
|
||||
0.34370604,
|
||||
0.29807833,
|
||||
0.25053367,
|
||||
0.22545385,
|
||||
0.19894916,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
)
|
||||
@@ -16,6 +16,7 @@ from typing import Protocol, cast
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
from .a1111_sampling import sample_euler_ancestral_a1111
|
||||
from .res4lyf_sampler_names import RES4LYF_SAMPLER_NAMES
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
EXTRA_SAMPLERS = ("euler_a_a1111",)
|
||||
@@ -33,7 +34,7 @@ def available_samplers() -> tuple[str, ...]:
|
||||
|
||||
comfy_samplers = _comfy_samplers()
|
||||
core_samplers = tuple(str(name) for name in comfy_samplers.KSampler.SAMPLERS)
|
||||
return _unique_sampler_names(core_samplers + EXTRA_SAMPLERS)
|
||||
return _unique_sampler_names(core_samplers + EXTRA_SAMPLERS + RES4LYF_SAMPLER_NAMES)
|
||||
|
||||
|
||||
def resolve_sampler(sampler_name: str) -> SamplerObject:
|
||||
@@ -57,6 +58,11 @@ def resolve_sampler(sampler_name: str) -> SamplerObject:
|
||||
if sampler_name in EXTRA_SAMPLERS:
|
||||
return _resolve_extra_sampler(sampler_name)
|
||||
|
||||
if sampler_name in RES4LYF_SAMPLER_NAMES:
|
||||
from .res4lyf_sampling import resolve_res4lyf_sampler
|
||||
|
||||
return resolve_res4lyf_sampler(sampler_name)
|
||||
|
||||
return cast(SamplerObject, _comfy_samplers().sampler_object(sampler_name))
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Protocol, cast
|
||||
import torch
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
from .sampling_reference_schedules import AYS_NOISE_LEVELS, GITS_DEFAULT_NOISE_LEVELS
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
@@ -27,6 +28,7 @@ EXTRA_SCHEDULERS = (
|
||||
"AYS SDXL",
|
||||
"GITS",
|
||||
"beta57",
|
||||
"bong_tangent",
|
||||
"automatic_a1111",
|
||||
"Flux2",
|
||||
)
|
||||
@@ -34,282 +36,6 @@ GITS_DEFAULT_COEFF = 1.20
|
||||
BETA57_ALPHA = 0.5
|
||||
BETA57_BETA = 0.7
|
||||
|
||||
AYS_NOISE_LEVELS: dict[str, tuple[float, ...]] = {
|
||||
"SD1": (
|
||||
14.6146412293,
|
||||
6.4745760956,
|
||||
3.8636745985,
|
||||
2.6946151520,
|
||||
1.8841921177,
|
||||
1.3943805092,
|
||||
0.9642583904,
|
||||
0.6523686016,
|
||||
0.3977456272,
|
||||
0.1515232662,
|
||||
0.0291671582,
|
||||
),
|
||||
"SDXL": (
|
||||
14.6146412293,
|
||||
6.3184485287,
|
||||
3.7681790315,
|
||||
2.1811480769,
|
||||
1.3405244945,
|
||||
0.8620721141,
|
||||
0.5550693289,
|
||||
0.3798540708,
|
||||
0.2332364134,
|
||||
0.1114188177,
|
||||
0.0291671582,
|
||||
),
|
||||
}
|
||||
|
||||
GITS_DEFAULT_NOISE_LEVELS: tuple[tuple[float, ...], ...] = (
|
||||
(14.61464119, 0.803307, 0.02916753),
|
||||
(14.61464119, 1.56271636, 0.52423614, 0.02916753),
|
||||
(14.61464119, 2.36326075, 0.92192322, 0.36617002, 0.02916753),
|
||||
(14.61464119, 2.84484982, 1.24153244, 0.59516323, 0.25053367, 0.02916753),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.05039096,
|
||||
0.95350921,
|
||||
0.45573691,
|
||||
0.17026083,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.45070267,
|
||||
1.24153244,
|
||||
0.64427125,
|
||||
0.29807833,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.45070267,
|
||||
1.36964464,
|
||||
0.803307,
|
||||
0.45573691,
|
||||
0.25053367,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.84484982,
|
||||
1.61558151,
|
||||
0.95350921,
|
||||
0.59516323,
|
||||
0.36617002,
|
||||
0.19894916,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.84484982,
|
||||
1.67050016,
|
||||
1.08895338,
|
||||
0.74807048,
|
||||
0.50118381,
|
||||
0.32104823,
|
||||
0.19894916,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
2.95596409,
|
||||
1.84880662,
|
||||
1.24153244,
|
||||
0.83188516,
|
||||
0.59516323,
|
||||
0.41087446,
|
||||
0.27464288,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
5.85520077,
|
||||
3.07277966,
|
||||
1.98035145,
|
||||
1.36964464,
|
||||
0.95350921,
|
||||
0.69515091,
|
||||
0.50118381,
|
||||
0.36617002,
|
||||
0.25053367,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
6.77309084,
|
||||
3.46139455,
|
||||
2.36326075,
|
||||
1.56271636,
|
||||
1.08895338,
|
||||
0.803307,
|
||||
0.59516323,
|
||||
0.45573691,
|
||||
0.34370604,
|
||||
0.25053367,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
6.77309084,
|
||||
3.46139455,
|
||||
2.45070267,
|
||||
1.61558151,
|
||||
1.162866,
|
||||
0.86115354,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.38853383,
|
||||
0.29807833,
|
||||
0.22545385,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.12350607,
|
||||
1.51179266,
|
||||
1.08895338,
|
||||
0.83188516,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.38853383,
|
||||
0.29807833,
|
||||
0.22545385,
|
||||
0.17026083,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.12350607,
|
||||
1.51179266,
|
||||
1.08895338,
|
||||
0.83188516,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.41087446,
|
||||
0.32104823,
|
||||
0.25053367,
|
||||
0.19894916,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.12350607,
|
||||
1.51179266,
|
||||
1.08895338,
|
||||
0.83188516,
|
||||
0.64427125,
|
||||
0.50118381,
|
||||
0.41087446,
|
||||
0.34370604,
|
||||
0.27464288,
|
||||
0.22545385,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.19988537,
|
||||
1.61558151,
|
||||
1.20157266,
|
||||
0.92192322,
|
||||
0.72133851,
|
||||
0.57119018,
|
||||
0.45573691,
|
||||
0.36617002,
|
||||
0.29807833,
|
||||
0.25053367,
|
||||
0.19894916,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.19988537,
|
||||
1.61558151,
|
||||
1.24153244,
|
||||
0.95350921,
|
||||
0.74807048,
|
||||
0.59516323,
|
||||
0.4783645,
|
||||
0.38853383,
|
||||
0.32104823,
|
||||
0.27464288,
|
||||
0.22545385,
|
||||
0.19894916,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
(
|
||||
14.61464119,
|
||||
7.49001646,
|
||||
4.65472794,
|
||||
3.07277966,
|
||||
2.19988537,
|
||||
1.61558151,
|
||||
1.24153244,
|
||||
0.95350921,
|
||||
0.74807048,
|
||||
0.59516323,
|
||||
0.50118381,
|
||||
0.41087446,
|
||||
0.34370604,
|
||||
0.29807833,
|
||||
0.25053367,
|
||||
0.22545385,
|
||||
0.19894916,
|
||||
0.17026083,
|
||||
0.13792117,
|
||||
0.09824532,
|
||||
0.02916753,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SamplingModel(Protocol):
|
||||
"""Expose the ComfyUI model sampling object needed for core schedulers."""
|
||||
@@ -513,6 +239,8 @@ def _calculate_extra_schedule(
|
||||
return _calculate_gits_schedule(steps)
|
||||
if scheduler_name == "beta57":
|
||||
return _calculate_beta57_schedule(model, steps)
|
||||
if scheduler_name == "bong_tangent":
|
||||
return _calculate_bong_tangent_schedule(model, steps)
|
||||
if scheduler_name == "automatic_a1111":
|
||||
return _calculate_automatic_a1111_schedule(model, steps)
|
||||
if scheduler_name == "Flux2":
|
||||
@@ -592,6 +320,18 @@ def _calculate_beta57_schedule(model: SamplingModel, steps: int) -> torch.Tensor
|
||||
)
|
||||
|
||||
|
||||
def _calculate_bong_tangent_schedule(model: SamplingModel, steps: int) -> torch.Tensor:
|
||||
"""Use RES4LYF's pinned tangent schedule with its default controls."""
|
||||
|
||||
res4lyf_sigmas = import_module("simple_syrup.third_party.res4lyf_runtime.sigmas")
|
||||
return cast(
|
||||
torch.Tensor,
|
||||
res4lyf_sigmas.bong_tangent_scheduler(
|
||||
model.get_model_object("model_sampling"), steps
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _calculate_automatic_a1111_schedule(
|
||||
model: SamplingModel,
|
||||
steps: int,
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Protocol
|
||||
|
||||
import torch
|
||||
|
||||
from ..services.simple_preview_segs_service import SegPreviewDocument
|
||||
from ..domain.seg_preview import SegPreviewDocument
|
||||
|
||||
SEG_PREVIEW_UI_KEY = "simple_syrup_segs_preview"
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ from dataclasses import dataclass
|
||||
import torch
|
||||
|
||||
from ..domain.segs import BoundingBox
|
||||
from ..masking.segs_mask_ops import normalize_mask
|
||||
from .ultralytics_loader import UltralyticsDetectorModel
|
||||
from ..domain.segs_mask_ops import normalize_mask
|
||||
from .ultralytics_model_adapter import UltralyticsDetectorModel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Ultralytics detector model discovery and lazy loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
from .model_catalog import ULTRALYTICS_ENTRIES, ModelEntry
|
||||
from .model_choices import ModelChoiceService
|
||||
from .model_downloads import DownloadRequest, ModelDownloader, ProgressReporter
|
||||
from .model_folders import (
|
||||
SUPPORTED_MODEL_EXTENSIONS,
|
||||
expected_model_file,
|
||||
resolve_model_file,
|
||||
)
|
||||
from .model_instance_cache import ModelInstanceCache
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
NO_LOCAL_ULTRALYTICS_MODELS = "No local Ultralytics models found"
|
||||
ULTRALYTICS_FOLDER = "ultralytics"
|
||||
ULTRALYTICS_BBOX_FOLDER = "ultralytics_bbox"
|
||||
ULTRALYTICS_SEGM_FOLDER = "ultralytics_segm"
|
||||
|
||||
ModelFolderRegistry: TypeAlias = dict[str, tuple[list[str], set[str]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltralyticsDetectorModel:
|
||||
"""Store a loaded Ultralytics detector with SimpleSyrup metadata."""
|
||||
|
||||
model_name: str
|
||||
model_path: Path
|
||||
model: Any
|
||||
task: str
|
||||
names: dict[int, str]
|
||||
supports_segmentation: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedUltralyticsDetector:
|
||||
"""Bundle native and compatibility detector outputs from the loader."""
|
||||
|
||||
detector_model: UltralyticsDetectorModel
|
||||
bbox_detector: object
|
||||
segm_detector: object
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltralyticsModelCacheKey:
|
||||
"""Identify a loaded Ultralytics detector for process-level reuse."""
|
||||
|
||||
model_path: Path
|
||||
|
||||
|
||||
_LOADED_ULTRALYTICS_MODELS: dict[
|
||||
UltralyticsModelCacheKey, LoadedUltralyticsDetector
|
||||
] = {}
|
||||
|
||||
|
||||
class UltralyticsLoaderService:
|
||||
"""Discover and load Ultralytics detector models from ComfyUI folders."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
folder_paths_module: ModuleType | None = None,
|
||||
ultralytics_module: ModuleType | None = None,
|
||||
downloader: ModelDownloader | None = None,
|
||||
choice_service: ModelChoiceService | None = None,
|
||||
cache: (
|
||||
MutableMapping[UltralyticsModelCacheKey, LoadedUltralyticsDetector] | None
|
||||
) = None,
|
||||
) -> None:
|
||||
"""Create the loader with injectable runtime modules for tests."""
|
||||
|
||||
self._folder_paths_module = folder_paths_module
|
||||
self._ultralytics_module = ultralytics_module
|
||||
self._downloader = downloader or ModelDownloader()
|
||||
self._choice_service = choice_service or ModelChoiceService()
|
||||
self._cache: ModelInstanceCache[
|
||||
UltralyticsModelCacheKey, LoadedUltralyticsDetector
|
||||
] = ModelInstanceCache(
|
||||
cache if cache is not None else _LOADED_ULTRALYTICS_MODELS
|
||||
)
|
||||
|
||||
def model_choices(self) -> list[str]:
|
||||
"""Return installed choices first, followed by downloadable catalog choices."""
|
||||
|
||||
self._register_model_folders()
|
||||
curated_choices = self._choice_service.ultralytics_choices()
|
||||
catalog_choice_labels = {
|
||||
_catalog_selection(entry): entry.display_name
|
||||
for entry in ULTRALYTICS_ENTRIES
|
||||
}
|
||||
available_choices = self.available_models()
|
||||
visible_catalog_choices = set(curated_choices)
|
||||
installed_catalog_choices = [
|
||||
entry.display_name
|
||||
for entry in ULTRALYTICS_ENTRIES
|
||||
if (
|
||||
entry.display_name in visible_catalog_choices
|
||||
and _catalog_selection(entry) in available_choices
|
||||
)
|
||||
]
|
||||
installed_non_catalog_choices = [
|
||||
choice
|
||||
for choice in available_choices
|
||||
if choice not in catalog_choice_labels
|
||||
]
|
||||
downloadable_choices = [
|
||||
choice
|
||||
for choice in curated_choices
|
||||
if choice not in installed_catalog_choices
|
||||
]
|
||||
choices = (
|
||||
installed_non_catalog_choices
|
||||
+ installed_catalog_choices
|
||||
+ downloadable_choices
|
||||
)
|
||||
return choices or [NO_LOCAL_ULTRALYTICS_MODELS]
|
||||
|
||||
def available_models(self) -> list[str]:
|
||||
"""Return supported model files in registered Ultralytics folders."""
|
||||
|
||||
self._register_model_folders()
|
||||
folder_paths = self._folder_paths()
|
||||
choices: set[str] = set()
|
||||
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
choices.update(path.name for path in _supported_files(folder))
|
||||
bbox_dir = folder / "bbox"
|
||||
segm_dir = folder / "segm"
|
||||
choices.update(f"bbox/{path.name}" for path in _supported_files(bbox_dir))
|
||||
choices.update(f"segm/{path.name}" for path in _supported_files(segm_dir))
|
||||
|
||||
for path in self._folder_paths_for(ULTRALYTICS_BBOX_FOLDER):
|
||||
choices.update(f"bbox/{file.name}" for file in _supported_files(path))
|
||||
for path in self._folder_paths_for(ULTRALYTICS_SEGM_FOLDER):
|
||||
choices.update(f"segm/{file.name}" for file in _supported_files(path))
|
||||
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
for folder_name in (
|
||||
ULTRALYTICS_FOLDER,
|
||||
ULTRALYTICS_BBOX_FOLDER,
|
||||
ULTRALYTICS_SEGM_FOLDER,
|
||||
):
|
||||
if folder_name not in registry:
|
||||
continue
|
||||
for filename in folder_paths.get_filename_list(folder_name):
|
||||
path = Path(str(filename))
|
||||
if path.suffix.lower() not in SUPPORTED_MODEL_EXTENSIONS:
|
||||
continue
|
||||
if folder_name == ULTRALYTICS_BBOX_FOLDER:
|
||||
choices.add(f"bbox/{path.name}")
|
||||
elif folder_name == ULTRALYTICS_SEGM_FOLDER:
|
||||
choices.add(f"segm/{path.name}")
|
||||
else:
|
||||
choices.add(path.as_posix())
|
||||
|
||||
return sorted(choices)
|
||||
|
||||
def load(
|
||||
self,
|
||||
model_name: str,
|
||||
progress: ProgressReporter | None = None,
|
||||
) -> LoadedUltralyticsDetector:
|
||||
"""Load one Ultralytics model and create compatibility facades."""
|
||||
|
||||
self.reject_sentinel(model_name)
|
||||
entry = _catalog_entry_or_none(model_name)
|
||||
if entry is None:
|
||||
model_path = self.resolve_model_path(model_name)
|
||||
normalized_name = _normalized_model_name(model_name)
|
||||
else:
|
||||
model_path = self._resolve_catalog_entry(entry, progress)
|
||||
normalized_name = _catalog_selection(entry)
|
||||
|
||||
key = UltralyticsModelCacheKey(model_path=model_path.resolve())
|
||||
already_loaded = key in self._cache.entries
|
||||
loaded = self._cache.get_or_load(
|
||||
key,
|
||||
lambda: self._load_uncached_detector(normalized_name, model_path),
|
||||
)
|
||||
if already_loaded:
|
||||
LOGGER.info(
|
||||
"Ultralytics model loaded from process cache",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": normalized_name,
|
||||
"model_path": str(model_path),
|
||||
"task": loaded.detector_model.task,
|
||||
},
|
||||
)
|
||||
return loaded
|
||||
|
||||
def _resolve_catalog_entry(
|
||||
self,
|
||||
entry: ModelEntry,
|
||||
progress: ProgressReporter | None,
|
||||
) -> Path:
|
||||
"""Resolve or securely download one curated Ultralytics checkpoint."""
|
||||
|
||||
if len(entry.artifacts) != 1:
|
||||
raise RuntimeError(
|
||||
f"Ultralytics catalog entry '{entry.entry_id}' must have one artifact."
|
||||
)
|
||||
|
||||
self._register_model_folders()
|
||||
artifact = entry.artifacts[0]
|
||||
existing = resolve_model_file(
|
||||
artifact.folder_name,
|
||||
artifact.filename,
|
||||
self._folder_paths_module,
|
||||
)
|
||||
destination = existing or expected_model_file(
|
||||
artifact.folder_name, artifact.filename, self._folder_paths_module
|
||||
)
|
||||
result = self._downloader.download(
|
||||
DownloadRequest(
|
||||
source_url=artifact.source_url,
|
||||
destination_path=destination,
|
||||
expected_folder=destination.parent,
|
||||
description=artifact.description,
|
||||
expected_sha256=artifact.sha256,
|
||||
),
|
||||
progress,
|
||||
)
|
||||
return result.path
|
||||
|
||||
def _load_uncached_detector(
|
||||
self,
|
||||
model_name: str,
|
||||
model_path: Path,
|
||||
) -> LoadedUltralyticsDetector:
|
||||
"""Load an Ultralytics detector after path resolution and cache lookup."""
|
||||
|
||||
ultralytics_module = self._ultralytics()
|
||||
model_class = getattr(ultralytics_module, "YOLO", None)
|
||||
if model_class is None:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires a module exposing the YOLO class."
|
||||
)
|
||||
|
||||
try:
|
||||
raw_model = model_class(str(model_path))
|
||||
except Exception as exc:
|
||||
LOGGER.error(
|
||||
"Failed to load Ultralytics model",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Ultralytics model '{model_name}' could not be loaded from "
|
||||
f"'{model_path}'."
|
||||
) from exc
|
||||
|
||||
task = _model_task(model_name, raw_model)
|
||||
device_hint = _model_device_hint(raw_model)
|
||||
detector_model = UltralyticsDetectorModel(
|
||||
model_name=model_name,
|
||||
model_path=model_path,
|
||||
model=raw_model,
|
||||
task=task,
|
||||
names=_model_names(raw_model),
|
||||
supports_segmentation=task in {"segment", "segm"},
|
||||
)
|
||||
|
||||
from .detector_compat import BBoxDetectorFacade, SegmDetectorFacade
|
||||
|
||||
bbox_detector = BBoxDetectorFacade(detector_model)
|
||||
segm_detector = SegmDetectorFacade(detector_model, bbox_detector)
|
||||
LOGGER.info(
|
||||
"Ultralytics model loaded",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
"task": task,
|
||||
"device": device_hint,
|
||||
},
|
||||
)
|
||||
return LoadedUltralyticsDetector(
|
||||
detector_model=detector_model,
|
||||
bbox_detector=bbox_detector,
|
||||
segm_detector=segm_detector,
|
||||
)
|
||||
|
||||
def reject_sentinel(self, model_name: str) -> None:
|
||||
"""Reject placeholder dropdown selections before filesystem work."""
|
||||
|
||||
if model_name == NO_LOCAL_ULTRALYTICS_MODELS:
|
||||
raise ValueError(
|
||||
"No local Ultralytics models are available. Enable 'Show "
|
||||
"downloadable models in loader dropdowns' in SimpleSyrup settings "
|
||||
"or install a model in models\\ultralytics, "
|
||||
"models\\ultralytics\\bbox, or models\\ultralytics\\segm."
|
||||
)
|
||||
|
||||
def resolve_model_path(self, model_name: str) -> Path:
|
||||
"""Resolve a safe model choice to a file inside ComfyUI model folders."""
|
||||
|
||||
safe_name = Path(model_name.replace("\\", "/"))
|
||||
if safe_name.is_absolute() or ".." in safe_name.parts:
|
||||
raise ValueError(
|
||||
f"Ultralytics model name '{model_name}' is not a safe relative path."
|
||||
)
|
||||
|
||||
candidates = self._candidate_paths(safe_name)
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
raise ValueError(
|
||||
f"Ultralytics model '{model_name}' was not found in configured "
|
||||
"ComfyUI model folders."
|
||||
)
|
||||
|
||||
def _candidate_paths(self, model_name: Path) -> list[Path]:
|
||||
"""Return bounded filesystem candidates for a model choice."""
|
||||
|
||||
self._register_model_folders()
|
||||
candidates: list[Path] = []
|
||||
parts = model_name.parts
|
||||
if len(parts) >= 2 and parts[0] == "bbox":
|
||||
relative = Path(*parts[1:])
|
||||
candidates.extend(
|
||||
folder / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_BBOX_FOLDER)
|
||||
)
|
||||
candidates.extend(
|
||||
folder / "bbox" / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER)
|
||||
)
|
||||
elif len(parts) >= 2 and parts[0] == "segm":
|
||||
relative = Path(*parts[1:])
|
||||
candidates.extend(
|
||||
folder / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_SEGM_FOLDER)
|
||||
)
|
||||
candidates.extend(
|
||||
folder / "segm" / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER)
|
||||
)
|
||||
else:
|
||||
candidates.extend(
|
||||
folder / model_name
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER)
|
||||
)
|
||||
return candidates
|
||||
|
||||
def _register_model_folders(self) -> None:
|
||||
"""Register conventional Ultralytics folders with ComfyUI when possible."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
add_model_folder_path = getattr(folder_paths, "add_model_folder_path", None)
|
||||
if add_model_folder_path is None:
|
||||
return
|
||||
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
registrations = (
|
||||
(ULTRALYTICS_FOLDER, models_dir / "ultralytics"),
|
||||
(ULTRALYTICS_BBOX_FOLDER, models_dir / "ultralytics" / "bbox"),
|
||||
(ULTRALYTICS_SEGM_FOLDER, models_dir / "ultralytics" / "segm"),
|
||||
)
|
||||
for folder_name, path in registrations:
|
||||
if folder_name in registry:
|
||||
continue
|
||||
add_model_folder_path(folder_name, str(path))
|
||||
|
||||
def _folder_paths_for(self, folder_name: str) -> list[Path]:
|
||||
"""Return registered paths for one ComfyUI model folder."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
fallback = {
|
||||
ULTRALYTICS_FOLDER: models_dir / "ultralytics",
|
||||
ULTRALYTICS_BBOX_FOLDER: models_dir / "ultralytics" / "bbox",
|
||||
ULTRALYTICS_SEGM_FOLDER: models_dir / "ultralytics" / "segm",
|
||||
}[folder_name]
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
paths = [fallback]
|
||||
if folder_name in registry:
|
||||
paths = [Path(str(path)) for path in registry[folder_name][0]] + paths
|
||||
return _unique_paths(paths)
|
||||
|
||||
def _folder_paths(self) -> ModuleType:
|
||||
"""Import ComfyUI folder path helpers lazily."""
|
||||
|
||||
if self._folder_paths_module is not None:
|
||||
return self._folder_paths_module
|
||||
module = importlib.import_module("folder_paths")
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("folder_paths import did not return a module.")
|
||||
self._folder_paths_module = module
|
||||
return module
|
||||
|
||||
def _ultralytics(self) -> ModuleType:
|
||||
"""Import Ultralytics lazily and fail with an actionable message."""
|
||||
|
||||
if self._ultralytics_module is not None:
|
||||
return self._ultralytics_module
|
||||
try:
|
||||
module = importlib.import_module("ultralytics")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires the 'ultralytics' package in the "
|
||||
"ComfyUI virtual environment."
|
||||
) from exc
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("ultralytics import did not return a module.")
|
||||
self._ultralytics_module = module
|
||||
return module
|
||||
|
||||
|
||||
def _supported_files(folder: Path) -> list[Path]:
|
||||
"""Return directly contained supported model files for a folder."""
|
||||
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in SUPPORTED_MODEL_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def _unique_paths(paths: list[Path]) -> list[Path]:
|
||||
"""Return unique paths while preserving order."""
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
key = str(path)
|
||||
if key in seen:
|
||||
continue
|
||||
unique.append(path)
|
||||
seen.add(key)
|
||||
return unique
|
||||
|
||||
|
||||
def _normalized_model_name(model_name: str) -> str:
|
||||
"""Return a stable model selection string for cache identity."""
|
||||
|
||||
return model_name.replace("\\", "/")
|
||||
|
||||
|
||||
def _catalog_entry_or_none(selection: str) -> ModelEntry | None:
|
||||
"""Return a curated Ultralytics entry when a dropdown label matches it."""
|
||||
|
||||
return next(
|
||||
(
|
||||
entry
|
||||
for entry in ULTRALYTICS_ENTRIES
|
||||
if selection in (entry.entry_id, entry.display_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _catalog_selection(entry: ModelEntry) -> str:
|
||||
"""Return the local conventional selection path for one catalog entry."""
|
||||
|
||||
if len(entry.artifacts) != 1:
|
||||
raise ValueError(
|
||||
f"Ultralytics catalog entry '{entry.entry_id}' must have one artifact."
|
||||
)
|
||||
|
||||
artifact = entry.artifacts[0]
|
||||
prefix_by_folder = {
|
||||
ULTRALYTICS_BBOX_FOLDER: "bbox",
|
||||
ULTRALYTICS_SEGM_FOLDER: "segm",
|
||||
}
|
||||
try:
|
||||
prefix = prefix_by_folder[artifact.folder_name]
|
||||
except KeyError as error:
|
||||
raise ValueError(
|
||||
f"Ultralytics catalog entry '{entry.entry_id}' has unsupported folder "
|
||||
f"'{artifact.folder_name}'."
|
||||
) from error
|
||||
return f"{prefix}/{artifact.filename}"
|
||||
|
||||
|
||||
def _model_task(model_name: str, raw_model: object) -> str:
|
||||
"""Infer detector task from choice prefix or model metadata."""
|
||||
|
||||
normalized_name = model_name.replace("\\", "/")
|
||||
if normalized_name.startswith("segm/"):
|
||||
return "segment"
|
||||
if normalized_name.startswith("bbox/"):
|
||||
return "detect"
|
||||
|
||||
task = getattr(raw_model, "task", None)
|
||||
if isinstance(task, str) and task:
|
||||
return task
|
||||
return "detect"
|
||||
|
||||
|
||||
def _model_names(raw_model: object) -> dict[int, str]:
|
||||
"""Extract class names from a loaded Ultralytics model."""
|
||||
|
||||
names = getattr(raw_model, "names", {})
|
||||
if isinstance(names, dict):
|
||||
return {int(key): str(value) for key, value in names.items()}
|
||||
if isinstance(names, list):
|
||||
return {index: str(value) for index, value in enumerate(names)}
|
||||
return {}
|
||||
|
||||
|
||||
def _model_device_hint(raw_model: object) -> str:
|
||||
"""Return a best-effort Ultralytics device hint for diagnostics."""
|
||||
|
||||
direct_device = getattr(raw_model, "device", None)
|
||||
if direct_device is not None:
|
||||
return str(direct_device)
|
||||
inner_model = getattr(raw_model, "model", None)
|
||||
inner_device = getattr(inner_model, "device", None)
|
||||
if inner_device is not None:
|
||||
return str(inner_device)
|
||||
return "runtime-owned"
|
||||
@@ -0,0 +1,141 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Load Ultralytics models behind a narrow runtime adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltralyticsDetectorModel:
|
||||
"""Store a loaded Ultralytics detector with SimpleSyrup metadata."""
|
||||
|
||||
model_name: str
|
||||
model_path: Path
|
||||
model: Any
|
||||
task: str
|
||||
names: dict[int, str]
|
||||
supports_segmentation: bool
|
||||
|
||||
|
||||
class UltralyticsModelAdapter:
|
||||
"""Construct detector models through the optional Ultralytics runtime."""
|
||||
|
||||
def __init__(self, ultralytics_module: ModuleType | None = None) -> None:
|
||||
"""Create the adapter with an optional runtime module override."""
|
||||
|
||||
self._ultralytics_module = ultralytics_module
|
||||
|
||||
def load(self, model_name: str, model_path: Path) -> UltralyticsDetectorModel:
|
||||
"""Load one detector checkpoint and expose normalized metadata."""
|
||||
|
||||
model_class = getattr(self._ultralytics(), "YOLO", None)
|
||||
if model_class is None:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires a module exposing the YOLO class."
|
||||
)
|
||||
|
||||
try:
|
||||
raw_model = model_class(str(model_path))
|
||||
except Exception as exc:
|
||||
LOGGER.error(
|
||||
"Failed to load Ultralytics model",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Ultralytics model '{model_name}' could not be loaded from "
|
||||
f"'{model_path}'."
|
||||
) from exc
|
||||
|
||||
task = _model_task(model_name, raw_model)
|
||||
detector_model = UltralyticsDetectorModel(
|
||||
model_name=model_name,
|
||||
model_path=model_path,
|
||||
model=raw_model,
|
||||
task=task,
|
||||
names=_model_names(raw_model),
|
||||
supports_segmentation=task in {"segment", "segm"},
|
||||
)
|
||||
LOGGER.info(
|
||||
"Ultralytics model loaded",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
"task": task,
|
||||
"device": _model_device_hint(raw_model),
|
||||
},
|
||||
)
|
||||
return detector_model
|
||||
|
||||
def _ultralytics(self) -> ModuleType:
|
||||
"""Import Ultralytics lazily and fail with an actionable message."""
|
||||
|
||||
if self._ultralytics_module is not None:
|
||||
return self._ultralytics_module
|
||||
try:
|
||||
module = importlib.import_module("ultralytics")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires the 'ultralytics' package in the "
|
||||
"ComfyUI virtual environment."
|
||||
) from exc
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("ultralytics import did not return a module.")
|
||||
self._ultralytics_module = module
|
||||
return module
|
||||
|
||||
|
||||
def _model_task(model_name: str, raw_model: object) -> str:
|
||||
"""Infer detector task from choice prefix or model metadata."""
|
||||
|
||||
normalized_name = model_name.replace("\\", "/")
|
||||
if normalized_name.startswith("segm/"):
|
||||
return "segment"
|
||||
if normalized_name.startswith("bbox/"):
|
||||
return "detect"
|
||||
|
||||
task = getattr(raw_model, "task", None)
|
||||
if isinstance(task, str) and task:
|
||||
return task
|
||||
return "detect"
|
||||
|
||||
|
||||
def _model_names(raw_model: object) -> dict[int, str]:
|
||||
"""Extract class names from a loaded Ultralytics model."""
|
||||
|
||||
names = getattr(raw_model, "names", {})
|
||||
if isinstance(names, dict):
|
||||
return {int(key): str(value) for key, value in names.items()}
|
||||
if isinstance(names, list):
|
||||
return {index: str(value) for index, value in enumerate(names)}
|
||||
return {}
|
||||
|
||||
|
||||
def _model_device_hint(raw_model: object) -> str:
|
||||
"""Return a best-effort Ultralytics device hint for diagnostics."""
|
||||
|
||||
direct_device = getattr(raw_model, "device", None)
|
||||
if direct_device is not None:
|
||||
return str(direct_device)
|
||||
inner_model = getattr(raw_model, "model", None)
|
||||
inner_device = getattr(inner_model, "device", None)
|
||||
if inner_device is not None:
|
||||
return str(inner_device)
|
||||
return "runtime-owned"
|
||||
@@ -0,0 +1,203 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Discover and resolve Ultralytics checkpoints in ComfyUI model folders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import TypeAlias, cast
|
||||
|
||||
from .model_folders import SUPPORTED_MODEL_EXTENSIONS
|
||||
|
||||
ULTRALYTICS_FOLDER = "ultralytics"
|
||||
ULTRALYTICS_BBOX_FOLDER = "ultralytics_bbox"
|
||||
ULTRALYTICS_SEGM_FOLDER = "ultralytics_segm"
|
||||
|
||||
ModelFolderRegistry: TypeAlias = dict[str, tuple[list[str], set[str]]]
|
||||
|
||||
|
||||
class UltralyticsModelFolders:
|
||||
"""Own ComfyUI folder registration, discovery, and safe path resolution."""
|
||||
|
||||
def __init__(self, folder_paths_module: ModuleType | None = None) -> None:
|
||||
"""Create the adapter with an optional ComfyUI module override."""
|
||||
|
||||
self._folder_paths_module = folder_paths_module
|
||||
|
||||
@property
|
||||
def folder_paths_module(self) -> ModuleType | None:
|
||||
"""Return the resolved or injected folder-paths module when available."""
|
||||
|
||||
return self._folder_paths_module
|
||||
|
||||
def available_models(self) -> list[str]:
|
||||
"""Return supported model files in registered Ultralytics folders."""
|
||||
|
||||
self.register()
|
||||
folder_paths = self._folder_paths()
|
||||
choices: set[str] = set()
|
||||
|
||||
for folder in self.paths_for(ULTRALYTICS_FOLDER):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
choices.update(path.name for path in _supported_files(folder))
|
||||
choices.update(
|
||||
f"bbox/{path.name}" for path in _supported_files(folder / "bbox")
|
||||
)
|
||||
choices.update(
|
||||
f"segm/{path.name}" for path in _supported_files(folder / "segm")
|
||||
)
|
||||
|
||||
for path in self.paths_for(ULTRALYTICS_BBOX_FOLDER):
|
||||
choices.update(f"bbox/{file.name}" for file in _supported_files(path))
|
||||
for path in self.paths_for(ULTRALYTICS_SEGM_FOLDER):
|
||||
choices.update(f"segm/{file.name}" for file in _supported_files(path))
|
||||
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
for folder_name in (
|
||||
ULTRALYTICS_FOLDER,
|
||||
ULTRALYTICS_BBOX_FOLDER,
|
||||
ULTRALYTICS_SEGM_FOLDER,
|
||||
):
|
||||
if folder_name not in registry:
|
||||
continue
|
||||
for filename in folder_paths.get_filename_list(folder_name):
|
||||
path = Path(str(filename))
|
||||
if path.suffix.lower() not in SUPPORTED_MODEL_EXTENSIONS:
|
||||
continue
|
||||
if folder_name == ULTRALYTICS_BBOX_FOLDER:
|
||||
choices.add(f"bbox/{path.name}")
|
||||
elif folder_name == ULTRALYTICS_SEGM_FOLDER:
|
||||
choices.add(f"segm/{path.name}")
|
||||
else:
|
||||
choices.add(path.as_posix())
|
||||
return sorted(choices)
|
||||
|
||||
def resolve(self, model_name: str) -> Path:
|
||||
"""Resolve a safe model choice inside the configured model folders."""
|
||||
|
||||
safe_name = Path(model_name.replace("\\", "/"))
|
||||
if safe_name.is_absolute() or ".." in safe_name.parts:
|
||||
raise ValueError(
|
||||
f"Ultralytics model name '{model_name}' is not a safe relative path."
|
||||
)
|
||||
for candidate in self._candidate_paths(safe_name):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise ValueError(
|
||||
f"Ultralytics model '{model_name}' was not found in configured "
|
||||
"ComfyUI model folders."
|
||||
)
|
||||
|
||||
def register(self) -> None:
|
||||
"""Register conventional Ultralytics folders with ComfyUI when possible."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
add_model_folder_path = getattr(folder_paths, "add_model_folder_path", None)
|
||||
if add_model_folder_path is None:
|
||||
return
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
registrations = (
|
||||
(ULTRALYTICS_FOLDER, models_dir / "ultralytics"),
|
||||
(ULTRALYTICS_BBOX_FOLDER, models_dir / "ultralytics" / "bbox"),
|
||||
(ULTRALYTICS_SEGM_FOLDER, models_dir / "ultralytics" / "segm"),
|
||||
)
|
||||
for folder_name, path in registrations:
|
||||
if folder_name not in registry:
|
||||
add_model_folder_path(folder_name, str(path))
|
||||
|
||||
def paths_for(self, folder_name: str) -> list[Path]:
|
||||
"""Return registered paths for one ComfyUI model folder."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
fallback = {
|
||||
ULTRALYTICS_FOLDER: models_dir / "ultralytics",
|
||||
ULTRALYTICS_BBOX_FOLDER: models_dir / "ultralytics" / "bbox",
|
||||
ULTRALYTICS_SEGM_FOLDER: models_dir / "ultralytics" / "segm",
|
||||
}[folder_name]
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
paths = [fallback]
|
||||
if folder_name in registry:
|
||||
paths = [Path(str(path)) for path in registry[folder_name][0]] + paths
|
||||
return _unique_paths(paths)
|
||||
|
||||
def _candidate_paths(self, model_name: Path) -> list[Path]:
|
||||
"""Return bounded filesystem candidates for a model choice."""
|
||||
|
||||
self.register()
|
||||
parts = model_name.parts
|
||||
if len(parts) >= 2 and parts[0] == "bbox":
|
||||
relative = Path(*parts[1:])
|
||||
return [
|
||||
*(
|
||||
folder / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_BBOX_FOLDER)
|
||||
),
|
||||
*(
|
||||
folder / "bbox" / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_FOLDER)
|
||||
),
|
||||
]
|
||||
if len(parts) >= 2 and parts[0] == "segm":
|
||||
relative = Path(*parts[1:])
|
||||
return [
|
||||
*(
|
||||
folder / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_SEGM_FOLDER)
|
||||
),
|
||||
*(
|
||||
folder / "segm" / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_FOLDER)
|
||||
),
|
||||
]
|
||||
return [folder / model_name for folder in self.paths_for(ULTRALYTICS_FOLDER)]
|
||||
|
||||
def _folder_paths(self) -> ModuleType:
|
||||
"""Import ComfyUI folder path helpers lazily."""
|
||||
|
||||
if self._folder_paths_module is None:
|
||||
module = importlib.import_module("folder_paths")
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("folder_paths import did not return a module.")
|
||||
self._folder_paths_module = module
|
||||
return self._folder_paths_module
|
||||
|
||||
|
||||
def _supported_files(folder: Path) -> list[Path]:
|
||||
"""Return directly contained supported model files for a folder."""
|
||||
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in SUPPORTED_MODEL_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def _unique_paths(paths: list[Path]) -> list[Path]:
|
||||
"""Return unique paths while preserving order."""
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
key = str(path)
|
||||
if key not in seen:
|
||||
unique.append(path)
|
||||
seen.add(key)
|
||||
return unique
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user