Add new nodes and update existing nodes
New nodes: Face Detailer, Mask Combine, Multi Detect Crop, Progress Reporter Updated: Styles Full, Styles Pipe, Video Combine, styles data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ace5516265
commit
cfb28ffcef
+12
@@ -15,12 +15,18 @@ from .nodes.image_fallback import NODE_CLASS_MAPPINGS as IMAGE_FALLBACK_MAPPINGS
|
||||
from .nodes.image_fallback import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_FALLBACK_DISPLAY
|
||||
from .nodes.mask_gate import NODE_CLASS_MAPPINGS as MASK_GATE_MAPPINGS
|
||||
from .nodes.mask_gate import NODE_DISPLAY_NAME_MAPPINGS as MASK_GATE_DISPLAY
|
||||
from .nodes.mask_combine import NODE_CLASS_MAPPINGS as MASK_COMBINE_MAPPINGS
|
||||
from .nodes.mask_combine import NODE_DISPLAY_NAME_MAPPINGS as MASK_COMBINE_DISPLAY
|
||||
from .nodes.latent_nodes import NODE_CLASS_MAPPINGS as LATENT_MAPPINGS
|
||||
from .nodes.latent_nodes import NODE_DISPLAY_NAME_MAPPINGS as LATENT_DISPLAY
|
||||
from .nodes.text_embed import NODE_CLASS_MAPPINGS as TEXT_EMBED_MAPPINGS
|
||||
from .nodes.text_embed import NODE_DISPLAY_NAME_MAPPINGS as TEXT_EMBED_DISPLAY
|
||||
from .nodes.save_image_with_metadata import NODE_CLASS_MAPPINGS as SAVE_IMAGE_MAPPINGS
|
||||
from .nodes.save_image_with_metadata import NODE_DISPLAY_NAME_MAPPINGS as SAVE_IMAGE_DISPLAY
|
||||
from .nodes.face_detailer import NODE_CLASS_MAPPINGS as FACE_DETAILER_MAPPINGS
|
||||
from .nodes.face_detailer import NODE_DISPLAY_NAME_MAPPINGS as FACE_DETAILER_DISPLAY
|
||||
from .nodes.multi_detect_crop import NODE_CLASS_MAPPINGS as MULTI_DETECT_MAPPINGS
|
||||
from .nodes.multi_detect_crop import NODE_DISPLAY_NAME_MAPPINGS as MULTI_DETECT_DISPLAY
|
||||
|
||||
# Combine all mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -30,9 +36,12 @@ NODE_CLASS_MAPPINGS = {
|
||||
**VIDEO_MAPPINGS,
|
||||
**IMAGE_FALLBACK_MAPPINGS,
|
||||
**MASK_GATE_MAPPINGS,
|
||||
**MASK_COMBINE_MAPPINGS,
|
||||
**LATENT_MAPPINGS,
|
||||
**TEXT_EMBED_MAPPINGS,
|
||||
**SAVE_IMAGE_MAPPINGS,
|
||||
**FACE_DETAILER_MAPPINGS,
|
||||
**MULTI_DETECT_MAPPINGS,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -42,9 +51,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**VIDEO_DISPLAY,
|
||||
**IMAGE_FALLBACK_DISPLAY,
|
||||
**MASK_GATE_DISPLAY,
|
||||
**MASK_COMBINE_DISPLAY,
|
||||
**LATENT_DISPLAY,
|
||||
**TEXT_EMBED_DISPLAY,
|
||||
**SAVE_IMAGE_DISPLAY,
|
||||
**FACE_DETAILER_DISPLAY,
|
||||
**MULTI_DETECT_DISPLAY,
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
+329
-692
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* RM Mask Combine
|
||||
* Adds dynamic "Add Object Input" button for additional mask inputs.
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
console.log("[RMMaskCombine] Script loading...");
|
||||
|
||||
app.registerExtension({
|
||||
name: "RMAutomation.MaskCombine",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "RMMaskCombine") return;
|
||||
|
||||
console.log("[RMMaskCombine] Setting up node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
this.objectCount = 0;
|
||||
this.addObjectButton();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
|
||||
// Count existing object_N inputs from saved state
|
||||
if (info.inputs) {
|
||||
let maxObject = 0;
|
||||
for (const input of info.inputs) {
|
||||
if (input.name && input.name.startsWith("object_")) {
|
||||
const num = parseInt(input.name.replace("object_", ""));
|
||||
if (num > maxObject) maxObject = num;
|
||||
}
|
||||
}
|
||||
this.objectCount = maxObject;
|
||||
}
|
||||
|
||||
// Ensure button exists
|
||||
this.addObjectButton();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
nodeType.prototype.addObjectButton = function() {
|
||||
if (!this.widgets?.find(w => w._btnType === "add_object")) {
|
||||
const node = this;
|
||||
const btn = this.addWidget("button", "Add Object Input", null, () => {
|
||||
node.addObjectInput();
|
||||
});
|
||||
btn._btnType = "add_object";
|
||||
btn.serialize = false;
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.addObjectInput = function() {
|
||||
this.objectCount = this.objectCount || 0;
|
||||
this.objectCount++;
|
||||
|
||||
const inputName = `object_${this.objectCount}`;
|
||||
this.addInput(inputName, "MASK");
|
||||
|
||||
// Resize node
|
||||
const newSize = this.computeSize();
|
||||
this.setSize([Math.max(this.size[0], newSize[0]), newSize[1]]);
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
console.log(`[RMMaskCombine] Added input: ${inputName}`);
|
||||
};
|
||||
|
||||
nodeType.prototype.removeLastObjectInput = function() {
|
||||
if (this.objectCount <= 0) return;
|
||||
|
||||
const inputName = `object_${this.objectCount}`;
|
||||
const idx = this.inputs?.findIndex(i => i.name === inputName);
|
||||
if (idx !== undefined && idx >= 0) {
|
||||
this.removeInput(idx);
|
||||
this.objectCount--;
|
||||
|
||||
const newSize = this.computeSize();
|
||||
this.setSize([Math.max(this.size[0], newSize[0]), newSize[1]]);
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
console.log(`[RMMaskCombine] Removed input: ${inputName}`);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.removeAllObjectInputs = function() {
|
||||
while (this.objectCount > 0) {
|
||||
this.removeLastObjectInput();
|
||||
}
|
||||
};
|
||||
|
||||
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
getExtraMenuOptions?.apply(this, arguments);
|
||||
|
||||
const node = this;
|
||||
options.unshift(
|
||||
{
|
||||
content: "Remove last object input",
|
||||
callback: () => { node.removeLastObjectInput(); },
|
||||
disabled: node.objectCount <= 0,
|
||||
},
|
||||
{
|
||||
content: "Remove all object inputs",
|
||||
callback: () => { node.removeAllObjectInputs(); },
|
||||
disabled: node.objectCount <= 0,
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[RMMaskCombine] Script loaded");
|
||||
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* RM Multi-Detect & Crop
|
||||
* Dynamic BBOX model inputs with individual thresholds.
|
||||
* Pattern adapted from RM Power LoRA Loader.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
console.log("[RMMultiDetectCrop] Script loading...");
|
||||
|
||||
const NODE_TYPE = "RMMultiDetectCrop";
|
||||
const DEFAULT_WIDTH = 380;
|
||||
|
||||
function fitString(ctx, str, maxWidth) {
|
||||
let width = ctx.measureText(str).width;
|
||||
if (width <= maxWidth) return str;
|
||||
const ellipsis = "...";
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||
let len = str.length;
|
||||
while (width > maxWidth - ellipsisWidth && len > 0) {
|
||||
len--;
|
||||
str = str.substring(0, len);
|
||||
width = ctx.measureText(str).width;
|
||||
}
|
||||
return str + ellipsis;
|
||||
}
|
||||
|
||||
// Cache for bbox model list
|
||||
let bboxCache = null;
|
||||
let bboxCachePromise = null;
|
||||
|
||||
async function getBBoxModels(force = false) {
|
||||
if (bboxCache && !force) return bboxCache;
|
||||
if (bboxCachePromise && !force) return bboxCachePromise;
|
||||
|
||||
bboxCachePromise = api.fetchApi("/object_info/RMMultiDetectCrop")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Fallback: fetch from UltralyticsDetectorProvider if our node isn't registered yet
|
||||
// Our node uses dynamic inputs so bbox list won't be in object_info.
|
||||
// Instead fetch from a known node that uses ultralytics_bbox.
|
||||
return null;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// Use a more reliable approach: fetch the bbox list from the embeddings/models endpoint
|
||||
// or from a node that declares ultralytics_bbox models
|
||||
bboxCachePromise = fetchBBoxModels();
|
||||
return bboxCachePromise;
|
||||
}
|
||||
|
||||
async function fetchBBoxModels() {
|
||||
// Try multiple known nodes that expose ultralytics_bbox model lists
|
||||
const candidates = [
|
||||
"RMFaceDetectCrop",
|
||||
"UltralyticsDetectorProvider",
|
||||
];
|
||||
|
||||
for (const nodeName of candidates) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/object_info/${nodeName}`);
|
||||
const data = await response.json();
|
||||
const nodeInfo = data?.[nodeName];
|
||||
if (!nodeInfo) continue;
|
||||
|
||||
// Look for model_name input that contains bbox models
|
||||
const required = nodeInfo?.input?.required || {};
|
||||
for (const [key, value] of Object.entries(required)) {
|
||||
if (key === "model_name" && Array.isArray(value?.[0])) {
|
||||
bboxCache = value[0];
|
||||
return bboxCache;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
console.warn("[RMMultiDetectCrop] Could not fetch BBOX model list from any known node");
|
||||
return [];
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "rm.MultiDetectCrop",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== NODE_TYPE) return;
|
||||
|
||||
console.log("[RMMultiDetectCrop] Setting up node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
this.bboxWidgets = [];
|
||||
this.serialize_widgets = true;
|
||||
|
||||
if (this.size && this.size[0] < DEFAULT_WIDTH) {
|
||||
this.size[0] = DEFAULT_WIDTH;
|
||||
}
|
||||
|
||||
this.addBBoxButton();
|
||||
return result;
|
||||
};
|
||||
|
||||
nodeType.prototype.onConfigure = function (info) {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
|
||||
if (info.widgets_values) {
|
||||
// Remove existing bbox widgets
|
||||
this.widgets = this.widgets?.filter(
|
||||
(w) => !w.name?.startsWith("bbox_") && w.name !== "add_bbox_btn"
|
||||
) || [];
|
||||
this.bboxWidgets = [];
|
||||
|
||||
for (const value of info.widgets_values) {
|
||||
if (value && typeof value === "object" && value.model !== undefined) {
|
||||
this.addBBoxWidget(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-add button
|
||||
this.addBBoxButton();
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Add BBOX Button
|
||||
// ================================================================
|
||||
|
||||
nodeType.prototype.addBBoxButton = function () {
|
||||
if (this.widgets?.find((w) => w.name === "add_bbox_btn")) return;
|
||||
|
||||
const node = this;
|
||||
|
||||
const button = {
|
||||
name: "add_bbox_btn",
|
||||
type: "custom",
|
||||
value: "Add BBOX",
|
||||
serialize: false,
|
||||
|
||||
draw: function (ctx, nodeRef, width, posY, height) {
|
||||
const margin = 15;
|
||||
const midY = posY + height / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(margin, posY, width - margin * 2, height, height * 0.4);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("Add BBOX Model", width / 2, midY);
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
mouse: function (event, pos, nodeRef) {
|
||||
if (event.type === "pointerdown" && event.button === 0) {
|
||||
node.showBBoxMenu();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
computeSize: function () {
|
||||
return [DEFAULT_WIDTH, 22];
|
||||
},
|
||||
};
|
||||
|
||||
this.widgets = this.widgets || [];
|
||||
this.widgets.push(button);
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// BBOX Menu (model selection)
|
||||
// ================================================================
|
||||
|
||||
nodeType.prototype.showBBoxMenu = async function () {
|
||||
const node = this;
|
||||
const models = await getBBoxModels();
|
||||
|
||||
if (!models || models.length === 0) {
|
||||
new LiteGraph.ContextMenu(
|
||||
[{ content: "(No BBOX models found)", disabled: true }],
|
||||
{ event: window.event, title: "Select BBOX Model" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const menuItems = this.buildBBoxMenu(models);
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
event: window.event,
|
||||
title: "Select BBOX Model",
|
||||
});
|
||||
};
|
||||
|
||||
nodeType.prototype.buildBBoxMenu = function (models, forWidget = null) {
|
||||
const node = this;
|
||||
// Build hierarchical menu from model paths (same pattern as lora menu)
|
||||
const tree = {};
|
||||
|
||||
for (const model of models) {
|
||||
const parts = model.split(/[\/\\]/);
|
||||
let current = tree;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const folder = parts[i];
|
||||
if (!current[folder]) {
|
||||
current[folder] = { __files: [], __folders: {} };
|
||||
}
|
||||
current = current[folder].__folders;
|
||||
}
|
||||
|
||||
const filename = parts[parts.length - 1];
|
||||
if (parts.length === 1) {
|
||||
if (!tree.__root) tree.__root = [];
|
||||
tree.__root.push({ name: filename, path: model });
|
||||
} else {
|
||||
let parent = tree;
|
||||
for (let i = 0; i < parts.length - 2; i++) {
|
||||
parent = parent[parts[i]].__folders;
|
||||
}
|
||||
const parentFolder = parts[parts.length - 2];
|
||||
parent[parentFolder].__files.push({ name: filename, path: model });
|
||||
}
|
||||
}
|
||||
|
||||
const buildMenuFromTree = (treeNode, isRoot = false) => {
|
||||
const items = [];
|
||||
const folders = [];
|
||||
const files = isRoot && treeNode.__root ? [...treeNode.__root] : [];
|
||||
|
||||
for (const [key, value] of Object.entries(treeNode)) {
|
||||
if (key === "__root" || key === "__files" || key === "__folders")
|
||||
continue;
|
||||
folders.push({ name: key, data: value });
|
||||
}
|
||||
|
||||
folders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const folder of folders) {
|
||||
const subItems = [];
|
||||
|
||||
if (folder.data.__folders) {
|
||||
subItems.push(...buildMenuFromTree(folder.data.__folders));
|
||||
}
|
||||
|
||||
if (folder.data.__files?.length > 0) {
|
||||
folder.data.__files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (subItems.length > 0) subItems.push(null);
|
||||
|
||||
for (const file of folder.data.__files) {
|
||||
const displayName = file.name.replace(/\.pt$/, "");
|
||||
subItems.push({
|
||||
content: displayName,
|
||||
callback: forWidget
|
||||
? () => {
|
||||
forWidget.value.model = file.path;
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
: () => node.addBBoxWidget({ model: file.path }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
content: `\uD83D\uDCC1 ${folder.name}`,
|
||||
has_submenu: true,
|
||||
callback: () => {},
|
||||
submenu: { options: subItems },
|
||||
});
|
||||
}
|
||||
|
||||
if (isRoot && files.length > 0) {
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (items.length > 0) items.push(null);
|
||||
|
||||
for (const file of files) {
|
||||
const displayName = file.name.replace(/\.pt$/, "");
|
||||
items.push({
|
||||
content: displayName,
|
||||
callback: forWidget
|
||||
? () => {
|
||||
forWidget.value.model = file.path;
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
: () => node.addBBoxWidget({ model: file.path }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
return buildMenuFromTree(tree, true);
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// BBOX Widget (each model row)
|
||||
// ================================================================
|
||||
|
||||
nodeType.prototype.addBBoxWidget = function (config = {}) {
|
||||
const defaults = {
|
||||
on: true,
|
||||
model: null,
|
||||
threshold: 0.5,
|
||||
};
|
||||
|
||||
const value = { ...defaults, ...config };
|
||||
const index = this.bboxWidgets?.length || 0;
|
||||
const widgetName = `bbox_${index}`;
|
||||
|
||||
const widget = {
|
||||
name: widgetName,
|
||||
type: "custom",
|
||||
value: value,
|
||||
|
||||
draw: (ctx, node, width, posY, height) => {
|
||||
this.drawBBoxWidget(ctx, widget, width, posY, height);
|
||||
},
|
||||
|
||||
mouse: (event, pos, node) => {
|
||||
return this.handleBBoxWidgetMouse(event, pos, widget);
|
||||
},
|
||||
|
||||
computeSize: () => {
|
||||
return [DEFAULT_WIDTH, 22];
|
||||
},
|
||||
|
||||
serializeValue: () => {
|
||||
return { ...widget.value };
|
||||
},
|
||||
};
|
||||
|
||||
// Insert before the button
|
||||
const buttonIndex =
|
||||
this.widgets?.findIndex((w) => w.name === "add_bbox_btn") ?? -1;
|
||||
if (buttonIndex >= 0) {
|
||||
this.widgets.splice(buttonIndex, 0, widget);
|
||||
} else {
|
||||
this.widgets = this.widgets || [];
|
||||
this.widgets.push(widget);
|
||||
}
|
||||
|
||||
this.bboxWidgets = this.bboxWidgets || [];
|
||||
this.bboxWidgets.push(widget);
|
||||
|
||||
const newSize = this.computeSize();
|
||||
newSize[0] = Math.max(newSize[0], DEFAULT_WIDTH);
|
||||
this.setSize(newSize);
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
return widget;
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Draw BBOX Widget
|
||||
// ================================================================
|
||||
|
||||
nodeType.prototype.drawBBoxWidget = function (
|
||||
ctx,
|
||||
widget,
|
||||
width,
|
||||
posY,
|
||||
height
|
||||
) {
|
||||
const margin = 15;
|
||||
const value = widget.value;
|
||||
const midY = posY + height / 2;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Row background
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(margin, posY, width - margin * 2, height, height * 0.4);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// === TOGGLE SWITCH ===
|
||||
const toggleHeight = height * 0.55;
|
||||
const toggleWidth = height * 1.1;
|
||||
const toggleX = margin + 5;
|
||||
const toggleY = midY - toggleHeight / 2;
|
||||
const toggleRadius = toggleHeight * 0.5;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(toggleX, toggleY, toggleWidth, toggleHeight, toggleRadius);
|
||||
ctx.fillStyle = value.on
|
||||
? "rgba(100, 160, 100, 0.4)"
|
||||
: "rgba(80, 80, 80, 0.4)";
|
||||
ctx.fill();
|
||||
|
||||
const knobRadius = toggleHeight * 0.38;
|
||||
const knobX = value.on
|
||||
? toggleX + toggleWidth - knobRadius - 2
|
||||
: toggleX + knobRadius + 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(knobX, midY, knobRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = value.on ? "#8B8" : "#888";
|
||||
ctx.fill();
|
||||
|
||||
// === GEAR ICON ===
|
||||
const gearSize = 10;
|
||||
const gearX = toggleX + toggleWidth + 8;
|
||||
this.drawGearIcon(
|
||||
ctx,
|
||||
gearX,
|
||||
midY,
|
||||
gearSize * 0.5,
|
||||
value.on ? "#777" : "#555"
|
||||
);
|
||||
|
||||
let posX = gearX + gearSize + 6;
|
||||
|
||||
// === LAYOUT ===
|
||||
const threshWidth = 40;
|
||||
const threshX = width - margin - threshWidth - 4;
|
||||
const modelWidth = threshX - posX - 8;
|
||||
|
||||
// === MODEL NAME ===
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = value.on ? LiteGraph.WIDGET_TEXT_COLOR : "#666";
|
||||
|
||||
const displayName = value.model
|
||||
? value.model
|
||||
.split(/[\/\\]/)
|
||||
.pop()
|
||||
.replace(/\.pt$/, "")
|
||||
: "(select model)";
|
||||
ctx.fillText(fitString(ctx, displayName, modelWidth), posX, midY);
|
||||
|
||||
// === THRESHOLD ===
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = value.on ? LiteGraph.WIDGET_TEXT_COLOR : "#666";
|
||||
ctx.fillText(value.threshold.toFixed(2), threshX + threshWidth / 2, midY);
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// Gear icon helper (same as Power LoRA Loader)
|
||||
nodeType.prototype.drawGearIcon = function (ctx, x, y, radius, color) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
const teeth = 6;
|
||||
const innerRadius = radius * 0.5;
|
||||
const outerRadius = radius;
|
||||
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < teeth * 2; i++) {
|
||||
const angle = (i * Math.PI) / teeth;
|
||||
const r = i % 2 === 0 ? outerRadius : innerRadius * 1.3;
|
||||
const px = x + Math.cos(angle) * r;
|
||||
const py = y + Math.sin(angle) * r;
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, innerRadius * 0.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Mouse Handling
|
||||
// ================================================================
|
||||
|
||||
nodeType.prototype.handleBBoxWidgetMouse = function (
|
||||
event,
|
||||
pos,
|
||||
widget
|
||||
) {
|
||||
const margin = 15;
|
||||
const width = this.size[0];
|
||||
const height = 22;
|
||||
const toggleWidth = height * 1.1;
|
||||
const gearSize = 10;
|
||||
const threshWidth = 40;
|
||||
|
||||
const localX = pos[0];
|
||||
const toggleX = margin + 5;
|
||||
const toggleEnd = toggleX + toggleWidth;
|
||||
const gearX = toggleEnd + 8;
|
||||
const gearRadius = gearSize * 0.5;
|
||||
const threshX = width - margin - threshWidth - 4;
|
||||
|
||||
// Right-click → context menu
|
||||
if (event.type === "pointerdown" && event.button === 2) {
|
||||
this.showBBoxWidgetMenu(widget, event);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.type !== "pointerdown" || event.button !== 0) return false;
|
||||
|
||||
// Toggle click
|
||||
if (localX >= toggleX && localX < toggleEnd) {
|
||||
widget.value.on = !widget.value.on;
|
||||
this.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Gear click
|
||||
if (
|
||||
localX >= gearX - gearRadius - 3 &&
|
||||
localX < gearX + gearRadius + 3
|
||||
) {
|
||||
this.showBBoxWidgetMenu(widget, event);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Threshold click → prompt
|
||||
if (localX >= threshX && localX < threshX + threshWidth) {
|
||||
const val = prompt(
|
||||
"Enter threshold (0.0 - 1.0):",
|
||||
widget.value.threshold
|
||||
);
|
||||
if (val !== null && !isNaN(parseFloat(val))) {
|
||||
widget.value.threshold = Math.max(
|
||||
0,
|
||||
Math.min(1, parseFloat(val))
|
||||
);
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Model name click → show model menu
|
||||
if (localX >= gearX + gearRadius + 3 && localX < threshX - 4) {
|
||||
this.showBBoxMenuForWidget(widget);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Context Menu
|
||||
// ================================================================
|
||||
|
||||
nodeType.prototype.showBBoxWidgetMenu = function (widget, event) {
|
||||
const node = this;
|
||||
const menuItems = [
|
||||
{
|
||||
content: widget.value.on ? "Disable" : "Enable",
|
||||
callback: () => {
|
||||
widget.value.on = !widget.value.on;
|
||||
this.setDirtyCanvas(true, true);
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
content: "Remove",
|
||||
callback: () => {
|
||||
const idx = this.widgets.indexOf(widget);
|
||||
if (idx >= 0) this.widgets.splice(idx, 1);
|
||||
|
||||
const bboxIdx = this.bboxWidgets.indexOf(widget);
|
||||
if (bboxIdx >= 0) this.bboxWidgets.splice(bboxIdx, 1);
|
||||
|
||||
const newSize = this.computeSize();
|
||||
newSize[0] = Math.max(newSize[0], DEFAULT_WIDTH);
|
||||
this.setSize(newSize);
|
||||
this.setDirtyCanvas(true, true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
event: event,
|
||||
title: "BBOX Options",
|
||||
});
|
||||
};
|
||||
|
||||
nodeType.prototype.showBBoxMenuForWidget = async function (widget) {
|
||||
const models = await getBBoxModels();
|
||||
if (!models || models.length === 0) return;
|
||||
|
||||
const menuItems = this.buildBBoxMenu(models, widget);
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
event: window.event,
|
||||
title: "Change BBOX Model",
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[RMMultiDetectCrop] Script loaded successfully!");
|
||||
+23
-7
@@ -404,10 +404,19 @@ styleSheet.textContent = `
|
||||
document.head.appendChild(styleSheet);
|
||||
|
||||
class RMStylesEditorModal {
|
||||
constructor() {
|
||||
constructor(customPath = "") {
|
||||
this.styles = [];
|
||||
this.selectedStyle = null;
|
||||
this.overlay = null;
|
||||
this.customPath = customPath;
|
||||
}
|
||||
|
||||
_apiUrl(path) {
|
||||
if (this.customPath) {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}path=${encodeURIComponent(this.customPath)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -424,7 +433,7 @@ class RMStylesEditorModal {
|
||||
|
||||
async loadStyles() {
|
||||
try {
|
||||
const response = await api.fetchApi("/api/rmautomation/styles");
|
||||
const response = await api.fetchApi(this._apiUrl("/api/rmautomation/styles"));
|
||||
const data = await response.json();
|
||||
this.styles = data.styles || [];
|
||||
} catch (e) {
|
||||
@@ -435,7 +444,7 @@ class RMStylesEditorModal {
|
||||
|
||||
async saveStyle(style) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/api/rmautomation/styles/${style.number}`, {
|
||||
const response = await api.fetchApi(this._apiUrl(`/api/rmautomation/styles/${style.number}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(style),
|
||||
@@ -452,7 +461,7 @@ class RMStylesEditorModal {
|
||||
async deleteStyle(number) {
|
||||
if (!confirm(`Delete style #${number}?`)) return;
|
||||
try {
|
||||
const response = await api.fetchApi(`/api/rmautomation/styles/${number}`, {
|
||||
const response = await api.fetchApi(this._apiUrl(`/api/rmautomation/styles/${number}`), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -473,11 +482,15 @@ class RMStylesEditorModal {
|
||||
if (e.target === this.overlay) this.close();
|
||||
};
|
||||
|
||||
const headerLabel = this.customPath
|
||||
? `RM Styles Editor - ${this.customPath.split(/[/\\]/).pop()}`
|
||||
: "RM Styles Editor";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "rm-styles-modal";
|
||||
modal.innerHTML = `
|
||||
<div class="rm-styles-modal-header">
|
||||
<h2>RM Styles Editor</h2>
|
||||
<h2>${headerLabel}</h2>
|
||||
<button class="rm-styles-modal-close">×</button>
|
||||
</div>
|
||||
<div class="rm-styles-modal-content">
|
||||
@@ -756,7 +769,7 @@ class RMStylesEditorModal {
|
||||
|
||||
async importStyles(importedStyles, mode) {
|
||||
try {
|
||||
const response = await api.fetchApi("/api/rmautomation/styles/import", {
|
||||
const response = await api.fetchApi(this._apiUrl("/api/rmautomation/styles/import"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -792,8 +805,11 @@ app.registerExtension({
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Add "Edit Styles" button
|
||||
const node = this;
|
||||
const editBtn = this.addWidget("button", "Edit Styles", null, () => {
|
||||
const modal = new RMStylesEditorModal();
|
||||
const pathWidget = node.widgets?.find(w => w.name === "custom_json_path");
|
||||
const customPath = pathWidget?.value?.trim() || "";
|
||||
const modal = new RMStylesEditorModal(customPath);
|
||||
modal.open();
|
||||
});
|
||||
};
|
||||
|
||||
+32
-76
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* RM Styles Full - Frontend Widget Controller
|
||||
* Handles mode switching, random/increment/decrement logic
|
||||
* Handles mode switching, increment/decrement logic.
|
||||
* Random mode is handled server-side (Python) with anti-repeat history.
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
@@ -8,69 +9,21 @@ import { api } from "../../scripts/api.js";
|
||||
|
||||
console.log("[RMStyles] Script loading...");
|
||||
|
||||
const RANDOM_HISTORY_SIZE = 30;
|
||||
const RM_STYLES_NODES = ["RMStylesFull", "RMStylesFullDisplay", "RMStylesPipe"];
|
||||
|
||||
/**
|
||||
* Generate a random integer between min and max (inclusive),
|
||||
* avoiding recently used values tracked in the history array.
|
||||
* Calculate the next prompt number based on mode (Increment/Decrement only)
|
||||
*/
|
||||
function randomInRange(min, max, history) {
|
||||
if (min >= max) return min;
|
||||
|
||||
const range = max - min + 1;
|
||||
|
||||
if (history) {
|
||||
const effectiveBuffer = Math.min(RANDOM_HISTORY_SIZE, range - 1);
|
||||
const avoid = new Set(history.slice(-effectiveBuffer));
|
||||
let result, attempts = 0;
|
||||
do {
|
||||
result = Math.floor(Math.random() * range) + min;
|
||||
attempts++;
|
||||
} while (avoid.has(result) && attempts < 100);
|
||||
|
||||
history.push(result);
|
||||
if (history.length > RANDOM_HISTORY_SIZE) {
|
||||
history.splice(0, history.length - RANDOM_HISTORY_SIZE);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return Math.floor(Math.random() * range) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next prompt number based on mode
|
||||
*/
|
||||
function calculateNextNumber(mode, current, min, max, history) {
|
||||
let result;
|
||||
function calculateNextNumber(mode, current, min, max) {
|
||||
switch (mode) {
|
||||
case "Random":
|
||||
result = randomInRange(min, max, history);
|
||||
break;
|
||||
case "Increment":
|
||||
result = current >= max ? min : current + 1;
|
||||
break;
|
||||
return current >= max ? min : current + 1;
|
||||
case "Decrement":
|
||||
result = current <= min ? max : current - 1;
|
||||
break;
|
||||
return current <= min ? max : current - 1;
|
||||
case "Manual":
|
||||
default:
|
||||
result = current;
|
||||
return current;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all widget values from a node for debugging
|
||||
*/
|
||||
function getWidgetValues(node) {
|
||||
if (!node?.widgets) return "no widgets";
|
||||
const values = {};
|
||||
for (const w of node.widgets) {
|
||||
values[w.name] = w.value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,11 +50,6 @@ function setupNodeWidgets(node) {
|
||||
// Store widget references on node for easy access
|
||||
node._rmWidgets = { modeWidget, prevWidget, nextWidget, minWidget, maxWidget };
|
||||
|
||||
// History buffer to prevent random repeats
|
||||
if (!node._rmHistory) {
|
||||
node._rmHistory = [];
|
||||
}
|
||||
|
||||
// Flag to prevent recursive callbacks
|
||||
node._rmUpdating = false;
|
||||
|
||||
@@ -115,12 +63,12 @@ function setupNodeWidgets(node) {
|
||||
if (node._rmUpdating) return;
|
||||
node._rmUpdating = true;
|
||||
|
||||
// Calculate new next_prompt based on mode
|
||||
if (value !== "Manual") {
|
||||
// Calculate new next_prompt for Increment/Decrement preview
|
||||
if (value !== "Manual" && value !== "Random") {
|
||||
const current = nextWidget.value;
|
||||
const min = minWidget.value;
|
||||
const max = maxWidget.value;
|
||||
const newValue = calculateNextNumber(value, current, min, max, node._rmHistory);
|
||||
const newValue = calculateNextNumber(value, current, min, max);
|
||||
nextWidget.value = newValue;
|
||||
console.log(`[RMStyles] Mode changed to ${value}, next_prompt: ${newValue}`);
|
||||
}
|
||||
@@ -147,31 +95,26 @@ function setupNodeWidgets(node) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node execution complete
|
||||
* Handle node execution complete (Increment/Decrement only)
|
||||
* Random mode is handled by the rm_styles_executed message from Python.
|
||||
*/
|
||||
function handleNodeExecuted(node) {
|
||||
const w = node._rmWidgets;
|
||||
if (!w) return;
|
||||
|
||||
const mode = w.modeWidget.value;
|
||||
const usedPrompt = w.nextWidget.value;
|
||||
const min = w.minWidget.value;
|
||||
const max = w.maxWidget.value;
|
||||
|
||||
// Update previous_prompt to show what was just used
|
||||
// Random mode: Python handles everything via rm_styles_executed message
|
||||
if (mode === "Random") return;
|
||||
|
||||
const usedPrompt = w.nextWidget.value;
|
||||
|
||||
node._rmUpdating = true;
|
||||
w.prevWidget.value = usedPrompt;
|
||||
|
||||
// Calculate new next_prompt for non-Manual modes
|
||||
// Calculate new next_prompt for Increment/Decrement
|
||||
if (mode !== "Manual") {
|
||||
// Add the USED prompt to history before generating new one
|
||||
if (mode === "Random" && !node._rmHistory.includes(usedPrompt)) {
|
||||
node._rmHistory.push(usedPrompt);
|
||||
if (node._rmHistory.length > RANDOM_HISTORY_SIZE) {
|
||||
node._rmHistory.splice(0, node._rmHistory.length - RANDOM_HISTORY_SIZE);
|
||||
}
|
||||
}
|
||||
const newNext = calculateNextNumber(mode, usedPrompt, min, max, node._rmHistory);
|
||||
const newNext = calculateNextNumber(mode, usedPrompt, w.minWidget.value, w.maxWidget.value);
|
||||
w.nextWidget.value = newNext;
|
||||
console.log(`[RMStyles] Execution complete: used=${usedPrompt}, next=${newNext}, mode=${mode}`);
|
||||
}
|
||||
@@ -229,6 +172,19 @@ app.registerExtension({
|
||||
pendingNodes.clear();
|
||||
};
|
||||
|
||||
// Listen for Python's random selection message
|
||||
api.addEventListener("rm_styles_executed", (e) => {
|
||||
const { node_id, prompt_number } = e.detail;
|
||||
const node = app.graph?.getNodeById(Number(node_id));
|
||||
if (!node || !node._rmWidgets) return;
|
||||
|
||||
node._rmUpdating = true;
|
||||
node._rmWidgets.prevWidget.value = prompt_number;
|
||||
node._rmUpdating = false;
|
||||
|
||||
console.log(`[RMStyles] Python selected prompt #${prompt_number} (node ${node_id})`);
|
||||
});
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
pendingNodes.clear();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
RMFaceDetectCrop and RMFaceComposite nodes.
|
||||
|
||||
RMFaceDetectCrop: Detects faces via YOLO, crops each face region with configurable
|
||||
expansion, and scales to a target max dimension. Uses OUTPUT_IS_LIST so that each
|
||||
face flows through the entire downstream pipeline independently (each gets its own
|
||||
VAE encode, its own guide/conditioning, its own sampler pass). Fails silently when
|
||||
no faces are found.
|
||||
|
||||
RMFaceComposite: Uses INPUT_IS_LIST to collect all independently processed faces,
|
||||
then composites them sequentially onto the original image with Gaussian-blurred
|
||||
masks for smooth blending.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
import cv2
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
import comfy.utils
|
||||
|
||||
try:
|
||||
from torchvision.transforms import GaussianBlur
|
||||
HAS_GAUSSIAN_BLUR = True
|
||||
except ImportError:
|
||||
HAS_GAUSSIAN_BLUR = False
|
||||
|
||||
|
||||
class RMFaceDetectCrop:
|
||||
"""
|
||||
Detects faces in an image, crops each face region (expanded by crop_factor),
|
||||
and scales to max_size. Uses OUTPUT_IS_LIST for the image output so ComfyUI
|
||||
executes all downstream nodes once per face — each face gets its own
|
||||
VAE encode, guide, sampler pass, and VAE decode independently.
|
||||
|
||||
Replaces: UltralyticsDetectorProvider + BBOX Detector (SEGS) +
|
||||
Decompose (SEGS) + From SEG_ELT + From SEG_ELT crop_region +
|
||||
Bounding Box + Simple Math x2 + CropByBBoxes + ImageScaleToMaxDimension
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
try:
|
||||
bbox_list = folder_paths.get_filename_list("ultralytics_bbox")
|
||||
except Exception:
|
||||
bbox_list = []
|
||||
if not bbox_list:
|
||||
bbox_list = ["none"]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"model_name": (bbox_list,),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"dilation": ("INT", {"default": 10, "min": -512, "max": 512, "step": 1}),
|
||||
"crop_factor": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
|
||||
"drop_size": ("INT", {"default": 10, "min": 1, "max": 8192, "step": 1}),
|
||||
"megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "RM_FACE_DATA", "INT")
|
||||
RETURN_NAMES = ("cropped_face", "face_data", "face_count")
|
||||
OUTPUT_IS_LIST = (True, False, False)
|
||||
FUNCTION = "detect"
|
||||
CATEGORY = "RMAutomation/Face"
|
||||
|
||||
def detect(self, image, model_name, threshold, dilation, crop_factor, drop_size, megapixels):
|
||||
from ultralytics import YOLO
|
||||
|
||||
img_tensor = image[0] # First image in batch: (H, W, C)
|
||||
h, w = img_tensor.shape[0], img_tensor.shape[1]
|
||||
|
||||
# Resolve model path
|
||||
model_path = folder_paths.get_full_path("ultralytics_bbox", model_name)
|
||||
if model_path is None:
|
||||
print(f"[RMFaceDetectCrop] Model not found: {model_name}")
|
||||
return self._no_faces(h, w)
|
||||
|
||||
# Run YOLO detection
|
||||
try:
|
||||
img_np = (img_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
img_pil = Image.fromarray(img_np)
|
||||
model = YOLO(model_path)
|
||||
results = model(img_pil, conf=threshold)
|
||||
except Exception as e:
|
||||
print(f"[RMFaceDetectCrop] Detection error: {e}")
|
||||
return self._no_faces(h, w)
|
||||
|
||||
if not results or results[0].boxes is None or len(results[0].boxes) == 0:
|
||||
return self._no_faces(h, w)
|
||||
|
||||
bboxes = results[0].boxes.xyxy.cpu().numpy()
|
||||
confidences = results[0].boxes.conf.cpu().numpy()
|
||||
|
||||
# Filter small detections
|
||||
faces = []
|
||||
for i in range(len(bboxes)):
|
||||
x1, y1, x2, y2 = bboxes[i]
|
||||
if (x2 - x1) < drop_size or (y2 - y1) < drop_size:
|
||||
continue
|
||||
faces.append((int(x1), int(y1), int(x2), int(y2), float(confidences[i])))
|
||||
|
||||
if not faces:
|
||||
return self._no_faces(h, w)
|
||||
|
||||
# Crop and scale each face independently
|
||||
face_images = []
|
||||
infos = []
|
||||
|
||||
for bx1, by1, bx2, by2, conf in faces:
|
||||
# Compute expanded crop region
|
||||
cl, ct, cr, cb = self._crop_region(w, h, (bx1, by1, bx2, by2), crop_factor)
|
||||
cw, ch = cr - cl, cb - ct
|
||||
|
||||
# Create rectangular bbox mask and apply dilation
|
||||
mask = np.zeros((h, w), dtype=np.float32)
|
||||
mask[by1:by2, bx1:bx2] = 1.0
|
||||
if dilation != 0:
|
||||
kern = np.ones((abs(dilation), abs(dilation)), np.uint8)
|
||||
if dilation > 0:
|
||||
mask = cv2.dilate(mask, kern, iterations=1)
|
||||
else:
|
||||
mask = cv2.erode(mask, kern, iterations=1)
|
||||
|
||||
# Crop mask and image to the expanded region
|
||||
cropped_mask = torch.from_numpy(mask[ct:cb, cl:cr].copy())
|
||||
cropped_img = img_tensor[ct:cb, cl:cr, :].clone()
|
||||
|
||||
# Scale to target megapixels while preserving aspect ratio
|
||||
current_pixels = cw * ch
|
||||
target_pixels = megapixels * 1_000_000
|
||||
scale_factor = (target_pixels / current_pixels) ** 0.5
|
||||
nw, nh = round(cw * scale_factor), round(ch * scale_factor)
|
||||
|
||||
scaled = comfy.utils.common_upscale(
|
||||
cropped_img.movedim(-1, 0).unsqueeze(0), # HWC -> BCHW
|
||||
nw, nh, "lanczos", "disabled"
|
||||
).squeeze(0).movedim(0, -1) # BCHW -> HWC
|
||||
|
||||
# Each face is an individual IMAGE tensor: (1, H, W, C)
|
||||
face_images.append(scaled.unsqueeze(0))
|
||||
|
||||
infos.append({
|
||||
"crop_region": (cl, ct, cr, cb),
|
||||
"crop_w": cw,
|
||||
"crop_h": ch,
|
||||
"cropped_mask": cropped_mask,
|
||||
})
|
||||
|
||||
face_data = {"found": True, "faces": infos, "original_size": (h, w)}
|
||||
|
||||
print(f"[RMFaceDetectCrop] Detected {len(faces)} face(s)")
|
||||
return (face_images, face_data, len(faces))
|
||||
|
||||
def _no_faces(self, h, w):
|
||||
"""Return a single dummy image and empty face_data when no faces found."""
|
||||
print("[RMFaceDetectCrop] No faces detected")
|
||||
dummy = torch.zeros(1, 256, 256, 3)
|
||||
return (
|
||||
[dummy],
|
||||
{"found": False, "faces": [], "original_size": (h, w)},
|
||||
0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _crop_region(img_w, img_h, bbox, factor):
|
||||
"""Compute crop region expanded by factor, clamped to image bounds."""
|
||||
x1, y1, x2, y2 = bbox
|
||||
bw, bh = x2 - x1, y2 - y1
|
||||
cw, ch = bw * factor, bh * factor
|
||||
cx, cy = x1 + bw / 2, y1 + bh / 2
|
||||
|
||||
nx1 = int(cx - cw / 2)
|
||||
ny1 = int(cy - ch / 2)
|
||||
nx2 = int(nx1 + cw)
|
||||
ny2 = int(ny1 + ch)
|
||||
|
||||
# Shift region into image bounds
|
||||
if nx1 < 0:
|
||||
nx2 -= nx1
|
||||
nx1 = 0
|
||||
if ny1 < 0:
|
||||
ny2 -= ny1
|
||||
ny1 = 0
|
||||
if nx2 > img_w:
|
||||
nx1 -= (nx2 - img_w)
|
||||
nx2 = img_w
|
||||
if ny2 > img_h:
|
||||
ny1 -= (ny2 - img_h)
|
||||
ny2 = img_h
|
||||
|
||||
return max(0, nx1), max(0, ny1), min(img_w, nx2), min(img_h, ny2)
|
||||
|
||||
|
||||
class RMFaceComposite:
|
||||
"""
|
||||
Collects all independently processed faces (via INPUT_IS_LIST) and composites
|
||||
them sequentially onto the original image. Face 0 is composited first, then
|
||||
face 1 onto that result, and so on. Each face uses its own Gaussian-blurred
|
||||
mask for smooth edge blending.
|
||||
|
||||
When face_data indicates no faces were found, passes the original image
|
||||
through unchanged.
|
||||
|
||||
Replaces: Upscale Image + Gaussian Blur Mask + ImageCompositeMasked
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"original_image": ("IMAGE",),
|
||||
"processed_faces": ("IMAGE",),
|
||||
"face_data": ("RM_FACE_DATA",),
|
||||
"blur_kernel_size": ("INT", {"default": 30, "min": 0, "max": 100, "step": 1}),
|
||||
"blur_sigma": ("FLOAT", {"default": 30.0, "min": 0.1, "max": 100.0, "step": 0.1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "composite"
|
||||
CATEGORY = "RMAutomation/Face"
|
||||
|
||||
def composite(self, original_image, processed_faces, face_data, blur_kernel_size, blur_sigma):
|
||||
# INPUT_IS_LIST=True: all inputs arrive as lists, unwrap single-item ones
|
||||
original = original_image[0]
|
||||
fd = face_data[0]
|
||||
bks = blur_kernel_size[0]
|
||||
bs = blur_sigma[0]
|
||||
# processed_faces is a list of N tensors, each (1, H, W, C)
|
||||
|
||||
if not fd.get("found", False):
|
||||
print("[RMFaceComposite] No faces in face_data, passing through original")
|
||||
return (original,)
|
||||
|
||||
result = original.clone()
|
||||
device = result.device
|
||||
faces = fd["faces"]
|
||||
|
||||
for i, info in enumerate(faces):
|
||||
if i >= len(processed_faces):
|
||||
print(f"[RMFaceComposite] Warning: face {i} has no matching processed image, stopping")
|
||||
break
|
||||
|
||||
# Get the processed face — single image (1, H, W, C) → (H, W, C)
|
||||
face = processed_faces[i][0].to(device)
|
||||
|
||||
# Scale back to original crop dimensions
|
||||
cw, ch = info["crop_w"], info["crop_h"]
|
||||
face = comfy.utils.common_upscale(
|
||||
face.movedim(-1, 0).unsqueeze(0),
|
||||
cw, ch, "nearest-exact", "disabled"
|
||||
).squeeze(0).movedim(0, -1)
|
||||
|
||||
# Blur the detection mask for smooth blending
|
||||
mask = info["cropped_mask"].float().to(device)
|
||||
mask = self._blur_mask(mask, bks, bs)
|
||||
mask_3d = mask.unsqueeze(-1) # (crop_h, crop_w, 1)
|
||||
|
||||
# Alpha-composite this face onto the accumulating result
|
||||
cl, ct, cr, cb = info["crop_region"]
|
||||
for b in range(result.shape[0]):
|
||||
region = result[b, ct:cb, cl:cr, :]
|
||||
result[b, ct:cb, cl:cr, :] = face * mask_3d + region * (1.0 - mask_3d)
|
||||
|
||||
print(f"[RMFaceComposite] Composited {min(len(faces), len(processed_faces))} face(s)")
|
||||
return (result,)
|
||||
|
||||
@staticmethod
|
||||
def _blur_mask(mask, kernel_size, sigma):
|
||||
"""Apply Gaussian blur to a 2D mask tensor for feathered edges.
|
||||
|
||||
After blurring the inner bbox rectangle, a border falloff ramp is
|
||||
multiplied in so the mask reaches zero at the crop region edges.
|
||||
This prevents hard seams when compositing back onto the original.
|
||||
"""
|
||||
if not HAS_GAUSSIAN_BLUR or kernel_size <= 0:
|
||||
return mask
|
||||
|
||||
k = kernel_size * 2 + 1
|
||||
h, w = mask.shape[0], mask.shape[1]
|
||||
shortest = min(h, w)
|
||||
if shortest <= k:
|
||||
k = int(shortest / 2)
|
||||
if k % 2 == 0:
|
||||
k += 1
|
||||
if k < 3:
|
||||
return mask
|
||||
|
||||
m = mask.unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
|
||||
blurred = GaussianBlur(kernel_size=k, sigma=sigma)(m)
|
||||
blurred = blurred.squeeze(0).squeeze(0)
|
||||
|
||||
# Border falloff: ramp from 0 at the crop edge to 1 over `margin` pixels.
|
||||
# This guarantees the mask is zero at the boundary — no hard seams.
|
||||
margin = max(k // 2, 1)
|
||||
falloff = torch.ones_like(blurred)
|
||||
|
||||
if h > margin * 2 and w > margin * 2:
|
||||
# Vertical ramps (top and bottom edges)
|
||||
ramp_v = torch.linspace(0.0, 1.0, margin, device=blurred.device)
|
||||
falloff[:margin, :] *= ramp_v.unsqueeze(1)
|
||||
falloff[-margin:, :] *= ramp_v.flip(0).unsqueeze(1)
|
||||
|
||||
# Horizontal ramps (left and right edges)
|
||||
ramp_h = torch.linspace(0.0, 1.0, margin, device=blurred.device)
|
||||
falloff[:, :margin] *= ramp_h.unsqueeze(0)
|
||||
falloff[:, -margin:] *= ramp_h.flip(0).unsqueeze(0)
|
||||
|
||||
blurred = blurred * falloff
|
||||
|
||||
return blurred
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"RMFaceDetectCrop": RMFaceDetectCrop,
|
||||
"RMFaceComposite": RMFaceComposite,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RMFaceDetectCrop": "RM Face Detect & Crop",
|
||||
"RMFaceComposite": "RM Face Composite",
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
RM Mask Combine - Combines body, face, and object masks into a single mask
|
||||
for downstream KSampler processing.
|
||||
|
||||
White = process, Black = protect.
|
||||
|
||||
Logic:
|
||||
body + face + objects → invert(body) + face - objects
|
||||
body + face → invert(body) + face
|
||||
face + objects → face - objects
|
||||
body + objects → invert(objects) (no face = nothing to add)
|
||||
body only → invert(body)
|
||||
face only → face
|
||||
objects only → invert(objects)
|
||||
none → all-white (process everything)
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
class FlexibleMaskInputType(dict):
|
||||
"""
|
||||
Allows dynamic inputs for RMMaskCombine.
|
||||
Accepts body, face as static keys and object_N as dynamic keys.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__({
|
||||
"body": ("MASK",),
|
||||
"face": ("MASK",),
|
||||
})
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key in dict.keys(self):
|
||||
return dict.__getitem__(self, key)
|
||||
if key.startswith("object_"):
|
||||
return ("MASK",)
|
||||
return ("*",)
|
||||
|
||||
def __contains__(self, key):
|
||||
if key in dict.keys(self):
|
||||
return True
|
||||
if key.startswith("object_"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class RMMaskCombine:
|
||||
"""
|
||||
Combines body, face, and dynamic object masks into a single mask.
|
||||
Use "Add Object Input" to add mask inputs for body parts to exclude.
|
||||
|
||||
White = process, Black = protect.
|
||||
The result masks the background + face, excluding the body and objects.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": FlexibleMaskInputType(),
|
||||
}
|
||||
|
||||
CATEGORY = "RMAutomation/Mask"
|
||||
RETURN_TYPES = ("MASK",)
|
||||
RETURN_NAMES = ("mask",)
|
||||
FUNCTION = "combine"
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_mask(mask):
|
||||
"""Check if a mask is usable (not None, not empty, no zero dims)."""
|
||||
if mask is None:
|
||||
return False
|
||||
if mask.numel() == 0:
|
||||
return False
|
||||
if any(d == 0 for d in mask.shape):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mask(mask):
|
||||
"""Ensure mask is 3D (B, H, W) and clamped to [0, 1]."""
|
||||
if mask.dim() == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
elif mask.dim() == 4:
|
||||
mask = mask.squeeze(1)
|
||||
return mask.clamp(0, 1)
|
||||
|
||||
@staticmethod
|
||||
def _resize_mask(mask, h, w):
|
||||
"""Resize mask to target dimensions using bilinear interpolation."""
|
||||
if mask.shape[-2] == h and mask.shape[-1] == w:
|
||||
return mask
|
||||
return F.interpolate(
|
||||
mask.unsqueeze(1), size=(h, w), mode="bilinear", align_corners=False
|
||||
).squeeze(1)
|
||||
|
||||
def combine(self, body=None, face=None, **kwargs):
|
||||
# Collect object masks from dynamic inputs
|
||||
object_masks = []
|
||||
for key in sorted(kwargs.keys()):
|
||||
if key.startswith("object_") and self._is_valid_mask(kwargs[key]):
|
||||
object_masks.append(self._normalize_mask(kwargs[key]))
|
||||
|
||||
has_body = self._is_valid_mask(body)
|
||||
has_face = self._is_valid_mask(face)
|
||||
has_objects = len(object_masks) > 0
|
||||
|
||||
# Normalize body and face
|
||||
if has_body:
|
||||
body = self._normalize_mask(body)
|
||||
if has_face:
|
||||
face = self._normalize_mask(face)
|
||||
|
||||
# Determine target dimensions from the first valid mask
|
||||
ref = None
|
||||
if has_body:
|
||||
ref = body
|
||||
elif has_face:
|
||||
ref = face
|
||||
elif has_objects:
|
||||
ref = object_masks[0]
|
||||
|
||||
# No inputs at all → all-white
|
||||
if ref is None:
|
||||
print("[RMMaskCombine] No valid inputs, returning all-white mask")
|
||||
return (None,)
|
||||
|
||||
b, h, w = ref.shape
|
||||
|
||||
# Resize all masks to match reference
|
||||
if has_body:
|
||||
body = self._resize_mask(body, h, w)
|
||||
if has_face:
|
||||
face = self._resize_mask(face, h, w)
|
||||
object_masks = [self._resize_mask(m, h, w) for m in object_masks]
|
||||
|
||||
# Merge all object masks into one (union)
|
||||
combined_objects = None
|
||||
if has_objects:
|
||||
combined_objects = torch.zeros(b, h, w, device=ref.device)
|
||||
for m in object_masks:
|
||||
# Handle batch size mismatch by expanding
|
||||
if m.shape[0] < b:
|
||||
m = m.expand(b, -1, -1)
|
||||
combined_objects = torch.max(combined_objects, m[:b])
|
||||
combined_objects = combined_objects.clamp(0, 1)
|
||||
|
||||
# Apply formula based on which inputs are present
|
||||
if has_body and has_face and has_objects:
|
||||
# invert(body) + face - objects
|
||||
result = (1.0 - body) + face - combined_objects
|
||||
print(f"[RMMaskCombine] Formula: invert(body) + face - objects")
|
||||
elif has_body and has_face:
|
||||
# invert(body) + face
|
||||
result = (1.0 - body) + face
|
||||
print(f"[RMMaskCombine] Formula: invert(body) + face")
|
||||
elif has_face and has_objects:
|
||||
# face - objects
|
||||
result = face - combined_objects
|
||||
print(f"[RMMaskCombine] Formula: face - objects")
|
||||
elif has_body and has_objects:
|
||||
# invert(objects) — body without face means nothing to add back
|
||||
result = 1.0 - combined_objects
|
||||
print(f"[RMMaskCombine] Formula: invert(objects)")
|
||||
elif has_body:
|
||||
# invert(body)
|
||||
result = 1.0 - body
|
||||
print(f"[RMMaskCombine] Formula: invert(body)")
|
||||
elif has_face:
|
||||
# face only
|
||||
result = face
|
||||
print(f"[RMMaskCombine] Formula: face")
|
||||
elif has_objects:
|
||||
# invert(objects)
|
||||
result = 1.0 - combined_objects
|
||||
print(f"[RMMaskCombine] Formula: invert(objects)")
|
||||
else:
|
||||
# Should not reach here due to earlier ref check
|
||||
return (None,)
|
||||
|
||||
result = result.clamp(0, 1)
|
||||
print(f"[RMMaskCombine] Output shape: {result.shape}")
|
||||
return (result,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"RMMaskCombine": RMMaskCombine,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RMMaskCombine": "RM Mask Combine",
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
RMMultiDetectCrop and companion composite node.
|
||||
|
||||
RMMultiDetectCrop: Like RMFaceDetectCrop but accepts multiple BBOX models,
|
||||
each with its own confidence threshold. Dynamic inputs are added via a
|
||||
frontend "Add BBOX" button (same pattern as RM Power Lora Loader).
|
||||
All detections are merged into a single flat list.
|
||||
|
||||
Works with RMFaceComposite for compositing results back onto the original.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
import cv2
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
import comfy.utils
|
||||
|
||||
|
||||
class FlexibleBBoxInputType(dict):
|
||||
"""
|
||||
Allows dynamic inputs added by the frontend JS.
|
||||
ComfyUI will accept any key whose value is a dict with {on, model, threshold}.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__({})
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key in dict.keys(self):
|
||||
return dict.__getitem__(self, key)
|
||||
return ("*",)
|
||||
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
|
||||
|
||||
class RMMultiDetectCrop:
|
||||
"""
|
||||
Detects objects using multiple BBOX models (each with its own threshold),
|
||||
crops each detection region (expanded by crop_factor), and scales to
|
||||
max_size. Uses OUTPUT_IS_LIST so each detection flows independently
|
||||
through downstream nodes.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"dilation": ("INT", {"default": 10, "min": -512, "max": 512, "step": 1}),
|
||||
"crop_factor": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
|
||||
"drop_size": ("INT", {"default": 10, "min": 1, "max": 8192, "step": 1}),
|
||||
"megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}),
|
||||
},
|
||||
"optional": FlexibleBBoxInputType(),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "RM_FACE_DATA", "INT")
|
||||
RETURN_NAMES = ("cropped_region", "region_data", "region_count")
|
||||
OUTPUT_IS_LIST = (True, False, False)
|
||||
FUNCTION = "detect"
|
||||
CATEGORY = "RMAutomation/Detection"
|
||||
|
||||
def detect(self, image, dilation, crop_factor, drop_size, megapixels, **kwargs):
|
||||
from ultralytics import YOLO
|
||||
|
||||
img_tensor = image[0] # (H, W, C)
|
||||
h, w = img_tensor.shape[0], img_tensor.shape[1]
|
||||
|
||||
# Convert image once for all models
|
||||
img_np = (img_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
img_pil = Image.fromarray(img_np)
|
||||
|
||||
# Collect BBOX configs from dynamic kwargs
|
||||
bbox_configs = []
|
||||
for key, value in kwargs.items():
|
||||
if key.upper().startswith("BBOX_") and isinstance(value, dict):
|
||||
if value.get("on", True):
|
||||
model_name = value.get("model")
|
||||
threshold = value.get("threshold", 0.5)
|
||||
if model_name and model_name != "none":
|
||||
bbox_configs.append((model_name, threshold))
|
||||
|
||||
if not bbox_configs:
|
||||
print("[RMMultiDetectCrop] No BBOX models configured")
|
||||
return self._no_detections(h, w)
|
||||
|
||||
# Run each model and collect all detections
|
||||
all_detections = [] # list of (x1, y1, x2, y2, conf)
|
||||
|
||||
for model_name, threshold in bbox_configs:
|
||||
model_path = folder_paths.get_full_path("ultralytics_bbox", model_name)
|
||||
if model_path is None:
|
||||
print(f"[RMMultiDetectCrop] Model not found: {model_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
model = YOLO(model_path)
|
||||
results = model(img_pil, conf=threshold)
|
||||
except Exception as e:
|
||||
print(f"[RMMultiDetectCrop] Detection error with {model_name}: {e}")
|
||||
continue
|
||||
|
||||
if not results or results[0].boxes is None or len(results[0].boxes) == 0:
|
||||
print(f"[RMMultiDetectCrop] No detections from {model_name}")
|
||||
continue
|
||||
|
||||
bboxes = results[0].boxes.xyxy.cpu().numpy()
|
||||
confidences = results[0].boxes.conf.cpu().numpy()
|
||||
|
||||
for i in range(len(bboxes)):
|
||||
x1, y1, x2, y2 = bboxes[i]
|
||||
if (x2 - x1) < drop_size or (y2 - y1) < drop_size:
|
||||
continue
|
||||
all_detections.append((int(x1), int(y1), int(x2), int(y2), float(confidences[i])))
|
||||
|
||||
print(f"[RMMultiDetectCrop] {model_name}: {len(bboxes)} raw, {sum(1 for b in bboxes if (b[2]-b[0]) >= drop_size and (b[3]-b[1]) >= drop_size)} kept")
|
||||
|
||||
if not all_detections:
|
||||
return self._no_detections(h, w)
|
||||
|
||||
# Crop and scale each detection
|
||||
crop_images = []
|
||||
infos = []
|
||||
|
||||
for bx1, by1, bx2, by2, conf in all_detections:
|
||||
cl, ct, cr, cb = self._crop_region(w, h, (bx1, by1, bx2, by2), crop_factor)
|
||||
cw, ch = cr - cl, cb - ct
|
||||
|
||||
# Create rectangular bbox mask and apply dilation
|
||||
mask = np.zeros((h, w), dtype=np.float32)
|
||||
mask[by1:by2, bx1:bx2] = 1.0
|
||||
if dilation != 0:
|
||||
kern = np.ones((abs(dilation), abs(dilation)), np.uint8)
|
||||
if dilation > 0:
|
||||
mask = cv2.dilate(mask, kern, iterations=1)
|
||||
else:
|
||||
mask = cv2.erode(mask, kern, iterations=1)
|
||||
|
||||
cropped_mask = torch.from_numpy(mask[ct:cb, cl:cr].copy())
|
||||
cropped_img = img_tensor[ct:cb, cl:cr, :].clone()
|
||||
|
||||
# Scale to target megapixels while preserving aspect ratio
|
||||
current_pixels = cw * ch
|
||||
target_pixels = megapixels * 1_000_000
|
||||
scale_factor = (target_pixels / current_pixels) ** 0.5
|
||||
nw, nh = round(cw * scale_factor), round(ch * scale_factor)
|
||||
|
||||
scaled = comfy.utils.common_upscale(
|
||||
cropped_img.movedim(-1, 0).unsqueeze(0),
|
||||
nw, nh, "lanczos", "disabled"
|
||||
).squeeze(0).movedim(0, -1)
|
||||
|
||||
crop_images.append(scaled.unsqueeze(0))
|
||||
|
||||
infos.append({
|
||||
"crop_region": (cl, ct, cr, cb),
|
||||
"crop_w": cw,
|
||||
"crop_h": ch,
|
||||
"cropped_mask": cropped_mask,
|
||||
})
|
||||
|
||||
region_data = {"found": True, "faces": infos, "original_size": (h, w)}
|
||||
|
||||
print(f"[RMMultiDetectCrop] Total detections: {len(all_detections)}")
|
||||
return (crop_images, region_data, len(all_detections))
|
||||
|
||||
def _no_detections(self, h, w):
|
||||
"""Return a single dummy image and empty data when nothing found."""
|
||||
print("[RMMultiDetectCrop] No detections found")
|
||||
dummy = torch.zeros(1, 256, 256, 3)
|
||||
return (
|
||||
[dummy],
|
||||
{"found": False, "faces": [], "original_size": (h, w)},
|
||||
0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _crop_region(img_w, img_h, bbox, factor):
|
||||
"""Compute crop region expanded by factor, clamped to image bounds."""
|
||||
x1, y1, x2, y2 = bbox
|
||||
bw, bh = x2 - x1, y2 - y1
|
||||
cw, ch = bw * factor, bh * factor
|
||||
cx, cy = x1 + bw / 2, y1 + bh / 2
|
||||
|
||||
nx1 = int(cx - cw / 2)
|
||||
ny1 = int(cy - ch / 2)
|
||||
nx2 = int(nx1 + cw)
|
||||
ny2 = int(ny1 + ch)
|
||||
|
||||
if nx1 < 0:
|
||||
nx2 -= nx1
|
||||
nx1 = 0
|
||||
if ny1 < 0:
|
||||
ny2 -= ny1
|
||||
ny1 = 0
|
||||
if nx2 > img_w:
|
||||
nx1 -= (nx2 - img_w)
|
||||
nx2 = img_w
|
||||
if ny2 > img_h:
|
||||
ny1 -= (ny2 - img_h)
|
||||
ny2 = img_h
|
||||
|
||||
return max(0, nx1), max(0, ny1), min(img_w, nx2), min(img_h, ny2)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"RMMultiDetectCrop": RMMultiDetectCrop,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RMMultiDetectCrop": "RM Multi-Detect & Crop",
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
RM Progress Reporter - Reports workflow progress to the Playground Viewer app.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
import time
|
||||
|
||||
try:
|
||||
import requests
|
||||
HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
HAS_REQUESTS = False
|
||||
|
||||
VIEWER_URL = "http://127.0.0.1:3001/api/comfy-progress"
|
||||
PREVIEW_DIR = r"C:\Users\rober\Playground\Viewer\public\previews"
|
||||
|
||||
|
||||
def ensure_preview_dir():
|
||||
"""Create preview directory if it doesn't exist."""
|
||||
if not os.path.exists(PREVIEW_DIR):
|
||||
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def send_progress(data: dict) -> bool:
|
||||
"""Send progress data to the Viewer app."""
|
||||
try:
|
||||
json_data = json.dumps(data)
|
||||
|
||||
if HAS_REQUESTS:
|
||||
response = requests.post(VIEWER_URL, json=data, timeout=2)
|
||||
return response.status_code == 200
|
||||
else:
|
||||
req = urllib.request.Request(
|
||||
VIEWER_URL,
|
||||
data=json_data.encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST'
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
return response.status == 200
|
||||
except Exception:
|
||||
# Viewer app may not be running - fail silently
|
||||
return False
|
||||
+107
-35
@@ -3,7 +3,7 @@ RM Styles Full - Load prompts from JSON styles file with random/increment/decrem
|
||||
|
||||
This node loads prompt data from a JSON file and supports multiple selection modes:
|
||||
- Manual: Use the exact next_prompt number specified
|
||||
- Random: JS calculates random number within min/max range
|
||||
- Random: Python picks a random style, avoiding the last 30 used
|
||||
- Increment: JS increments after each execution (wraps at max)
|
||||
- Decrement: JS decrements after each execution (wraps at min)
|
||||
|
||||
@@ -12,10 +12,39 @@ The JSON file can be edited via a modal editor accessible from a button on the n
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
# Anti-repeat history for Random mode (module-level, shared across all style nodes)
|
||||
RANDOM_HISTORY_SIZE = 30
|
||||
_random_history = []
|
||||
|
||||
|
||||
def _get_random_prompt(minimum, maximum, available_numbers):
|
||||
"""Pick a random style number from available styles, avoiding recently used ones."""
|
||||
global _random_history
|
||||
|
||||
candidates = [n for n in available_numbers if minimum <= n <= maximum]
|
||||
if not candidates:
|
||||
return random.randint(minimum, maximum)
|
||||
|
||||
effective_buffer = min(RANDOM_HISTORY_SIZE, len(candidates) - 1)
|
||||
avoid = set(_random_history[-effective_buffer:]) if effective_buffer > 0 else set()
|
||||
|
||||
good = [n for n in candidates if n not in avoid]
|
||||
if not good:
|
||||
good = candidates # All avoided, pick any
|
||||
|
||||
result = random.choice(good)
|
||||
|
||||
_random_history.append(result)
|
||||
if len(_random_history) > RANDOM_HISTORY_SIZE:
|
||||
_random_history[:] = _random_history[-RANDOM_HISTORY_SIZE:]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class RMStylesFull:
|
||||
"""
|
||||
@@ -51,18 +80,27 @@ class RMStylesFull:
|
||||
def __init__(self):
|
||||
self._styles_cache = None
|
||||
self._styles_mtime = 0
|
||||
self._styles_path = None
|
||||
|
||||
def _load_styles(self) -> dict:
|
||||
def _load_styles(self, custom_path=None) -> dict:
|
||||
"""Load styles from JSON file with caching."""
|
||||
try:
|
||||
if not self.STYLES_FILE.exists():
|
||||
print(f"[RMStylesFull] Styles file not found: {self.STYLES_FILE}")
|
||||
styles_file = Path(custom_path) if custom_path else self.STYLES_FILE
|
||||
|
||||
# Invalidate cache if file path changed
|
||||
if self._styles_path != str(styles_file):
|
||||
self._styles_cache = None
|
||||
self._styles_mtime = 0
|
||||
self._styles_path = str(styles_file)
|
||||
|
||||
if not styles_file.exists():
|
||||
print(f"[RMStylesFull] Styles file not found: {styles_file}")
|
||||
return {}
|
||||
|
||||
current_mtime = self.STYLES_FILE.stat().st_mtime
|
||||
current_mtime = styles_file.stat().st_mtime
|
||||
|
||||
if self._styles_cache is None or current_mtime > self._styles_mtime:
|
||||
with open(self.STYLES_FILE, 'r', encoding='utf-8') as f:
|
||||
with open(styles_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self._styles_cache = {}
|
||||
@@ -72,7 +110,7 @@ class RMStylesFull:
|
||||
self._styles_cache[num] = style
|
||||
|
||||
self._styles_mtime = current_mtime
|
||||
print(f"[RMStylesFull] Loaded {len(self._styles_cache)} styles from JSON")
|
||||
print(f"[RMStylesFull] Loaded {len(self._styles_cache)} styles from {styles_file}")
|
||||
|
||||
return self._styles_cache
|
||||
except Exception as e:
|
||||
@@ -113,6 +151,13 @@ class RMStylesFull:
|
||||
"default": False,
|
||||
"tooltip": "Add quality prefixes to positive/negative prompts"
|
||||
}),
|
||||
"custom_json_path": ("STRING", {
|
||||
"default": "",
|
||||
"tooltip": "Path to a custom styles JSON file. Leave empty to use the default styles.json in the node pack."
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,15 +190,20 @@ class RMStylesFull:
|
||||
|
||||
return ", ".join(formatted)
|
||||
|
||||
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False):
|
||||
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, custom_json_path: str = "", unique_id=None):
|
||||
"""Main node execution function."""
|
||||
empty_result = ("", "", "", "", "", "", 0, "")
|
||||
|
||||
styles = self._load_styles()
|
||||
custom_path = custom_json_path.strip() if custom_json_path else None
|
||||
styles = self._load_styles(custom_path)
|
||||
if not styles:
|
||||
return empty_result
|
||||
|
||||
selected_number = next_prompt
|
||||
# Random mode: Python picks the number, ignoring next_prompt
|
||||
if mode == "Random":
|
||||
selected_number = _get_random_prompt(minimum, maximum, list(styles.keys()))
|
||||
else:
|
||||
selected_number = next_prompt
|
||||
|
||||
if selected_number not in styles:
|
||||
available = sorted(styles.keys())
|
||||
@@ -190,6 +240,13 @@ class RMStylesFull:
|
||||
motion_loras_high_str = self._format_loras(motion_loras_high)
|
||||
motion_loras_low_str = self._format_loras(motion_loras_low)
|
||||
|
||||
# Send actual used number back to JS for widget update
|
||||
if unique_id is not None:
|
||||
PromptServer.instance.send_sync("rm_styles_executed", {
|
||||
"node_id": str(unique_id),
|
||||
"prompt_number": selected_number,
|
||||
})
|
||||
|
||||
return (
|
||||
positive,
|
||||
negative,
|
||||
@@ -236,15 +293,18 @@ class RMStylesFullDisplay(RMStylesFull):
|
||||
"use_prefix": ("BOOLEAN", {
|
||||
"default": False,
|
||||
}),
|
||||
"custom_json_path": ("STRING", {
|
||||
"default": "",
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, unique_id=None):
|
||||
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, custom_json_path: str = "", unique_id=None):
|
||||
"""Same as parent but also returns UI data to display on the node."""
|
||||
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix)
|
||||
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix, custom_json_path=custom_json_path, unique_id=unique_id)
|
||||
|
||||
positive = result[0] if result[0] else "(empty)"
|
||||
prompt_num = result[6]
|
||||
@@ -268,14 +328,26 @@ def setup_styles_api():
|
||||
"""Register API routes for the styles editor modal."""
|
||||
try:
|
||||
data_dir = Path(__file__).parent.parent / "data"
|
||||
styles_file = data_dir / "styles.json"
|
||||
default_styles_file = data_dir / "styles.json"
|
||||
|
||||
def _resolve_styles_file(request):
|
||||
"""Get the styles file path, using custom path from query param if provided."""
|
||||
custom = request.query.get('path', '').strip()
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return default_styles_file
|
||||
|
||||
def _ensure_parent_dir(file_path):
|
||||
"""Create parent directory if it doesn't exist."""
|
||||
os.makedirs(file_path.parent, exist_ok=True)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/rmautomation/styles")
|
||||
async def get_styles(request):
|
||||
try:
|
||||
if not styles_file.exists():
|
||||
target = _resolve_styles_file(request)
|
||||
if not target.exists():
|
||||
return web.json_response({"styles": []})
|
||||
with open(styles_file, 'r', encoding='utf-8') as f:
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return web.json_response(data)
|
||||
except Exception as e:
|
||||
@@ -285,8 +357,9 @@ def setup_styles_api():
|
||||
async def save_styles(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
with open(styles_file, 'w', encoding='utf-8') as f:
|
||||
target = _resolve_styles_file(request)
|
||||
_ensure_parent_dir(target)
|
||||
with open(target, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as e:
|
||||
@@ -296,9 +369,10 @@ def setup_styles_api():
|
||||
async def get_style(request):
|
||||
try:
|
||||
number = int(request.match_info['number'])
|
||||
if not styles_file.exists():
|
||||
target = _resolve_styles_file(request)
|
||||
if not target.exists():
|
||||
return web.json_response({"error": "Styles file not found"}, status=404)
|
||||
with open(styles_file, 'r', encoding='utf-8') as f:
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for style in data.get('styles', []):
|
||||
if style.get('number') == number:
|
||||
@@ -314,10 +388,11 @@ def setup_styles_api():
|
||||
new_style = await request.json()
|
||||
new_style['number'] = number
|
||||
|
||||
if not styles_file.exists():
|
||||
target = _resolve_styles_file(request)
|
||||
if not target.exists():
|
||||
data = {"styles": []}
|
||||
else:
|
||||
with open(styles_file, 'r', encoding='utf-8') as f:
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
styles = data.get('styles', [])
|
||||
@@ -333,8 +408,8 @@ def setup_styles_api():
|
||||
styles.sort(key=lambda x: x.get('number', 0))
|
||||
|
||||
data['styles'] = styles
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
with open(styles_file, 'w', encoding='utf-8') as f:
|
||||
_ensure_parent_dir(target)
|
||||
with open(target, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return web.json_response({"success": True})
|
||||
@@ -345,16 +420,17 @@ def setup_styles_api():
|
||||
async def delete_style(request):
|
||||
try:
|
||||
number = int(request.match_info['number'])
|
||||
if not styles_file.exists():
|
||||
target = _resolve_styles_file(request)
|
||||
if not target.exists():
|
||||
return web.json_response({"error": "Styles file not found"}, status=404)
|
||||
|
||||
with open(styles_file, 'r', encoding='utf-8') as f:
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
styles = [s for s in data.get('styles', []) if s.get('number') != number]
|
||||
data['styles'] = styles
|
||||
|
||||
with open(styles_file, 'w', encoding='utf-8') as f:
|
||||
with open(target, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return web.json_response({"success": True})
|
||||
@@ -372,34 +448,30 @@ def setup_styles_api():
|
||||
if not imported_styles:
|
||||
return web.json_response({"error": "No styles provided"}, status=400)
|
||||
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
target = _resolve_styles_file(request)
|
||||
_ensure_parent_dir(target)
|
||||
|
||||
if mode == 'replace':
|
||||
# Replace all existing styles with imported ones
|
||||
# Ensure proper numbering
|
||||
for i, style in enumerate(imported_styles):
|
||||
if 'number' not in style:
|
||||
style['number'] = i + 1
|
||||
imported_styles.sort(key=lambda x: x.get('number', 0))
|
||||
data = {"styles": imported_styles}
|
||||
else:
|
||||
# Append mode: add imported styles after existing ones
|
||||
if styles_file.exists():
|
||||
with open(styles_file, 'r', encoding='utf-8') as f:
|
||||
if target.exists():
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"styles": []}
|
||||
|
||||
existing_styles = data.get('styles', [])
|
||||
|
||||
# Find the highest existing number
|
||||
max_number = 0
|
||||
for style in existing_styles:
|
||||
num = style.get('number', 0)
|
||||
if num > max_number:
|
||||
max_number = num
|
||||
|
||||
# Renumber imported styles to start after the highest existing number
|
||||
for i, style in enumerate(imported_styles):
|
||||
style['number'] = max_number + i + 1
|
||||
|
||||
@@ -407,7 +479,7 @@ def setup_styles_api():
|
||||
existing_styles.sort(key=lambda x: x.get('number', 0))
|
||||
data['styles'] = existing_styles
|
||||
|
||||
with open(styles_file, 'w', encoding='utf-8') as f:
|
||||
with open(target, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return web.json_response({"success": True, "count": len(imported_styles)})
|
||||
|
||||
+26
-2
@@ -2,7 +2,11 @@
|
||||
RM Styles Pipe - Style pipe input/output nodes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import glob
|
||||
import time
|
||||
from .styles_full import RMStylesFull
|
||||
from .progress_reporter import send_progress, ensure_preview_dir, PREVIEW_DIR
|
||||
|
||||
|
||||
class RMStylesPipe(RMStylesFull):
|
||||
@@ -25,12 +29,32 @@ class RMStylesPipe(RMStylesFull):
|
||||
"next_prompt": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
||||
"minimum": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
||||
"maximum": ("INT", {"default": 100, "min": 1, "max": 9999}),
|
||||
"custom_json_path": ("STRING", {"default": ""}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int):
|
||||
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, custom_json_path: str = "", unique_id=None):
|
||||
"""Load style and return as a pipe tuple (without prefixes)."""
|
||||
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix=False)
|
||||
# --- Progress Start: clear previews and notify viewer ---
|
||||
try:
|
||||
ensure_preview_dir()
|
||||
for f in glob.glob(os.path.join(PREVIEW_DIR, "preview_*.jpg")):
|
||||
try:
|
||||
os.remove(f)
|
||||
except Exception:
|
||||
pass
|
||||
send_progress({
|
||||
"event": "workflow_start",
|
||||
"workflow_name": "Generation",
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[RMProgress] Error sending workflow_start: {e}")
|
||||
|
||||
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix=False, custom_json_path=custom_json_path, unique_id=unique_id)
|
||||
return (result,)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import logging
|
||||
|
||||
import folder_paths
|
||||
from comfy.utils import ProgressBar
|
||||
from .progress_reporter import send_progress
|
||||
|
||||
# Logger setup
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
@@ -690,6 +691,16 @@ class RMVideoCombine:
|
||||
|
||||
logger.info(f"Preview info - filename: {file}, subfolder: {preview_subfolder}, type: {preview_type}")
|
||||
|
||||
# --- Progress End: notify viewer that generation is complete ---
|
||||
try:
|
||||
send_progress({
|
||||
"event": "workflow_end",
|
||||
"message": "Complete",
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"[RMProgress] Error sending workflow_end: {e}")
|
||||
|
||||
preview = {
|
||||
"filename": file,
|
||||
"subfolder": preview_subfolder,
|
||||
|
||||
Reference in New Issue
Block a user