diff --git a/README.md b/README.md
index 9606c58..3d755df 100644
--- a/README.md
+++ b/README.md
@@ -7,32 +7,53 @@ PortraitUtils is a focused collection of ComfyUI nodes designed for pipelines us
Each entry links to a short, task-oriented reference page (coming soon) for deeper usage notes and parameter breakdowns.
### Image Adjustments & Preparation
-- `AutoAdjustNode` – One-click levels, tone, and color balancing. [Docs](docs/AutoAdjustNode.md)
-- `AutoWBColorMatch` – White-balances and colour matches against a reference image using several algorithms. [Docs](docs/AutoWBColorMatch.md)
-- `AutoColorConfigNode` – Lightweight toggle bundle for sharing Auto Adjust presets. [Docs](docs/AutoColorConfigNode.md)
-- `FluxResolutionPrepare` – Crops and resizes to the nearest Flux-friendly resolution, with optional pre-upscale. [Docs](docs/FluxResolutionPrepare.md)
-- `ImageMegapixelSelector` – Chooses image sizes by target megapixels to match downstream model limits. [Docs](docs/ImageMegapixelSelector.md)
+- `AutoAdjustNode` - One-click levels, tone, and color balancing. [Docs](docs/AutoAdjustNode.md)
+

+- `AutoWBColorMatch` - White-balances and colour matches against a reference image using several algorithms. [Docs](docs/AutoWBColorMatch.md)
+ 
+- `AutoColorConfigNode` - Lightweight toggle bundle for sharing Auto Adjust presets. [Docs](docs/AutoColorConfigNode.md)
+ 
+- `FluxResolutionPrepare` - Crops and resizes to the nearest Flux-friendly resolution, with optional pre-upscale. [Docs](docs/FluxResolutionPrepare.md)
+ 
+- `ImageMegapixelSelector` - Chooses image sizes by target megapixels to match downstream model limits. [Docs](docs/ImageMegapixelSelector.md)
+ 
### Cropping, Framing, and Stitching
-- `AutoCropBorders` – Removes uniform borders using adaptive fuzzy detection. GPU acceleration is currently broken, so leave that off for now. [Docs](docs/AutoCropBorders.md)
-- `CropImageByMargins` – Manual margin-based cropper for RGB images. [Docs](docs/CropImageByMargins.md)
-- `CropMaskByMargins` – Margin cropper designed for MASK tensors. [Docs](docs/CropMaskByMargins.md)
-- `FitAspectHeadSafe` – Rescales and pads while preserving face-safe regions for portrait framing. [Docs](docs/FitAspectHeadSafe.md)
-- `MQBBoxMin` – Utility for merging bounding boxes while enforcing minimum sizes. [Docs](docs/MQBBoxMin.md)
-- `StitchByMask` – Composites foreground and background images using mask-driven blending. [Docs](docs/StitchByMask.md)
+- `AutoCropBorders` - Removes uniform borders using adaptive fuzzy detection. GPU acceleration is currently broken, so leave that off for now. [Docs](docs/AutoCropBorders.md)
+ 
+- `CropImageByMargins` - Manual margin-based cropper for RGB images. [Docs](docs/CropImageByMargins.md)
+ 
+- `CropMaskByMargins` - Margin cropper designed for MASK tensors. [Docs](docs/CropMaskByMargins.md)
+ 
+- `FitAspectHeadSafe` - Rescales and pads while preserving face-safe regions for portrait framing. [Docs](docs/FitAspectHeadSafe.md)
+ 
+- `MQBBoxMin` - Utility for merging bounding boxes while enforcing minimum sizes. [Docs](docs/MQBBoxMin.md)
+ 
+- `StitchByMask` - Composites foreground and background images using mask-driven blending. [Docs](docs/StitchByMask.md)
+ 
### Prompting & Workflow Configuration
-- `MultiPromptNode` – Cycles through prompt variants with per-slot weights. [Docs](docs/MultiPromptNode.md)
-- `WorkflowConfig` – Centralized key/value store for workflow-wide settings. [Docs](docs/WorkflowConfig.md)
-- `OutpaintConfigNode` – Packs outpainting preferences (steps, padding, sampler choices). [Docs](docs/OutpaintConfigNode.md)
-- `OutpaintPaddingComputeNode` – Calculates padding offsets to feed outpaint chains as either percentage or absolute pixels, with gravity. [Docs](docs/OutpaintPaddingComputeNode.md)
+- `MultiPromptNode` - Cycles through prompt variants with per-slot weights. [Docs](docs/MultiPromptNode.md)
+ 
+- `WorkflowConfig` - Centralized key/value store for workflow-wide settings. [Docs](docs/WorkflowConfig.md)
+ 
+- `OutpaintConfigNode` - Packs outpainting preferences (steps, padding, sampler choices). [Docs](docs/OutpaintConfigNode.md)
+ 
+- `OutpaintPaddingComputeNode` - Calculates padding offsets to feed outpaint chains as either percentage or absolute pixels, with gravity. [Docs](docs/OutpaintPaddingComputeNode.md)
+ 
### I/O & Utility Helpers
-- `LoadImageCombined` – Hybrid single/batch image loader with auto-advance support. [Docs](docs/LoadImageCombined.md)
-- `PortraitUtils_PairedImageLoader` – Loads synchronized source/output pairs for A/B comparison workflows. [Docs](docs/PortraitUtils_PairedImageLoader.md)
-- `FilenameAppendSuffix` – Adds suffixes to filenames while respecting multi-extension cases. [Docs](docs/FilenameAppendSuffix.md)
-- `GpuSyncCacheClear` – Forces a CUDA sync and empties selected caches to avoid VRAM buildup. [Docs](docs/GpuSyncCacheClear.md)
-- `SeedVR2Prep` – Convenience helper for SeedVR2/VRAM-heavy pipelines (cache priming & cleanup). [Docs](docs/SeedVR2Prep.md)
+- `LoadImageCombined` - Hybrid single/batch image loader with auto-advance support. [Docs](docs/LoadImageCombined.md)
+ 
+- `PairedImageLoader` - Loads synchronized source/output pairs for A/B comparison workflows. [Docs](docs/PairedImageLoader.md)
+ 
+- `ComparisonGate` - Gates comparison outputs until two populated images are available. [Docs](docs/ComparisonGate.md)
+- `FilenameAppendSuffix` - Adds suffixes to filenames while respecting multi-extension cases. [Docs](docs/FilenameAppendSuffix.md)
+ 
+- `GpuSyncCacheClear` - Forces a CUDA sync and empties selected caches to avoid VRAM buildup. [Docs](docs/GpuSyncCacheClear.md)
+ 
+- `SeedVR2Prep` - Convenience helper for SeedVR2/VRAM-heavy pipelines (cache priming & cleanup). [Docs](docs/SeedVR2Prep.md)
+ 
## License
diff --git a/__init__.py b/__init__.py
index 04f61fb..fe7dcd4 100644
--- a/__init__.py
+++ b/__init__.py
@@ -58,6 +58,10 @@ from .paired_image_loader import (
NODE_DISPLAY_NAME_MAPPINGS as PAIRED_LOADER_DISPLAY_MAP,
)
from .load_image_combined import LoadImageCombined
+from .comparison_gate import (
+ NODE_CLASS_MAPPINGS as COMPARISON_GATE_CLASS_MAP,
+ NODE_DISPLAY_NAME_MAPPINGS as COMPARISON_GATE_DISPLAY_MAP,
+)
NODE_CLASS_MAPPINGS = {}
@@ -77,6 +81,7 @@ NODE_CLASS_MAPPINGS.update(FLUX_PREP_CLASS_MAP)
NODE_CLASS_MAPPINGS.update(WORKFLOW_CONFIG_CLASS_MAP)
NODE_CLASS_MAPPINGS.update(SMART_CROP_CLASS_MAP)
NODE_CLASS_MAPPINGS.update(PAIRED_LOADER_CLASS_MAP)
+NODE_CLASS_MAPPINGS.update(COMPARISON_GATE_CLASS_MAP)
NODE_DISPLAY_NAME_MAPPINGS.update(AUTO_ADJUST_DISPLAY_MAP)
NODE_DISPLAY_NAME_MAPPINGS.update(OUTPAINT_DISPLAY_MAP)
@@ -91,6 +96,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(FLUX_PREP_DISPLAY_MAP)
NODE_DISPLAY_NAME_MAPPINGS.update(WORKFLOW_CONFIG_DISPLAY_MAP)
NODE_DISPLAY_NAME_MAPPINGS.update(SMART_CROP_DISPLAY_MAP)
NODE_DISPLAY_NAME_MAPPINGS.update(PAIRED_LOADER_DISPLAY_MAP)
+NODE_DISPLAY_NAME_MAPPINGS.update(COMPARISON_GATE_DISPLAY_MAP)
# Register the load-image node which does not expose pre-built mappings.
NODE_CLASS_MAPPINGS["LoadImageCombined"] = LoadImageCombined
diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..815e91f
Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ
diff --git a/__pycache__/auto_adjust.cpython-312.pyc b/__pycache__/auto_adjust.cpython-312.pyc
new file mode 100644
index 0000000..776a4fd
Binary files /dev/null and b/__pycache__/auto_adjust.cpython-312.pyc differ
diff --git a/__pycache__/auto_color_match.cpython-312.pyc b/__pycache__/auto_color_match.cpython-312.pyc
new file mode 100644
index 0000000..673b8b0
Binary files /dev/null and b/__pycache__/auto_color_match.cpython-312.pyc differ
diff --git a/__pycache__/comparison_gate.cpython-312.pyc b/__pycache__/comparison_gate.cpython-312.pyc
new file mode 100644
index 0000000..9ea0fcd
Binary files /dev/null and b/__pycache__/comparison_gate.cpython-312.pyc differ
diff --git a/__pycache__/crop_utils.cpython-312.pyc b/__pycache__/crop_utils.cpython-312.pyc
new file mode 100644
index 0000000..79464fb
Binary files /dev/null and b/__pycache__/crop_utils.cpython-312.pyc differ
diff --git a/__pycache__/filename_append_suffix.cpython-312.pyc b/__pycache__/filename_append_suffix.cpython-312.pyc
new file mode 100644
index 0000000..7231ee4
Binary files /dev/null and b/__pycache__/filename_append_suffix.cpython-312.pyc differ
diff --git a/__pycache__/flux_resolution_prepare.cpython-312.pyc b/__pycache__/flux_resolution_prepare.cpython-312.pyc
new file mode 100644
index 0000000..214c00c
Binary files /dev/null and b/__pycache__/flux_resolution_prepare.cpython-312.pyc differ
diff --git a/__pycache__/gpu_cache_clear.cpython-312.pyc b/__pycache__/gpu_cache_clear.cpython-312.pyc
new file mode 100644
index 0000000..bb6343b
Binary files /dev/null and b/__pycache__/gpu_cache_clear.cpython-312.pyc differ
diff --git a/__pycache__/image_megapixel_selector.cpython-312.pyc b/__pycache__/image_megapixel_selector.cpython-312.pyc
new file mode 100644
index 0000000..b0ab1d0
Binary files /dev/null and b/__pycache__/image_megapixel_selector.cpython-312.pyc differ
diff --git a/__pycache__/load_image_combined.cpython-312.pyc b/__pycache__/load_image_combined.cpython-312.pyc
new file mode 100644
index 0000000..66057c6
Binary files /dev/null and b/__pycache__/load_image_combined.cpython-312.pyc differ
diff --git a/__pycache__/multiprompt.cpython-312.pyc b/__pycache__/multiprompt.cpython-312.pyc
new file mode 100644
index 0000000..a468663
Binary files /dev/null and b/__pycache__/multiprompt.cpython-312.pyc differ
diff --git a/__pycache__/outpaint_config.cpython-312.pyc b/__pycache__/outpaint_config.cpython-312.pyc
new file mode 100644
index 0000000..64d54e5
Binary files /dev/null and b/__pycache__/outpaint_config.cpython-312.pyc differ
diff --git a/__pycache__/paired_image_loader.cpython-312.pyc b/__pycache__/paired_image_loader.cpython-312.pyc
new file mode 100644
index 0000000..7ca6964
Binary files /dev/null and b/__pycache__/paired_image_loader.cpython-312.pyc differ
diff --git a/__pycache__/smart_crop.cpython-312.pyc b/__pycache__/smart_crop.cpython-312.pyc
new file mode 100644
index 0000000..f752029
Binary files /dev/null and b/__pycache__/smart_crop.cpython-312.pyc differ
diff --git a/__pycache__/stitch_image_by_mask.cpython-312.pyc b/__pycache__/stitch_image_by_mask.cpython-312.pyc
new file mode 100644
index 0000000..03a50b6
Binary files /dev/null and b/__pycache__/stitch_image_by_mask.cpython-312.pyc differ
diff --git a/__pycache__/workflow_config.cpython-312.pyc b/__pycache__/workflow_config.cpython-312.pyc
new file mode 100644
index 0000000..3856529
Binary files /dev/null and b/__pycache__/workflow_config.cpython-312.pyc differ
diff --git a/comparison_gate.py b/comparison_gate.py
new file mode 100644
index 0000000..12feb15
--- /dev/null
+++ b/comparison_gate.py
@@ -0,0 +1,104 @@
+"""
+Gate a source image and up to three comparison inputs before forwarding them.
+
+ComfyUI passes ``None`` through empty sockets. This node inspects every input,
+selects an appropriate pair of populated images, and only emits them when both
+slots are ready. Otherwise, it mirrors the behaviour of an unplugged viewer
+socket by returning ``None`` for both outputs.
+"""
+from __future__ import annotations
+
+from typing import Iterable, List, Tuple, Any
+
+
+def _collect_present(values: Iterable[Any]) -> List[Any]:
+ """
+ Return the sequence of values that look like real images.
+
+ ComfyUI represents disconnected sockets as ``None`` and occasionally wraps
+ outputs in small containers. We filter out anything that is clearly empty so
+ the gate never forwards placeholders into downstream nodes.
+ """
+ present: List[Any] = []
+ for value in values:
+ if value is None:
+ continue
+ if isinstance(value, (list, tuple)):
+ if not value:
+ continue
+ if all(item is None for item in value):
+ continue
+ present.append(value)
+ return present
+
+
+class ComparisonGate:
+ """
+ Emit two images only when at least two inputs are populated.
+
+ Designed for comparison viewers: the first populated comparison becomes the
+ "final image", while the designated source socket feeds the secondary output
+ (with a fallback to the next comparison if the source is missing). If fewer
+ than two images are available the node returns ``None`` for both sockets, so
+ downstream widgets remain blank.
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ # Mark every socket as optional so that workflows can omit connections
+ # without triggering validation errors. ComfyUI fills unspecified inputs
+ # with ``None`` which this node explicitly understands.
+ return {
+ "optional": {
+ "source_image": ("IMAGE", {"default": None}),
+ "image_a": ("IMAGE", {"default": None}),
+ "image_b": ("IMAGE", {"default": None}),
+ "image_c": ("IMAGE", {"default": None}),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE", "IMAGE")
+ RETURN_NAMES = ("final_image", "source_image")
+ FUNCTION = "forward_images"
+ CATEGORY = "PortraitUtils"
+
+ def forward_images(
+ self,
+ source_image=None,
+ image_a=None,
+ image_b=None,
+ image_c=None,
+ ) -> Tuple[Any, Any]:
+ # Gather the inputs in priority order to ensure predictable selection.
+ total_inputs = (source_image, image_a, image_b, image_c)
+ populated = _collect_present(total_inputs)
+ if len(populated) < 2:
+ return None, None
+
+ # The first comparison with data becomes the final output. The source
+ # socket remains reserved for the secondary output to keep viewer labels
+ # aligned with the user's expectations.
+ candidate_finals = _collect_present((image_a, image_b, image_c))
+ final_image = candidate_finals[0] if candidate_finals else None
+ source_output = source_image
+
+ # If the source socket is empty but multiple comparisons are available,
+ # fall back to the next comparison so we still deliver two images.
+ if source_output is None and len(candidate_finals) > 1:
+ source_output = candidate_finals[1]
+
+ # Avoid emitting half-populated pairs; downstream nodes expect either a
+ # full tuple of tensors or pure ``None`` values.
+ if final_image is None or source_output is None:
+ return None, None
+
+ return final_image, source_output
+
+
+NODE_CLASS_MAPPINGS = {
+ "ComparisonGate": ComparisonGate,
+}
+
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "ComparisonGate": "Comparison Gate",
+}
diff --git a/docs/AutoAdjustNode.md b/docs/AutoAdjustNode.md
index a6cea6e..dd04723 100644
--- a/docs/AutoAdjustNode.md
+++ b/docs/AutoAdjustNode.md
@@ -1,5 +1,4 @@
# AutoAdjustNode
-
diff --git a/docs/AutoColorConfigNode.md b/docs/AutoColorConfigNode.md
index e088a45..e4e371c 100644
--- a/docs/AutoColorConfigNode.md
+++ b/docs/AutoColorConfigNode.md
@@ -1,5 +1,4 @@
# AutoColorConfigNode
-
## Overview
diff --git a/docs/AutoCropBorders.md b/docs/AutoCropBorders.md
index 0b98b83..7cf417e 100644
--- a/docs/AutoCropBorders.md
+++ b/docs/AutoCropBorders.md
@@ -1,5 +1,4 @@
# AutoCropBorders
-
## Overview
diff --git a/docs/AutoWBColorMatch.md b/docs/AutoWBColorMatch.md
index 4eb8fa2..60158ef 100644
--- a/docs/AutoWBColorMatch.md
+++ b/docs/AutoWBColorMatch.md
@@ -1,5 +1,4 @@
# AutoWBColorMatch
-
## Overview
diff --git a/docs/ComparisonGate.md b/docs/ComparisonGate.md
new file mode 100644
index 0000000..c14c840
--- /dev/null
+++ b/docs/ComparisonGate.md
@@ -0,0 +1,27 @@
+# ComparisonGate
+
+## Overview
+`ComparisonGate` keeps comparison viewers tidy by only emitting images when a complete pair is available. It listens to a designated `source_image` input together with up to three comparison sockets and mirrors ComfyUI's unplugged behaviour (`None`, `None`) until two populated tensors are ready to forward.
+
+## Inputs
+- `source_image` (`IMAGE`, optional): Preferred reference image for the secondary output. Leave disconnected when you want the first populated comparison to feed both sockets.
+- `image_a` (`IMAGE`, optional): Primary comparison feed. Becomes the `final_image` output whenever populated.
+- `image_b` (`IMAGE`, optional): Secondary comparison feed used either as a fallback `final_image` or to backfill the `source_image` output when the dedicated source socket is empty.
+- `image_c` (`IMAGE`, optional): Tertiary comparison feed that participates in the fallback cascade.
+
+All inputs accept `None`; the node filters out empty lists/tuples as well as bare `None` values so placeholder widgets will not trigger accidental forwards.
+
+## Outputs
+- `final_image` (`IMAGE`): The first populated comparison input (`image_a` › `image_b` › `image_c`). Returns `None` until at least one comparison produces data.
+- `source_image` (`IMAGE`): Mirrors the dedicated `source_image` socket, or falls back to the next populated comparison input so the viewer always receives a pair. Returns `None` alongside `final_image` when fewer than two valid images are present.
+
+## Forwarding Rules
+- The node inspects every input on each execution and counts how many look like real image tensors. If fewer than two are found it emits `(None, None)` to keep downstream nodes blank.
+- When only comparison sockets are populated, the first comparison feeds `final_image` and the second feeds `source_image`, ensuring A/B layouts remain synchronised.
+- When `source_image` is connected, it always feeds the `source_image` output unless it is empty—in that case the node automatically falls back to the second non-empty comparison input.
+- Containers such as one-item tuples or lists are unwrapped only when they contain real data; sequences of `None` values are ignored.
+
+## Tips
+- Place the gate directly before two-input comparison viewers to prevent half-populated renders during workflow warm-up.
+- Combine with `PairedImageLoader` for workflow QA: let the loader drive comparisons and feed its outputs through the gate so the viewer only refreshes when both sockets advance.
+- Because the node simply relays its inputs, it adds no meaningful processing cost; you can safely sprinkle it anywhere you need to guarantee paired images.*** End Patch
diff --git a/docs/CropImageByMargins.md b/docs/CropImageByMargins.md
index bcabd08..df6e86c 100644
--- a/docs/CropImageByMargins.md
+++ b/docs/CropImageByMargins.md
@@ -1,5 +1,4 @@
# CropImageByMargins
-
## Overview
diff --git a/docs/CropMaskByMargins.md b/docs/CropMaskByMargins.md
index 7244322..098fd87 100644
--- a/docs/CropMaskByMargins.md
+++ b/docs/CropMaskByMargins.md
@@ -1,5 +1,4 @@
# CropMaskByMargins
-
## Overview
diff --git a/docs/FilenameAppendSuffix.md b/docs/FilenameAppendSuffix.md
index 56e67d7..4c19786 100644
--- a/docs/FilenameAppendSuffix.md
+++ b/docs/FilenameAppendSuffix.md
@@ -1,5 +1,4 @@
# FilenameAppendSuffix
-
## Overview
@@ -22,5 +21,5 @@
- Directory handling uses `os.path.join`, so platform-specific separators are respected.
## Tips
-- Chain this node after `LoadImageCombined` or `PortraitUtils_PairedImageLoader` to build save paths that mirror the input file names.
+- Chain this node after `LoadImageCombined` or `PairedImageLoader` to build save paths that mirror the input file names.
- Disable `preserve_directory` when you want to relocate files into a new output folder without carrying over their original path.
diff --git a/docs/FitAspectHeadSafe.md b/docs/FitAspectHeadSafe.md
index f5d10e6..4e6c85b 100644
--- a/docs/FitAspectHeadSafe.md
+++ b/docs/FitAspectHeadSafe.md
@@ -1,5 +1,4 @@
# FitAspectHeadSafe
-
## Overview
diff --git a/docs/FluxResolutionPrepare.md b/docs/FluxResolutionPrepare.md
index bd34dbf..344b15a 100644
--- a/docs/FluxResolutionPrepare.md
+++ b/docs/FluxResolutionPrepare.md
@@ -1,5 +1,4 @@
# FluxResolutionPrepare
-
## Overview
diff --git a/docs/GpuSyncCacheClear.md b/docs/GpuSyncCacheClear.md
index 44df6f9..c272bdf 100644
--- a/docs/GpuSyncCacheClear.md
+++ b/docs/GpuSyncCacheClear.md
@@ -1,5 +1,4 @@
# GpuSyncCacheClear
-
## Overview
diff --git a/docs/ImageMegapixelSelector.md b/docs/ImageMegapixelSelector.md
index f554cd0..d72031a 100644
--- a/docs/ImageMegapixelSelector.md
+++ b/docs/ImageMegapixelSelector.md
@@ -1,5 +1,4 @@
# ImageMegapixelSelector
-
## Overview
diff --git a/docs/LoadImageCombined.md b/docs/LoadImageCombined.md
index 9d64c38..9aae271 100644
--- a/docs/LoadImageCombined.md
+++ b/docs/LoadImageCombined.md
@@ -1,5 +1,4 @@
# LoadImageCombined
-
## Overview
@@ -26,6 +25,6 @@
- All images are converted to RGB using Pillow, with Exif orientation applied.
## Tips
-- Chain this node with `PortraitUtils_PairedImageLoader` or comparison nodes to quickly inspect before/after renders.
+- Chain this node with `PairedImageLoader` or comparison nodes to quickly inspect before/after renders.
- Use `repeat_last` when you need to re-run the same batch frame after adjusting downstream parameters.
- Combine with `FilenameAppendSuffix` to build descriptive output paths based on `filename_no_ext`.*** End Patch
diff --git a/docs/MQBBoxMin.md b/docs/MQBBoxMin.md
index 2e8912a..4779fa8 100644
--- a/docs/MQBBoxMin.md
+++ b/docs/MQBBoxMin.md
@@ -1,5 +1,4 @@
# MQBBoxMin
-
## Overview
diff --git a/docs/MultiPromptNode.md b/docs/MultiPromptNode.md
index 0273ff0..44dc535 100644
--- a/docs/MultiPromptNode.md
+++ b/docs/MultiPromptNode.md
@@ -1,5 +1,4 @@
# MultiPromptNode
-
## Overview
diff --git a/docs/OutpaintConfigNode.md b/docs/OutpaintConfigNode.md
index a6168ab..9439c30 100644
--- a/docs/OutpaintConfigNode.md
+++ b/docs/OutpaintConfigNode.md
@@ -1,5 +1,4 @@
# OutpaintConfigNode
-
## Overview
diff --git a/docs/OutpaintPaddingComputeNode.md b/docs/OutpaintPaddingComputeNode.md
index 37d82bd..1cc6ddc 100644
--- a/docs/OutpaintPaddingComputeNode.md
+++ b/docs/OutpaintPaddingComputeNode.md
@@ -1,5 +1,4 @@
# OutpaintPaddingComputeNode
-
## Overview
diff --git a/docs/PortraitUtils_PairedImageLoader.md b/docs/PairedImageLoader.md
similarity index 80%
rename from docs/PortraitUtils_PairedImageLoader.md
rename to docs/PairedImageLoader.md
index 530b2c0..1a6221e 100644
--- a/docs/PortraitUtils_PairedImageLoader.md
+++ b/docs/PairedImageLoader.md
@@ -1,9 +1,8 @@
-# PortraitUtils_PairedImageLoader
-
+# PairedImageLoader
## Overview
-`PortraitUtils_PairedImageLoader` pairs images from two directories (typically “source” and “output”) and emits the next matching duo on every run. It keeps its position per node instance, supports forward/backward stepping, and reports the active filename for UI display or logging.
+`PairedImageLoader` pairs images from two directories (typically "source" and "output") and emits the next matching duo on every run. It keeps its position per node instance, supports forward/backward stepping, and reports the active filename for UI display or logging.
## Inputs
- `source_dir` (`STRING`): Directory containing the reference/source images. Relative paths resolve against the current working directory.
diff --git a/docs/SeedVR2Prep.md b/docs/SeedVR2Prep.md
index da7ab1f..605efa3 100644
--- a/docs/SeedVR2Prep.md
+++ b/docs/SeedVR2Prep.md
@@ -1,5 +1,4 @@
# SeedVR2Prep
-
## Overview
diff --git a/docs/StitchByMask.md b/docs/StitchByMask.md
index 6175089..96182d0 100644
--- a/docs/StitchByMask.md
+++ b/docs/StitchByMask.md
@@ -1,5 +1,4 @@
# StitchByMask
-
## Overview
diff --git a/docs/WorkflowConfig.md b/docs/WorkflowConfig.md
index 801367d..fb366f5 100644
--- a/docs/WorkflowConfig.md
+++ b/docs/WorkflowConfig.md
@@ -1,5 +1,4 @@
# WorkflowConfig
-
## Overview
diff --git a/docs/smart_crop_before_refactor.py b/docs/smart_crop_before_refactor.py
new file mode 100644
index 0000000..419d553
--- /dev/null
+++ b/docs/smart_crop_before_refactor.py
@@ -0,0 +1,433 @@
+import math
+import numpy as np
+import torch
+
+
+def _to_numpy_mask(m):
+ if torch is not None and isinstance(m, torch.Tensor):
+ arr = m.detach().cpu().numpy()
+ elif isinstance(m, dict) and "mask" in m:
+ v = m["mask"]
+ arr = v.detach().cpu().numpy() if torch is not None and isinstance(v, torch.Tensor) else np.asarray(v)
+ else:
+ arr = np.asarray(m)
+ arr = np.squeeze(arr)
+ if arr.ndim == 3:
+ arr = arr.max(axis=0) if arr.shape[0] in (1, 3, 4) else arr.max(axis=-1)
+ if arr.ndim != 2:
+ h, w = arr.shape[-2], arr.shape[-1]
+ arr = arr.reshape(h, w)
+ return arr.astype(np.float32, copy=False)
+
+
+def _normalize01(m):
+ mmax = float(m.max()) if m.size else 0.0
+ if mmax <= 0:
+ return m
+ if mmax > 1.5:
+ return (m / 255.0).astype(np.float32, copy=False)
+ return m
+
+
+def _border_mass_ratio(m, border_px=8):
+ H, W = m.shape
+ border_px = int(max(1, min(border_px, min(H, W) // 4)))
+ total = float(m.sum()) + 1e-12
+ top = m[:border_px, :].sum()
+ bottom = m[-border_px:, :].sum()
+ left = m[:, :border_px].sum()
+ right = m[:, -border_px:].sum()
+ corners = (
+ m[:border_px, :border_px].sum()
+ + m[:border_px, -border_px:].sum()
+ + m[-border_px:, :border_px].sum()
+ + m[-border_px:, -border_px:].sum()
+ )
+ border = top + bottom + left + right - corners
+ return float(border) / total
+
+
+def _auto_pick_foreground(m, mode="auto"):
+ m = _normalize01(m)
+ if mode == "false":
+ fg, pick = m, "fg"
+ elif mode == "true":
+ fg, pick = 1.0 - m, "bg"
+ else:
+ r_fg = _border_mass_ratio(m)
+ r_bg = _border_mass_ratio(1.0 - m)
+ if r_bg < r_fg:
+ fg, pick = 1.0 - m, "auto->bg"
+ else:
+ fg, pick = m, "auto->fg"
+ return fg, pick
+
+
+def _quantile_bounds(mass, qL, qR, qT, qB, min_span):
+ H, W = mass.shape
+ eps = 1e-8
+ c = mass.sum(axis=0) + eps
+ r = mass.sum(axis=1) + eps
+ cc = np.cumsum(c)
+ rc = np.cumsum(r)
+ ct = float(cc[-1])
+ rt = float(rc[-1])
+
+ def qidx(cum, total, q):
+ return int(np.clip(np.searchsorted(cum, total * float(q), "left"), 0, len(cum) - 1))
+
+ x0 = qidx(cc, ct, qL)
+ x1 = qidx(cc, ct, qR)
+ y0 = qidx(rc, rt, qT)
+ y1 = qidx(rc, rt, qB)
+ if x1 < x0:
+ x0, x1 = x1, x0
+ if y1 < y0:
+ y0, y1 = y1, y0
+ if (x1 - x0 + 1) < min_span:
+ cx = (x0 + x1) // 2
+ half = max(1, min_span // 2)
+ x0 = max(0, cx - half)
+ x1 = min(W - 1, cx + half)
+ if (y1 - y0 + 1) < min_span:
+ cy = (y0 + y1) // 2
+ half = max(1, min_span // 2)
+ y0 = max(0, cy - half)
+ y1 = min(H - 1, cy + half)
+ return int(x0), int(y0), int(x1 - x0 + 1), int(y1 - y0 + 1)
+
+
+def _pad_rect(x, y, w, h, pad, W, H):
+ x2 = max(0, x - pad)
+ y2 = max(0, y - pad)
+ w2 = min(W - x2, w + 2 * pad)
+ h2 = min(H - y2, h + 2 * pad)
+ return int(x2), int(y2), int(w2), int(h2)
+
+
+def _container_for_ar(x, y, w, h, R, W, H):
+ """
+ Smallest rectangle of aspect R (=w/h) that contains (x,y,w,h), centered,
+ then clamped to image. If it can't fit (too large), return None.
+ """
+ wA = max(w, int(np.ceil(h * R)))
+ hA = int(np.ceil(wA / R))
+ hB = max(h, int(np.ceil(w / R)))
+ wB = int(np.ceil(hB * R))
+ cand = []
+ if wA <= W and hA <= H:
+ cand.append((wA, hA))
+ if wB <= W and hB <= H:
+ cand.append((wB, hB))
+ if not cand:
+ return None
+ cw, ch = min(cand, key=lambda t: t[0] * t[1])
+
+ cx = x + w / 2.0
+ cy = y + h / 2.0
+ X = int(np.round(cx - cw / 2.0))
+ Y = int(np.round(cy - ch / 2.0))
+ X = max(0, min(W - cw, X))
+ Y = max(0, min(H - ch, Y))
+ return X, Y, int(cw), int(ch)
+
+
+class MQBBoxMin:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "mask": ("MASK",),
+ "invert_mask": (["auto", "false", "true"], {"default": "auto"}),
+ "q_left": ("FLOAT", {"default": 0.005, "min": 0.0, "max": 0.2, "step": 0.001}),
+ "q_right": ("FLOAT", {"default": 0.995, "min": 0.8, "max": 1.0, "step": 0.001}),
+ "q_top": ("FLOAT", {"default": 0.005, "min": 0.0, "max": 0.2, "step": 0.001}),
+ "q_bottom": ("FLOAT", {"default": 0.995, "min": 0.8, "max": 1.0, "step": 0.001}),
+ "min_span_px": ("INT", {"default": 8, "min": 1, "max": 2048}),
+ "tight_crop": (
+ "BOOLEAN",
+ {
+ "default": False,
+ "tooltip": "ON: tight subject bbox with smart AR; OFF: full frame (minimal AR crop downstream).",
+ },
+ ),
+ }
+ }
+
+ RETURN_TYPES = ("INT", "INT", "INT", "INT", "STRING")
+ RETURN_NAMES = ("x", "y", "w", "h", "debug")
+ FUNCTION = "run"
+ CATEGORY = "utils/debug"
+
+ def run(self, mask, invert_mask, q_left, q_right, q_top, q_bottom, min_span_px, tight_crop=True):
+ m = _to_numpy_mask(mask)
+ H, W = m.shape
+
+ if not tight_crop:
+ dbg = f"tight=False full=({0},{0},{W},{H})"
+ return (0, 0, int(W), int(H), dbg)
+
+ # --- original tight bbox ---
+ fg, picked = _auto_pick_foreground(m, invert_mask)
+ x, y, w, h = _quantile_bounds(fg, q_left, q_right, q_top, q_bottom, int(min_span_px))
+
+ # --- smart AR (minimal-area container), internal only ---
+ pad_px = max(8, int(round(0.02 * min(H, W))))
+ sx, sy, sw, sh = _pad_rect(x, y, w, h, pad_px, W, H)
+
+ # Baseline: container using current image AR
+ R0 = float(W) / max(1.0, float(H))
+ base = _container_for_ar(sx, sy, sw, sh, R0, W, H)
+ if base is None:
+ dbg = f"tight=True pick={picked} mq=({x},{y},{w},{h})"
+ return (x, y, w, h, dbg)
+ bx, by, bw, bh = base
+ base_area = bw * bh
+
+ # Try common ARs in BOTH orientations (includes 9:16)
+ ratios = [
+ (1, 1),
+ (2, 3),
+ (3, 2),
+ (3, 4),
+ (4, 3),
+ (9, 16),
+ (16, 9),
+ ]
+
+ best_rect = base
+ best_area = base_area
+ best_info = f"AR={W}:{H}"
+
+ for aw, ah in ratios:
+ R = float(aw) / float(ah)
+ cand = _container_for_ar(sx, sy, sw, sh, R, W, H)
+ if cand is None:
+ continue
+ cx, cy, cw, ch = cand
+ area = cw * ch
+ if area < best_area:
+ best_area = area
+ best_rect = cand
+ best_info = f"AR={aw}:{ah}"
+
+ rx, ry, rw, rh = best_rect
+ dbg = f"tight=True pick={picked} mq=({x},{y},{w},{h}) -> smart({rx},{ry},{rw},{rh}) {best_info}, pad={pad_px}"
+ return (int(rx), int(ry), int(rw), int(rh), dbg)
+
+
+def _get_hw(image):
+ t = image["image"] if isinstance(image, dict) and "image" in image else image
+ s = tuple(getattr(t, "shape", np.asarray(t).shape))
+ if len(s) == 4:
+ H, W = (s[1], s[2]) if s[-1] in (1, 3, 4) else (s[2], s[3])
+ elif len(s) == 3:
+ H, W = (s[0], s[1]) if s[0] not in (1, 3, 4) else (s[1], s[2])
+ else:
+ H, W = s[-2], s[-1]
+ return int(H), int(W)
+
+
+def _parse_aspects(s):
+ out = []
+ for p in s.split(","):
+ p = p.strip()
+ if not p:
+ continue
+ if ":" in p:
+ a, b = p.split(":", 1)
+ else:
+ a, b = p.replace("X", "x").split("x", 1)
+ try:
+ a = float(a)
+ b = float(b)
+ if a > 0 and b > 0:
+ out.append((a / b, f"{int(a)}:{int(b)}"))
+ except Exception:
+ pass
+ return out or [(2 / 3.0, "2:3")]
+
+
+def _closest_ratio(target_ratio, candidates):
+ tr = float(target_ratio)
+ best = None
+ for r, tag in candidates:
+ d = abs(math.log(r) - math.log(tr))
+ if best is None or d < best[0]:
+ best = (d, r, tag)
+ return best[1], best[2]
+
+
+def _cover_min_rect(x0, y0, w0, h0, ratio):
+ """
+ Smallest rectangle of aspect 'ratio' that fully covers the rect (x0,y0,w0,h0).
+ Returns floats (xa,ya,wa,ha) without clamping to image.
+ """
+ if w0 <= 0 or h0 <= 0:
+ return x0, y0, w0, h0
+ r0 = w0 / max(1.0, h0)
+ if r0 >= ratio:
+ wa = w0
+ ha = w0 / ratio
+ else:
+ ha = h0
+ wa = h0 * ratio
+ cx = x0 + w0 / 2.0
+ cy = y0 + h0 / 2.0
+ return cx - wa / 2.0, cy - ha / 2.0, wa, ha
+
+
+class FitAspectHeadSafe:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "x": ("INT",),
+ "y": ("INT",),
+ "w": ("INT",),
+ "h": ("INT",),
+ "aspects_csv": (
+ "STRING",
+ {"default": "2:3,3:4,1:1,9:16,16:9,5:8,8:5"},
+ ),
+ "match_to": (["image", "mq_box"], {"default": "mq_box"}),
+ "headroom_ratio": ("FLOAT", {"default": 0.12, "min": 0.0, "max": 0.5, "step": 0.01}),
+ "footroom_ratio": ("FLOAT", {"default": 0.06, "min": 0.0, "max": 0.5, "step": 0.01}),
+ "side_margin_ratio": ("FLOAT", {"default": 0.08, "min": 0.0, "max": 0.5, "step": 0.01}),
+ "bottom_priority": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.05}),
+ "horiz_gravity": (["center", "left", "right"], {"default": "center"}),
+ }
+ }
+
+ RETURN_TYPES = ("INT", "INT", "INT", "INT", "STRING", "STRING")
+ RETURN_NAMES = ("w", "h", "x", "y", "aspect_used", "debug")
+ FUNCTION = "run"
+ CATEGORY = "utils/geometry"
+
+ def run(
+ self,
+ image,
+ x,
+ y,
+ w,
+ h,
+ aspects_csv,
+ match_to,
+ headroom_ratio,
+ footroom_ratio,
+ side_margin_ratio,
+ bottom_priority,
+ horiz_gravity,
+ ):
+
+ H, W = _get_hw(image)
+ img_ratio = W / max(1.0, H)
+ mq_ratio = w / max(1.0, h)
+ target_ratio = img_ratio if match_to == "image" else mq_ratio
+
+ aspects = _parse_aspects(aspects_csv)
+ ratio, tag = _closest_ratio(target_ratio, aspects)
+
+ req_x0 = x
+ req_y0 = y
+ req_x1 = x + w
+ req_y1 = y + h
+
+ guess_h = H
+ head_px = headroom_ratio * guess_h
+ foot_px = footroom_ratio * guess_h
+ side_px = side_margin_ratio * (guess_h * ratio)
+
+ req_x0p = req_x0 - side_px
+ req_x1p = req_x1 + side_px
+ req_y0p = req_y0 - head_px
+ req_y1p = req_y1 + foot_px
+
+ xa, ya, wa, ha = _cover_min_rect(
+ req_x0p,
+ req_y0p,
+ max(1.0, req_x1p - req_x0p),
+ max(1.0, req_y1p - req_y0p),
+ ratio,
+ )
+
+ head_px = headroom_ratio * ha
+ foot_px = footroom_ratio * ha
+ side_px = side_margin_ratio * wa
+ req_x0p = req_x0 - side_px
+ req_x1p = req_x1 + side_px
+ req_y0p = req_y0 - head_px
+ req_y1p = req_y1 + foot_px
+ xa, ya, wa, ha = _cover_min_rect(
+ req_x0p,
+ req_y0p,
+ max(1.0, req_x1p - req_x0p),
+ max(1.0, req_y1p - req_y0p),
+ ratio,
+ )
+
+ if horiz_gravity == "left":
+ xa = 0.0
+ elif horiz_gravity == "right":
+ xa = W - wa
+ else:
+ cx = x + w / 2.0
+ xa = cx - wa / 2.0
+ xa = max(0.0, min(xa, W - wa))
+
+ y_head = y
+ y_feet = y + h
+ y_top_des = y_head - head_px
+ y_bot_des = y_feet + foot_px
+ ya = y_top_des
+ ya = max(0.0, min(ya, H - ha))
+
+ def violations(Y):
+ top_gap = (Y) - (y_head - head_px)
+ bot_gap = (Y + ha) - (y_feet + foot_px)
+ loss_top = max(0.0, -top_gap)
+ loss_bot = max(0.0, -bot_gap)
+ return loss_top, loss_bot, (1.0 - bottom_priority) * loss_top + bottom_priority * loss_bot
+
+ candidates = [
+ max(0.0, min(y_top_des, H - ha)),
+ max(0.0, min(y_bot_des - ha, H - ha)),
+ 0.0,
+ float(H - ha),
+ ]
+ best = None
+ for Yc in candidates:
+ lt, lb, cost = violations(Yc)
+ if best is None or cost < best[0]:
+ best = (cost, Yc, lt, lb)
+ _, ya, lt, lb = best
+ ya = max(0.0, min(ya, H - ha))
+
+ X = int(round(xa))
+ Y = int(round(ya))
+ Wc = int(round(wa))
+ Hc = int(round(ha))
+ X = max(0, min(X, W - 1))
+ Y = max(0, min(Y, H - 1))
+ Wc = max(1, min(Wc, W - X))
+ Hc = max(1, min(Hc, H - Y))
+
+ dbg = (
+ f"match_to={match_to}, chosen={tag}({ratio:.4f}), "
+ f"img={W}x{H}, mq=({x},{y},{w},{h}), "
+ f"margins(px): head={head_px:.1f}, foot={foot_px:.1f}, side≈{side_px:.1f}, "
+ f"viol: top={lt:.1f}, bot={lb:.1f}; final=({X},{Y},{Wc},{Hc})"
+ )
+ return (Wc, Hc, X, Y, tag, dbg)
+
+
+NODE_CLASS_MAPPINGS = {
+ "MQBBoxMin": MQBBoxMin,
+ "FitAspectHeadSafe": FitAspectHeadSafe,
+}
+
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "MQBBoxMin": "MQ BBox (MIN)",
+ "FitAspectHeadSafe": "Fit Aspect (Head-Safe) - Closest AR + Tight Cover",
+}
diff --git a/paired_image_loader.py b/paired_image_loader.py
index d35f697..78b4139 100644
--- a/paired_image_loader.py
+++ b/paired_image_loader.py
@@ -93,7 +93,7 @@ def _coerce_str(value) -> str:
return str(value)
-class PortraitUtils_PairedImageLoader:
+class PairedImageLoader:
CATEGORY = "PortraitUtils"
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING")
FUNCTION = "load_next_pair"
@@ -161,20 +161,20 @@ class PortraitUtils_PairedImageLoader:
self._state_key = state_key
if not source_dir:
- raise ValueError("PortraitUtils_PairedImageLoader: source_dir is required.")
+ raise ValueError("PairedImageLoader: source_dir is required.")
if not output_dir:
- raise ValueError("PortraitUtils_PairedImageLoader: output_dir is required.")
+ raise ValueError("PairedImageLoader: output_dir is required.")
src_path = _resolve_directory(source_dir)
out_path = _resolve_directory(output_dir)
if not src_path.exists() or not src_path.is_dir():
raise ValueError(
- f"PortraitUtils_PairedImageLoader: source_dir not found: {src_path}"
+ f"PairedImageLoader: source_dir not found: {src_path}"
)
if not out_path.exists() or not out_path.is_dir():
raise ValueError(
- f"PortraitUtils_PairedImageLoader: output_dir not found: {out_path}"
+ f"PairedImageLoader: output_dir not found: {out_path}"
)
state = _STATE.setdefault(state_key, migrated_state or _NodeState())
@@ -196,7 +196,7 @@ class PortraitUtils_PairedImageLoader:
if not pairs:
raise RuntimeError(
- "PortraitUtils_PairedImageLoader: no matching images found "
+ "PairedImageLoader: no matching images found "
f"between {src_path} and {out_path}."
)
@@ -216,7 +216,7 @@ class PortraitUtils_PairedImageLoader:
_SIGNATURE_INDEX[signature] = state.index
print(
- "[PortraitUtils_PairedImageLoader] "
+ "[PairedImageLoader] "
f"{pair.display_name} ({next_index + 1}/{len(pairs)})"
)
@@ -383,23 +383,23 @@ class PortraitUtils_PairedImageLoader:
if source_only:
print(
- "[PortraitUtils_PairedImageLoader] Unmatched source files "
+ "[PairedImageLoader] Unmatched source files "
f"({len(source_only)}) in {source_dir}: {_summarize(source_only)}"
)
if output_only:
print(
- "[PortraitUtils_PairedImageLoader] Unmatched output files "
+ "[PairedImageLoader] Unmatched output files "
f"({len(output_only)}) in {output_dir}: {_summarize(output_only)}"
)
if collisions:
for key, files in collisions.items():
print(
- "[PortraitUtils_PairedImageLoader] Normalised name collision "
+ "[PairedImageLoader] Normalised name collision "
f"for '{key}': {_summarize(sorted(set(files), key=_natural_sort_key))}"
)
if ext_mismatch:
print(
- "[PortraitUtils_PairedImageLoader] Chosen fallback matches with differing "
+ "[PairedImageLoader] Chosen fallback matches with differing "
f"extensions ({len(ext_mismatch)}): {_summarize(ext_mismatch)}"
)
@@ -473,9 +473,9 @@ class PortraitUtils_PairedImageLoader:
NODE_CLASS_MAPPINGS = {
- "PortraitUtils_PairedImageLoader": PortraitUtils_PairedImageLoader,
+ "PairedImageLoader": PairedImageLoader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
- "PortraitUtils_PairedImageLoader": "Paired Image Loader",
+ "PairedImageLoader": "Paired Image Loader",
}