With Resize on, Image Crop + Rotate + Pad and both Video Crop + Rotate + Pad nodes resized the padded canvas as one picture. Along every seam that mixed fill into the picture and left the mask part on, part off. A hard paste (stitch blend 0) kept that column as a faint line, and a band under a pixel wide after the resize came out as nothing but that column: 1 px of padding at a 0.62 scale left a line in the stitched result whatever the model painted. resize_then_pad (nodes/_transform_engine.py) resizes only the picture, with the chosen method, into the box it has on the resized canvas, and puts the fill around it. Bands are whole pixels, the picture's edge is picture, and a band that comes to under half a pixel is left out. The mask is 1 on the fill; inside the box it is the transform's own mask there (the feather's ramp, the corners of a turn, see-through parts), resized bilinear with the picture. The prompt view is placed the same way. The box is transform_picture_box (nodes/_inpaint_crop_helpers.py), which the stitcher now uses for its source bbox too, so the paste and the bbox cannot disagree. It is never empty. The output size is unchanged. With Resize off, or with no padding, image and mask are bit for bit what they were. With padding, the picture can sit up to half a pixel from where it did.
587 lines
26 KiB
Python
587 lines
26 KiB
Python
"""Pure rotate, crop, and pad processing shared by the AusBoss loaders."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
import numpy as np
|
|
from ._execution_helpers import advance_progress, frame_progress, raise_if_interrupted
|
|
from PIL import Image, ImageFilter
|
|
import torch
|
|
|
|
from ._color_helpers import parse_fill_color
|
|
from ._inpaint_crop_helpers import transform_picture_box
|
|
from ._pad_helpers import _axis_layout
|
|
|
|
|
|
MAX_DIMENSION = 65536
|
|
MAX_PADDING = 32768
|
|
|
|
# A source pixel stays picture when its alpha is at least this share, in
|
|
# percent, of the most solid pixel's; anything more see-through is area to
|
|
# paint, like the padding. Measured on stranger-test cutouts: an oval photo
|
|
# whose edge ramp stores colours darkened by their alpha keeps a rim at most
|
|
# 10% dark at 90 (half dark at 50, the ring the model painted back), while
|
|
# matting noise inside solid subjects sits above 90, so no holes open there.
|
|
# Relative to the most solid pixel, a picture saved at 50% opacity throughout
|
|
# is kept whole instead of painted over. A picture at least this solid
|
|
# everywhere has no see-through part and goes through exactly as before
|
|
# (generated pictures often carry alpha 249-254 in places).
|
|
SEE_THROUGH_KEEP_PERCENT = 90
|
|
# How see-through parts are shown to a prompt writer (prompt_image) and in
|
|
# the untransformed `original`: on white, as a picture viewer shows them.
|
|
SEE_THROUGH_BACKDROP = (255, 255, 255)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TransformSpec:
|
|
rotation_degrees: float = 0.0
|
|
crop_aspect_ratio: str = "free"
|
|
crop_x: int = 0
|
|
crop_y: int = 0
|
|
crop_width: int = 0
|
|
crop_height: int = 0
|
|
pad_left: int = 0
|
|
pad_top: int = 0
|
|
pad_right: int = 0
|
|
pad_bottom: int = 0
|
|
feather: int = 0
|
|
canvas_multiple: int = 1
|
|
fill_color: str = "#808080"
|
|
|
|
def normalized(self) -> "TransformSpec":
|
|
angle = ((float(self.rotation_degrees) + 180.0) % 360.0) - 180.0
|
|
if abs(angle) < 0.00005:
|
|
angle = 0.0
|
|
return TransformSpec(
|
|
rotation_degrees=round(angle, 4),
|
|
crop_aspect_ratio=normalize_aspect_ratio(self.crop_aspect_ratio),
|
|
crop_x=max(0, int(self.crop_x)),
|
|
crop_y=max(0, int(self.crop_y)),
|
|
crop_width=max(0, int(self.crop_width)),
|
|
crop_height=max(0, int(self.crop_height)),
|
|
pad_left=_bounded_padding(self.pad_left, "pad_left"),
|
|
pad_top=_bounded_padding(self.pad_top, "pad_top"),
|
|
pad_right=_bounded_padding(self.pad_right, "pad_right"),
|
|
pad_bottom=_bounded_padding(self.pad_bottom, "pad_bottom"),
|
|
feather=max(0, min(int(self.feather), 4096)),
|
|
canvas_multiple=max(1, min(int(self.canvas_multiple), 4096)),
|
|
fill_color=normalize_fill_color(self.fill_color),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TransformGeometry:
|
|
rotated_width: int
|
|
rotated_height: int
|
|
crop_x: int
|
|
crop_y: int
|
|
crop_width: int
|
|
crop_height: int
|
|
pad_left: int
|
|
pad_top: int
|
|
pad_right: int
|
|
pad_bottom: int
|
|
output_width: int
|
|
output_height: int
|
|
|
|
|
|
def _bounded_padding(value: int, name: str) -> int:
|
|
value = int(value)
|
|
if value < 0 or value > MAX_PADDING:
|
|
raise ValueError(
|
|
f"Transform: input '{name}' expected 0..{MAX_PADDING}, received {value}."
|
|
)
|
|
return value
|
|
|
|
|
|
def normalize_fill_color(value: str) -> str:
|
|
return "#" + "".join(f"{channel:02x}" for channel in parse_fill_color(value))
|
|
|
|
|
|
def fill_rgb(value: str) -> tuple[int, int, int]:
|
|
return parse_fill_color(value)
|
|
|
|
|
|
def normalize_aspect_ratio(value: str) -> str:
|
|
text = str(value or "free").strip().lower()
|
|
if text in {"free", "source"}:
|
|
return text
|
|
try:
|
|
width, height = (int(part) for part in text.split(":", 1))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(
|
|
"Transform: input 'crop_aspect_ratio' expected free, source, or W:H."
|
|
) from exc
|
|
if width < 1 or height < 1:
|
|
raise ValueError("Transform: crop aspect ratio values must be positive.")
|
|
common = math.gcd(width, height)
|
|
return f"{width // common}:{height // common}"
|
|
|
|
|
|
def _validate_source(image: Image.Image) -> None:
|
|
width, height = image.size
|
|
if width < 1 or height < 1:
|
|
raise ValueError("Transform: source image must contain at least one pixel.")
|
|
if width > MAX_DIMENSION or height > MAX_DIMENSION:
|
|
raise ValueError(
|
|
f"Transform: source dimensions must be at most {MAX_DIMENSION} pixels per side."
|
|
)
|
|
|
|
|
|
def see_through_kept(image: Image.Image) -> np.ndarray | None:
|
|
"""[H, W] bool map of the pixels that stay picture; the rest are see-through.
|
|
|
|
None when every pixel is at least SEE_THROUGH_KEEP_PERCENT solid, so an
|
|
opaque source takes exactly the path it always took. Otherwise a pixel
|
|
stays when its alpha is at least SEE_THROUGH_KEEP_PERCENT of the most
|
|
solid pixel's; a source with no solid pixel at all is see-through
|
|
everywhere.
|
|
"""
|
|
if "A" not in image.getbands():
|
|
return None
|
|
alpha = np.asarray(image.getchannel("A"))
|
|
if int(alpha.min()) * 100 >= 255 * SEE_THROUGH_KEEP_PERCENT:
|
|
return None
|
|
top = int(alpha.max())
|
|
return alpha.astype(np.int32) * 100 >= top * SEE_THROUGH_KEEP_PERCENT if top else np.zeros(alpha.shape, bool)
|
|
|
|
|
|
def _empty_see_through(rgba: Image.Image, kept: np.ndarray) -> Image.Image:
|
|
"""The source with its see-through pixels made empty, alpha 0 like a
|
|
rotation corner, and every kept pixel fully solid in its own colour.
|
|
|
|
The stored colour of a see-through pixel is often black or the old
|
|
background, and blending it over the fill left a dark ring that the model
|
|
painted back; with alpha 0 it never reaches the canvas (Pillow's turn
|
|
resamples with the alpha applied), and the mask marks it as area to paint.
|
|
"""
|
|
array = np.array(rgba, dtype=np.uint8)
|
|
array[..., 3] = np.where(kept, 255, 0).astype(np.uint8)
|
|
return Image.fromarray(array, "RGBA")
|
|
|
|
|
|
def _solid(image: Image.Image) -> Image.Image:
|
|
"""The source fully solid: every pixel in its stored colour."""
|
|
return image.convert("RGB").convert("RGBA")
|
|
|
|
|
|
def _rotate_rgba(image: Image.Image, spec: TransformSpec) -> Image.Image:
|
|
rgba = image.convert("RGBA")
|
|
if spec.rotation_degrees == 0.0:
|
|
return rgba.copy()
|
|
color = fill_rgb(spec.fill_color)
|
|
return rgba.rotate(
|
|
-spec.rotation_degrees,
|
|
resample=Image.Resampling.BICUBIC,
|
|
expand=True,
|
|
fillcolor=(*color, 0),
|
|
)
|
|
|
|
|
|
def _ratio_box(width: int, height: int, ratio_width: int, ratio_height: int) -> tuple[int, int]:
|
|
"""The largest ratio_width:ratio_height box inside width x height.
|
|
|
|
Integer maths: the float version lost a pixel on the limiting side
|
|
(21 * (3813 / 21) is 3812.99...). A box already within a pixel of the
|
|
ratio on either side is kept, so a resolved crop written back resolves
|
|
to itself. transform_geometry.mjs ratioBox is the same rule, which keeps
|
|
the editor's size readout equal to the run.
|
|
"""
|
|
fit_height = width * ratio_height // ratio_width
|
|
fit_width = height * ratio_width // ratio_height
|
|
if height == fit_height or width == fit_width:
|
|
return width, height
|
|
if width * ratio_height > height * ratio_width:
|
|
return max(1, fit_width), height
|
|
return width, max(1, fit_height)
|
|
|
|
|
|
def _geometry(rotated: Image.Image, spec: TransformSpec) -> TransformGeometry:
|
|
rotated_width, rotated_height = rotated.size
|
|
crop_x = min(spec.crop_x, rotated_width - 1)
|
|
crop_y = min(spec.crop_y, rotated_height - 1)
|
|
available_width = rotated_width - crop_x
|
|
available_height = rotated_height - crop_y
|
|
crop_width = available_width if spec.crop_width <= 0 else min(spec.crop_width, available_width)
|
|
crop_height = available_height if spec.crop_height <= 0 else min(spec.crop_height, available_height)
|
|
crop_width = max(1, crop_width)
|
|
crop_height = max(1, crop_height)
|
|
if spec.crop_aspect_ratio != "free":
|
|
if spec.crop_aspect_ratio == "source":
|
|
ratio_width, ratio_height = rotated_width, rotated_height
|
|
else:
|
|
ratio_width, ratio_height = (int(part) for part in spec.crop_aspect_ratio.split(":"))
|
|
crop_width, crop_height = _ratio_box(crop_width, crop_height, ratio_width, ratio_height)
|
|
|
|
# The canvas rounds to canvas_multiple one axis at a time, by Load Image +
|
|
# Pad's rule (_pad_helpers._axis_layout): the leftover joins a side the
|
|
# user padded, and an axis nobody padded has its crop trimmed evenly to
|
|
# the multiple below instead. The leftover used to go to the right and
|
|
# bottom whatever was padded, so an unpadded edge got a strip of fill a
|
|
# pixel or more wide; a resize then smeared it into the picture's last
|
|
# column, and the stitch kept that as a light line.
|
|
# transform_geometry.mjs resolvePadding is the same rule, which keeps
|
|
# the editor's picture and size readout equal to the run.
|
|
pad_left, pad_right, trim_left, _, output_width = _axis_layout(
|
|
crop_width, spec.pad_left, spec.pad_right, spec.canvas_multiple
|
|
)
|
|
pad_top, pad_bottom, trim_top, _, output_height = _axis_layout(
|
|
crop_height, spec.pad_top, spec.pad_bottom, spec.canvas_multiple
|
|
)
|
|
if output_width > MAX_DIMENSION or output_height > MAX_DIMENSION:
|
|
raise ValueError(
|
|
"Transform: requested crop and padding exceed the 65536-pixel output limit."
|
|
)
|
|
|
|
return TransformGeometry(
|
|
rotated_width=rotated_width,
|
|
rotated_height=rotated_height,
|
|
crop_x=crop_x + trim_left,
|
|
crop_y=crop_y + trim_top,
|
|
crop_width=output_width - pad_left - pad_right,
|
|
crop_height=output_height - pad_top - pad_bottom,
|
|
pad_left=pad_left,
|
|
pad_top=pad_top,
|
|
pad_right=pad_right,
|
|
pad_bottom=pad_bottom,
|
|
output_width=output_width,
|
|
output_height=output_height,
|
|
)
|
|
|
|
|
|
def transform_pil(image: Image.Image, spec: TransformSpec) -> tuple[Image.Image, Image.Image, TransformGeometry]:
|
|
"""Apply rotate -> crop -> pad and return opaque RGB, BHW-style mask image, and geometry."""
|
|
output, mask, geometry, _ = _transform_frame(image, spec, False)
|
|
return output, mask, geometry
|
|
|
|
|
|
def _transform_frame(
|
|
image: Image.Image, spec: TransformSpec, view: bool, keep_see_through: bool = False
|
|
) -> tuple[Image.Image, Image.Image, TransformGeometry, Image.Image | None]:
|
|
"""transform_pil, plus the prompt view (:func:`_see_through_view`) when
|
|
``view`` is set and the source has see-through parts; None otherwise.
|
|
|
|
With ``keep_see_through`` the stored colour under a see-through part
|
|
stays in the picture and only the mask marks it, so there is no prompt
|
|
view either: the picture is whole. That is what a mask painted in the
|
|
MaskEditor needs when a later step reads what is under the paint.
|
|
"""
|
|
_validate_source(image)
|
|
spec = spec.normalized()
|
|
rgba = image if image.mode == "RGBA" else image.convert("RGBA")
|
|
kept = see_through_kept(rgba)
|
|
if kept is not None:
|
|
rgba = _empty_see_through(rgba, kept)
|
|
rotated = _rotate_rgba(rgba, spec)
|
|
geometry = _geometry(rotated, spec)
|
|
crop_box = (
|
|
geometry.crop_x,
|
|
geometry.crop_y,
|
|
geometry.crop_x + geometry.crop_width,
|
|
geometry.crop_y + geometry.crop_height,
|
|
)
|
|
cropped = rotated.crop(crop_box)
|
|
|
|
# What the mask is cut from: the kept picture, see-through parts empty.
|
|
alpha = cropped.getchannel("A")
|
|
if kept is not None and keep_see_through:
|
|
# The picture itself goes through whole, turned and cropped the same
|
|
# way; its own alpha is only the corners a turn leaves empty.
|
|
cropped = _rotate_rgba(_solid(image), spec).crop(crop_box)
|
|
fill = fill_rgb(spec.fill_color)
|
|
filled_crop = Image.new("RGB", cropped.size, fill)
|
|
filled_crop.paste(cropped.convert("RGB"), mask=cropped.getchannel("A"))
|
|
|
|
output = Image.new("RGB", (geometry.output_width, geometry.output_height), fill)
|
|
output.paste(filled_crop, (geometry.pad_left, geometry.pad_top))
|
|
|
|
generated_crop = Image.fromarray(255 - np.asarray(alpha, dtype=np.uint8))
|
|
mask = Image.new("L", output.size, 255)
|
|
mask.paste(generated_crop, (geometry.pad_left, geometry.pad_top))
|
|
if spec.feather > 0:
|
|
original = np.asarray(mask, dtype=np.uint8)
|
|
blurred = np.asarray(
|
|
mask.filter(ImageFilter.GaussianBlur(spec.feather)), dtype=np.uint16
|
|
)
|
|
# A blurred step edge sits at ~50% exactly on the boundary, so using
|
|
# the blur directly (or max() with it) leaves a visible 255->127 seam.
|
|
# Doubling and clipping pins the generated side at 255 and starts a
|
|
# smooth ramp exactly at the edge; max() keeps thin generated slivers
|
|
# fully masked.
|
|
feathered = np.maximum(original, np.minimum(blurred * 2, 255).astype(np.uint8))
|
|
mask = Image.fromarray(feathered)
|
|
# Only the mask is feathered; the picture keeps its hard edge against
|
|
# the fill, as Load Image + Pad's does. Outpaint models key on a solid
|
|
# hard-edged fill and read a faded ramp as content (a darker band, or
|
|
# LTX's IC-LoRA leaving the bars unpainted), and the stitcher built
|
|
# from this canvas must hold real pixels for its color match.
|
|
|
|
prompt_view = None
|
|
if view and kept is not None and not keep_see_through:
|
|
prompt_view = _see_through_view(output, alpha, rgba.size, crop_box, spec, geometry)
|
|
return output, mask, geometry, prompt_view
|
|
|
|
|
|
def _see_through_view(
|
|
output: Image.Image,
|
|
picture_alpha: Image.Image,
|
|
source_size: tuple[int, int],
|
|
crop_box: tuple[int, int, int, int],
|
|
spec: TransformSpec,
|
|
geometry: TransformGeometry,
|
|
) -> Image.Image:
|
|
"""The canvas with its see-through parts on SEE_THROUGH_BACKDROP.
|
|
|
|
``picture_alpha`` is the kept picture's alpha, turned and cropped. The
|
|
source's whole rectangle, turned and cropped the same way, covers the
|
|
picture plus its see-through parts, so what it covers beyond the picture
|
|
is see-through; padding and the corners a turn leaves keep the fill.
|
|
A partly covered edge pixel keeps each share: picture, backdrop, fill.
|
|
"""
|
|
area = Image.new("L", source_size, 255)
|
|
if spec.rotation_degrees != 0.0:
|
|
area = area.rotate(
|
|
-spec.rotation_degrees, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=0
|
|
)
|
|
area = np.asarray(area.crop(crop_box), dtype=np.int16)
|
|
share = np.clip(area - np.asarray(picture_alpha, dtype=np.int16), 0, 255)
|
|
canvas = np.array(output, dtype=np.uint8)
|
|
rows = np.flatnonzero(share.any(axis=1))
|
|
cols = np.flatnonzero(share.any(axis=0))
|
|
if rows.size == 0:
|
|
return Image.fromarray(canvas, "RGB")
|
|
# Only the see-through part's bounding box changes; whole-number maths:
|
|
# (2 * share * lift + 255) // 510 is share * lift / 255 rounded.
|
|
y0, y1, x0, x1 = int(rows[0]), int(rows[-1]) + 1, int(cols[0]), int(cols[-1]) + 1
|
|
lift = np.asarray(SEE_THROUGH_BACKDROP, dtype=np.int32) - np.asarray(fill_rgb(spec.fill_color), dtype=np.int32)
|
|
top, left = geometry.pad_top + y0, geometry.pad_left + x0
|
|
window = canvas[top : top + (y1 - y0), left : left + (x1 - x0)]
|
|
rise = (2 * share[y0:y1, x0:x1, None].astype(np.int32) * lift + 255) // 510
|
|
window[...] = np.clip(window.astype(np.int32) + rise, 0, 255).astype(np.uint8)
|
|
return Image.fromarray(canvas, "RGB")
|
|
|
|
|
|
def transform_pil_batch(
|
|
images: Iterable[Image.Image], spec: TransformSpec, *, view: bool = False, keep_see_through: bool = False
|
|
) -> tuple:
|
|
"""transform_pil over every frame, as BHWC image and BHW mask batches.
|
|
|
|
With ``view`` a fourth item follows: the prompt view batch, each frame's
|
|
canvas with its see-through parts on SEE_THROUGH_BACKDROP, or None when
|
|
no frame has any (the view is then the image itself). With
|
|
``keep_see_through`` the picture keeps what is stored under its
|
|
see-through parts and only the mask marks them.
|
|
"""
|
|
frames: list[torch.Tensor] = []
|
|
masks: list[torch.Tensor] = []
|
|
views: list[Image.Image] = []
|
|
first_geometry: TransformGeometry | None = None
|
|
for index, image in enumerate(images):
|
|
output, mask, geometry, prompt_view = _transform_frame(image, spec, view, keep_see_through)
|
|
if first_geometry is None:
|
|
first_geometry = geometry
|
|
elif output.size != (first_geometry.output_width, first_geometry.output_height):
|
|
raise ValueError(
|
|
f"Transform: frame {index} produced dimensions that differ from frame 0."
|
|
)
|
|
image_array = np.asarray(output, dtype=np.float32) / 255.0
|
|
mask_array = np.asarray(mask, dtype=np.float32) / 255.0
|
|
frames.append(torch.from_numpy(image_array.copy()))
|
|
masks.append(torch.from_numpy(mask_array.copy()))
|
|
views.append(prompt_view)
|
|
|
|
if not frames or first_geometry is None:
|
|
raise ValueError("Transform: source contained no decodable frames.")
|
|
result = (torch.stack(frames, dim=0), torch.stack(masks, dim=0), first_geometry)
|
|
if not view:
|
|
return result
|
|
if all(item is None for item in views):
|
|
return (*result, None)
|
|
shown = [
|
|
frames[index] if item is None else torch.from_numpy(np.asarray(item, dtype=np.float32) / 255.0)
|
|
for index, item in enumerate(views)
|
|
]
|
|
return (*result, torch.stack(shown, dim=0))
|
|
|
|
|
|
def transform_tensor_batch(
|
|
image: torch.Tensor, spec: TransformSpec, source_mask: torch.Tensor | None = None
|
|
) -> tuple[torch.Tensor, torch.Tensor, TransformGeometry]:
|
|
if not isinstance(image, torch.Tensor) or image.ndim != 4 or image.shape[-1] not in (3, 4):
|
|
received = tuple(image.shape) if isinstance(image, torch.Tensor) else type(image).__name__
|
|
raise ValueError(
|
|
"Transform: input 'image' expected BHWC with 3 or 4 channels, "
|
|
f"received {received}."
|
|
)
|
|
if source_mask is not None:
|
|
if not isinstance(source_mask, torch.Tensor) or tuple(source_mask.shape) != tuple(image.shape[:3]):
|
|
raise ValueError(
|
|
"Transform: input 'source_mask' expected BHW matching the image batch."
|
|
)
|
|
|
|
device = image.device
|
|
dtype = image.dtype
|
|
pil_frames: list[Image.Image] = []
|
|
image_cpu = image.detach().to(device="cpu", dtype=torch.float32).clamp(0.0, 1.0)
|
|
mask_cpu = None if source_mask is None else source_mask.detach().to(device="cpu", dtype=torch.float32).clamp(0.0, 1.0)
|
|
for index in range(image_cpu.shape[0]):
|
|
rgb = (image_cpu[index, ..., :3].numpy() * 255.0).round().astype(np.uint8)
|
|
if image_cpu.shape[-1] == 4:
|
|
alpha = (image_cpu[index, ..., 3].numpy() * 255.0).round().astype(np.uint8)
|
|
else:
|
|
alpha = np.full(image_cpu.shape[1:3], 255, dtype=np.uint8)
|
|
if mask_cpu is not None:
|
|
alpha = np.minimum(alpha, ((1.0 - mask_cpu[index].numpy()) * 255.0).round().astype(np.uint8))
|
|
pil_frames.append(Image.fromarray(np.dstack((rgb, alpha))))
|
|
|
|
output, mask, geometry = transform_pil_batch(pil_frames, spec)
|
|
return output.to(device=device, dtype=dtype), mask.to(device=device, dtype=dtype), geometry
|
|
|
|
|
|
def transform_tensor_batch_chunked(
|
|
image: torch.Tensor, spec: TransformSpec, chunk_size: int = 16
|
|
) -> tuple[torch.Tensor, torch.Tensor, TransformGeometry]:
|
|
"""transform_tensor_batch over a long batch, one chunk of frames at a time.
|
|
|
|
Every chunk goes through the same PIL round trip, but peak memory stays
|
|
at the finished output plus one chunk instead of every frame twice, and
|
|
the queue's interrupt and progress bar are serviced between chunks - the
|
|
video node feeds whole clips through here.
|
|
"""
|
|
if not isinstance(image, torch.Tensor) or image.ndim != 4:
|
|
received = tuple(image.shape) if isinstance(image, torch.Tensor) else type(image).__name__
|
|
raise ValueError(f"Transform: input 'image' expected a BHWC batch, received {received}.")
|
|
total = int(image.shape[0])
|
|
size = max(1, int(chunk_size))
|
|
output = mask = geometry = None
|
|
progress = frame_progress(total)
|
|
for start in range(0, total, size):
|
|
raise_if_interrupted()
|
|
chunk_output, chunk_mask, chunk_geometry = transform_tensor_batch(image[start : start + size], spec)
|
|
if output is None:
|
|
geometry = chunk_geometry
|
|
output = torch.empty((total, *chunk_output.shape[1:]), dtype=chunk_output.dtype, device=chunk_output.device)
|
|
mask = torch.empty((total, *chunk_mask.shape[1:]), dtype=chunk_mask.dtype, device=chunk_mask.device)
|
|
elif chunk_output.shape[1:] != output.shape[1:]:
|
|
raise ValueError(f"Transform: frame {start} produced dimensions that differ from frame 0.")
|
|
count = int(chunk_output.shape[0])
|
|
output[start : start + count] = chunk_output
|
|
mask[start : start + count] = chunk_mask
|
|
advance_progress(progress, min(start + count, total), total)
|
|
if output is None or mask is None or geometry is None:
|
|
raise ValueError("Transform: source contained no decodable frames.")
|
|
return output, mask, geometry
|
|
|
|
|
|
def stable_file_fingerprint(path: str | os.PathLike[str], inputs: dict[str, object]) -> str:
|
|
normalized_path = str(Path(path).resolve()).casefold()
|
|
try:
|
|
stat = os.stat(path)
|
|
file_state: dict[str, object] = {
|
|
"path": normalized_path,
|
|
"mtime_ns": stat.st_mtime_ns,
|
|
"size": stat.st_size,
|
|
}
|
|
except OSError:
|
|
file_state = {"path": normalized_path, "missing": True}
|
|
payload = {"file": file_state, "inputs": inputs}
|
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def scale_to_megapixels(
|
|
width: int, height: int, megapixels: float, steps: int = 1
|
|
) -> tuple[int, int]:
|
|
"""Target size whose pixel count is ~megapixels, aspect preserved.
|
|
|
|
Mirrors core ImageScaleToTotalPixels semantics so the two stay
|
|
interchangeable in a workflow: the budget is megapixels * 1024 * 1024,
|
|
and each dimension rounds independently to the nearest multiple of
|
|
steps (never below one step)."""
|
|
source_width = max(1, int(width))
|
|
source_height = max(1, int(height))
|
|
total = max(1.0, float(megapixels) * 1024 * 1024)
|
|
scale = (total / (source_width * source_height)) ** 0.5
|
|
step = max(1, int(steps))
|
|
scaled_width = max(step, round(source_width * scale / step) * step)
|
|
scaled_height = max(step, round(source_height * scale / step) * step)
|
|
return int(scaled_width), int(scaled_height)
|
|
|
|
|
|
def resize_then_pad(output, mask, geometry, fill_color, megapixels, method, steps, view=None):
|
|
"""A transform's canvas at a pixel budget: the picture resized, then padded.
|
|
|
|
``output`` and ``mask`` are a transform's BHWC canvas and BHW mask,
|
|
``view`` its prompt view or None. Returns the three at the size
|
|
:func:`scale_to_megapixels` gives the canvas.
|
|
|
|
Resizing the padded canvas as one picture mixed fill into the picture
|
|
along every seam: a column part fill with a part mask, which a hard
|
|
paste kept as a line, and a band under a pixel wide came out as nothing
|
|
else. So only the picture is resized, with the chosen method, into the
|
|
box it has on the resized canvas (transform_picture_box, the stitcher's
|
|
own source bbox), and the fill goes around it. Bands are whole pixels,
|
|
the picture's edge is picture, and a band that comes to under half a
|
|
pixel is gone.
|
|
|
|
The mask is 1 on the fill. Inside the box it is the transform's own
|
|
mask there, resized with the picture: the feather's ramp from each
|
|
seam, the corners of a turn and see-through parts. It resizes bilinear:
|
|
it is a soft coverage map, and a ringing method would push it outside
|
|
0..1. Without padding the box is the whole canvas, and this is a plain
|
|
resize of image and mask.
|
|
"""
|
|
import comfy.utils
|
|
|
|
height, width = int(output.shape[1]), int(output.shape[2])
|
|
target_width, target_height = scale_to_megapixels(width, height, megapixels, steps)
|
|
if (target_width, target_height) == (width, height):
|
|
return output, mask, view
|
|
x0, y0, x1, y1 = transform_picture_box(geometry, target_width, target_height)
|
|
rows = slice(geometry.pad_top, geometry.pad_top + geometry.crop_height)
|
|
cols = slice(geometry.pad_left, geometry.pad_left + geometry.crop_width)
|
|
fill = torch.tensor([channel / 255.0 for channel in fill_rgb(fill_color)])
|
|
|
|
def place(canvas):
|
|
picture = comfy.utils.common_upscale(
|
|
canvas[:, rows, cols].movedim(-1, 1), x1 - x0, y1 - y0, str(method), "disabled"
|
|
)
|
|
placed = fill.to(canvas).expand(canvas.shape[0], target_height, target_width, -1).clone()
|
|
placed[:, y0:y1, x0:x1] = picture.movedim(1, -1).clamp(0.0, 1.0)
|
|
return placed
|
|
|
|
resized = mask.new_ones((mask.shape[0], target_height, target_width))
|
|
inside = mask[:, rows, cols]
|
|
if bool(inside.any()):
|
|
inside = comfy.utils.common_upscale(inside.unsqueeze(1), x1 - x0, y1 - y0, "bilinear", "disabled")
|
|
resized[:, y0:y1, x0:x1] = inside.squeeze(1).clamp(0.0, 1.0)
|
|
else:
|
|
resized[:, y0:y1, x0:x1] = 0.0
|
|
return place(output), resized, None if view is None else place(view)
|
|
|
|
|
|
def original_image_batch(images: Iterable[Image.Image], keep_see_through: bool = False) -> torch.Tensor:
|
|
"""Untransformed, EXIF-oriented RGB source frames for reference outputs.
|
|
|
|
See-through parts (:func:`see_through_kept`) show on
|
|
SEE_THROUGH_BACKDROP, as a picture viewer shows them. Dropping the alpha
|
|
showed their stored colour instead, black for most cutouts, and a prompt
|
|
writer reading this output described a black backdrop. With
|
|
``keep_see_through`` the stored colour is what the caller asked for.
|
|
"""
|
|
frames = []
|
|
for image in images:
|
|
array = np.asarray(image.convert("RGB"), dtype=np.float32).copy()
|
|
kept = None if keep_see_through else see_through_kept(image if image.mode == "RGBA" else image.convert("RGBA"))
|
|
if kept is not None:
|
|
array[~kept] = SEE_THROUGH_BACKDROP
|
|
frames.append(torch.from_numpy(array / 255.0))
|
|
return torch.stack(frames)
|