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) +
AutoAdjustNode screenshot
+- `AutoWBColorMatch` - White-balances and colour matches against a reference image using several algorithms. [Docs](docs/AutoWBColorMatch.md) +
AutoWBColorMatch screenshot
+- `AutoColorConfigNode` - Lightweight toggle bundle for sharing Auto Adjust presets. [Docs](docs/AutoColorConfigNode.md) +
AutoColorConfigNode screenshot
+- `FluxResolutionPrepare` - Crops and resizes to the nearest Flux-friendly resolution, with optional pre-upscale. [Docs](docs/FluxResolutionPrepare.md) +
FluxResolutionPrepare screenshot
+- `ImageMegapixelSelector` - Chooses image sizes by target megapixels to match downstream model limits. [Docs](docs/ImageMegapixelSelector.md) +
ImageMegapixelSelector screenshot
### 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) +
AutoCropBorders screenshot
+- `CropImageByMargins` - Manual margin-based cropper for RGB images. [Docs](docs/CropImageByMargins.md) +
CropImageByMargins screenshot
+- `CropMaskByMargins` - Margin cropper designed for MASK tensors. [Docs](docs/CropMaskByMargins.md) +
CropMaskByMargins screenshot
+- `FitAspectHeadSafe` - Rescales and pads while preserving face-safe regions for portrait framing. [Docs](docs/FitAspectHeadSafe.md) +
FitAspectHeadSafe screenshot
+- `MQBBoxMin` - Utility for merging bounding boxes while enforcing minimum sizes. [Docs](docs/MQBBoxMin.md) +
MQBBoxMin screenshot
+- `StitchByMask` - Composites foreground and background images using mask-driven blending. [Docs](docs/StitchByMask.md) +
StitchByMask screenshot
### 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) +
MultiPromptNode screenshot
+- `WorkflowConfig` - Centralized key/value store for workflow-wide settings. [Docs](docs/WorkflowConfig.md) +
WorkflowConfig screenshot
+- `OutpaintConfigNode` - Packs outpainting preferences (steps, padding, sampler choices). [Docs](docs/OutpaintConfigNode.md) +
OutpaintConfigNode screenshot
+- `OutpaintPaddingComputeNode` - Calculates padding offsets to feed outpaint chains as either percentage or absolute pixels, with gravity. [Docs](docs/OutpaintPaddingComputeNode.md) +
OutpaintPaddingComputeNode screenshot
### 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) +
LoadImageCombined screenshot
+- `PairedImageLoader` - Loads synchronized source/output pairs for A/B comparison workflows. [Docs](docs/PairedImageLoader.md) +
PairedImageLoader screenshot
+- `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) +
FilenameAppendSuffix screenshot
+- `GpuSyncCacheClear` - Forces a CUDA sync and empties selected caches to avoid VRAM buildup. [Docs](docs/GpuSyncCacheClear.md) +
GpuSyncCacheClear screenshot
+- `SeedVR2Prep` - Convenience helper for SeedVR2/VRAM-heavy pipelines (cache priming & cleanup). [Docs](docs/SeedVR2Prep.md) +
SeedVR2Prep screenshot
## 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 -
Screenshot
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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
+# 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 -
Screenshot
## 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 -
Screenshot
## 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 -
Screenshot
## 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", }