mod: SmartImageRegion -- Added polygon region type

This commit is contained in:
Slava Cherkasov
2025-04-21 22:43:44 +02:00
parent bdcb7bf839
commit bec442cb8a
2 changed files with 649 additions and 255 deletions
+568 -218
View File
@@ -12,6 +12,11 @@ app.registerExtension({
function getRegionWidget(node) {
return node.widgets.find(w => w.name === "region_data");
}
// Find the type widget
function getTypeWidget(node) {
return node.widgets.find(w => w.name === "type");
}
// Initialize region properties
const initProperties = (ctx) => {
@@ -21,36 +26,73 @@ app.registerExtension({
// Get the hidden widget
const regionWidget = getRegionWidget(ctx);
let region = { x1: 0.25, y1: 0.25, x2: 0.75, y2: 0.75 };
const typeWidget = getTypeWidget(ctx);
const regionType = typeWidget ? typeWidget.value : "Rectangle";
if (regionWidget && regionWidget.value) {
// Parse region from the widget value
try {
const parsed = JSON.parse(regionWidget.value);
if (typeof parsed === 'object' && parsed !== null) {
region = {
x1: parsed.x1 !== undefined ? parseFloat(parsed.x1) : 0.25,
y1: parsed.y1 !== undefined ? parseFloat(parsed.y1) : 0.25,
x2: parsed.x2 !== undefined ? parseFloat(parsed.x2) : 0.75,
y2: parsed.y2 !== undefined ? parseFloat(parsed.y2) : 0.75
};
if (regionType === "Rectangle") {
let region = { x1: 0.25, y1: 0.25, x2: 0.75, y2: 0.75 };
if (regionWidget && regionWidget.value) {
// Parse region from the widget value
try {
const parsed = JSON.parse(regionWidget.value);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
region = {
x1: parsed.x1 !== undefined ? parseFloat(parsed.x1) : 0.25,
y1: parsed.y1 !== undefined ? parseFloat(parsed.y1) : 0.25,
x2: parsed.x2 !== undefined ? parseFloat(parsed.x2) : 0.75,
y2: parsed.y2 !== undefined ? parseFloat(parsed.y2) : 0.75
};
}
} catch (e) {
console.error("Error parsing region_data:", e);
}
} catch (e) {
console.error("Error parsing region_data:", e);
}
// Store region in properties
ctx.properties.region = region;
} else { // Polygon
// Default polygon (triangle)
let points = [
{ x: 0.25, y: 0.25 },
{ x: 0.75, y: 0.25 },
{ x: 0.5, y: 0.75 }
];
if (regionWidget && regionWidget.value) {
// Parse polygon points from the widget value
try {
const parsed = JSON.parse(regionWidget.value);
if (Array.isArray(parsed) && parsed.length >= 3) {
// Validate points
points = parsed.map(point => ({
x: point.x !== undefined ? Math.max(0, Math.min(1, parseFloat(point.x))) : 0.5,
y: point.y !== undefined ? Math.max(0, Math.min(1, parseFloat(point.y))) : 0.5
}));
}
} catch (e) {
console.error("Error parsing polygon points:", e);
}
}
// Store points in properties
ctx.properties.points = points;
}
// Update widget with initial value based on type
if (regionWidget) {
if (regionType === "Rectangle") {
regionWidget.value = JSON.stringify(ctx.properties.region);
} else { // Polygon
regionWidget.value = JSON.stringify(ctx.properties.points);
}
}
// Store region in properties
ctx.properties.region = region;
// Update widget with initial value
if (regionWidget) {
regionWidget.value = JSON.stringify(region);
}
ctx.dragCorner = null; // Which corner is being dragged: null, "tl", "tr", "bl", "br"
ctx.dragCorner = null; // Which corner/point is being dragged: null, "tl", "tr", "bl", "br" or point index
ctx.isDragging = false;
ctx.resetButtonRect = null;
ctx.addButtonRect = null;
ctx.removeButtonRect = null;
// Initialize SmartImage
ctx.smartImage = new SmartImage();
@@ -58,18 +100,25 @@ app.registerExtension({
// Function to update widget when region changes
const updateRegionWidget = (node) => {
if (!node.properties.region) return;
const regionWidget = getRegionWidget(node);
if (regionWidget) {
// Convert y coordinates to bottom-left system (ComfyUI convention)
// No need to flip here as we do it in the drawing logic
regionWidget.value = JSON.stringify(node.properties.region);
const typeWidget = getTypeWidget(node);
if (!regionWidget) return;
const regionType = typeWidget ? typeWidget.value : "Rectangle";
if (regionType === "Rectangle") {
if (!node.properties.region) return;
// Notify ComfyUI that a parameter changed
if (regionWidget.callback) {
regionWidget.callback(regionWidget.value);
}
regionWidget.value = JSON.stringify(node.properties.region);
} else { // Polygon
if (!node.properties.points) return;
regionWidget.value = JSON.stringify(node.properties.points);
}
// Notify ComfyUI that a parameter changed
if (regionWidget.callback) {
regionWidget.callback(regionWidget.value);
}
};
@@ -78,6 +127,22 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
initProperties(this);
// Setup callback on type widget to reinitialize properties
const typeWidget = getTypeWidget(this);
if (typeWidget) {
const originalCallback = typeWidget.callback;
typeWidget.callback = (value) => {
if (originalCallback) {
originalCallback(value);
}
// Re-initialize properties when type changes
initProperties(this);
this.setDirtyCanvas(true, true);
};
}
return result;
};
@@ -99,6 +164,10 @@ app.registerExtension({
this.smartImage.setDimensions(null);
}
// Get the region type
const typeWidget = getTypeWidget(this);
const regionType = typeWidget ? typeWidget.value : "Rectangle";
// Check if region_data is provided from the backend
if (message?.region_data) {
try {
@@ -111,28 +180,46 @@ app.registerExtension({
} else {
const regionFromBackend = JSON.parse(regionDataString);
// Verify it's a valid object with required properties
if (typeof regionFromBackend === 'object' && regionFromBackend !== null) {
// Validate and ensure all required properties exist
const validRegion = {
x1: 'x1' in regionFromBackend ? parseFloat(regionFromBackend.x1) : 0.25,
y1: 'y1' in regionFromBackend ? parseFloat(regionFromBackend.y1) : 0.25,
x2: 'x2' in regionFromBackend ? parseFloat(regionFromBackend.x2) : 0.75,
y2: 'y2' in regionFromBackend ? parseFloat(regionFromBackend.y2) : 0.75
};
// Ensure all values are in valid range
validRegion.x1 = Math.max(0, Math.min(1, validRegion.x1));
validRegion.y1 = Math.max(0, Math.min(1, validRegion.y1));
validRegion.x2 = Math.max(0, Math.min(1, validRegion.x2));
validRegion.y2 = Math.max(0, Math.min(1, validRegion.y2));
// Update region properties
if (newImageLoaded || JSON.stringify(this.properties.region) !== JSON.stringify(validRegion)) {
this.properties.region = validRegion;
if (regionType === "Rectangle") {
// Rectangle mode
if (typeof regionFromBackend === 'object' && regionFromBackend !== null && !Array.isArray(regionFromBackend)) {
// Validate and ensure all required properties exist
const validRegion = {
x1: 'x1' in regionFromBackend ? parseFloat(regionFromBackend.x1) : 0.25,
y1: 'y1' in regionFromBackend ? parseFloat(regionFromBackend.y1) : 0.25,
x2: 'x2' in regionFromBackend ? parseFloat(regionFromBackend.x2) : 0.75,
y2: 'y2' in regionFromBackend ? parseFloat(regionFromBackend.y2) : 0.75
};
// Ensure all values are in valid range
validRegion.x1 = Math.max(0, Math.min(1, validRegion.x1));
validRegion.y1 = Math.max(0, Math.min(1, validRegion.y1));
validRegion.x2 = Math.max(0, Math.min(1, validRegion.x2));
validRegion.y2 = Math.max(0, Math.min(1, validRegion.y2));
// Update region properties
if (newImageLoaded || JSON.stringify(this.properties.region) !== JSON.stringify(validRegion)) {
this.properties.region = validRegion;
}
} else {
console.warn("Rectangle region_data is not a valid object:", regionFromBackend);
}
} else { // Polygon
// Polygon mode
if (Array.isArray(regionFromBackend) && regionFromBackend.length >= 3) {
// Validate and normalize points
const validPoints = regionFromBackend.map(point => ({
x: 'x' in point ? Math.max(0, Math.min(1, parseFloat(point.x))) : 0.5,
y: 'y' in point ? Math.max(0, Math.min(1, parseFloat(point.y))) : 0.5
}));
// Update points
if (newImageLoaded || JSON.stringify(this.properties.points) !== JSON.stringify(validPoints)) {
this.properties.points = validPoints;
}
} else {
console.warn("Polygon region_data is not a valid array:", regionFromBackend);
}
} else {
console.warn("region_data is not a valid object:", regionFromBackend);
}
}
} catch (e) {
@@ -148,7 +235,7 @@ app.registerExtension({
onDrawForeground?.apply(this, arguments);
// Ensure properties are initialized if they were somehow lost
if (!this.properties || !this.properties.region || !this.smartImage) {
if (!this.properties || (!this.properties.region && !this.properties.points) || !this.smartImage) {
initProperties(this);
}
@@ -156,6 +243,7 @@ app.registerExtension({
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT || 20;
const bottomBarHeight = 16; // Height for buttons and text
const buttonWidth = 60; // Larger button
const smallButtonWidth = 40; // Smaller button for Add/Remove
const textFontSize = 10;
const nodeWidth = this.size[0];
const nodeHeight = this.size[1];
@@ -166,156 +254,384 @@ app.registerExtension({
const canvasScale = 1; //app.canvas?.ds?.scale || 1;
const handleSize = baseHandleSize / canvasScale;
// Get the region type
const typeWidget = getTypeWidget(this);
const regionType = typeWidget ? typeWidget.value : "Rectangle";
// Draw the image using SmartImage
const imageRect = this.smartImage.drawImage(ctx, 0, imageTopMargin, nodeWidth, nodeHeight - bottomBarHeight - imageTopMargin);
if (imageRect) {
// Get normalized region coordinates
const region = this.properties.region;
// Convert normalized coordinates to canvas coordinates
const x1Canvas = imageRect.x + region.x1 * imageRect.w;
const y1Canvas = imageRect.y + region.y1 * imageRect.h;
const x2Canvas = imageRect.x + region.x2 * imageRect.w;
const y2Canvas = imageRect.y + region.y2 * imageRect.h;
// Draw rectangle outline
ctx.strokeStyle = "rgba(0, 255, 255, 0.8)"; // Cyan color
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(
Math.min(x1Canvas, x2Canvas),
Math.min(y1Canvas, y2Canvas),
Math.abs(x2Canvas - x1Canvas),
Math.abs(y2Canvas - y1Canvas)
);
ctx.stroke();
// Draw semi-transparent fill
ctx.fillStyle = "rgba(0, 255, 255, 0.2)"; // Cyan with transparency
ctx.fill();
// Draw corner handles (squares)
const cornerPoints = [
{ x: x1Canvas, y: y1Canvas, id: "tl" }, // Top-left
{ x: x2Canvas, y: y1Canvas, id: "tr" }, // Top-right
{ x: x1Canvas, y: y2Canvas, id: "bl" }, // Bottom-left
{ x: x2Canvas, y: y2Canvas, id: "br" } // Bottom-right
];
ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; // White fill
ctx.strokeStyle = "rgba(0, 0, 0, 0.9)"; // Black outline
ctx.lineWidth = 0.5;
for (const corner of cornerPoints) {
// Draw corner handle (centered on corner point)
if (regionType === "Rectangle") {
// RECTANGLE MODE
// Get normalized region coordinates
const region = this.properties.region;
// Convert normalized coordinates to canvas coordinates
const x1Canvas = imageRect.x + region.x1 * imageRect.w;
const y1Canvas = imageRect.y + region.y1 * imageRect.h;
const x2Canvas = imageRect.x + region.x2 * imageRect.w;
const y2Canvas = imageRect.y + region.y2 * imageRect.h;
// Draw rectangle outline
ctx.strokeStyle = "rgba(0, 255, 255, 0.8)"; // Cyan color
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(
corner.x - handleSize/2,
corner.y - handleSize/2,
handleSize,
handleSize
Math.min(x1Canvas, x2Canvas),
Math.min(y1Canvas, y2Canvas),
Math.abs(x2Canvas - x1Canvas),
Math.abs(y2Canvas - y1Canvas)
);
ctx.fill();
ctx.stroke();
// Draw semi-transparent fill
ctx.fillStyle = "rgba(0, 255, 255, 0.2)"; // Cyan with transparency
ctx.fill();
// Draw corner handles (squares)
const cornerPoints = [
{ x: x1Canvas, y: y1Canvas, id: "tl" }, // Top-left
{ x: x2Canvas, y: y1Canvas, id: "tr" }, // Top-right
{ x: x1Canvas, y: y2Canvas, id: "bl" }, // Bottom-left
{ x: x2Canvas, y: y2Canvas, id: "br" } // Bottom-right
];
ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; // White fill
ctx.strokeStyle = "rgba(0, 0, 0, 0.9)"; // Black outline
ctx.lineWidth = 0.5;
for (const corner of cornerPoints) {
// Draw corner handle (centered on corner point)
ctx.beginPath();
ctx.rect(
corner.x - handleSize/2,
corner.y - handleSize/2,
handleSize,
handleSize
);
ctx.fill();
ctx.stroke();
}
// Bottom bar text for rectangle
const regionWidth = Math.abs(this.properties.region.x2 - this.properties.region.x1);
const regionHeight = Math.abs(this.properties.region.y2 - this.properties.region.y1);
let displayText = `Region: ${(regionWidth*100).toFixed(0)}% × ${(regionHeight*100).toFixed(0)}%`;
if (this.smartImage.dimensions) {
const pixelWidth = Math.round(regionWidth * this.smartImage.dimensions[0]);
const pixelHeight = Math.round(regionHeight * this.smartImage.dimensions[1]);
displayText += ` (${pixelWidth}×${pixelHeight}px)`;
}
// Draw Bottom Bar with rectangle info and reset button
const bottomY = nodeHeight - margin - bottomBarHeight;
// Display region dimensions as text
ctx.fillStyle = "#CCC";
ctx.font = `${textFontSize}px Arial`;
ctx.textAlign = "left";
ctx.fillText(displayText, margin, bottomY + bottomBarHeight/2 + textFontSize/3);
// "Reset" Button (right aligned)
this.resetButtonRect = {
x: nodeWidth - margin - buttonWidth,
y: bottomY,
w: buttonWidth,
h: bottomBarHeight
};
ctx.fillStyle = "#444"; // Button background
ctx.fillRect(this.resetButtonRect.x, this.resetButtonRect.y, this.resetButtonRect.w, this.resetButtonRect.h);
ctx.fillStyle = "#CCC";
ctx.textAlign = "center";
ctx.fillText("Reset", this.resetButtonRect.x + buttonWidth/2, bottomY + bottomBarHeight/2 + textFontSize/3);
// Clear polygon-specific buttons
this.addButtonRect = null;
this.removeButtonRect = null;
} else {
// POLYGON MODE
// Get normalized polygon points
const points = this.properties.points;
if (points.length < 3) {
// Ensure we have at least 3 points
this.properties.points = [
{ x: 0.25, y: 0.25 },
{ x: 0.75, y: 0.25 },
{ x: 0.5, y: 0.75 }
];
}
// Convert normalized coordinates to canvas coordinates
const canvasPoints = points.map(point => ({
x: imageRect.x + point.x * imageRect.w,
y: imageRect.y + point.y * imageRect.h
}));
// Draw polygon outline
ctx.strokeStyle = "rgba(0, 255, 255, 0.8)"; // Cyan color
ctx.lineWidth = 1;
ctx.beginPath();
if (canvasPoints.length > 0) {
ctx.moveTo(canvasPoints[0].x, canvasPoints[0].y);
for (let i = 1; i < canvasPoints.length; i++) {
ctx.lineTo(canvasPoints[i].x, canvasPoints[i].y);
}
ctx.closePath();
}
ctx.stroke();
// Draw semi-transparent fill
ctx.fillStyle = "rgba(0, 255, 255, 0.2)"; // Cyan with transparency
ctx.fill();
// Draw vertex handles (squares)
ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; // White fill
ctx.strokeStyle = "rgba(0, 0, 0, 0.9)"; // Black outline
ctx.lineWidth = 0.5;
for (let i = 0; i < canvasPoints.length; i++) {
const point = canvasPoints[i];
// Draw vertex handle
ctx.beginPath();
ctx.rect(
point.x - handleSize/2,
point.y - handleSize/2,
handleSize,
handleSize
);
ctx.fill();
ctx.stroke();
}
// Bottom bar for polygon
const bottomY = nodeHeight - margin - bottomBarHeight;
// Display polygon vertex count
ctx.fillStyle = "#CCC";
ctx.font = `${textFontSize}px Arial`;
ctx.textAlign = "left";
let displayText = `Polygon: ${points.length} vertices`;
ctx.fillText(displayText, margin + 2*smallButtonWidth + 10, bottomY + bottomBarHeight/2 + textFontSize/3);
// "Add" Button (left aligned)
this.addButtonRect = {
x: margin,
y: bottomY,
w: smallButtonWidth,
h: bottomBarHeight
};
ctx.fillStyle = "#2a662a"; // Green button background
ctx.fillRect(this.addButtonRect.x, this.addButtonRect.y, this.addButtonRect.w, this.addButtonRect.h);
ctx.fillStyle = "#CCC";
ctx.textAlign = "center";
ctx.fillText("Add", this.addButtonRect.x + smallButtonWidth/2, bottomY + bottomBarHeight/2 + textFontSize/3);
// "Remove" Button (second from left)
this.removeButtonRect = {
x: margin + smallButtonWidth + 5,
y: bottomY,
w: smallButtonWidth,
h: bottomBarHeight
};
ctx.fillStyle = points.length <= 3 ? "#666" : "#662a2a"; // Red button background (gray if disabled)
ctx.fillRect(this.removeButtonRect.x, this.removeButtonRect.y, this.removeButtonRect.w, this.removeButtonRect.h);
ctx.fillStyle = "#CCC";
ctx.textAlign = "center";
ctx.fillText("Remove", this.removeButtonRect.x + smallButtonWidth/2, bottomY + bottomBarHeight/2 + textFontSize/3);
// "Reset" Button (right aligned)
this.resetButtonRect = {
x: nodeWidth - margin - buttonWidth,
y: bottomY,
w: buttonWidth,
h: bottomBarHeight
};
ctx.fillStyle = "#444"; // Button background
ctx.fillRect(this.resetButtonRect.x, this.resetButtonRect.y, this.resetButtonRect.w, this.resetButtonRect.h);
ctx.fillStyle = "#CCC";
ctx.textAlign = "center";
ctx.fillText("Reset", this.resetButtonRect.x + buttonWidth/2, bottomY + bottomBarHeight/2 + textFontSize/3);
}
}
// Draw Bottom Bar with reset button
const bottomY = nodeHeight - margin - bottomBarHeight;
// Display region dimensions as text
ctx.fillStyle = "#CCC";
ctx.font = `${textFontSize}px Arial`;
ctx.textAlign = "left";
const regionWidth = Math.abs(this.properties.region.x2 - this.properties.region.x1);
const regionHeight = Math.abs(this.properties.region.y2 - this.properties.region.y1);
let displayText = `Region: ${(regionWidth*100).toFixed(0)}% × ${(regionHeight*100).toFixed(0)}%`;
if (this.smartImage.dimensions) {
const pixelWidth = Math.round(regionWidth * this.smartImage.dimensions[0]);
const pixelHeight = Math.round(regionHeight * this.smartImage.dimensions[1]);
displayText += ` (${pixelWidth}×${pixelHeight}px)`;
}
ctx.fillText(displayText, margin, bottomY + bottomBarHeight/2 + textFontSize/3);
// "Reset" Button (right aligned)
this.resetButtonRect = {
x: nodeWidth - margin - buttonWidth,
y: bottomY,
w: buttonWidth,
h: bottomBarHeight
};
ctx.fillStyle = "#444"; // Button background
ctx.fillRect(this.resetButtonRect.x, this.resetButtonRect.y, this.resetButtonRect.w, this.resetButtonRect.h);
ctx.fillStyle = "#CCC";
ctx.textAlign = "center";
ctx.fillText("Reset", this.resetButtonRect.x + buttonWidth/2, bottomY + bottomBarHeight/2 + textFontSize/3);
};
nodeType.prototype.onMouseDown = function(e, localPos, graphCanvas) {
if (!e.isPrimary) return false; // Only left click
const clickPos = { x: localPos[0], y: localPos[1] };
const typeWidget = getTypeWidget(this);
const regionType = typeWidget ? typeWidget.value : "Rectangle";
// Check buttons regardless of type
// Check Reset Button Click
if (this.resetButtonRect && this.smartImage.pointInRect(clickPos, this.resetButtonRect)) {
// Reset to default region (centered at 50% of width/height)
this.properties.region = { x1: 0.25, y1: 0.25, x2: 0.75, y2: 0.75 };
if (regionType === "Rectangle") {
// Reset to default rectangle
this.properties.region = { x1: 0.25, y1: 0.25, x2: 0.75, y2: 0.75 };
} else {
// Reset to default triangle
this.properties.points = [
{ x: 0.25, y: 0.25 },
{ x: 0.75, y: 0.25 },
{ x: 0.5, y: 0.75 }
];
}
updateRegionWidget(this);
this.setDirtyCanvas(true, true);
return true; // Event handled
}
// Check for corner dragging
// Polygon-specific buttons
if (regionType === "Polygon") {
// Add button
if (this.addButtonRect && this.smartImage.pointInRect(clickPos, this.addButtonRect)) {
const points = this.properties.points;
if (points && points.length >= 3) {
// Add a new point by averaging the last two points
const lastPoint = points[points.length - 1];
const secondLastPoint = points[points.length - 2];
const newPoint = {
x: (lastPoint.x + secondLastPoint.x) / 2,
y: (lastPoint.y + secondLastPoint.y) / 2
};
// Offset slightly to make it visible
newPoint.x += 0.05;
newPoint.y += 0.05;
// Ensure in bounds
newPoint.x = Math.max(0, Math.min(1, newPoint.x));
newPoint.y = Math.max(0, Math.min(1, newPoint.y));
points.push(newPoint);
updateRegionWidget(this);
this.setDirtyCanvas(true, true);
}
return true; // Event handled
}
// Remove button (only if we have more than 3 points)
if (this.removeButtonRect && this.smartImage.pointInRect(clickPos, this.removeButtonRect)) {
const points = this.properties.points;
if (points && points.length > 3) {
points.pop(); // Remove the last point
updateRegionWidget(this);
this.setDirtyCanvas(true, true);
}
return true; // Event handled
}
}
// Process region/points dragging based on type
if (this.smartImage.imageDrawRect) {
const imageRect = this.smartImage.imageDrawRect;
const region = this.properties.region;
// Convert normalized coordinates to canvas coordinates
const x1Canvas = imageRect.x + region.x1 * imageRect.w;
const y1Canvas = imageRect.y + region.y1 * imageRect.h;
const x2Canvas = imageRect.x + region.x2 * imageRect.w;
const y2Canvas = imageRect.y + region.y2 * imageRect.h;
// Define corner hit areas with zoom adjustment
const baseHandleSize = 12; // Slightly larger than visual size for easier clicking
const canvasScale = app.canvas?.ds?.scale || 1;
const handleSize = baseHandleSize / canvasScale;
const cornerPoints = [
{ x: x1Canvas, y: y1Canvas, id: "tl" }, // Top-left
{ x: x2Canvas, y: y1Canvas, id: "tr" }, // Top-right
{ x: x1Canvas, y: y2Canvas, id: "bl" }, // Bottom-left
{ x: x2Canvas, y: y2Canvas, id: "br" } // Bottom-right
];
// Check if click is on a corner handle
for (const corner of cornerPoints) {
const dx = clickPos.x - corner.x;
const dy = clickPos.y - corner.y;
if (Math.abs(dx) <= handleSize/2 && Math.abs(dy) <= handleSize/2) {
if (regionType === "Rectangle") {
// RECTANGLE DRAG HANDLING
const region = this.properties.region;
// Convert normalized coordinates to canvas coordinates
const x1Canvas = imageRect.x + region.x1 * imageRect.w;
const y1Canvas = imageRect.y + region.y1 * imageRect.h;
const x2Canvas = imageRect.x + region.x2 * imageRect.w;
const y2Canvas = imageRect.y + region.y2 * imageRect.h;
// Define corner hit areas with zoom adjustment
const baseHandleSize = 12; // Slightly larger than visual size for easier clicking
const canvasScale = 1;
const handleSize = baseHandleSize / canvasScale;
const cornerPoints = [
{ x: x1Canvas, y: y1Canvas, id: "tl" }, // Top-left
{ x: x2Canvas, y: y1Canvas, id: "tr" }, // Top-right
{ x: x1Canvas, y: y2Canvas, id: "bl" }, // Bottom-left
{ x: x2Canvas, y: y2Canvas, id: "br" } // Bottom-right
];
// Check if click is on a corner handle
for (const corner of cornerPoints) {
const dx = clickPos.x - corner.x;
const dy = clickPos.y - corner.y;
if (Math.abs(dx) <= handleSize/2 && Math.abs(dy) <= handleSize/2) {
this.isDragging = true;
this.dragCorner = corner.id;
this.setDirtyCanvas(true, false);
return true; // Event handled
}
}
// Check if click is inside rectangle (for moving the entire rectangle)
const rectLeft = Math.min(x1Canvas, x2Canvas);
const rectTop = Math.min(y1Canvas, y2Canvas);
const rectWidth = Math.abs(x2Canvas - x1Canvas);
const rectHeight = Math.abs(y2Canvas - y1Canvas);
if (clickPos.x >= rectLeft && clickPos.x <= rectLeft + rectWidth &&
clickPos.y >= rectTop && clickPos.y <= rectTop + rectHeight) {
// Store drag start position and region state for move operation
this.isDragging = true;
this.dragCorner = corner.id;
this.dragCorner = "move";
this.dragStartPos = { x: clickPos.x, y: clickPos.y };
this.dragStartRegion = { ...this.properties.region };
this.setDirtyCanvas(true, false);
return true; // Event handled
}
} else {
// POLYGON DRAG HANDLING
const points = this.properties.points;
if (!points || points.length < 3) return false;
// Convert normalized coordinates to canvas coordinates
const canvasPoints = points.map(point => ({
x: imageRect.x + point.x * imageRect.w,
y: imageRect.y + point.y * imageRect.h
}));
// Define vertex hit area size
const baseHandleSize = 12; // Slightly larger than visual for easier clicking
const canvasScale = 1;
const handleSize = baseHandleSize / canvasScale;
// Check if click is on a vertex handle
for (let i = 0; i < canvasPoints.length; i++) {
const point = canvasPoints[i];
const dx = clickPos.x - point.x;
const dy = clickPos.y - point.y;
if (Math.abs(dx) <= handleSize/2 && Math.abs(dy) <= handleSize/2) {
this.isDragging = true;
this.dragCorner = i; // Store vertex index
this.setDirtyCanvas(true, false);
return true; // Event handled
}
}
// Check if click is inside polygon (for moving entire polygon)
// This implementation uses the even-odd rule to check if a point is in a polygon
let isInside = false;
for (let i = 0, j = canvasPoints.length - 1; i < canvasPoints.length; j = i++) {
if (((canvasPoints[i].y > clickPos.y) != (canvasPoints[j].y > clickPos.y)) &&
(clickPos.x < (canvasPoints[j].x - canvasPoints[i].x) * (clickPos.y - canvasPoints[i].y) /
(canvasPoints[j].y - canvasPoints[i].y) + canvasPoints[i].x)) {
isInside = !isInside;
}
}
if (isInside) {
// Store drag start position and polygon state for move operation
this.isDragging = true;
this.dragCorner = "move";
this.dragStartPos = { x: clickPos.x, y: clickPos.y };
this.dragStartPoints = JSON.parse(JSON.stringify(this.properties.points)); // Deep copy
this.setDirtyCanvas(true, false);
return true; // Event handled
}
}
// Check if click is inside rectangle (for moving the entire rectangle)
const rectLeft = Math.min(x1Canvas, x2Canvas);
const rectTop = Math.min(y1Canvas, y2Canvas);
const rectWidth = Math.abs(x2Canvas - x1Canvas);
const rectHeight = Math.abs(y2Canvas - y1Canvas);
if (clickPos.x >= rectLeft && clickPos.x <= rectLeft + rectWidth &&
clickPos.y >= rectTop && clickPos.y <= rectTop + rectHeight) {
// Store drag start position and region state for move operation
this.isDragging = true;
this.dragCorner = "move";
this.dragStartPos = { x: clickPos.x, y: clickPos.y };
this.dragStartRegion = { ...this.properties.region };
this.setDirtyCanvas(true, false);
return true; // Event handled
}
}
@@ -323,59 +639,92 @@ app.registerExtension({
};
nodeType.prototype.onMouseMove = function(e, localPos, graphCanvas) {
if (!this.isDragging || !this.dragCorner || !this.smartImage.imageDrawRect) return false;
if (!this.isDragging || this.dragCorner === null || !this.smartImage.imageDrawRect) return false;
const rect = this.smartImage.imageDrawRect;
const typeWidget = getTypeWidget(this);
const regionType = typeWidget ? typeWidget.value : "Rectangle";
// Convert mouse position to normalized image coordinates
const mouseNormX = Math.max(0, Math.min(1, (localPos[0] - rect.x) / rect.w));
const mouseNormY = Math.max(0, Math.min(1, (localPos[1] - rect.y) / rect.h));
// Get current region
const region = this.properties.region;
if (this.dragCorner === "move") {
// Moving the entire rectangle
// Calculate the delta in canvas coordinates
const deltaCanvasX = localPos[0] - this.dragStartPos.x;
const deltaCanvasY = localPos[1] - this.dragStartPos.y;
if (regionType === "Rectangle") {
// RECTANGLE MOVE/RESIZE
// Get current region
const region = this.properties.region;
// Convert to normalized coordinates
const deltaNormX = deltaCanvasX / rect.w;
const deltaNormY = deltaCanvasY / rect.h;
// Original width and height
const width = this.dragStartRegion.x2 - this.dragStartRegion.x1;
const height = this.dragStartRegion.y2 - this.dragStartRegion.y1;
// Calculate new position ensuring it stays within bounds
let newX1 = Math.max(0, Math.min(1 - width, this.dragStartRegion.x1 + deltaNormX));
let newY1 = Math.max(0, Math.min(1 - height, this.dragStartRegion.y1 + deltaNormY));
// Update all corners preserving width/height
region.x1 = newX1;
region.y1 = newY1;
region.x2 = newX1 + width;
region.y2 = newY1 + height;
if (this.dragCorner === "move") {
// Moving the entire rectangle
// Calculate the delta in canvas coordinates
const deltaCanvasX = localPos[0] - this.dragStartPos.x;
const deltaCanvasY = localPos[1] - this.dragStartPos.y;
// Convert to normalized coordinates
const deltaNormX = deltaCanvasX / rect.w;
const deltaNormY = deltaCanvasY / rect.h;
// Original width and height
const width = this.dragStartRegion.x2 - this.dragStartRegion.x1;
const height = this.dragStartRegion.y2 - this.dragStartRegion.y1;
// Calculate new position ensuring it stays within bounds
let newX1 = Math.max(0, Math.min(1 - width, this.dragStartRegion.x1 + deltaNormX));
let newY1 = Math.max(0, Math.min(1 - height, this.dragStartRegion.y1 + deltaNormY));
// Update all corners preserving width/height
region.x1 = newX1;
region.y1 = newY1;
region.x2 = newX1 + width;
region.y2 = newY1 + height;
} else {
// Dragging a corner
switch (this.dragCorner) {
case "tl": // Top-left
region.x1 = mouseNormX;
region.y1 = mouseNormY;
break;
case "tr": // Top-right
region.x2 = mouseNormX;
region.y1 = mouseNormY;
break;
case "bl": // Bottom-left
region.x1 = mouseNormX;
region.y2 = mouseNormY;
break;
case "br": // Bottom-right
region.x2 = mouseNormX;
region.y2 = mouseNormY;
break;
}
}
} else {
// Dragging a corner
switch (this.dragCorner) {
case "tl": // Top-left
region.x1 = mouseNormX;
region.y1 = mouseNormY;
break;
case "tr": // Top-right
region.x2 = mouseNormX;
region.y1 = mouseNormY;
break;
case "bl": // Bottom-left
region.x1 = mouseNormX;
region.y2 = mouseNormY;
break;
case "br": // Bottom-right
region.x2 = mouseNormX;
region.y2 = mouseNormY;
break;
// POLYGON VERTEX MOVE
const points = this.properties.points;
if (this.dragCorner === "move") {
// Moving the entire polygon
if (!this.dragStartPoints) return false;
// Calculate the delta in canvas coordinates
const deltaCanvasX = localPos[0] - this.dragStartPos.x;
const deltaCanvasY = localPos[1] - this.dragStartPos.y;
// Convert to normalized coordinates
const deltaNormX = deltaCanvasX / rect.w;
const deltaNormY = deltaCanvasY / rect.h;
// Apply delta to all points with bounds checking
for (let i = 0; i < points.length; i++) {
if (i < this.dragStartPoints.length) {
points[i].x = Math.max(0, Math.min(1, this.dragStartPoints[i].x + deltaNormX));
points[i].y = Math.max(0, Math.min(1, this.dragStartPoints[i].y + deltaNormY));
}
}
} else if (typeof this.dragCorner === 'number' && this.dragCorner >= 0 && this.dragCorner < points.length) {
// Moving a specific vertex
points[this.dragCorner].x = mouseNormX;
points[this.dragCorner].y = mouseNormY;
}
}
@@ -392,6 +741,7 @@ app.registerExtension({
this.dragCorner = null;
this.dragStartPos = null;
this.dragStartRegion = null;
this.dragStartPoints = null;
this.setDirtyCanvas(true, false);
return true; // Event handled
}
+81 -37
View File
@@ -1097,7 +1097,7 @@ class SmartImageRegion:
return {
"required": {
"image": ("IMAGE",),
"type": (["Rectangle"],), # Only Rectangle for now, more to be added later
"type": (["Rectangle", "Polygon"],), # Added Polygon type
# Hidden input to store region data as JSON string
"region_data": ("STRING", {"default": '{"x1":0.25,"y1":0.25,"x2":0.75,"y2":0.75}', "multiline": False, "hidden": True}),
},
@@ -1133,57 +1133,101 @@ class SmartImageRegion:
# 2. Parse region_data JSON
try:
region = json.loads(region_data)
if not isinstance(region, dict):
if type == "Rectangle" and not isinstance(region, dict):
region = {"x1": 0.25, "y1": 0.25, "x2": 0.75, "y2": 0.75}
elif type == "Polygon" and not isinstance(region, list):
# Default triangle if invalid polygon data
region = [{"x": 0.25, "y": 0.25}, {"x": 0.75, "y": 0.25}, {"x": 0.5, "y": 0.75}]
except Exception as e:
print(f"Error parsing region_data: {e}")
region = {"x1": 0.25, "y1": 0.25, "x2": 0.75, "y2": 0.75}
if type == "Rectangle":
region = {"x1": 0.25, "y1": 0.25, "x2": 0.75, "y2": 0.75}
else: # Polygon
region = [{"x": 0.25, "y": 0.25}, {"x": 0.75, "y": 0.25}, {"x": 0.5, "y": 0.75}]
# 3. Generate mask image
# Create a binary mask where the region is 1.0 and everywhere else is 0.0
# Standard MASK format is [batch, height, width] (no channels dimension)
mask_np = np.zeros((h, w), dtype=np.float32)
# Get pixel coordinates (convert from 0-1 to pixel coords)
# Note: ComfyUI and our JS use top-left as origin with y increasing downward
# so we need to flip the y-coordinates for the mask
x1 = max(0, min(w-1, int(region.get("x1", 0.25) * w)))
y1 = max(0, min(h-1, int(region.get("y1", 0.25) * h))) # No y-flip for consistency with bbox
x2 = max(0, min(w-1, int(region.get("x2", 0.75) * w)))
y2 = max(0, min(h-1, int(region.get("y2", 0.75) * h))) # No y-flip for consistency with bbox
if type == "Rectangle":
# Get pixel coordinates (convert from 0-1 to pixel coords)
# Note: ComfyUI and our JS use top-left as origin with y increasing downward
# so we need to flip the y-coordinates for the mask
x1 = max(0, min(w-1, int(region.get("x1", 0.25) * w)))
y1 = max(0, min(h-1, int(region.get("y1", 0.25) * h))) # No y-flip for consistency with bbox
x2 = max(0, min(w-1, int(region.get("x2", 0.75) * w)))
y2 = max(0, min(h-1, int(region.get("y2", 0.75) * h))) # No y-flip for consistency with bbox
# Ensure x1 <= x2 and y1 <= y2
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
# Ensure x1 <= x2 and y1 <= y2
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
# Fill the region in the mask
mask_np[y1:y2+1, x1:x2+1] = 1.0
# Fill the region in the mask
mask_np[y1:y2+1, x1:x2+1] = 1.0
# 4. Create bbox output (normalized coordinates)
# Use original normalized coordinates from region data for consistency
x1_norm = region.get("x1", 0.25)
y1_norm = region.get("y1", 0.25)
x2_norm = region.get("x2", 0.75)
y2_norm = region.get("y2", 0.75)
# Ensure normalized coordinates are in correct order and range
x1_norm, x2_norm = min(x1_norm, x2_norm), max(x1_norm, x2_norm)
y1_norm, y2_norm = min(y1_norm, y2_norm), max(y1_norm, y2_norm)
x1_norm = max(0.0, min(1.0, x1_norm))
y1_norm = max(0.0, min(1.0, y1_norm))
x2_norm = max(0.0, min(1.0, x2_norm))
y2_norm = max(0.0, min(1.0, y2_norm))
# Calculate width and height
width_norm = x2_norm - x1_norm
height_norm = y2_norm - y1_norm
# BBOX format: [x, y, width, height] (normalized 0-1)
bbox = [x1_norm, y1_norm, width_norm, height_norm]
else: # Polygon type
# Convert normalized polygon points to pixel coordinates
if not region or len(region) < 3: # Ensure we have at least 3 points for a valid polygon
# Default triangle if invalid polygon data
region = [{"x": 0.25, "y": 0.25}, {"x": 0.75, "y": 0.25}, {"x": 0.5, "y": 0.75}]
poly_points = []
for point in region:
x = max(0, min(w-1, int(point.get("x", 0.5) * w)))
y = max(0, min(h-1, int(point.get("y", 0.5) * h)))
poly_points.append([x, y])
# Convert to numpy array for OpenCV
poly_points_np = np.array(poly_points, dtype=np.int32)
# Fill polygon in the mask using OpenCV
cv2.fillPoly(mask_np, [poly_points_np], 1.0)
# Calculate bounding box of the polygon
min_x, min_y = 1.0, 1.0
max_x, max_y = 0.0, 0.0
for point in region:
x_norm = max(0.0, min(1.0, point.get("x", 0.5)))
y_norm = max(0.0, min(1.0, point.get("y", 0.5)))
min_x = min(min_x, x_norm)
min_y = min(min_y, y_norm)
max_x = max(max_x, x_norm)
max_y = max(max_y, y_norm)
# Calculate width and height
width_norm = max_x - min_x
height_norm = max_y - min_y
# BBOX format: [x, y, width, height] (normalized 0-1)
bbox = [min_x, min_y, width_norm, height_norm]
# Convert to tensor with batch dimension [batch, height, width]
mask_tensor = torch.from_numpy(mask_np).unsqueeze(0)
# 4. Create bbox output (normalized coordinates)
# Use original normalized coordinates from region data for consistency
x1_norm = region.get("x1", 0.25)
y1_norm = region.get("y1", 0.25)
x2_norm = region.get("x2", 0.75)
y2_norm = region.get("y2", 0.75)
# Ensure normalized coordinates are in correct order and range
x1_norm, x2_norm = min(x1_norm, x2_norm), max(x1_norm, x2_norm)
y1_norm, y2_norm = min(y1_norm, y2_norm), max(y1_norm, y2_norm)
x1_norm = max(0.0, min(1.0, x1_norm))
y1_norm = max(0.0, min(1.0, y1_norm))
x2_norm = max(0.0, min(1.0, x2_norm))
y2_norm = max(0.0, min(1.0, y2_norm))
# Calculate width and height
width_norm = x2_norm - x1_norm
height_norm = y2_norm - y1_norm
# BBOX format: [x, y, width, height] (normalized 0-1)
bbox = [x1_norm, y1_norm, width_norm, height_norm]
# Return results for the workflow graph AND data for the UI
return {"result": (mask_tensor, bbox), "ui": ui_data}