Compare commits

...
1 Commits
Author SHA1 Message Date
ausboss b4657e2b58 Load Image: a loader that also sets the size
A new node, Load Image. It loads a picture and sets its size in the
same node, so a workflow needs no resize node and no size node after
its loader.

- Size: off, megapixels, longest side, scale, fit inside, or crop to
  fill. Each choice shows only the numbers it uses. Round to rounds
  the result to a multiple. Under More: the resize method, and Enlarge,
  which when off only ever makes a picture smaller.
- Arrows under the file picker step through the input folder, with
  the picture's place in the list beside them.
- The node shows the size going in and the size coming out before a
  run. The browser repeats the backend's arithmetic, and a table of
  252 cases holds the two to the same answers.
- Outputs: image, mask, width, height, filename, original_width,
  original_height. The mask is the picture's transparency, as the
  stock loader gives it, at the new size.
- The picture on the node is ComfyUI's own preview, so the mask editor
  and clipspace work as they do on the stock loader.
- With Size off the pixels are the file's own.

Mask by Touch and the run check know the new loader as a source.
2026-11-10 17:42:00 -06:00
21 changed files with 1440 additions and 4 deletions
+9
View File
@@ -4,6 +4,15 @@ All notable changes to ComfyUI-AusBoss are documented here.
## Unreleased
- **New node: Load Image.** Loads a picture and sets its size in the same
node, so a workflow needs no resize node after its loader. Size can be a
megapixel count, a longest side, a scale, a box the picture fits inside, or
a box it fills by cropping, rounded to a multiple you choose. Arrows step
through the pictures in the input folder, and the node shows the size
going in and coming out before you run. It also gives the file's name and
its original size. With Size off the picture comes out exactly as the file
has it.
- **Mask by Touch: type a name to take it out of the mask.** The name field
under the picture now has a sign in front. Click it to switch between **+**
(add by name) and **−** (take away by name), then type "face" or "hair" and
+6
View File
@@ -79,6 +79,12 @@ It returns the `image`, a `mask` of everything the model paints (padding, see-th
Krea 2 Rotate + Outpaint turns a photo, pads it and paints the new area in one pass. [Try the Krea 2 Rotate + Outpaint example →](example_workflows/Krea%202%20Rotate%20%2B%20Outpaint%20%28AusBoss%29.json)
### Load Image
Loads a picture and sets its size in one node. Pick a file, upload one, or step through the input folder with the arrows. **Size** can stay off, or bring the picture to a megapixel count, a longest side, a scale, a box it must fit inside, or a box it fills by cropping. **Round to** keeps the result on a multiple of 8, 16 or whatever the model wants. The node shows the size going in and the size coming out before you run, such as `912 × 1632 → 1144 × 2048`.
It returns the `image`, a `mask` of its see-through parts, the new `width` and `height`, the `filename` for a save prefix, and the file's `original_width` and `original_height`.
### Load Image + Pad
Makes a canvas for outpainting: drag the edges out to add room around a picture. The Source list shows the picture under the pointer. The new area can be a solid color, the average color of the nearest edge, the edge pixels smeared outward, a blurred copy of the picture, or a mirror of it. The card also has a seam feather, a canvas multiple and a megapixel budget, and **Exact padding** opens one control per side. A budget of 0 keeps the picture's size, and anything higher resizes the picture before padding. A picture wired into `source_image` is padded instead of the chosen file.
+1
View File
@@ -35,6 +35,7 @@ NODE_MODULES = [
"node_lama_inpaint",
"node_layout_boxes",
"node_literals",
"node_load_image",
"node_load_image_pad",
"node_load_audio",
"node_load_video",
+83
View File
@@ -0,0 +1,83 @@
# Load Image
Loads a picture and sets its size in the same node, so a workflow does not
need a resize node and a size node after its loader.
Pick a file in **Source**, press **Load** to upload one, or use the arrows
under it to step through the pictures in ComfyUI's input folder. The line
beside the arrows shows the size of the file and, when the size changes, the
size that comes out:
`912 × 1632 → 1144 × 2048`
That is the real output size, worked out before you run.
## Size
**Size** (`size`) picks how the picture is resized. Each choice shows only
the numbers it uses.
| Choice | What it does | Its number |
|---|---|---|
| **off** | The picture comes out exactly as the file has it. | none |
| **megapixels** | Brings the whole picture to a total size and keeps its shape. | **Megapixels** (`megapixels`): 1.0 is about 1024 × 1024 |
| **longest side** | Sets the longer edge; the other edge follows. | **Longest side** (`longest_side`), in pixels |
| **scale** | Multiplies both edges. | **Scale** (`scale`): 0.5 is half, 2 is double |
| **fit inside** | Makes the picture as big as it can be inside a box, with nothing cut off. | **Width** and **Height** (`width`, `height`) |
| **crop to fill** | Covers the box completely and cuts off what sticks out, evenly from both sides. | **Width** and **Height** |
**Round to** (`multiple`) rounds the new width and height to a multiple of
that number. Most models want 8 or 16. Set it to 1 to keep the numbers as
they are. It does nothing while Size is off.
## More
- **Method** (`method`): how pixels are resampled. `lanczos` is the sharpest
for photos. `nearest` keeps pixel art crisp.
- **Enlarge** (`enlarge`): on, a small picture is made bigger to reach the
size you asked for. Off, a picture is only ever made smaller: one that is
already below the target comes out at its own size, and crop to fill cuts
the box's shape out of it without growing it.
## The picture on the node
The picture under the settings is ComfyUI's own preview, the same one the
stock Load Image shows. Drop a file on the node or paste one to upload it,
and right-click the node for the mask editor and for copy and paste.
## Outputs
- `image`: the picture at its new size.
- `mask`: white where the picture is see-through and black where it is
solid, as the stock loader gives it. A picture with no transparency gives
an all black mask. It is always the same size as `image`.
- `width`, `height`: the size that comes out.
- `filename`: the file's name without its folder or its ending
(`shots/hero.png` gives `hero`), ready for a save node's prefix.
- `original_width`, `original_height`: the size of the file before any
resize. Use them to bring a result back to the size you started from.
## Which loader to use
- **Load Image** (this node): load a picture and set its size.
- **Load Image + Pad**: add empty space around a picture for outpainting.
- **Image Crop + Rotate + Pad**: turn, crop and pad a picture by hand.
## Technical details
- Every frame of an animated file comes through as a batch, all at the same
new size. A phone photo is turned the way its EXIF data says.
- With Size off the pixels are the file's own, bit for bit, and Round to is
not applied.
- Rounding moves each edge to the nearest multiple, so the shape can change
by up to half a step. Fit inside rounds down instead, so the result never
leaves its box.
- Crop to fill resizes the picture until it covers the box, then cuts the
middle out. The mask goes through the same resize and the same cut.
- With Enlarge off, a rounded size is rounded down so it never passes the
file's own size.
- The size line on the node uses the same arithmetic as the run. The pack's
tests hold the two to the same answers over a table of cases.
- Files are read only from ComfyUI's input, output and temp folders. A name
such as `photo.png [output]` loads a picture ComfyUI made.
- The node runs again when the file changes on disk or a setting changes.
+1
View File
@@ -17,6 +17,7 @@ help panel.
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
| Load a picture and set its size (megapixels, longest side, a box) in one node | Load Image (`AUSBOSS_NODES_LoadImage`) |
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
+120
View File
@@ -0,0 +1,120 @@
// The row under the file picker on Load Image: arrows that step through the
// input folder, where the file is in the list ("14 / 212"), and the size the
// picture goes in at and comes out at ("912 × 1632 → 1144 × 2048"). The
// widgets underneath stay the source of truth: an arrow only changes the
// image widget's value, the way picking from the list does.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { placeText, planLoadSize, sizeText, stepFile } from "./load_size.mjs";
import { mediaViewQuery } from "./media_list.mjs";
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-load-strip-css";
// Sizes already read, by file: stepping back to a picture shows its size at once.
const SIZES_KEPT = 200;
const sizes = new Map();
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-load-strip{display:flex;align-items:center;gap:6px;height:100%;min-width:0;font:500 11px/1 ${FONT_UI};color:${T.label}}
.ausboss-load-step{flex:none;width:26px;height:100%;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT_UI};cursor:pointer}
.ausboss-load-step:hover{background:${T.fillHover};color:${T.inkHover}}
.ausboss-load-step:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
.ausboss-load-step:disabled{opacity:.4;cursor:default}
.ausboss-load-place{flex:none;color:${T.faint};font-variant-numeric:tabular-nums;white-space:nowrap;user-select:none}
.ausboss-load-size{flex:1 1 0;min-width:0;overflow:hidden;text-align:right;white-space:nowrap;text-overflow:ellipsis;color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums}
.ausboss-load-size b{color:${T.brandInk};font-weight:700}
.ausboss-load-size i{color:${T.faint};font-style:normal;margin:0 5px}
`;
document.head.append(style);
}
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
// The picture's own size, read from the file the way the browser shows it.
function readSize(file, done) {
const known = sizes.get(file);
if (known) return known;
const picture = new Image();
picture.onload = () => {
if (sizes.size >= SIZES_KEPT) sizes.delete(sizes.keys().next().value);
sizes.set(file, { width: picture.naturalWidth, height: picture.naturalHeight });
done();
};
picture.src = viewUrl(file);
return null;
}
export function loadImageStrip(node) {
ensureCss();
const root = document.createElement("div");
root.className = "ausboss-load-strip";
const widget = () => node.widgets?.find((item) => item.name === "image") ?? null;
const linked = () => {
const slot = node.inputs?.find((input) => input?.widget?.name === "image" || input?.name === "image");
return slot?.link != null;
};
let last = {};
const step = (delta, label, title) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-load-step";
button.textContent = label;
button.title = title;
button.addEventListener("click", () => {
const image = widget();
if (!image) return;
const next = stepFile(comboValues(image), image.value, delta);
if (next === null || next === image.value) return;
commitWidgetValue(node, image, next, app.canvas);
node.setDirtyCanvas?.(true, true);
});
root.append(button);
return button;
};
const back = step(-1, "◀", "The picture before this one in the input folder");
const on = step(1, "▶", "The next picture in the input folder");
const place = document.createElement("span");
place.className = "ausboss-load-place";
place.title = "Where this picture is in the input folder's list";
const size = document.createElement("span");
size.className = "ausboss-load-size";
root.append(place, size);
const sync = (values = last) => {
last = values;
const image = widget();
const files = comboValues(image);
const file = String(values.image ?? "");
place.textContent = placeText(files, file);
const stuck = linked() || stepFile(files, file, 1) === null;
back.disabled = stuck;
on.disabled = stuck;
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
size.replaceChildren();
if (!source) {
size.title = "";
return;
}
const plan = planLoadSize(source.width, source.height, values);
const text = sizeText(source.width, source.height, plan);
const [from, to] = text.split(" → ");
size.append(from);
if (to) {
const arrow = document.createElement("i");
arrow.textContent = "→";
const out = document.createElement("b");
out.textContent = to;
size.append(arrow, out);
}
size.title = to
? `The file is ${from}. The picture comes out at ${to}.`
: `The picture comes out at its own size, ${from}.`;
};
return { element: root, sync };
}
+116
View File
@@ -0,0 +1,116 @@
// Load Image: the size a picture comes out at, worked out in the browser so
// the node can show it before a run. This repeats plan_load_size in
// nodes/_load_image_helpers.py; tests/fixtures/load_size_cases.json holds
// both to the same answers. No DOM in here.
export const SIZE_MODES = Object.freeze(["off", "megapixels", "longest side", "scale", "fit inside", "crop to fill"]);
const roundHalfUp = (value) => Math.floor(value + 0.5);
const whole = (value, fallback) => (Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback);
// Nearest multiple, never below one step.
const nearest = (value, step) => Math.max(step, roundHalfUp(value / step) * step);
// The largest multiple that is not above the value, never below one step.
const below = (value, step) => Math.max(step, Math.floor(value / step) * step);
function target(sourceW, sourceH, size, { megapixels, longest_side: longestSide, scale, width, height }) {
const scaled = (factor) => [Math.max(1, roundHalfUp(sourceW * factor)), Math.max(1, roundHalfUp(sourceH * factor))];
if (size === "megapixels") {
const amount = Number(megapixels);
return amount > 0 ? scaled(Math.sqrt((amount * 1e6) / (sourceW * sourceH))) : [sourceW, sourceH];
}
if (size === "longest side") {
const edge = whole(longestSide, 0);
if (edge <= 0) return [sourceW, sourceH];
const [w, h] = scaled(edge / Math.max(sourceW, sourceH));
return sourceW >= sourceH ? [edge, h] : [w, edge];
}
if (size === "scale") {
const factor = Number(scale);
return factor > 0 ? scaled(factor) : [sourceW, sourceH];
}
const fit = Math.min(Math.max(1, whole(width, 1)) / sourceW, Math.max(1, whole(height, 1)) / sourceH);
return scaled(fit);
}
// { resizeWidth, resizeHeight, cropX, cropY, width, height } for a picture of
// sourceWidth x sourceHeight and the node's values, or null when the picture
// has no size yet or the choice is not one the node knows.
export function planLoadSize(sourceWidth, sourceHeight, values = {}) {
const sourceW = whole(sourceWidth, 0);
const sourceH = whole(sourceHeight, 0);
const size = values.size ?? "off";
if (sourceW <= 0 || sourceH <= 0 || !SIZE_MODES.includes(size)) return null;
const same = { resizeWidth: sourceW, resizeHeight: sourceH, cropX: 0, cropY: 0, width: sourceW, height: sourceH };
if (size === "off") return same;
const step = Math.max(1, whole(values.multiple, 1));
const enlarge = values.enlarge !== false;
const boxW = Math.max(1, whole(values.width, 1024));
const boxH = Math.max(1, whole(values.height, 1024));
if (size === "crop to fill") {
let width = boxW;
let height = boxH;
if (enlarge) {
width = nearest(width, step);
height = nearest(height, step);
} else {
const grow = Math.max(width / sourceW, height / sourceH);
if (grow > 1) {
width = Math.max(1, Math.floor(width / grow + 1e-6));
height = Math.max(1, Math.floor(height / grow + 1e-6));
}
width = Math.min(below(width, step), Math.max(step, sourceW));
height = Math.min(below(height, step), Math.max(step, sourceH));
}
const cover = Math.max(width / sourceW, height / sourceH);
const resizeWidth = Math.max(width, Math.ceil(sourceW * cover - 1e-6));
const resizeHeight = Math.max(height, Math.ceil(sourceH * cover - 1e-6));
return {
resizeWidth, resizeHeight,
cropX: Math.floor((resizeWidth - width) / 2), cropY: Math.floor((resizeHeight - height) / 2),
width, height,
};
}
let [targetW, targetH] = target(sourceW, sourceH, size, { ...values, width: boxW, height: boxH });
if (!enlarge && (targetW > sourceW || targetH > sourceH)) [targetW, targetH] = [sourceW, sourceH];
const rounded = (wanted, source, box) => {
let value = nearest(wanted, step);
if (size === "fit inside" && value > box) value = below(box, step);
if (!enlarge && value > source) value = below(source, step);
return value;
};
const width = rounded(targetW, sourceW, boxW);
const height = rounded(targetH, sourceH, boxH);
return { resizeWidth: width, resizeHeight: height, cropX: 0, cropY: 0, width, height };
}
// "912 × 1632" for a picture that comes out as it went in, "912 × 1632 →
// 1144 × 2048" when its size changes, "" while the picture's size is unknown.
export function sizeText(sourceWidth, sourceHeight, plan) {
if (!(sourceWidth > 0 && sourceHeight > 0)) return "";
const from = `${sourceWidth} × ${sourceHeight}`;
if (!plan || (plan.width === sourceWidth && plan.height === sourceHeight)) return from;
return `${from} → ${plan.width} × ${plan.height}`;
}
// The file one step before or after the current one in the list, going
// round at the ends. A file that is not in the list steps to the first or
// the last. null when the list has nothing to step to.
export function stepFile(files, current, delta) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return null;
const at = list.indexOf(current);
if (at < 0) return delta < 0 ? list[list.length - 1] : list[0];
if (list.length === 1) return null;
return list[(at + (delta < 0 ? -1 : 1) + list.length) % list.length];
}
// "14 / 212": where the current file is in the list. "" for an empty list,
// "– / 212" for a file that is not in it.
export function placeText(files, current) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return "";
const at = list.indexOf(current);
return `${at < 0 ? "–" : at + 1} / ${list.length}`;
}
+1
View File
@@ -37,6 +37,7 @@ export const SOURCE_LOADERS = Object.freeze({
LoadImageOutput: picture("image"),
LoadVideo: video("file"),
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
AUSBOSS_NODES_LoadImage: picture("image"),
// A wired source_image replaces the file, so the file may stay blank.
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
AUSBOSS_NODES_LoadAudio: sound("audio"),
+2 -2
View File
@@ -57,9 +57,9 @@ export function goesBehind(flying, ask, epoch) {
// The pack's own picture loaders. One of them wired straight into Mask by
// Touch is run by the node itself, the way a press on Show picture runs it:
// it may crop, turn or pad its file, so the file alone is not the picture,
// it may crop, turn, pad or resize its file, so the file alone is not the picture,
// but running it costs next to nothing.
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
// What a loader will hand on, as far as the page can tell: its file and its
// settings, as one text. Another text means another picture. Widgets that
+23
View File
@@ -12,6 +12,7 @@ import { formatWidgetVisibility } from "../shared/save_video_formats.mjs";
import { hideInputsInDef } from "../shared/widget_visibility.mjs";
import { castChips } from "../shared/h3_cast_chips.mjs";
import { clipSetsDuration } from "../shared/h3_duration.mjs";
import { loadImageStrip } from "../shared/load_image_strip.mjs";
import { momentStrip } from "../shared/moment_strip.mjs";
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { FONT_UI, THEME } from "../shared/theme.mjs";
@@ -400,6 +401,28 @@ const CARDS = {
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
],
},
// Load Image: the file, then where it is in the folder and the size it
// goes in and comes out at, then the size settings. ComfyUI's own picture
// preview sits under the card, so its mask editor and copy and paste work
// as they do on the stock loader.
AUSBOSS_NODES_LoadImage: {
minWidth: 320, first: true, hide: ["upload"],
rows: [
{ widget: "image", label: "Source", kind: "select", preview: { kind: "image", url: viewUrl },
button: { text: PANEL_LABELS.load, title: "Upload a picture into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
{ panel: loadImageStrip, height: 26 },
{ widget: "size", label: "Size", kind: "select" },
{ widget: "megapixels", label: "Megapixels", suffix: "MP", when: isMode("size", "megapixels") },
{ widget: "longest_side", label: "Longest side", suffix: "px", when: isMode("size", "longest side") },
{ widget: "scale", label: "Scale", suffix: "\u00d7", when: isMode("size", "scale") },
{ widget: "width", label: "Width", suffix: "px", when: isMode("size", "fit inside", "crop to fill") },
{ widget: "height", label: "Height", suffix: "px", when: isMode("size", "fit inside", "crop to fill") },
{ widget: "multiple", label: "Round to", snap: true, step: 8, when: (values) => values.size !== "off" },
{ group: "more", label: "More" },
{ widget: "method", label: "Method", kind: "select", group: "more" },
{ widget: "enlarge", label: "Enlarge", group: "more" },
],
},
AUSBOSS_NODES_LoadImagePad: {
minWidth: 340, first: true, hide: ["upload"],
rows: [
+59
View File
@@ -593,6 +593,65 @@
}
}
},
"AUSBOSS_NODES_LoadImage": {
"display_name": "加载图像 🆎",
"description": "在同一个节点里加载图片并设定尺寸。可以选择文件、上传,或用箭头逐张浏览输入文件夹。尺寸可以关闭,也可以按百万像素、最长边、缩放倍数、放入一个框内,或裁剪填满一个框来调整。运行之前,节点就会显示输入尺寸和输出尺寸。",
"inputs": {
"image": {
"name": "图像"
},
"size": {
"name": "尺寸"
},
"megapixels": {
"name": "百万像素"
},
"longest_side": {
"name": "最长边"
},
"scale": {
"name": "缩放倍数"
},
"width": {
"name": "宽度"
},
"height": {
"name": "高度"
},
"multiple": {
"name": "取整倍数"
},
"method": {
"name": "插值"
},
"enlarge": {
"name": "放大"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "宽度"
},
"3": {
"name": "高度"
},
"4": {
"name": "文件名"
},
"5": {
"name": "原始宽度"
},
"6": {
"name": "原始高度"
}
}
},
"AUSBOSS_NODES_LoadImagePad": {
"display_name": "加载图像 + 扩边 🆎",
"description": "在一个节点里加载图像并扩边成扩图画布。拖动节点上画布的任意一边,直观地设置每边的扩边量;遮罩正好覆盖新扩出的部分,可选择向内羽化跨过接缝。画布会取整到整数倍,但不会给你没扩的边加条带;设置百万像素目标时会先缩放原图,让遮罩接缝保持清晰。",
+165
View File
@@ -0,0 +1,165 @@
"""Load Image: the size a picture comes out at, as plain numbers.
``plan_load_size`` turns the node's size choice into what the picture is
resized to and what is cut from it. It holds no tensors, so it is tested as
numbers, and ``js/shared/load_size.mjs`` repeats it for the size the node
shows before a run (``tests/fixtures/load_size_cases.json`` holds both to the
same answers). ``apply_load_size`` runs a plan on a picture and its mask.
"""
from __future__ import annotations
import math
import numpy as np
import torch
from ._resize_helpers import plan_resize, resample_batch, resolve_target, snap_to_multiple
SIZE_MODES = (
"off",
"megapixels",
"longest side",
"scale",
"fit inside",
"crop to fill",
)
def _round_half_up(value: float) -> int:
return int(math.floor(value + 0.5))
def _multiple_below(value: int, multiple: int) -> int:
"""The largest multiple that is not above ``value``, never below one step."""
step = max(1, int(multiple))
return max(step, (int(value) // step) * step)
def plan_load_size(
source_width: int,
source_height: int,
size: str = "off",
megapixels: float = 1.0,
longest_side: int = 1024,
scale: float = 1.0,
width: int = 1024,
height: int = 1024,
multiple: int = 1,
enlarge: bool = True,
) -> dict[str, int]:
"""What one picture is resized to (``resize_width``/``resize_height``),
where a crop starts in the resized picture (``crop_x``/``crop_y``) and
the size that comes out (``width``/``height``).
``off`` gives the picture back as it is; ``multiple`` rounds only a size
that is being changed. With ``enlarge`` off nothing comes out larger than
the picture: a target above it leaves the picture at its own size, and
``crop to fill`` cuts the box's shape out of the picture without growing
it. ``fit inside`` rounds down, so the result never leaves its box.
"""
source_w, source_h = int(source_width), int(source_height)
if source_w <= 0 or source_h <= 0:
raise ValueError("Load Image needs a picture larger than 0x0.")
if size not in SIZE_MODES:
raise ValueError(f"size must be one of {SIZE_MODES}, got '{size}'.")
if size == "off":
return {
"resize_width": source_w,
"resize_height": source_h,
"crop_x": 0,
"crop_y": 0,
"width": source_w,
"height": source_h,
}
step = max(1, int(multiple))
if size == "crop to fill":
box_w, box_h = max(1, int(width)), max(1, int(height))
if not enlarge:
grow = max(box_w / source_w, box_h / source_h)
if grow > 1.0:
# The box keeps its shape and shrinks to what the picture holds.
box_w = max(1, int(math.floor(box_w / grow + 1e-6)))
box_h = max(1, int(math.floor(box_h / grow + 1e-6)))
canvas_w = min(_multiple_below(box_w, step), max(step, source_w))
canvas_h = min(_multiple_below(box_h, step), max(step, source_h))
else:
canvas_w = snap_to_multiple(box_w, step)
canvas_h = snap_to_multiple(box_h, step)
plan = plan_resize(source_w, source_h, canvas_w, canvas_h, "cover_crop", 1)
return {
"resize_width": plan["resize_width"],
"resize_height": plan["resize_height"],
"crop_x": plan["crop_x"],
"crop_y": plan["crop_y"],
"width": canvas_w,
"height": canvas_h,
}
if size == "megapixels":
target_w, target_h = resolve_target(source_w, source_h, "megapixels", megapixels=megapixels)
elif size == "longest side":
target_w, target_h = resolve_target(source_w, source_h, "longest_edge", edge_length=longest_side)
elif size == "scale":
target_w, target_h = resolve_target(source_w, source_h, "scale_factor", scale_factor=scale)
else: # fit inside
fit = min(max(1, int(width)) / source_w, max(1, int(height)) / source_h)
target_w = max(1, _round_half_up(source_w * fit))
target_h = max(1, _round_half_up(source_h * fit))
if not enlarge and (target_w > source_w or target_h > source_h):
target_w, target_h = source_w, source_h
def rounded(target: int, source: int, box: int) -> int:
value = snap_to_multiple(target, step)
if size == "fit inside" and value > box:
value = _multiple_below(box, step)
if not enlarge and value > source:
value = _multiple_below(source, step)
return value
out_w = rounded(target_w, source_w, max(1, int(width)))
out_h = rounded(target_h, source_h, max(1, int(height)))
return {
"resize_width": out_w,
"resize_height": out_h,
"crop_x": 0,
"crop_y": 0,
"width": out_w,
"height": out_h,
}
def frames_to_image_and_mask(frames) -> tuple[torch.Tensor, torch.Tensor]:
"""RGBA PIL frames to a BHWC RGB batch and a BHW mask.
The mask is what ComfyUI's own Load Image gives: white where the picture
is see-through, black where it is solid, and all black for a picture
with no transparency (here at the picture's own size).
"""
images, masks = [], []
for frame in frames:
array = np.asarray(frame.convert("RGBA"), dtype=np.float32) / 255.0
images.append(torch.from_numpy(array[..., :3].copy()))
masks.append(torch.from_numpy(1.0 - array[..., 3]))
return torch.stack(images, dim=0), torch.stack(masks, dim=0)
def apply_load_size(
image: torch.Tensor, mask: torch.Tensor, plan: dict[str, int], method: str
) -> tuple[torch.Tensor, torch.Tensor]:
"""Run a plan on a BHWC picture and its BHW mask. A plan that changes
nothing hands both back untouched."""
resize_w, resize_h = int(plan["resize_width"]), int(plan["resize_height"])
out_w, out_h = int(plan["width"]), int(plan["height"])
if (int(image.shape[2]), int(image.shape[1])) != (resize_w, resize_h):
image = resample_batch(image, resize_w, resize_h, method)
mask = resample_batch(mask.unsqueeze(-1), resize_w, resize_h, method).squeeze(-1).clamp(0.0, 1.0)
if (resize_w, resize_h) != (out_w, out_h):
x0, y0 = int(plan["crop_x"]), int(plan["crop_y"])
image = image[:, y0 : y0 + out_h, x0 : x0 + out_w, :].contiguous()
mask = mask[:, y0 : y0 + out_h, x0 : x0 + out_w].contiguous()
return image, mask
__all__ = ["SIZE_MODES", "apply_load_size", "frames_to_image_and_mask", "plan_load_size"]
+230
View File
@@ -0,0 +1,230 @@
"""Load Image 🆎."""
from __future__ import annotations
from ._execution_helpers import ignore_extra_inputs
from ._load_image_helpers import (
SIZE_MODES,
apply_load_size,
frames_to_image_and_mask,
plan_load_size,
)
from ._media_helpers import list_input_images, load_image_frames, resolve_input_path
from ._resize_helpers import INTERPOLATION_MODES
from ._transform_engine import stable_file_fingerprint
class AusBossLoadImage:
CATEGORY = "🆎 AusBoss/Image"
DESCRIPTION = (
"Loads a picture and sets its size in the same node. Pick a file, "
"upload one, or step through the input folder with the arrows. "
"Size can stay off, or bring the picture to a megapixel count, a "
"longest side, a scale, a box it must fit inside, or a box it fills "
"by cropping. The node shows the size going in and the size coming "
"out before you run."
)
SEARCH_ALIASES = [
"load image",
"load picture",
"image loader",
"resize on load",
"megapixels",
"longest side",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": (
list_input_images(),
{
"image_upload": True,
"tooltip": "The picture to load. Pick one from ComfyUI's input folder or upload a new one.",
},
),
"size": (
list(SIZE_MODES),
{
"default": "off",
"tooltip": (
"How the picture is resized. off keeps it as it "
"is. megapixels, longest side and scale keep its "
"shape. fit inside shrinks or grows it to sit in "
"a box. crop to fill covers the box and cuts off "
"what sticks out."
),
},
),
"megapixels": (
"FLOAT",
{
"default": 1.0,
"min": 0.01,
"max": 64.0,
"step": 0.01,
"tooltip": "For megapixels: the picture's total size. 1.0 is about 1024 x 1024.",
},
),
"longest_side": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For longest side: the length of the longer edge, in pixels. The other edge follows.",
},
),
"scale": (
"FLOAT",
{
"default": 1.0,
"min": 0.05,
"max": 8.0,
"step": 0.01,
"tooltip": "For scale: 0.5 is half the size, 2 is double.",
},
),
"width": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For fit inside and crop to fill: the box's width in pixels.",
},
),
"height": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For fit inside and crop to fill: the box's height in pixels.",
},
),
"multiple": (
"INT",
{
"default": 8,
"min": 1,
"max": 256,
"step": 1,
"tooltip": (
"Rounds the new width and height to a multiple of "
"this. Most models want 8 or 16. 1 leaves the "
"numbers as they are. Not used while size is off."
),
},
),
"method": (
list(INTERPOLATION_MODES),
{
"default": "lanczos",
"tooltip": "How pixels are resampled. lanczos is the sharpest for photos; nearest keeps pixel art crisp.",
},
),
"enlarge": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: a small picture is made bigger to reach the "
"size you asked for. Off: a picture is only ever "
"made smaller, and a small one comes out at its "
"own size."
),
},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = (
"image",
"mask",
"width",
"height",
"filename",
"original_width",
"original_height",
)
OUTPUT_TOOLTIPS = (
"The picture, at its new size.",
"White where the picture is see-through, black where it is solid. "
"All black for a picture with no transparency. Same size as the picture.",
"Width of the picture that comes out, in pixels.",
"Height of the picture that comes out, in pixels.",
"The file's name without its folder or its ending, ready for a save "
"node's prefix.",
"Width of the file before any resize.",
"Height of the file before any resize.",
)
FUNCTION = "load"
def load(
self,
image,
size="off",
megapixels=1.0,
longest_side=1024,
scale=1.0,
width=1024,
height=1024,
multiple=8,
method="lanczos",
enlarge=True,
):
path = resolve_input_path(image)
picture, mask = frames_to_image_and_mask(load_image_frames(path))
source_w, source_h = int(picture.shape[2]), int(picture.shape[1])
plan = plan_load_size(
source_w,
source_h,
str(size),
float(megapixels),
int(longest_side),
float(scale),
int(width),
int(height),
int(multiple),
bool(enlarge),
)
picture, mask = apply_load_size(picture, mask, plan, str(method))
return (
picture,
mask,
int(plan["width"]),
int(plan["height"]),
path.stem,
source_w,
source_h,
)
@classmethod
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, image):
try:
resolve_input_path(image)
except Exception as exc:
return f"Load Image: {exc}"
return True
@classmethod
def IS_CHANGED(cls, image, **values):
try:
path = resolve_input_path(image)
except Exception:
path = image or ""
return stable_file_fingerprint(path, {"image": image, **values})
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadImage": AusBossLoadImage}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadImage": "Load Image 🆎"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1
View File
@@ -70,6 +70,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_LaMaInpaint",
"AUSBOSS_NODES_LayoutBoxes",
"AUSBOSS_NODES_LoadAudio",
"AUSBOSS_NODES_LoadImage",
"AUSBOSS_NODES_LoadImagePad",
"AUSBOSS_NODES_LoadVideo",
"AUSBOSS_NODES_LoraLoader",
+256
View File
@@ -0,0 +1,256 @@
{"about":"Load Image size plans. nodes/_load_image_helpers.py made these; js/shared/load_size.mjs must give the same.",
"columns":["source_width", "source_height", "size", "megapixels", "longest_side", "scale", "width", "height", "multiple", "enlarge", "resize_width", "resize_height", "crop_x", "crop_y", "out_width", "out_height"],
"rows":[
[912,1632,"off",1.0,1024,1.0,1024,1024,1,true,912,1632,0,0,912,1632],
[912,1632,"off",1.0,1024,1.0,1024,1024,16,true,912,1632,0,0,912,1632],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,true,374,669,0,0,374,669],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,true,1057,1892,0,0,1057,1892],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,true,559,1000,0,0,559,1000],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,true,1144,2048,0,0,1144,2048],
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,true,301,539,0,0,301,539],
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,true,1368,2448,0,0,1368,2448],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,true,572,1024,0,0,572,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1024,1833,0,404,1024,1024],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,true,402,720,0,0,402,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,2291,0,785,1280,720],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,true,335,600,0,0,335,600],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,1790,0,595,1000,600],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,false,374,669,0,0,374,669],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,false,912,1632,0,0,912,1632],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,false,559,1000,0,0,559,1000],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,false,912,1632,0,0,912,1632],
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,false,301,539,0,0,301,539],
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,false,912,1632,0,0,912,1632],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,false,572,1024,0,0,572,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,false,912,1632,0,360,912,912],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,false,402,720,0,0,402,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,false,912,1632,0,559,912,513],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,false,335,600,0,0,335,600],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,false,912,1632,0,542,912,547],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,true,368,672,0,0,368,672],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,true,1056,1888,0,0,1056,1888],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,true,560,1008,0,0,560,1008],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,true,1152,2048,0,0,1152,2048],
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,true,304,544,0,0,304,544],
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,true,1376,2448,0,0,1376,2448],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,true,576,1024,0,0,576,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1024,1833,0,404,1024,1024],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,true,400,720,0,0,400,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,2291,0,785,1280,720],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,true,336,592,0,0,336,592],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,1804,0,598,1008,608],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,false,368,672,0,0,368,672],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,false,912,1632,0,0,912,1632],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,false,560,1008,0,0,560,1008],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,false,912,1632,0,0,912,1632],
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,false,304,544,0,0,304,544],
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,false,912,1632,0,0,912,1632],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,false,576,1024,0,0,576,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,false,912,1632,0,360,912,912],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,false,400,720,0,0,400,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,false,912,1632,0,560,912,512],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,false,336,592,0,0,336,592],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,false,912,1632,0,544,912,544],
[1920,1080,"off",1.0,1024,1.0,1024,1024,1,true,1920,1080,0,0,1920,1080],
[1920,1080,"off",1.0,1024,1.0,1024,1024,16,true,1920,1080,0,0,1920,1080],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,true,667,375,0,0,667,375],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,true,1886,1061,0,0,1886,1061],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,563,0,0,1000,563],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1152,0,0,2048,1152],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,true,634,356,0,0,634,356],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,true,2880,1620,0,0,2880,1620],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,true,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,true,1000,563,0,0,1000,563],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,true,1067,600,33,0,1000,600],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,false,667,375,0,0,667,375],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,false,1886,1061,0,0,1886,1061],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,false,1000,563,0,0,1000,563],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,false,1920,1080,0,0,1920,1080],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,false,634,356,0,0,634,356],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,false,1920,1080,0,0,1920,1080],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,false,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,false,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,false,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,false,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,false,1000,563,0,0,1000,563],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,false,1067,600,33,0,1000,600],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,true,672,368,0,0,672,368],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,true,1888,1056,0,0,1888,1056],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,560,0,0,1008,560],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1152,0,0,2048,1152],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,true,640,352,0,0,640,352],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,true,2880,1616,0,0,2880,1616],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,true,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,true,992,560,0,0,992,560],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,true,1081,608,36,0,1008,608],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,false,672,368,0,0,672,368],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,false,1888,1056,0,0,1888,1056],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,false,1008,560,0,0,1008,560],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,false,1920,1072,0,0,1920,1072],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,false,640,352,0,0,640,352],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,false,1920,1072,0,0,1920,1072],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,false,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,false,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,false,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,false,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,false,992,560,0,0,992,560],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,false,1053,592,30,0,992,592],
[640,480,"off",1.0,1024,1.0,1024,1024,1,true,640,480,0,0,640,480],
[640,480,"off",1.0,1024,1.0,1024,1024,16,true,640,480,0,0,640,480],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,true,577,433,0,0,577,433],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,true,1633,1225,0,0,1633,1225],
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,750,0,0,1000,750],
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1536,0,0,2048,1536],
[640,480,"scale",1.0,1024,0.33,1024,1024,1,true,211,158,0,0,211,158],
[640,480,"scale",1.0,1024,1.5,1024,1024,1,true,960,720,0,0,960,720],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,768,0,0,1024,768],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1366,1024,171,0,1024,1024],
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,true,960,720,0,0,960,720],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,960,0,120,1280,720],
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,true,800,600,0,0,800,600],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,750,0,75,1000,600],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,false,577,433,0,0,577,433],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"scale",1.0,1024,0.33,1024,1024,1,false,211,158,0,0,211,158],
[640,480,"scale",1.0,1024,1.5,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,false,640,480,80,0,480,480],
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,false,640,480,0,60,640,360],
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,false,640,480,0,48,640,384],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,true,576,432,0,0,576,432],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,true,1632,1232,0,0,1632,1232],
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,752,0,0,1008,752],
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1536,0,0,2048,1536],
[640,480,"scale",1.0,1024,0.33,1024,1024,16,true,208,160,0,0,208,160],
[640,480,"scale",1.0,1024,1.5,1024,1024,16,true,960,720,0,0,960,720],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,768,0,0,1024,768],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1366,1024,171,0,1024,1024],
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,true,960,720,0,0,960,720],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,960,0,120,1280,720],
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,true,800,592,0,0,800,592],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,756,0,74,1008,608],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,false,576,432,0,0,576,432],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"scale",1.0,1024,0.33,1024,1024,16,false,208,160,0,0,208,160],
[640,480,"scale",1.0,1024,1.5,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,false,640,480,80,0,480,480],
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,false,640,480,0,64,640,352],
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,false,640,480,0,48,640,384],
[333,777,"off",1.0,1024,1.0,1024,1024,1,true,333,777,0,0,333,777],
[333,777,"off",1.0,1024,1.0,1024,1024,16,true,333,777,0,0,333,777],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,true,327,764,0,0,327,764],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,true,926,2160,0,0,926,2160],
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,true,429,1000,0,0,429,1000],
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,true,878,2048,0,0,878,2048],
[333,777,"scale",1.0,1024,0.33,1024,1024,1,true,110,256,0,0,110,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,1,true,500,1166,0,0,500,1166],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,true,439,1024,0,0,439,1024],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1024,2390,0,683,1024,1024],
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,true,309,720,0,0,309,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,2987,0,1133,1280,720],
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,true,257,600,0,0,257,600],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,2334,0,867,1000,600],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,false,327,764,0,0,327,764],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"scale",1.0,1024,0.33,1024,1024,1,false,110,256,0,0,110,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,false,333,777,0,222,333,333],
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,false,309,720,0,0,309,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,false,333,777,0,295,333,187],
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,false,257,600,0,0,257,600],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,false,333,777,0,289,333,199],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,true,320,768,0,0,320,768],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,true,928,2160,0,0,928,2160],
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,true,432,1008,0,0,432,1008],
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,true,880,2048,0,0,880,2048],
[333,777,"scale",1.0,1024,0.33,1024,1024,16,true,112,256,0,0,112,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,16,true,496,1168,0,0,496,1168],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,true,432,1024,0,0,432,1024],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1024,2390,0,683,1024,1024],
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,true,304,720,0,0,304,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,2987,0,1133,1280,720],
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,true,256,592,0,0,256,592],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,2352,0,872,1008,608],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"scale",1.0,1024,0.33,1024,1024,16,false,112,256,0,0,112,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,false,320,747,0,213,320,320],
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,false,304,720,0,0,304,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,false,320,747,0,285,320,176],
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,false,256,592,0,0,256,592],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,false,320,747,0,277,320,192],
[8192,5464,"off",1.0,1024,1.0,1024,1024,1,true,8192,5464,0,0,8192,5464],
[8192,5464,"off",1.0,1024,1.0,1024,1024,16,true,8192,5464,0,0,8192,5464],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,true,612,408,0,0,612,408],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,true,1732,1155,0,0,1732,1155],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,667,0,0,1000,667],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1366,0,0,2048,1366],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,true,2703,1803,0,0,2703,1803],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,true,12288,8196,0,0,12288,8196],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,683,0,0,1024,683],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,true,1079,720,0,0,1079,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,true,900,600,0,0,900,600],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,667,0,33,1000,600],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,false,612,408,0,0,612,408],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,false,1732,1155,0,0,1732,1155],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,false,1000,667,0,0,1000,667],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,false,2048,1366,0,0,2048,1366],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,false,2703,1803,0,0,2703,1803],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,false,8192,5464,0,0,8192,5464],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,false,1024,683,0,0,1024,683],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,false,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,false,1079,720,0,0,1079,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,false,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,false,900,600,0,0,900,600],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,false,1000,667,0,33,1000,600],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,true,608,416,0,0,608,416],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,true,1728,1152,0,0,1728,1152],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,672,0,0,1008,672],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1360,0,0,2048,1360],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,true,2704,1808,0,0,2704,1808],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,true,12288,8192,0,0,12288,8192],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,688,0,0,1024,688],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,true,1072,720,0,0,1072,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,true,896,592,0,0,896,592],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,673,0,32,1008,608],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,false,608,416,0,0,608,416],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,false,1728,1152,0,0,1728,1152],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,false,1008,672,0,0,1008,672],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,false,2048,1360,0,0,2048,1360],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,false,2704,1808,0,0,2704,1808],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,false,8192,5456,0,0,8192,5456],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,false,1024,688,0,0,1024,688],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,false,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,false,1072,720,0,0,1072,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,false,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,false,896,592,0,0,896,592],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,false,992,662,0,35,992,592],
[1000,1000,"longest side",1.0,1000,1.0,1024,1024,64,true,1024,1024,0,0,1024,1024],
[30,20,"megapixels",1.0,1024,1.0,1024,1024,64,false,64,64,0,0,64,64]
]}
+45
View File
@@ -522,6 +522,51 @@
]
]
},
"AUSBOSS_NODES_LoadImage": {
"required": [
"image",
"size",
"megapixels",
"longest_side",
"scale",
"width",
"height",
"multiple",
"method",
"enlarge"
],
"optional": [],
"outputs": [
[
"IMAGE",
"image"
],
[
"MASK",
"mask"
],
[
"INT",
"width"
],
[
"INT",
"height"
],
[
"STRING",
"filename"
],
[
"INT",
"original_width"
],
[
"INT",
"original_height"
]
]
},
"AUSBOSS_NODES_LoadImagePad": {
"required": [
"image",
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { SIZE_MODES, placeText, planLoadSize, sizeText, stepFile } from "../js/shared/load_size.mjs";
const table = JSON.parse(readFileSync(new URL("./fixtures/load_size_cases.json", import.meta.url), "utf8"));
test("the size the node shows is the size the run gives, case by case", () => {
assert.ok(table.rows.length > 200);
const seen = new Set();
for (const row of table.rows) {
const [sourceWidth, sourceHeight, size, megapixels, longest_side, scale, width, height, multiple, enlarge, ...wanted] = row;
seen.add(size);
const plan = planLoadSize(sourceWidth, sourceHeight, { size, megapixels, longest_side, scale, width, height, multiple, enlarge });
assert.deepEqual(
[plan.resizeWidth, plan.resizeHeight, plan.cropX, plan.cropY, plan.width, plan.height],
wanted,
JSON.stringify(row.slice(0, 10)),
);
}
assert.deepEqual([...seen].sort(), [...SIZE_MODES].sort(), "every size choice is in the table");
});
test("off gives the picture back whatever the other settings say", () => {
const plan = planLoadSize(913, 1631, { size: "off", multiple: 64, megapixels: 4, enlarge: false });
assert.deepEqual([plan.width, plan.height, plan.cropX, plan.cropY], [913, 1631, 0, 0]);
});
test("longest side lands on the number asked for, and round to moves it to a multiple", () => {
assert.deepEqual(planLoadSize(912, 1632, { size: "longest side", longest_side: 2048, multiple: 1 }).height, 2048);
const rounded = planLoadSize(912, 1632, { size: "longest side", longest_side: 2000, multiple: 16 });
assert.equal(rounded.height, 2000);
assert.equal(rounded.width % 16, 0);
});
test("with enlarge off a small picture keeps its size, and crop to fill only cuts", () => {
const kept = planLoadSize(640, 480, { size: "megapixels", megapixels: 4, multiple: 1, enlarge: false });
assert.deepEqual([kept.width, kept.height], [640, 480]);
const cut = planLoadSize(800, 600, { size: "crop to fill", width: 1024, height: 1024, multiple: 1, enlarge: false });
assert.deepEqual([cut.width, cut.height, cut.resizeWidth, cut.resizeHeight, cut.cropX, cut.cropY], [600, 600, 800, 600, 100, 0]);
});
test("fit inside never leaves its box, rounding included", () => {
const plan = planLoadSize(1920, 1080, { size: "fit inside", width: 1000, height: 1000, multiple: 64 });
assert.ok(plan.width <= 1000 && plan.height <= 1000);
assert.equal(plan.width % 64, 0);
});
test("a picture with no size yet, or a choice the node does not know, has no plan", () => {
assert.equal(planLoadSize(0, 0, { size: "scale" }), null);
assert.equal(planLoadSize(100, 100, { size: "" }), null);
assert.equal(planLoadSize(100, 100, { size: "stretch" }), null);
});
test("the size line shows one size when nothing changes and both when it does", () => {
assert.equal(sizeText(912, 1632, planLoadSize(912, 1632, { size: "off" })), "912 × 1632");
assert.equal(
sizeText(912, 1632, planLoadSize(912, 1632, { size: "longest side", longest_side: 2048, multiple: 8 })),
"912 × 1632 → 1144 × 2048",
);
assert.equal(sizeText(0, 0, null), "");
assert.equal(sizeText(912, 1632, null), "912 × 1632");
});
test("the arrows go round the list, and a file that is not listed steps onto it", () => {
const files = ["a.png", "b.png", "sub/c.png"];
assert.equal(stepFile(files, "a.png", 1), "b.png");
assert.equal(stepFile(files, "sub/c.png", 1), "a.png");
assert.equal(stepFile(files, "a.png", -1), "sub/c.png");
assert.equal(stepFile(files, "gone.png", 1), "a.png");
assert.equal(stepFile(files, "gone.png", -1), "sub/c.png");
assert.equal(stepFile(["only.png"], "only.png", 1), null);
assert.equal(stepFile([""], "", 1), null, "an empty input folder lists one blank");
assert.equal(stepFile(undefined, "a.png", 1), null);
});
test("the place in the list counts from one", () => {
const files = ["a.png", "b.png", "sub/c.png"];
assert.equal(placeText(files, "b.png"), "2 / 3");
assert.equal(placeText(files, "gone.png"), "– / 3");
assert.equal(placeText([""], ""), "");
});
test("Load Image is one of the loaders checked for a picked file before a run", async () => {
const { SOURCE_LOADERS } = await import("../js/shared/run_check.mjs");
assert.equal(typeof SOURCE_LOADERS.AUSBOSS_NODES_LoadImage, "function");
assert.ok(SOURCE_LOADERS.AUSBOSS_NODES_LoadImage({ image: "" }));
assert.equal(SOURCE_LOADERS.AUSBOSS_NODES_LoadImage({ image: "pic.png" }), null);
});
+228
View File
@@ -0,0 +1,228 @@
"""Load Image: the size plan as numbers, and the node on real files."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import numpy as np
import torch
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes import _media_helpers # noqa: E402
from nodes._load_image_helpers import ( # noqa: E402
SIZE_MODES,
apply_load_size,
plan_load_size,
)
from nodes.node_load_image import ( # noqa: E402
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
AusBossLoadImage,
)
TABLE = ROOT / "tests" / "fixtures" / "load_size_cases.json"
class PlanTests(unittest.TestCase):
def test_the_table_the_browser_is_held_to_is_what_the_planner_gives(self):
table = json.loads(TABLE.read_text(encoding="utf-8"))
self.assertGreater(len(table["rows"]), 200)
for row in table["rows"]:
source_w, source_h, size, megapixels, longest_side, scale, width, height, multiple, enlarge = row[:10]
plan = plan_load_size(source_w, source_h, size, megapixels, longest_side, scale, width, height, multiple, enlarge)
got = [plan["resize_width"], plan["resize_height"], plan["crop_x"], plan["crop_y"], plan["width"], plan["height"]]
self.assertEqual(got, row[10:], row[:10])
def test_off_gives_the_picture_back_whatever_else_is_set(self):
plan = plan_load_size(913, 1631, "off", megapixels=4.0, multiple=64, enlarge=False)
self.assertEqual((plan["width"], plan["height"], plan["crop_x"], plan["crop_y"]), (913, 1631, 0, 0))
def test_each_choice_keeps_its_promise(self):
plan = plan_load_size(912, 1632, "megapixels", megapixels=2.0, multiple=1)
self.assertAlmostEqual(plan["width"] * plan["height"] / 1e6, 2.0, delta=0.01)
self.assertAlmostEqual(plan["width"] / plan["height"], 912 / 1632, delta=0.002)
plan = plan_load_size(912, 1632, "longest side", longest_side=2048, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1144, 2048))
plan = plan_load_size(1920, 1080, "longest side", longest_side=1280, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1280, 720))
plan = plan_load_size(1920, 1080, "scale", scale=0.5, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (960, 540))
plan = plan_load_size(1920, 1080, "fit inside", width=1024, height=1024, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1024, 576))
plan = plan_load_size(1920, 1080, "crop to fill", width=1024, height=1024, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1024, 1024))
self.assertEqual((plan["resize_width"], plan["resize_height"]), (1821, 1024))
self.assertEqual((plan["crop_x"], plan["crop_y"]), (398, 0))
def test_round_to_gives_multiples_and_fit_inside_stays_in_its_box(self):
for size in SIZE_MODES[1:]:
plan = plan_load_size(1000, 777, size, megapixels=1.3, longest_side=1111, scale=1.37, width=900, height=700, multiple=64)
self.assertEqual((plan["width"] % 64, plan["height"] % 64), (0, 0), size)
plan = plan_load_size(1920, 1080, "fit inside", width=1000, height=1000, multiple=64)
self.assertLessEqual(plan["width"], 1000)
self.assertLessEqual(plan["height"], 1000)
def test_with_enlarge_off_nothing_comes_out_bigger_than_the_file(self):
for size in SIZE_MODES[1:]:
for multiple in (1, 8, 16):
plan = plan_load_size(640, 480, size, megapixels=4.0, longest_side=4096, scale=3.0, width=2048, height=2048, multiple=multiple, enlarge=False)
self.assertLessEqual(plan["width"], 640, (size, multiple))
self.assertLessEqual(plan["height"], 480, (size, multiple))
self.assertLessEqual(plan["resize_width"], 640, (size, multiple))
# Crop to fill still gives the box's shape, cut from the file as it is.
plan = plan_load_size(800, 600, "crop to fill", width=1024, height=1024, multiple=1, enlarge=False)
self.assertEqual((plan["width"], plan["height"], plan["resize_width"], plan["resize_height"], plan["crop_x"]), (600, 600, 800, 600, 100))
# A picture that is big enough is resized as usual.
plan = plan_load_size(4000, 3000, "longest side", longest_side=1024, multiple=1, enlarge=False)
self.assertEqual((plan["width"], plan["height"]), (1024, 768))
def test_bad_input_is_named(self):
with self.assertRaisesRegex(ValueError, "size must be one of"):
plan_load_size(100, 100, "stretch")
with self.assertRaisesRegex(ValueError, "larger than 0x0"):
plan_load_size(0, 100, "off")
def test_a_plan_that_changes_nothing_hands_the_same_tensors_back(self):
image, mask = torch.rand(1, 20, 30, 3), torch.zeros(1, 20, 30)
out_image, out_mask = apply_load_size(image, mask, plan_load_size(30, 20, "off"), "lanczos")
self.assertIs(out_image, image)
self.assertIs(out_mask, mask)
class NodeTests(unittest.TestCase):
def setUp(self):
# The temp folder stands in for ComfyUI's input folder.
self.folder = tempfile.mkdtemp(prefix="ausboss_load_image_")
self.addCleanup(shutil.rmtree, self.folder, True)
stand_in = SimpleNamespace(
get_input_directory=lambda: self.folder,
get_annotated_filepath=lambda name: os.path.join(self.folder, name),
)
patcher = patch.object(_media_helpers, "folder_paths", stand_in)
patcher.start()
self.addCleanup(patcher.stop)
def write(self, name="pic.png", width=96, height=64, mode="RGB"):
path = Path(self.folder) / name
path.parent.mkdir(parents=True, exist_ok=True)
values = np.zeros((height, width, 4), dtype=np.uint8)
values[..., 0] = np.linspace(0, 255, width, dtype=np.uint8)[None, :]
values[..., 1] = np.linspace(0, 255, height, dtype=np.uint8)[:, None]
values[..., 2] = 90
values[..., 3] = 255
values[: height // 2, : width // 2, 3] = 0 # the top left quarter is see-through
Image.fromarray(values).convert(mode).save(path)
return name
def run_node(self, name, **values):
return AusBossLoadImage().load(name, **values)
def test_the_node_is_registered_under_its_permanent_key(self):
self.assertIs(NODE_CLASS_MAPPINGS["AUSBOSS_NODES_LoadImage"], AusBossLoadImage)
self.assertEqual(NODE_DISPLAY_NAME_MAPPINGS["AUSBOSS_NODES_LoadImage"], "Load Image \U0001f18e")
spec = AusBossLoadImage.INPUT_TYPES()
self.assertEqual(
list(spec["required"]),
["image", "size", "megapixels", "longest_side", "scale", "width", "height", "multiple", "method", "enlarge"],
)
self.assertEqual(spec["required"]["size"][0], list(SIZE_MODES))
self.assertEqual(spec["required"]["size"][1]["default"], "off")
self.assertEqual(len(AusBossLoadImage.RETURN_TYPES), len(AusBossLoadImage.RETURN_NAMES))
self.assertEqual(len(AusBossLoadImage.RETURN_TYPES), len(AusBossLoadImage.OUTPUT_TOOLTIPS))
for name, (_, options) in spec["required"].items():
self.assertTrue(options.get("tooltip"), name)
def test_with_size_off_the_file_comes_out_as_it_is(self):
name = self.write()
image, mask, width, height, filename, original_w, original_h = self.run_node(name)
self.assertEqual(tuple(image.shape), (1, 64, 96, 3))
self.assertEqual(tuple(mask.shape), (1, 64, 96))
self.assertEqual((width, height, original_w, original_h), (96, 64, 96, 64))
self.assertEqual(filename, "pic")
wanted = np.asarray(Image.open(Path(self.folder) / name).convert("RGB"), dtype=np.float32) / 255.0
self.assertTrue(np.array_equal(image[0].numpy(), wanted), "every pixel as the file has it")
self.assertEqual(float(mask.max()), 0.0, "no transparency: an all black mask")
def test_a_see_through_picture_gives_its_mask_the_way_the_stock_loader_does(self):
name = self.write("cut.png", mode="RGBA")
_, mask, *_ = self.run_node(name)
self.assertEqual(float(mask[0, 5, 5]), 1.0, "see-through is white")
self.assertEqual(float(mask[0, 60, 90]), 0.0, "solid is black")
def test_every_size_choice_gives_the_planned_size_for_picture_and_mask(self):
name = self.write("cut.png", mode="RGBA")
for size in SIZE_MODES[1:]:
with self.subTest(size=size):
values = {"megapixels": 0.02, "longest_side": 160, "scale": 1.5, "width": 80, "height": 80, "multiple": 8}
image, mask, width, height, _, original_w, original_h = self.run_node(name, size=size, **values)
plan = plan_load_size(96, 64, size, **values)
self.assertEqual((width, height), (plan["width"], plan["height"]))
self.assertEqual(tuple(image.shape), (1, height, width, 3))
self.assertEqual(tuple(mask.shape), (1, height, width))
self.assertEqual((original_w, original_h), (96, 64))
self.assertGreaterEqual(float(image.min()), 0.0)
self.assertLessEqual(float(image.max()), 1.0)
self.assertGreaterEqual(float(mask.min()), 0.0)
self.assertLessEqual(float(mask.max()), 1.0)
def test_crop_to_fill_cuts_the_middle(self):
name = self.write("wide.png", width=200, height=100)
image, _, width, height, *_ = self.run_node(name, size="crop to fill", width=100, height=100, multiple=1, method="nearest")
self.assertEqual((width, height), (100, 100))
whole, *_ = self.run_node(name)
self.assertTrue(torch.equal(image, whole[:, :, 50:150, :]), "the middle of the file, untouched")
def test_enlarge_off_leaves_a_small_file_alone(self):
name = self.write()
image, _, width, height, *_ = self.run_node(name, size="longest side", longest_side=1024, multiple=1, enlarge=False)
self.assertEqual((width, height), (96, 64))
whole, *_ = self.run_node(name)
self.assertTrue(torch.equal(image, whole))
def test_a_file_in_a_subfolder_gives_its_bare_name(self):
name = self.write("shots/day one/hero.final.png")
*_, filename, _, _ = self.run_node(name)
self.assertEqual(filename, "hero.final")
def test_every_frame_of_an_animation_comes_through(self):
frames = [Image.new("RGB", (40, 30), (index * 60, 20, 20)) for index in range(3)]
path = Path(self.folder) / "turn.gif"
frames[0].save(path, save_all=True, append_images=frames[1:], duration=80, loop=0)
image, mask, width, height, *_ = self.run_node("turn.gif", size="scale", scale=2.0, multiple=1)
self.assertEqual(tuple(image.shape), (3, 60, 80, 3))
self.assertEqual(tuple(mask.shape), (3, 60, 80))
self.assertEqual((width, height), (80, 60))
def test_a_missing_or_outside_file_stops_before_the_run_with_the_nodes_name(self):
self.assertIs(AusBossLoadImage.VALIDATE_INPUTS(image=self.write()), True)
for name in ("", "gone.png", "../secret.png", "//fileserver/share/pic.png"):
verdict = AusBossLoadImage.VALIDATE_INPUTS(image=name)
self.assertIsInstance(verdict, str, name)
self.assertTrue(verdict.startswith("Load Image: "), verdict)
def test_a_changed_file_or_setting_runs_the_node_again(self):
name = self.write()
first = AusBossLoadImage.IS_CHANGED(name, size="off")
self.assertEqual(first, AusBossLoadImage.IS_CHANGED(name, size="off"))
self.assertNotEqual(first, AusBossLoadImage.IS_CHANGED(name, size="scale"))
path = Path(self.folder) / name
Image.new("RGB", (10, 10), (1, 2, 3)).save(path)
os.utime(path, ns=(1, 1))
self.assertNotEqual(first, AusBossLoadImage.IS_CHANGED(name, size="off"))
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -436,10 +436,11 @@ class LoaderValidationTests(TempFolders):
"""The loaders refuse the same paths before a run, with one message each."""
def test_image_loaders_take_what_core_load_image_takes_and_nothing_else(self):
from nodes import node_image_crop_rotate_pad, node_load_image_pad
from nodes import node_image_crop_rotate_pad, node_load_image, node_load_image_pad
for module, cls, label in (
(node_image_crop_rotate_pad, node_image_crop_rotate_pad.AusBossImageCropRotatePad, "Image Crop + Rotate + Pad"),
(node_load_image, node_load_image.AusBossLoadImage, "Load Image"),
(node_load_image_pad, node_load_image_pad.AusBossLoadImagePad, "Load Image + Pad"),
):
with self.subTest(node=label):
+1
View File
@@ -44,6 +44,7 @@ NAMED = {
"AusBossImageFolder": ["source", "folder"],
"AusBossLaMaInpaint": ["model"],
"AusBossLoadAudio": ["audio"],
"AusBossLoadImage": ["image"],
"AusBossLoadImagePad": ["image", "source_image"],
"AusBossLoadVideo": ["video"],
"AusBossLoraLoader": ["loras", "on_missing"],
+1 -1
View File
@@ -943,7 +943,7 @@ test("a name is shown only when SAM 3 is sure of it, and surer still for a big t
});
test("the pack's own picture loaders are the ones the node runs by itself", () => {
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
// A core Load Image is not in the list: its file is the picture, and the node shows it without a run.
assert.equal(OWN_LOADERS.has("LoadImage"), false);
});